【C++11———线程】
闭上眼睛,什么都不听,也许有救,反正会内疚......................................................................
文章目录
一、【线程库(thread)】
1.1、【thread库的引入】
1.2、【线程对象的构造方式】
1、【调用无参的构造函数】
2、【调用带参的构造函数】
3、【调用移动构造函数】
1.3【thread提供的成员函数】
1.4【获取线程id的方式】
1.5、【线程参数问题】
1、【std::ref函数】
2、【传指针类型】
3、【使用lambda表达式】
1.6、【join与detach函数】
1、【join方式】
2、【detach方式】
二、【互斥量库(mutex)】
2.1、【引入线程安全问题】
2.2、【四种mutex互斥量】
1、【std::mutex】
2、【std::recursive_mutex】
3、【std::timed_mutex】
4、【std::recursive_timed_mutex】
2.3、【锁的使用】
1、【解决线程安全问题】
2、【并行,串行】
2.4、【lock_guard和unique_lock】
1、【lock_guard】
2、【unique_lock】
三、【原子性操作库(atomic)】
3.1、【再谈线程安全】
3.2、【原子类解维护程安全】
3.3、【CAS——无锁编程】
四、【条件变量库(condition_variable)】
4.1、【wait系列成员函数】
4.2、【notify系列成员函数】
4.3、【实现两个线程交替打印1-100】
总结
前言
本篇博客主要介绍了,C++11中有关线程的知识,主要包括线程库thread,锁mutex,条件变量condition_variable,以及原子库atomic,请耐心观看!
一、【线程库(thread)】
1.1、【thread库的引入】
在C++11之前,只要是涉及到多线程的问题,都是和平台相关的,由于windows和Linux下各自有自己的接口,这使得代码的可移植性比较差,为了解决这个问题C++11中对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,可以通过直接包含对应的库就可以实现线程的相关操作,并且在库中引入了,原子操作中以及原子类的概念。
要使用标准库的线程,必须包含<thread> 库,而它的底层是通过条件编译来实现的,像下面这样:
#ifdef _WIN32 CreateThread()//windows下 #else pthread_create()//Linux等 #endif
1.2、【线程对象的构造方式】
thread库中提供了多种构造方式如下图:
1、【调用无参的构造函数】
thread提供了无参的构造函数,调用无参的构造函数创建出来的线程对象没有关联任何线程函数,即没有启动任何线程。比如:
//1.无参构造,创建线程但是不启动 thread t1;
使用无参构造出来的线程对象没有关联任何线程函数,即没有启动任何线程。但是这里并不意味着无参构造就没有丝毫作用,由于thread提供了移动赋值函数,因此当后续需要让该线程对象与线程函数关联时,可以以带参的方式创建一个匿名对象,然后调用移动赋值将该匿名对象关联线程的状态转移给该线程对象。比如下面这样:
void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } int main() { thread t1; //... t1 = thread(func, 10);//带参构造 t1.join(); return 0; }
结果:
无参构造的应用场景: 实现线程池的时候就是需要先创建一批线程,但一开始这些线程什么也不做,当有任务到来时再让这些线程来处理这些任务。
2、【调用带参的构造函数】
thread的带参的构造函数的定义如下:
template <class Fn, class... Args> explicit thread (Fn&& fn, Args&&... args);
参数说明:
fn
:可调用对象,比如函数指针、仿函数、lambda表达式、被包装器包装后的可调用对象等。args...
:调用可调用对象fn时所需要的若干参数。调用带参的构造函数创建线程对象,能够将线程对象与线程函数fn进行关联。比如:
void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } int main() { thread t2(func, 10); t2.join();//对线程进行等待 return 0; }
结果:
3、【调用移动构造函数】
thread提供了移动构造函数,能够用一个右值线程对象来构造一个线程对象。比如:
void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } int main() { thread t3 = thread(func, 10); t3.join(); return 0; }
结果:
说明一下:
- 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。
- 如果创建线程对象时没有提供线程函数,那么该线程对象实际没有对应任何线程。
- 如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
- thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
1.3【thread提供的成员函数】
thread中常用的成员函数如下:
此外,
joinable
函数还可以用于判定线程是否是有效的,如果是以下任意情况,则线程无效:
- 采用无参构造函数构造的线程对象。(该线程对象没有关联任何线程)
- 线程对象的状态已经转移给其他线程对象。(已经将线程交给其他线程对象管理)
- 线程已经调用join或detach结束。(线程已经结束)
1.4【获取线程id的方式】
调用thread的成员函数
get_id
可以获取线程的id,但该方法必须通过线程对象来调用get_id
函数,如果要在线程对象关联的线程函数中获取线程id,可以调用this_thread
命名空间下的get_id
函数。比如:void func() { cout << this_thread::get_id() << endl; //获取线程id } int main() { thread t(func); cout << t.get_id() << endl; t.join(); return 0; }
结果:
这里的
this_thread就是一个
命名空间:
其中还提供了以下三个函数,使用到去搜索:
1.5、【线程参数问题】
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。比如:
void add(int& num) { num++; } int main() { int num = 0; thread t(add, num); t.join(); cout << num << endl; //0 return 0; }
结果:
那么这是为什么呢?
我们要知道我们在传参的时候,num并不会直接传给add函数,而是要先传给thread的构造函数,那么就会导致add中的(int& num)并不是直接对num进行引用,更像是引用了num的拷贝,因为add函数中的参数num实际上来源于thread的构造函数,类似于thread的构造生成了num的拷贝,并将其传递给了add,由于这里的底层逻辑相当复杂涉及到模板的可变参数以及引用折叠,所以我们只需记住当线程中的函数需要接收一个左值引用,我们就需要对线程函数对应的参数使用ref,如下图:
这里如果要通过线程函数的形参改变外部的实参,我们可以通过给参数加上ref(),也就是借助std::ref函数:
1、【std::ref函数】
当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。比如:
void add(int& num) { num++; } int main() { int num = 0; //thread t1(add, num); thread t2(add, ref(num)); //t1.join(); t2.join(); cout << num << endl; //0 return 0; }
结果:
关于ref函数的作用还可以参见下图:
2、【传指针类型】
除了使用ref,我们还可以通过地址的拷贝将线程函数的参数类型改为指针类型,将实参的地址传入线程函数,此时在线程函数中可以通过修改该地址处的变量,进而影响到外部实参。比如:
void add(int* num) { (*num)++; } int main() { int num = 0; thread t(add, &num); t.join(); cout << num << endl; //1 return 0; }
结果:
3、【使用lambda表达式】
也可以通过借助lambda表达式,因为lambda表达式可以不传参,而是通过捕获的方式获得参数,将lambda表达式作为线程函数,利用lambda函数的捕捉列表,以引用的方式对外部实参进行捕捉,此时在lambda表达式中对形参的修改也能影响到外部实参。比如:
int main() { int num = 0; thread t([&num]{num++; }); t.join(); cout << num << endl; //1 return 0; }
结果:
1.6、【join与detach函数】
我们启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。thread库给我们提供了如下两种回收线程资源的方式:
1、【join方式】
主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时
join
函数就会自动清理线程相关的资源。
join
函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join
,否则程序会崩溃。比如:void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } int main() { thread t(func, 20); t.join(); t.join(); //程序崩溃,主要是我们对同一份资源清理了两次。 return 0; }
但如果一个线程对象
join
后,又调用移动赋值函数,将一个右值线程对象的关联线程的状态转移过来了,那么这个线程对象又可以调用一次join
。比如:void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } int main() { thread t(func, 20); t.join(); t = thread(func, 30); t.join(); return 0; }
结果:
但采用
join
的方式结束线程,在某些场景下也可能会出现问题。比如在该线程被join
之前,如果中途因为某些原因导致程序不再执行后续代码,这时这个线程将不会被join
。比如:
void func(int n) { for (int i = 0; i <= n; i++) { cout << i << endl; } } bool DoSomething() { return false; } int main() { thread t(func, 20); //... if (!DoSomething()) return -1;//从这里就返回了 //... t.join(); //不会被执行 return 0; }
结果:
因此采用
join
方式结束线程时,join
的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。比如:class myThread { public: myThread(thread& t) :_t(t) {} ~myThread() { if (_t.joinable()) _t.join(); } //防拷贝和赋值 myThread(myThread const&) = delete; myThread& operator=(const myThread&) = delete; private: thread& _t; };
使用方式如下:
- 每当创建一个线程对象后,就用myThread类对其进行封装产生一个myThread对象。
- 当myThread对象生命周期结束时就会调用析构函数,在析构中会通过
joinable
判断这个线程是否需要被join
,如果需要那么就会调用join
对其该线程进行等待。例如刚才的代码中,使用myThread类对线程对象进行封装后,就能保证线程一定会被
join
。
2、【detach方式】
主线程创建新线程后,也可以调用
detach
函数将新线程与主线程进行分离,分离后新线程会在后台运行,其所有权和控制权将会交给C++运行库,此时C++运行库会保证当线程退出时,其相关资源能够被正确回收。
- 使用
detach
的方式回收线程的资源,一般在线程对象创建好之后就立即调用detach
函数。- 否则线程对象可能会因为某些原因,在后续调用
detach
函数分离线程之前被销毁掉,这时就会导致程序崩溃。- 因为当线程对象被销毁时会调用thread的析构函数,而在thread的析构函数中会通过
joinable
判断这个线程是否需要被join
,如果需要那么就会调用terminate
终止当前程序(程序崩溃)。下面有个例子:
#include <iostream> #include <thread> #include <windows.h> //#include <chrono> // <chrono>是C++标准库中的一个头文件,它专门用于处理时间和日期相关的操作。 // 该库引入了一组类型和函数,使得在程序中进行时间点、时钟和时间间隔的操作变得方便和精确 // 一个简单的线程函数 void printMessage() { //std::this_thread::sleep_for(std::chrono::seconds(2)); // 等待2秒 Sleep(2000);//默认为毫秒 std::cout << "线程:"<< this_thread::get_id()<< "是分离线程" << std::endl; } int main() { // 创建一个线程并运行 printMessage 函数 std::thread t(printMessage); // 分离线程 t.detach(); // 主线程继续执行 std::cout << "主线程继续执行..." << std::endl; // 为了确保分离的线程有机会完成,让主线程等待一会儿 //std::this_thread::sleep_for(std::chrono::seconds(3)); Sleep(3000); std::cout << "主线程结束" << std::endl; return 0; }
结果:
二、【互斥量库(mutex)】
2.1、【引入线程安全问题】
这里的线程安全问题与Linux中的线程安全问题相同,主要就是,当有多个执行流访问临界资源时会对共享数据进行修改的问题,比如下面的情形:
unsigned long sum = 0; void fun(size_t num) { for (size_t i = 0; i < num; ++i) sum++; } int main() { cout << "Before joining,sum = " << sum << std::endl; thread t1(fun, 10000000); thread t2(fun, 10000000); t1.join(); t2.join(); cout << "After joining,sum = " << sum << std::endl; return 0; }
结果:
这里我们创建两个线程模拟对sun相加的情形,因为两个线程一块执行,所以我们的预期应该20000000,但是根据结果可以看出,两个线程都对一个全局变量进行相加时,结果与我们预期的大不相同,造成这个结果的原因就在于引发了线程安全问题,这里我们实际上就可以通过加锁来避免这样的问题,至于造成这个问题的原因是什么,我们后续会有讲解。
2.2、【四种mutex互斥量】
在C++11中,mutex中总共包了四种互斥量:
1、【std::mutex】
mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。
mutex中常用的成员函数如下:
线程函数调用
lock
时,可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用
unlock
之前,该线程一致拥有该锁。- 如果该互斥量已经被其他线程锁住,则当前的调用线程会被阻塞。
- 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
线程调用
try_lock
时,类似也可能会发生以下三种情况:
- 如果该互斥量当前没有被其他线程锁住,则调用线程将该互斥量锁住,直到调用
unlock
之前,该线程一致拥有该锁。- 如果该互斥量已经被其他线程锁住,则
try_lock
调用返回false,当前的调用线程不会被阻塞。- 如果该互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
2、【std::recursive_mutex】
recursive_mutex叫做递归互斥锁,该锁专门用于递归函数中的加锁操作。
- 如果在递归函数中使用mutex互斥锁进行加锁,那么在线程进行递归调用时,可能会重复申请已经申请到但自己还未释放的锁,进而导致死锁问题。
- 而recursive_mutex允许同一个线程对互斥量多次上锁(即递归上锁),来获得互斥量对象的多层所有权,但是释放互斥量时需要调用与该锁层次深度相同次数的
unlock
。void func(int i)//递归函数 { mutex //lock时就死锁了 func(i-1); } //递归互斥锁在lock前判断再次加锁的是不是当前线程,如果还是的就不给予加锁操作。
除此之外,recursive_mutex也提供了
lock
、try_lock
和unlock
成员函数,其的特性与mutex大致相同。3、【std::timed_mutex】
timed_mutex中提供了以下两个成员函数:
try_lock_for
:接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间之内还是没有获得锁),则返回false。try_lock_untill
:接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间点到来时还是没有获得锁),则返回false。除此之外,timed_mutex也提供了
lock
、try_lock
和unlock
成员函数,其的特性与mutex相同。4、【std::recursive_timed_mutex】
recursive_timed_mutex就是recursive_mutex和timed_mutex的结合,recursive_timed_mutex既支持在递归函数中进行加锁操作,也支持定时尝试申请锁。
下面我们来使用以下该锁:
int x = 0; recursive_mutex mtx2; void Func3(int n) { if (n == 0) return; mtx2.lock(); ++x; Func3(n - 1); mtx2.unlock(); //当普通锁的解锁在这里定义的时候会造成死锁,递归锁解决在递归场景中的死锁问题 } void main() { thread t1(Func3, 1000); thread t2(Func3, 1000); t1.join(); t2.join(); cout << x << endl; }
结果:
2.3、【锁的使用】
1、【解决线程安全问题】
先让我们用锁,把上面的线程安全问题进行解决一下:
#include<mutex> unsigned long sum = 0; mutex mtx; void fun(size_t num) { for (size_t i = 0; i < num; ++i) { mtx.lock(); sum++; mtx.unlock(); } } int main() { cout << "Before joining,sum = " << sum << std::endl; thread t1(fun, 10000000); thread t2(fun, 10000000); t1.join(); t2.join(); cout << "After joining,sum = " << sum << std::endl; return 0; }
结果:
我们知道这里我们进行加锁,实际上是对这两个线程做一个约束,那么这里有个问题:加锁我们是在循环外面加还是在循环里面加呢?
2、【并行,串行】
这里再来看一个例子,就是在没有使用互斥锁保证线程安全的情况下,让两个线程各自打印1-100的数字,就会导致控制台输出错乱,这也是因为引发了线程安全问题。
void func(int n) { for (int i = 1; i <= n; i++) { cout << i << endl; } } int main() { thread t1(func, 100); thread t2(func, 100); t1.join(); t2.join(); return 0; }
结果:
如果要让两个线程的输出不会相互影响,即不会让某一次输出中途被另一个线程打断,那么就需要用互斥锁对打印过程进行保护,因为这里打印会使两个线程共享屏幕这个临界资源。
这里加锁的方式还是有两种,一种是在for循环体内进行加锁,一种是在for循环体外进行加锁。比如:
void func(int n, mutex& mtx) { mtx.lock(); //for循环体外加锁 for (int i = 1; i <= n; i++) { //mtx.lock(); //for循环体内加锁 cout << i << endl; //mtx.unlock(); } mtx.unlock(); } int main() { mutex mtx; thread t1(func, 100, ref(mtx)); thread t2(func, 100, ref(mtx)); t1.join(); t2.join(); return 0; }
结果:
这里说明一下:
- 此处在for循环体外加锁比在for循环体内加锁更高效,因为在for循环体内加锁会导致线程打印数字时频繁进行加锁解锁操作,而如果在for循环体外加锁,那么这两个线程只需要在开始打印1之前进行一次加锁,在打印完100后进行一次解锁就行了。
- 在for循环体外加锁也就意味着两个线程的打印过程变成了串行的,即一个线程打印完1-100后另一个线程再打印,但这时打印效率提高了,因为避免了这两个线程间的频繁切换。
- 如果是在for循环体内加锁就意味着两个线程的打印过程变成了并行的,即两个线程同时打印1-100的数字,但是由于这里锁的存在,会出现持有锁的线程在打印,而没有锁的线程会进行阻塞,由于时间片的原因,这里本质上两个线程会一直频繁切换,这时打印效率就会降低。
- 为了保证两个线程使用的是同一个互斥锁,线程函数必须以引用的方式接收传入的互斥锁,并且在传参时需要使用ref函数保持对互斥锁的引用。
- 此外,也可以将互斥锁定义为全局变量,或是用lambda表达式定义线程函数,然后以引用的方式将局部的互斥锁进行捕捉,这两种方法也能保证两个线程使用的是同一个互斥锁。
那么这里我们该如何定义串行和并行的效率高低呢?下面让我们来看这样一个例子:
#include<list> #include<mutex> list<int> lt; int x = 0;//定义全局的 mutex mtx;//定义全局锁 void Func2(int n) { //并行执行 //for (int i = 0; i < n; i++) //{ // mtx.lock(); // ++x; // mtx.unlock(); // cout << i << endl; // cout << i << endl; // cout << i << endl; //} //串行执行 //mtx.lock(); //for (int i = 0; i < n; i++) //{ // ++x; // cout << i << endl; // cout << i << endl; // cout << i << endl; //} //mtx.unlock(); } void main() { int n = 2000000; size_t begin = clock(); thread t1(Func2, 100); //每个线程都有自己独自的栈,这个栈在共享区中,由库提供 thread t2(Func2, 200); t1.join(); t2.join(); size_t end = clock(); cout << "时间是:" << (end - begin) << endl;//获得时间 cout << x << endl; }
这里还是说明一下:
- 串行执行:在上面的代码中,串行执行的例子是将整个循环体包裹在一个单一的锁(
mtx
)之内,串行执行意味着代码块按顺序执行,一次只执行一行或一段代码。代码中的整个循环是在mtx
锁的保护下执行的。这意味着在任何一个时刻,只有一个线程可以执行循环内的代码。即使是在多线程环境下,由于锁的存在,这段代码的行为就像是在单线程环境中执行一样。这种方式保证了数据的一致性和正确性,但牺牲了并行性,因为所有的操作都是按顺序进行的。- 并行执行:并行执行的代码是将锁操作(
mtx1.lock()
和mtx1.unlock()
)放在循环内部,并行执行意味着代码块可以同时在多个线程中执行,从而提高程序的总体执行速度,尤其是在多核处理器上。在这个并行执行中,每次循环迭代都会对x
进行加锁、递增、解锁操作。这意味着多个线程可以同时进入循环,但在执行++x
时,只有一个线程能够持有锁mtx1
。这样做的好处是,在锁释放后,其他线程可以继续执行它们的迭代,而不需要等待整个循环完成。这增加了程序的并行性,尤其是在处理大量数据或执行大量计算时,可以显著提高性能。看一下结果:
说明:
简单操作(如仅
++x
):在这种情况下,由于操作本身非常快,锁的开销(加锁、解锁以及线程上下文切换)可能变得显著,导致并行执行的效率并不比串行执行高,甚至可能更低。复杂操作(如
++x
后push_back
):当操作变得复杂或耗时较长时,并行执行的优势就会显现出来。虽然锁的开销仍然存在,但由于每次锁定的操作耗时较长,相对而言锁的开销就变得不那么重要了。此外,多线程可以更有效地利用多核处理器的资源。锁的使用:锁是实现线程同步的关键工具,它确保了在多线程环境中对共享资源的访问是安全的。但是,锁的过度使用会导致性能下降。
临界区:临界区是需要保护的代码段,以防止多个线程同时访问共享资源导致数据不一致。在并行执行中,应尽量减小临界区的大小,以减少锁的竞争。
线程上下文切换:线程上下文切换是操作系统在多线程环境中切换线程执行时所需的时间。频繁的上下文切换会降低程序的性能。
有关全局变量的经验分享:
- 在项目中实际不太建议定义全局变量,因为全局变量如果定义在头文件中,当这个头文件被多个源文件包含时,在这多个源文件中都会对这个全局变量进行定义,这时就会导致变量重定义,但如果将全局变量定义为静态,那这个全局变量就只在当前文件可见。
- 如果确实有一些变量需要在多个文件中使用,那么一般建议将这些变量封装到一个类当中,然后将这个类设计成单例模式,当需要使用这些变量时就通过这个单例对象去访问即可。
2.4、【lock_guard和unique_lock】
使用互斥锁时,如果加锁的范围太大,那么极有可能在中途返回时忘记了解锁,此后申请这个互斥锁的线程就会被阻塞住,也就是造成了死锁问题。比如:
mutex mtx; void func() { mtx.lock(); //... FILE* fout = fopen("data.txt", "r"); if (fout == nullptr) { //... return; //中途返回(未解锁) } //... mtx.unlock(); } int main() { func(); return 0; }
结果:
因此使用互斥锁时如果控制不好就会造成死锁,最常见的就是此处在锁中间代码返回,此外还有一个比较常见的情况就是在锁的范围内抛异常,也很容易导致死锁问题。
因此C++11采用RAII的方式对锁进行了封装,于是就出现了lock_guard和unique_lock。
1、【lock_guard】
lock_guard是C++11中的一个模板类,其定义如下:
template <class Mutex> class lock_guard;
lock_guard类模板主要是通过RAII的方式,对其管理的互斥锁进行了封装。
- 在需要加锁的地方,用互斥锁实例化一个lock_guard对象,在lock_guard的构造函数中会调用
lock
进行加锁。- 当lock_guard对象出作用域前会调用析构函数,在lock_guard的析构函数中会调用
unlock
自动解锁。通过这种构造对象时加锁,析构对象时自动解锁的方式就有效的避免了死锁问题。比如:
mutex mtx; void func() { lock_guard<mutex> lg(mtx); //调用构造函数加锁 //... FILE* fout = fopen("data.txt", "r"); if (fout == nullptr) { //... return; //调用析构函数解锁 } //... } //调用析构函数解锁 int main() { func(); return 0; }
从lock_guard对象定义到该对象析构,这段区域的代码都属于互斥锁的保护范围。
如果只想用lock_guard保护某一段代码,可以通过定义匿名的局部域来控制lock_guard对象的生命周期。比如:
mutex mtx; void func() { //... //匿名局部域 { lock_guard<mutex> lg(mtx); //调用构造函数加锁 FILE* fout = fopen("data.txt", "r"); if (fout == nullptr) { //... return; //调用析构函数解锁 } } //调用析构函数解锁 //... } int main() { func(); return 0; }
让我们模拟实现一下lock_guard,模拟实现lock_guard类的步骤如下:
- lock_guard类中包含一个锁成员变量(引用类型),这个锁就是每个lock_guard对象管理的互斥锁。
- 调用lock_guard的构造函数时需要传入一个被管理互斥锁,用该互斥锁来初始化锁成员变量后,调用互斥锁的
lock
函数进行加锁。- lock_guard的析构函数中调用互斥锁的
unlock
进行解锁。- 需要删除lock_guard类的拷贝构造和拷贝赋值,因为lock_guard类中的锁成员变量本身也是不支持拷贝的。
代码如下:
namespace xzc { template<class Mutex> class lock_guard { public: lock_guard(Mutex& mtx) :_mtx(mtx) { mtx.lock(); //加锁 } ~lock_guard() { mtx.unlock(); //解锁 } lock_guard(const lock_guard&) = delete; lock_guard& operator=(const lock_guard&) = delete; private: Mutex& _mtx; }; }
2、【unique_lock】
由于lock_guard太单一,用户没有办法直接对锁进行控制,因此C++11又提供了unique_lock。
unique_lock与lock_guard类似,unique_lock类模板也是采用RAII的方式对锁进行了封装。在创建unique_lock对象调用构造函数时也会调用lock进行加锁,在unique_lock对象销毁调用析构函数时也会调用unlock进行解锁。
但lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:
- 加锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock。
- 修改操作:移动赋值、swap、release(返回它所管理的互斥量对象的指针,并释放所有权)。
- 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool(与owns_lock的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
比如如下场景就适合使用unique_lock:
- 要用互斥锁保护函数1的大部分代码,但是中间有一小块代码调用了函数2,而调用函数2时不需要用函数1中的互斥锁进行保护,函数2内部的代码由其他互斥锁进行保护。
- 因此在调用函数2之前需要对当前互斥锁进行解锁,当函数2调用返回后再进行加锁,这样当调用函数2时其他线程调用函数1就能够获取到这个锁。
如下图:
三、【原子性操作库(atomic)】
3.1、【再谈线程安全】
我们知道,多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。就比如下面的例子:
void func(int& n, int times) { for (int i = 0; i < times; i++) { n++; } } int main() { int n = 0; int times = 100000; //每个线程对n++的次数 thread t1(func, ref(n), times); thread t2(func, ref(n), times); t1.join(); t2.join(); cout << n << endl; //打印n的值 return 0; }
上述代码中分别让两个线程对同一个变量n进行了100000次
++
操作,理论上最终n的值应该是200000,但最终打印出n的值却是小于200000的。这就是出现了一个或多个线程要修改临界资源时,没有对临界资源给予相关的保护措施,比如这里我们知道++操作其实并不是原子操作,所谓原子操作就是一件事要么完成,要么就什么都不做;而这里的++
操作并不是一个原子操作,该操作分为三步:
load
:将共享变量n从内存加载到寄存器中。update
:更新寄存器里面的值,执行+1操作。store
:将新值从寄存器写回共享变量n的内存地址。
++
操作对应的汇编代码如下:
因此可能当线程1刚将n的值加载到寄存器中就被切走了,也就是只完成了
++
操作的第一步,而线程2可能顺利完成了一次完整的++
操作才被切走,而这时线程1继续用之前加载到寄存器中的值完成剩余的两步操作,最终就会导致两个线程分别对共享变量n进行了一次++
操作,但最终n的值却只被++
了一次。最终导致n并没有发生什么实质性的变化;具体关于原子操作,以及线程安全问题请移步至——【】。我们知道这里可以通过加锁来解决线程安全问题,C++98中对于这里出现的线程安全的问题,会选择对共享修改的数据进行加锁保护。比如:
void func(int& n, int times, mutex& mtx) { mtx.lock(); for (int i = 0; i < times; i++) { //mtx.lock(); n++; //mtx.unlock(); } mtx.unlock(); } int main() { int n = 0; int times = 100000; //每个线程对n++的次数 mutex mtx; thread t1(func, ref(n), times, ref(mtx)); thread t2(func, ref(n), times, ref(mtx)); t1.join(); t2.join(); cout << n << endl; //打印n的值 return 0; }
这里可以选择在for循环体里面进行加锁解锁,也可以选择在for循环体外进行加锁解锁。但效果终究是不尽人意的,在for循环体里面进行加锁解锁会导致线程的频繁进行加锁解锁操作,在for循环体外面进行加锁解锁会导致两个线程的执行逻辑变为串行,而且如果锁控制得不好,还容易造成死锁,所以这里我们就可以从根本上解决问题,也就是让++操作变成原子的,这里就需要用到原子类。
3.2、【原子类解维护程安全】
C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。如下:
注意: 需要用大括号对原子类型的变量进行初始化。
程序员不需要对原子类型进行加锁解锁操作,线程能够对原子类型变量互斥访问。比如刚才的代码可以改为:
void func(atomic_int& n, int times) { for (int i = 0; i < times; i++) { n++; } } int main() { atomic_int n = { 0 }; int times = 100000; //每个线程对n++的次数 thread t1(func, ref(n), times); thread t2(func, ref(n), times); t1.join(); t2.join(); cout << n << endl; //打印n的值 return 0; }
除此之外,也可以使用atomic类模板定义出任意原子类型。比如上述代码还可以改为:
void func(atomic<int>& n, int times) { for (int i = 0; i < times; i++) { n++; } } int main() { atomic<int> n = 0; int times = 100000; //每个线程对n++的次数 thread t1(func, ref(n), times); thread t2(func, ref(n), times); t1.join(); t2.join(); cout << n << endl; //打印n的值 return 0; }
说明一下:
- 原子类型通常属于“资源类型”数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及
operator=
等。- 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、
operator=
默认删除掉了。- 原子类型不仅仅支持原子的
++
操作,还支持原子的--
、加一个值、减一个值、与、或、异或操作。3.3、【CAS——无锁编程】
CAS操作——【Compare & Set,或是 Compare & Swap】,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。这个操作用C语言来描述就是下面这个样子:意思就是说,看一看内存
*reg
里的值是不是oldval
,如果是的话,则对其赋值newval
。int compare_and_swap (int* reg, int oldval, int newval) { int old_reg_val = *reg; if (old_reg_val == oldval) { *reg = newval; } return old_reg_val; }
我们可以看到,
old_reg_val
总是返回,于是,我们可以在compare_and_swap
操作之后对其进行测试,以查看它是否与oldval
相匹配,因为它可能有所不同,这意味着另一个并发线程已成功地竞争到compare_and_swap
并成功将reg
值从oldval
更改为别的值了。这个操作可以变种为返回bool值的形式(返回 bool值的好处在于,可以调用者知道有没有更新成功):
bool compare_and_swap (int *addr, int oldval, int newval) { if ( *addr != oldval ) { return false; } *addr = newval; return true; }
说明一下:
现在我们回到++的问题,解释一下为什么使用原子类中的++就能够保证原子性呢?
我们先来看看问题描述:
CAS是如何解决的:
四、【条件变量库(condition_variable)】
condition_variable中提供的成员函数,可分为wait系列和notify系列两类。
4.1、【wait系列成员函数】
wait系列成员函数的作用就是让调用线程进行阻塞等待,包括
wait
、wait_for
和wait_until
。下面先以
wait
为例进行介绍,wait函数提供了两个不同版本的接口://版本一 void wait(unique_lock<mutex>& lck); //版本二 template<class Predicate> void wait(unique_lock<mutex>& lck, Predicate pred);
函数说明:
- 调用第一个版本的wait函数时只需要传入一个互斥锁,线程调用wait后会立即被阻塞,直到被唤醒。
- 调用第二个版本的wait函数时除了需要传入一个互斥锁,还需要传入一个返回值类型为bool的可调用对象,与第一个版本的wait不同的是,当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么该线程还需要继续被阻塞。
为什么调用wait系列函数时需要传入一个互斥锁?
- 因为wait系列函数一般是在临界区中调用的,为了让当前线程调用wait阻塞时其他线程能够获取到锁,因此调用wait系列函数时需要传入一个互斥锁,当线程被阻塞时这个互斥锁会被自动解锁,而当这个线程被唤醒时,又会自动参与到这个互斥锁的竞争中。
- 因此wait系列函数实际上有两个功能,一个是让线程在条件不满足时进行阻塞等待,另一个是让线程将对应的互斥锁进行解锁。
wait_for和wait_until函数的使用方式与wait函数类似:
- wait_for函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个时间段,表示让线程在该时间段内进行阻塞等待,如果超过这个时间段则线程被自动唤醒。
- wait_until函数也提供了两个版本的接口,只不过这两个版本的接口都比wait函数对应的接口多了一个参数,这个参数是一个具体的时间点,表示让线程在该时间点之前进行阻塞等待,如果超过这个时间点则线程被自动唤醒。
线程调用wait_for或wait_until函数在阻塞等待期间,其他线程调用notify系列函数也可以将其唤醒。此外,如果调用的是wait_for或wait_until函数的第二个版本的接口,那么当线程被唤醒后还需要调用传入的可调用对象,如果可调用对象的返回值为false,那么当前线程还需要继续被阻塞。
注意: 调用wait系列函数时,传入互斥锁的类型必须是unique_lock。
4.2、【notify系列成员函数】
notify系列成员函数的作用就是唤醒等待的线程,包括
notify_one
和notify_all
。
notify_one
:唤醒等待队列中的首个线程,如果等待队列为空则什么也不做。notify_all
:唤醒等待队列中的所有线程,如果等待队列为空则什么也不做。注意: 条件变量下可能会有多个线程在进行阻塞等待,这些线程会被放到一个等待队列中进行排队。
4.3、【实现两个线程交替打印1-100】
我们先看一下要求:尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。
该题目主要考察的就是线程的同步和互斥。
- 互斥:两个线程都在向控制台打印数据,为了保证两个线程的打印数据不会相互影响,因此需要对线程的打印过程进行加锁保护。
- 同步:两个线程必须交替进行打印,因此需要用到条件变量让两个线程进行同步,当一个线程打印完再唤醒另一个线程进行打印。
但如果只有同步和互斥是无法满足题目要求的:
- 首先,我们无法保证哪一个线程会先进行打印,不能说先创建的线程就一定先打印,后创建的线程先打印也是有可能的。
- 此外,有可能会出现某个线程连续多次打印的情况,比如线程1先创建并打印了一个数字,当线程1准备打印第二个数字的时候线程2可能还没有创建出来,或是线程2还没有在互斥锁上进行等待,这时线程1就会再次获取到锁进行打印。
鉴于此,这里还需要定义一个flag变量,该变量的初始值设置为true:
- 假设让线程1打印奇数,线程2打印偶数。那么就让线程1调用wait函数阻塞等待时,传入的可调用对象返回
flag
的值,而让线程2调用wait函数阻塞等待时,传入的可调用对象返回!flag
的值。- 由于flag的初始值是true,就算线程2先获取到互斥锁也不能进行打印,因为最开始线程2调用wait函数时,会因为可调用对象的返回值为false而被阻塞,这就保证了线程1一定先进行打印。
- 为了让两个线程交替进行打印,因此两个线程每次打印后都需要更改flag的值,线程1打印完后将flag的值改为false并唤醒线程2,这时线程2被唤醒时其可调用对象的返回值就变成了true,这时线程2就可以进行打印了。
- 当线程2打印完后再将flag的值改为true并唤醒线程1,这时线程1就又可以打印了,就算线程2想要连续打印也不行,因为如果线程1不打印,那么线程2的可调用对象的返回值就一直为false,对于线程1也是一样的道理。
代码如下:
int main() { int n = 100; mutex mtx; condition_variable cv; bool flag = true; //奇数 thread t1([&]{ int i = 1; while (i <= 100) { unique_lock<mutex> ul(mtx); cv.wait(ul, [&flag]()->bool{return flag; }); //等待条件变量满足 cout << this_thread::get_id() << ":" << i << endl; i += 2; flag = false; cv.notify_one(); //唤醒条件变量下等待的一个线程 } }); //偶数 thread t2([&]{ int j = 2; while (j <= 100) { unique_lock<mutex> ul(mtx); cv.wait(ul, [&flag]()->bool{return !flag; }); //等待条件变量满足 cout << this_thread::get_id() << ":" << j << endl; j += 2; flag = true; cv.notify_one(); //唤醒条件变量下等待的一个线程 } }); t1.join(); t2.join(); return 0; }
结果:
还可以像下面这样编码:
int main() { mutex mtx; int x = 1; condition_variable cv; bool flag = false; // 如何保证t1先运行——>condition_variable+flag // 交替运行 thread t1([&]() { for (size_t i = 0; i < 100; i++) { unique_lock<mutex> lock(mtx); if (flag) cv.wait(lock); cout << this_thread::get_id() << ":" << x << endl; ++x; flag = true; cv.notify_one(); // t1notify_one的时候 t2还没有wait } }); thread t2([&]() { for (size_t i = 0; i < 100; i++) { unique_lock<mutex> lock(mtx); if (!flag) cv.wait(lock); cout << this_thread::get_id() << ":" << x << endl; ++x; flag = false; cv.notify_one(); } }); t1.join(); t2.join(); return 0; }
说明:
总结
本篇博客到这里就结束了,感谢观看!
........................................................你能不能再编一个理由,等我回家,等我回家,等我回家
————《等我回家》