C++11—Threading Library

C++ thread library

Thread creation

image-20230912193316140

  1. Call the constructor without parameters
thread() noexcept;
#include<iostream>
#include<thread>
using namespace std;
int main()
{
    
    
	thread t1;
	return 0;
}
  • Thread provides a parameterless construct, and the created thread object is not associated with any thread function, that is, no thread is started. To create a thread in Linux, you must call pthread_createa function and pass parameters such as parameters 线程执行的函数and 该函数需要的参数parameters, that is, the thread will be started when the program is running.
  1. Calling a constructor with parameters
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
  • fn is a callable object, which can be a function pointer, lambda expression, or function object.

  • Args are several parameters required by the thread to call the object.

  • Note that the constructor with parameters is modified with the explicit keyword, that is, the constructor with parameters does not support implicit type conversion.

#include<iostream>
#include<thread>
using namespace std;
void Add(int left, int right)
{
    
    
	cout << left + right << endl;
}

int main()
{
    
    
	int x = 10;
	int y = 20;
	thread t1(Add,x,y);

	t1.join();
	return 0;
}
  • Pass the Add function and integer x and y variables to the t1 thread. When the program runs, the t1 thread will call the Add function and pass x and y as parameters to left and right in order.
  1. Thread disable copy function
  • The function of disabling the copy function is to prohibit copy construction and copy assignment, preventing multiple threads from copying the same thread object, thereby avoiding potential concurrency and data inconsistency issues. Its main functions are as follows:

    1. Prevent different threads from sharing the same thread object : If the thread object is allowed to be copied, then different threads may share the same thread state, making the behavior of the thread undetermined. This can lead to concurrency issues such as race conditions and data races. ,

    2. Force explicit management of the life cycle of thread objects : Disabling copy functions forces programmers to explicitly manage the life cycle of thread objects. This ensures that thread creation, destruction, and use are explicit, reducing the possibility of resource leaks or thread leaks.

    3. Improved thread safety : Disabling copy functions helps improve thread safety because the state of thread objects cannot be accidentally shared between different threads. This helps reduce bugs and hard-to-debug problems in concurrent programming.

  1. Allows the use of move constructors to construct thread objects
thread (thread&& x) noexcept;

By std::movemoving a thread object from one place to another using

#include<iostream>
#include<thread>
using namespace std;

void Add(int left, int right)
{
    
    
	cout << left + right << endl;
}

int main()
{
    
    
	int x = 10;
	int y = 20;
	thread t1=thread(Add,x,y);
	t1.join();
	return 0;
}
  • Use parameterized construction to create an anonymous thread object, and then construct the t1 object through move assignment. The resources managed by the anonymous object are moved to the t1 object for management, and the life cycle of the thread object is extended.
#include<iostream>
#include<thread>
using namespace std;

void Add(int left, int right)
{
    
    
	cout <<"left+right: " << left + right << endl;
}
class ADD
{
    
    
public:
	int operator()()
	{
    
    
		int x = 20, y = 10;
		return x + y;
	}
};
int main()
{
    
    
	int x = 10;
	int y = 20;
	thread t1(Add,x,y);//可调用对象为函数指针
	thread t2([x, y] {
    
    cout <<"x+y: " << x + y << endl; });//可调用对象为lambda表达式
	ADD dd;
	thread t3(dd);//可调用对象为函数对象
	t1.join();
	t2.join();
	t3.join();
	return 0;
}
  • The ADD class contains only one functor operator(), indicating that the class can be used like a function. An ADD object dd is created in the main function, and the function object dd is passed to thread t3 as a callable object.

Member functions provided by thread

The commonly used member functions in thread are as follows:

member function Function
join Wait for the thread. Before the waiting thread returns, the thread calling the join function will be blocked.
joinable Determine whether the thread has completed execution, if so, return true, otherwise return false
detach Separate the thread from the creating thread. The separated thread no longer needs to call the join function of the creating thread to wait for it.
get_id Get the id of the thread
swap Exchange the status of the associated threads between two thread objects

In addition, joinablethe function can determine whether the thread is valid. If the thread is invalid in any of the following situations:

  1. A thread object constructed using a parameterless constructor.
  2. The state of the thread object has been transferred to other thread objects.
  3. The thread has ended by calling join or detach.

get_id

  • Call the thread member function get_id through the thread object to obtain the thread id.
  • If it is in the thread function associated with the thread object, you can obtain the thread id by calling the get_id function of the this_thread namespace.
void func()
{
    
    
	cout <<"this_thread::get_id: " << this_thread::get_id() << endl;
}
int main()
{
    
    
	thread t1(func);
	cout << "t1 id: "<<t1.get_id() << endl;

	t1.join();
	return 0;
}

this_thread namespace

In order to better call thread member functions in thread functions, the library wraps several commonly used member functions in the this_thread namespace. It can be called through this_thread::xxx in the thread function associated with the thread.

The member functions in the this_thread namespace are:

get_id Get the id of the thread
yield Give up the execution rights of the current thread, allowing the system to allocate execution time slices to other runnable threads
sleep_until Let the current thread sleep until a specific point in time
sleep_for Let the current thread sleep for a period of time

Explain:

The functions of yield function include:

  1. Give up execution rights of the current thread . yield()The function will actively give up the execution rights of the current thread and tell the operating system that the thread is willing to give up the remaining time slice to other threads. Doing so can improve the multi-thread concurrency performance of the system and avoid the problem of a thread occupying CPU resources for a long time, causing other threads to be delayed in scheduling.
  2. Controls the execution priority of threads . By using yield()the function, you can control the execution priority of the thread in some way. When a thread yield()gives up execution rights through , the operating system can reselect the next thread to run according to a certain scheduling algorithm.
  3. The yield function is just a suggestion for thread scheduling, and the operating system may not necessarily schedule it according to your requirements. Specific behavior may be affected by the operating system and hardware platform. That is, there is no guarantee that the next thread to obtain execution rights must be another thread, or it may be that the same thread before continues execution.
  4. In some cases, yield()this can cause thread performance degradation because the thread needs to give up and regain execution rights frequently.
  5. yield()Functions are usually used for lock-free operations. When the current thread needs to switch to another thread, you can call yield to cause the current thread to give up the time slice, switch context, and give up the CPU to another thread.

Yield allows the current thread to yield the CPU time slice and let other threads occupy the CPU. It is usually used for lock-free operations.

in conclusion:

  1. Thread is a concept in the operating system. A thread object can be associated with a thread and used to control the thread and obtain the status of the thread.
  2. If no thread function is provided when creating a thread object, then the thread object does not actually correspond to any thread.
  3. If a thread function is provided when the thread object is created, a thread will be started to execute the thread function, and the thread will run together with the main thread.
  4. The thread class is copy-proof and does not allow copy construction and copy assignment. However, move construction and move assignment are possible. The state of a thread associated with a thread object can be transferred to other thread objects without affecting the execution of the thread during the transfer.

Thread recycling strategy

After starting a thread, the thread will occupy some resources. When the thread exits, the resources occupied by the thread need to be recycled, otherwise it will cause memory leaks. Therefore, the thread library provides us with two strategies for recycling thread resources.

join

After the main thread creates a new thread, it needs to call joina function to recycle the new thread. The main thread will block at jointhe function until the new thread exits and the main thread successfully recycles the new thread resources.

  • Therefore, after the main thread calls jointhe function to recycle the resources related to the new thread, the new thread object has nothing to do with the stack frame that was just destroyed. Therefore, it can only be used once for the same thread, otherwise the program joinwill crash.
void func()
{
    
    
	cout << "this_thread::get_id: " << this_thread::get_id() << endl;
}

int main()
{
    
    
	thread t1(func);
	cout << "t1 id: " << t1.get_id() << endl;

	t1.join();
	t1.join();
	return 0;
}
  • But if a thread object joinlater calls the move assignment function to transfer the state of an rvalue thread object's associated thread, then the thread object can be called again join.
void func()
{
    
    
	cout << "this_thread::get_id: " << this_thread::get_id() << endl;
}

int main()
{
    
    
	thread t1(func);
	cout << "t1 id: " << t1.get_id() << endl;

	t1.join();
	t1 = thread(func);
	t1.join();
	return 0;
}
  • When a method is used jointo recycle thread resources, it may joinfail due to an exception being thrown or other reasons exiting the current stack frame before the join.
void func()
{
    
    
	cout << "this_thread::get_id: " << this_thread::get_id() << endl;
}

int main()
{
    
    
	thread t1(func);
	cout << "t1 id: " << t1.get_id() << endl;

	if (3 != 0)
	{
    
    
		return -1;//退出当前栈帧
	}

	t1.join();
	return 0;
}

Therefore, RAII can be used to wrap thread objects, and the life cycle of the object can be used to control the release of thread resources, that is, when the current stack frame is exited, a joinmethod is automatically called to release thread resources.

class mythread
{
    
    
public:
	mythread(thread& t):_t(t)
	{
    
    
		cout << "thread id: " << _t.get_id() << endl;
	}

	~mythread()
	{
    
    
		cout << "thread id: " << _t.get_id() << " join success" << endl;
		_t.join();
	}

	mythread(mythread const&) = delete;
	mythread& operator=(const mythread&) = delete;
private:
	thread& _t;
};
void func()
{
    
    
	cout << "this_thread::get_id: " << this_thread::get_id() << endl;
}
int main()
{
    
    
	thread t1(func);
	mythread tt(t1);
	return 0;
}

image-20230913160735032

detach

After the main thread creates a new thread, it can also call detacha function to separate the new thread from the main thread. After the separation, the new thread will run in the background, and its ownership and control will be handed over to the C++ runtime. At this time, the C++ runtime will ensure that When a thread exits, its related resources can be correctly recycled.

  • The method used detachto recycle thread resources is generally to call the function immediately after the thread object is created detach. Avoid exiting the current stack frame for some reason before calling detach, causing thread detachment to fail and causing the program to crash.

Thread function parameters

The parameters of the thread function are copied to the thread stack space in the form of value copy. Therefore, even if the thread parameters are reference types, the external actual parameters cannot be modified after being modified in the thread, because they actually refer to the copy in the thread stack. , rather than external arguments.

void ADD(int& num)
{
    
    
	num = num + 1;
}

int main()
{
    
    
	int num = 10;
	thread t1(ADD,num);
	t1.join();
	cout << "after num: " << num << endl;//10 not 11
	return 0;
}

If you want to change the external actual parameters through the formal parameters of the thread function, you can refer to the following three methods:

  1. When you want the formal parameters of the thread function to refer to actual parameters passed in from outside, use the std::ref function to maintain the reference characteristics of the actual parameters.
void ADD(int& num)
{
    
    
	num = num + 1;
}

int main()
{
    
    
	int num = 10;
	thread t1(ADD,ref(num));
	t1.join();
	cout << "after num: " << num << endl;//11
	return 0;
}
  1. Pass in the address of the actual parameter , and the thread function can get the actual parameter through this address and modify it, thereby affecting the external actual parameter.
void ADD(int* num)
{
    
    
	*num = *num + 1;
}

int main()
{
    
    
	int num = 10;
	thread t1(ADD,&num);
	t1.join();
	cout << "after num: " << num << endl;//11
	return 0;
}
  1. With the help of lambda expressions, the capture list of the lambda expression is used to capture the reference of the external actual parameter, and the actual parameter is modified in the function body, thereby affecting the external actual parameter.
int main()
{
    
    
	int num = 10;
	thread t1([&num] {
    
    num = num + 1; });
	t1.join();
	cout << "after num: " << num << endl;//11
	return 0;
}

Types of mutex

In C++11, Mutex includes a total of four types of mutexes:

  1. std::mutex
    is the most basic mutex provided by C++11. Objects of this class cannot be copied or moved. The three most commonly used functions of mutex:

    Function name function function
    lock() Lock: Lock the mutex. If the mutex is occupied by other threads, block and wait for the lock to be unlocked.
    unlock() Unlock: Release ownership of the mutex
    try_lock() Try to lock the mutex. If the mutex is already occupied by another thread, it will return directly.

    Note that when the thread function calls lock():

    • If the mutex is not currently locked, the calling thread locks the mutex and holds the lock until unlock is called.
    • If the current mutex is locked by another thread, the current calling thread is blocked.
    • If the current mutex is locked by the current thread, and then you apply for the mutex through lock(), it will cause a deadlock problem.

    Note that when the thread function calls try_lock():

    • If the current mutex is not occupied by another thread, the thread locks the mutex until the thread calls unlock to release the mutex.
    • If the current mutex is locked by another thread, the current calling thread returns false and will not be blocked.
    • If the current mutex is locked by the current thread, and then the current thread applies for the mutex through try_lock, it will return immediately if the application for the mutex fails, without causing a deadlock problem.

Deadlock concept: Deadlock refers to a permanent waiting state in which each process in a group of processes occupies resources that will not be released, but is in a permanent waiting state due to mutual application for resources that are occupied by other processes and will not be released. In other words, if a thread holds resources but does not release them, applies for resources that cannot be applied for, and if the application fails and is in a blocking waiting state, then the resources held will not be released or used by other threads, and the thread will be in a deadlock state .

  1. std::recursive_mutex
    allows the same thread to lock the mutex multiple times (that is, recursive locking) to obtain multi-level ownership of the mutex object. When releasing the mutex, it needs to be called the same number of times as the depth of the lock level. unlock().

    Otherwise, std::recursive_mutex has roughly the same characteristics as std::mutex.

  2. std::timed_mutex

    There are two more member functions than std::mutex, try_lock_for() and try_lock_until().

    • try_lock_for()
      accepts a time range, indicating that the thread will be blocked if the lock is not acquired within this time range (different from try_lock() of std::mutex, try_lock will directly return false if the lock is not acquired when called) , if other threads release the lock during this period, the thread can obtain the lock on the mutex. If it times out (that is, the lock is not obtained within the specified time), false is returned.
    • try_lock_until()
      accepts a time point as a parameter. If the thread does not acquire the lock before the specified time point arrives, it will be blocked. If other threads release the lock during this period, the thread can obtain the lock on the mutex. If Timeout (that is, the lock is not obtained within the specified time), then false is returned.

    In addition, timed_mutex also provides lock, try_lockand unlockmember functions, whose characteristics are roughly the same as mutex.

  3. std::recursive_timed_mutex
    recursive_timed_mutex is the combination of recursive_mutex and timed_mutex. recursive_timed_mutex supports both locking operations in recursive functions and timing attempts to apply for locks.

Locking example

int gval = 0;
void func1(int val)
{
    
    
	for (int i = 0; i < val; i++)
	{
    
    
		gval++;
	}
}
int main()
{
    
    
	int m = 1000000;
	thread t1(func1,m);
	thread t2(func1,2*m);
	t1.join();
	t2.join();
	
	cout << "val: " << gval << endl;
	return 0;
}
  • In this example, both thread t1 and thread t2 can get the global variable gval, and then call the same function func to add gval. Since there are no protection measures, the program will not run as we want. Maybe when thread t1 adds to gval, thread t2 also adds to gval. Then the two threads add the same number and then copy it back. In fact, they only add gval once.
    image-20230913175505706

Therefore, a mutex needs to be used to protect the critical section.

int gval = 0;
mutex mut;
void func1(int val)
{
    
    
	for (int i = 0; i < val; i++)
	{
    
    
		mut.lock();
		gval++;
		mut.unlock();//锁放里面相比于锁放外面会多一个是加锁解锁的消耗二个是切换上下文的消耗
	}	
}

int main()
{
    
    
	int m = 1000000;
	thread t1(func1,m);
	thread t2(func1,2*m);
	t1.join();
	t2.join();
	
	cout << "val: " << gval << endl;
	return 0;
}

image-20230913175447968

  • When two threads run to mut.lock()this line, the two threads will compete for the mutex lock. The thread that competes can enter the critical section to execute relevant code, while the thread that fails the competition blocks and waits. When the mutex lock is released by the thread holding it, the thread that failed the competition can be awakened and have the opportunity to compete for the mutex lock again. Only one thread can enter the critical section at a time.
  • In fact, placing the lock and unlock operation inside the loop will consume more resources than placing it outside the loop. Put locking and unlocking in a loop. Firstly, frequent locking and unlocking is expensive. Secondly, threads that cannot grab the lock must block sleep and switch contexts. This will cause multiple threads to frequently switch contexts, and the second is the consumption of context switching.
int gval = 0;
mutex mut;
void func1(int val)
{
    
    
    mut.lock();
	for (int i = 0; i < val; i++)
	{
    
    
		gval++;
	}	
    mut.unlock();
}

int main()
{
    
    
	int m = 1000000;
	thread t1(func1,m);
	thread t2(func1,2*m);
	t1.join();
	t2.join();
	
	cout << "val: " << gval << endl;
	return 0;
}

lock_guard

When using a mutex lock, it is possible that after locking, the current stack frame is exited due to the end of the life cycle of the thread object or other reasons before unlocking, and the unlocking is not completed. This will cause other threads to be blocked when applying for the current lock, causing a deadlock problem. Therefore, the mutex lock can be packaged in RAII. A lock_guard object is instantiated where the lock is needed, and the constructor is called to successfully lock and go out of scope. Before, the lock_guard object is destroyed and the destructor is called to automatically unlock it, which can effectively avoid deadlock problems.

lock_guard is a template class in C++11, which is defined as follows:

template <class Mutex>
class lock_guard;
class Lock_guard
{
    
    
public:
	Lock_guard(mutex& mut):_mut(mut)
	{
    
    
		_mut.lock();
	}
	~Lock_guard()
	{
    
    
		_mut.unlock();
	}
    Lock_guard(const Lock_guard&) = delete;
	Lock_guard& operator=(const Lock_guard&) = delete;
private:
	mutex& _mut;
};
int gval = 0;
mutex mut;
void func1(int val)
{
    
    	
	{
    
    
		Lock_guard lg(mut);
		for (int i = 0; i < val; i++)
		{
    
    
			gval++;
		}
	}
}
int main()
{
    
    
	int m = 1000000;
	thread t1(func1,m);
	thread t2(func1,2*m);
	t1.join();
	t2.join();
	cout << "val: " << gval << endl;
	return 0;
}
  • lock_guard needs to contain a lock member variable, which needs to be a reference type. In the constructor, a mutex mut is passed as a parameter, and the mutex is used to initialize the lock member variable. This member variable is the mutex that the lock_guard object needs to maintain. Repulse lock.
  • Where a lock is needed, instantiate a lock_guard object and call the constructor to successfully lock.
  • Before going out of scope, the lock_guard object will be destroyed and the destructor will be called to automatically unlock it.
  • You can define an anonymous local field to control the life cycle of lock_guard, and the code in this local area will be protected by lock_guard.
  • It is necessary to delete the copy construction and copy assignment of lock_guard because the lock member variables in lock_guard do not support copying.
  • There is also lock_guard in the C++ library, and its usage is similar to the one implemented above.
  • The disadvantage of lock_guard is that it is too simple and users have no way to control the lock, so C++11 provides unique_lock .

unique_lock

  • Similar to lock_gard, the unique_lock class template also uses RAII to encapsulate locks, and also manages the locking and unlocking operations of mutex objects in an exclusive ownership manner, that is, no copying can occur between its objects.
  • During construction (or move assignment), the unique_lock object needs to pass a Mutex object as its parameter, and the newly created unique_lock object is responsible for the locking and unlocking operations of the incoming Mutex object.
  • When using the above type of mutex to instantiate a unique_lock object, the constructor is automatically called to lock it. When the unique_lock object is destroyed, the destructor is automatically called to unlock it, which can easily prevent deadlock problems.

Different from lock_guard, unique_lock is more flexible and provides more member functions:

  1. Lock/unlock operations : lock, try_lock, try_lock_for, try_lock_until and unlock.
  2. Modification operations : move assignment, exchange (swap: exchange the ownership of the mutex managed by another unique_lock object), release (release: return the pointer of the mutex object it manages, and release ownership).
  3. Obtain attributes : owns_lock (returns whether the current object is locked), operator bool() (same function as owns_lock()), mutex (returns the pointer of the mutex managed by the current unique_lock).

The usage of unique_lock is similar to lock_guard

mutex mut;
void func()
{
    
    
unique_lock<mutex> ul(mut);//调用构造函数加锁
//......
ul.lock();
func1();
//......
ul.unclock();


}//调用析构函数解锁

Atomic operation library (atomic)

The main problem with multithreading is the problem caused by shared data (ie, thread safety). If the shared data is all read-only, then there is no problem, because the read-only operation will not affect the data, let alone modify the data, so all threads will get the same data. However, when one or more threads want to modify shared data, a lot of potential trouble arises.

int gval = 0;
void func1(int val)
{
    
    
		for (int i = 0; i < val; i++)
		{
    
    
			gval++;
		}
}
int main()
{
    
    
	int m = 1000000;
	thread t1(func1, m);
	thread t2(func1, 2 * m);
	t1.join();
	t2.join();

	cout << "val: " << gval << endl;
	return 0;
}
  • The result printed by the above code will be less than 3000000. The fundamental reason is that the addition operation of gval is not atomic.

The addition operation is divided into three steps:

  • load: Load the shared variable gval from memory into the register.
  • update: Update the value in the register and perform +1 operation.
  • store: Write the new value from the register back to the memory address of shared variable n.
0039310F  mov         eax,dword ptr [gval (03A03D0h)]  
00393114  add         eax,1  
00393117  mov         dword ptr [gval (03A03D0h)],eax
  • There are many reasons why the value of gval is not 3000000 when the program is finished running. In the case of a single CPU, thread t1 goes to the memory to get the gval data and puts it in the CPU register. It has just completed the first step of the addition operation and the time slice is up. Thread t1 needs to be switched, and the OS switches the context of t1. Then t2 may successfully complete the addition operation and write the added value back to the memory. At this time, switch back to thread t1 and continue to complete the remaining addition operations using the value previously obtained in the memory. Therefore, the two threads each added the same variable gval once. In fact, gval was only added once.
  • C++98 uses locking protection for shared data to address the thread safety issues that arise here.

Although locking can be solved, there is a flaw in locking: as long as one thread is dealing with sum++, other threads will be blocked, which will affect the efficiency of program operation. Moreover, if the lock is not well controlled, it can easily cause deadlock.

Therefore, atomic operations were introduced in C++11. The so-called atomic operation: that is, one or a series of operations that cannot be interrupted. The atomic operation type introduced by C++11 makes the synchronization of data between threads very efficient. The following are the names of atomic operation types and their corresponding built-in type names

image-20230913212857136

Note: When you need to use the above atomic operation variables, you must add a header file

#include<atomic>

Atomic types corresponding to built-in types can be defined through the atomic class template.

atomic<T> t;
//atomic_int gval=0;
atomic<int>gval=0;
void func1(int val)
{
    
    
		for (int i = 0; i < val; i++)
		{
    
    
			gval++;
		}
}

int main()
{
    
    
	int m = 1000000;
	thread t1(func1, m);
	thread t2(func1, 2 * m);
	t1.join();
	t2.join();

	cout << "val: " << gval << endl;
	return 0;
}
  • In C++11, there is no need to lock and unlock atomic type variables, and threads can have mutually exclusive access to atomic type variables.
  • Atomic types usually belong to "resource" data, and multiple threads can only access a copy of a single atomic type . Therefore, in C++11, atomic types can only be constructed from their template parameters, and atomic types are not allowed to undergo copy construction, move construction, operator=, etc.
  • In order to prevent accidents, the standard library has deleted the copy constructor, move constructor, and assignment operator overloading in the atmoic template class by default.

cas operation

CAS: Compare and Swap, that is, compare and exchange.

For example, multiple threads perform an addition operation on the same data. The thread puts the data into a register. The register saves a copy of the original data, and then puts the data on the CPU for addition. By comparing the data in the memory, when the saved When the original data is the same as the data in the memory at this time, the added data is put back into the memory; when the original data saved is different from the data in the memory at this time, it means that other threads have already changed the data. The data is put back into the memory, then the current thread cannot put the data back.

Disadvantages of cas: high CPU overhead. When the amount of concurrency is relatively high, if many threads repeatedly try to update a certain value, but the update is unsuccessful, and the cycle continues, it will put a lot of pressure on the CPU.

The difference between windows and Linux process creation

The thread library is called in the upper layer, and conditional compilation is used to distinguish between calling the windows thread library or the Linux thread library.

Guess you like

Origin blog.csdn.net/m0_71841506/article/details/132865687