浅谈C++11中的多线程(二)
摘要
本篇文章围绕以下几个问题展开:
- 进程和线程的区别
- 何为并发?C++中如何解决并发问题?C++中多线程的基本操作 浅谈C++11中的多线程(一) - 唯有自己强大 - 博客园 (cnblogs.com)
- 同步互斥原理以及如何处理数据竞争
- 条件变量和原子操作
一,同步互斥原理
首先说明两个专业名词。
临界资源:对于同一进程的多个线程,进程资源中有些对线程是共享的,但有些资源一次只能供一个线程使用,这样的资源被称为临界资源,也可以叫做互斥资源,即只能被各个线程互斥访问。
临界区:线程中对临界资源实施操作的那段程序,称之为临界区。
- 同步是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。比如 A 任务的运行依赖于 B 任务产生的数据。
举例:假如程序中有一个静态变量,static int a;线程1负责往里写入数据,线程2需要读取其中的数据,那么线程2在读数据之前必须是线程1写入了数据,如果不是,那么线程2必须停下来等待线程1的操作结束。这就是线程之间在某些地方上的合作关系,协同工作嘛!
- 互斥是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。
举例:还是假如程序中有一个静态变量,static int b;线程1想要往里写入数据,线程2也想要往里写入数据,那么此时静态变量b就是一个临界资源(互斥资源),即一次只能被一个线程访问。
同步跟互斥都是针对于线程来说的,可以把这理解为是线程之间的合作关系和制约关系。可以这样理解,同一进程的各个线程之间不可能是完全独立的,或多或少会有关系,或是合作关系,或是制约关系。具体的例子放在了同步和互斥的介绍中。
二,如何处理数据竞争
从上面数据竞争形成的条件入手,数据竞争源于并发修改同一数据结构,那么最简单的处理数据竞争的方法就是对该数据结构采用某种保护机制,确保只有进行修改的线程才能看到数据被修改的中间状态,从其他访问线程的角度看,修改不是已经完成就是还未开始。
在线程里也有这么一把锁——互斥锁(mutex),互斥锁是一种简单的加锁的方法来控制对共享资源的访问。只要某一个线程上锁了,那么就会强行霸占公共资源的访问权,其他的线程无法访问直到这个线程解锁了,从而保护共享资源。
1️⃣ lock与unlock保护共享资源
Mutex全名mutual exclusion(互斥体),是个object对象,用来协助采取独占排他方式控制对资源的并发访问。这里的资源可能是个对象,或多个对象的组合。为了获得独占式的资源访问能力,相应的线程必须锁定(lock) mutex,这样可以防止其他线程也锁定mutex,直到第一个线程解锁(unlock) mutex。mutex类的主要操作函数见下表:
由图可知互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。mutex不仅提供了常规锁,还为常规锁可能造成的阻塞提供了尝试锁(带时间的锁需要带时间的互斥类timed_mutex支持,具体见下文)。下面先给出一段示例代码:
// mutex1.cpp 通过互斥体lock与unlock保护共享全局变量 #include <chrono> #include <mutex> #include <thread> #include <iostream> std::chrono::milliseconds interval(100); std::mutex mutex;
//两个全局变量 int job_shared = 0; //两个线程都能修改'job_shared',mutex将保护此变量 int job_exclusive = 0; //只有一个线程能修改'job_exclusive',不需要保护 //此线程只能修改 'job_shared' void job_1() { mutex.lock(); std::this_thread::sleep_for(5 * interval); //令‘job_1’持锁等待 ++job_shared; std::cout << "job_1 shared (" << job_shared << ")\n"; mutex.unlock(); } // 此线程能修改'job_shared'和'job_exclusive' void job_2() { while (true) { //无限循环,直到获得锁并修改'job_shared' if (mutex.try_lock()) { //尝试获得锁成功则修改'job_shared' ++job_shared; std::cout << "job_2 shared (" << job_shared << ")\n"; mutex.unlock(); return; } else { //尝试获得锁失败,接着修改'job_exclusive' ++job_exclusive; std::cout << "job_2 exclusive (" << job_exclusive << ")\n"; std::this_thread::sleep_for(interval); } } } int main() { std::thread thread_1(job_1); std::thread thread_2(job_2); thread_1.join(); thread_2.join(); getchar(); return 0; }
从上面的代码看,创建了两个线程和两个全局变量。
- 全局变量job_exclusive只有job2调用,因此两线程并不共享,不会产生数据竞争,所以不需要锁保护。
- 另一个全局变量job_shared是两线程共享的,会引起数据竞争,因此需要锁保护。线程thread_1持有互斥锁lock的时间较长,线程thread_2为免于空闲等待,使用了尝试锁try_lock,如果获得互斥锁则操作共享变量job_shared,未获得互斥锁则操作排他变量job_exclusive,提高多线程效率。
输出结果:
2️⃣ lock_guard与unique_lock保护共享资源
但lock与unlock必须成对合理配合使用,使用不当可能会造成资源被永远锁住,甚至出现死锁(两个线程在释放它们自己的lock之前彼此等待对方的lock)。
相关知识点:
是不是想起了C++另一对儿需要配合使用的对象new与delete,若使用不当可能会造成内存泄漏等严重问题,为此C++引入了智能指针shared_ptr与unique_ptr。智能指针借用了RAII技术(Resource Acquisition Is Initialization—使用类来封装资源的分配和初始化,在构造函数中完成资源的分配和初始化,在析构函数中完成资源的清理,可以保证正确的初始化和资源释放)对普通指针进行封装,达到智能管理动态内存释放的效果。
同样的,C++也针对lock与unlock引入了智能锁lock_guard与unique_lock,同样使用了RAII技术对普通锁进行封装,达到智能管理互斥锁资源释放的效果。lock_guard与unique_lock的区别如下:
lock_guard:
unique_lock:
从上面两个支持的操作函数表对比来看,unique_lock功能丰富灵活得多。如果需要实现更复杂的锁策略可以用unique_lock,如果只需要基本的锁功能,优先使用更严格高效的lock_guard。两种锁的简单概述与策略对比见下表:
如果将上面的普通锁lock/unlock替换为智能锁lock_guard,其中job_1函数代码修改如下:
void job_1() { std::lock_guard<std::mutex> lockg(mutex); //获取RAII智能锁,离开作用域会自动析构解锁 std::this_thread::sleep_for(5 * interval); //令‘job_1’持锁等待 ++job_shared; std::cout << "job_1 shared (" << job_shared << ")\n"; }
如果也想将job_2的尝试锁try_lock也使用智能锁替代,由于lock_guard锁策略不支持尝试锁,只好使用unique_lock来替代,代码修改如下(其余代码和程序执行结果与上面相同):
void job_2() { while (true) { //无限循环,直到获得锁并修改'job_shared' std::unique_lock<std::mutex> ulock(mutex, std::try_to_lock); //以尝试锁策略创建智能锁 //尝试获得锁成功则修改'job_shared' if (ulock) { ++job_shared; std::cout << "job_2 shared (" << job_shared << ")\n"; return; } else { //尝试获得锁失败,接着修改'job_exclusive' ++job_exclusive; std::cout << "job_2 exclusive (" << job_exclusive << ")\n"; std::this_thread::sleep_for(interval); } } }
3️⃣timed_mutex与recursive_mutex提供更强大的锁
前面介绍的互斥量mutex提供了普通锁lock/unlock和智能锁lock_guard/unique_lock,基本能满足我们大多数对共享数据资源的保护需求。但在某些特殊情况下,我们需要更复杂的功能,比如某个线程中函数的嵌套调用可能带来对某共享资源的嵌套锁定需求,mutex在一个线程中却只能锁定一次;再比如我们想获得一个锁,但不想一直阻塞,只想等待特定长度的时间,mutex也没提供可设定时间的锁。针对这些特殊需求,< mutex >库也提供了下面几种功能更丰富的互斥类,它们间的区别见下表:
继续用前面的例子,将mutex替换为timed_mutex,将job_2的尝试锁tyr_lock()替换为带时间的尝试锁try_lock_for(duration)。由于改变了尝试锁的时间,所以在真正获得锁之前的尝试次数也有变化,该变化体现在尝试锁失败后对排他变量job_exclusive的最终修改结果或修改次数上。更新后的代码如下所示:
#include <chrono> #include <mutex> #include <thread> #include <iostream> std::chrono::milliseconds interval(100); std::timed_mutex tmutex; int job_shared = 0; //两个线程都能修改'job_shared',mutex将保护此变量 int job_exclusive = 0; //只有一个线程能修改'job_exclusive',不需要保护 //此线程只能修改 'job_shared' void job_1() { std::lock_guard<std::timed_mutex> lockg(tmutex); //获取RAII智能锁,离开作用域会自动析构解锁 std::this_thread::sleep_for(5 * interval); //令‘job_1’持锁等待 ++job_shared; std::cout << "job_1 shared (" << job_shared << ")\n"; } // 此线程能修改'job_shared'和'job_exclusive' void job_2() { while (true) { //无限循环,直到获得锁并修改'job_shared' std::unique_lock<std::timed_mutex> ulock(tmutex,std::defer_lock); //创建一个智能锁但先不锁定 //尝试获得锁成功则修改'job_shared' if (ulock.try_lock_for(3 * interval)) { //在3个interval时间段内尝试获得锁 ++job_shared; std::cout << "job_2 shared (" << job_shared << ")\n"; return; } else { //尝试获得锁失败,接着修改'job_exclusive' ++job_exclusive; std::cout << "job_2 exclusive (" << job_exclusive << ")\n"; std::this_thread::sleep_for(interval); } } } int main() { std::thread thread_1(job_1); std::thread thread_2(job_2); thread_1.join(); thread_2.join(); getchar(); return 0; }
前章解答
前一篇文章中最后的程序运行结果可能会出现某行与其他行交叠错乱的情况,主要是由于不止一个线程并发访问了std::cout显示终端资源导致的,解决方案就是对cout << “somethings” << endl语句加锁,保证多个线程对cout资源的访问同步。为了尽可能降低互斥锁对性能的影响,应使用微粒锁,即只对cout资源访问语句进行加锁保护,cout资源访问完毕尽快解锁以供其他线程访问该资源。添加互斥锁保护后的代码如下:
//thread2.cpp 增加对cout显示终端资源并发访问的互斥锁保护 #include <iostream> #include <thread> #include <chrono> #include <mutex> using namespace std; std::mutex mutex1; void thread_function(int n) { std::thread::id this_id = std::this_thread::get_id(); //获取线程ID for (int i = 0; i < 5; i++) { mutex1.lock(); cout << "子线程" << this_id << " 运行 : " << i + 1 << endl; mutex1.unlock(); std::this_thread::sleep_for(std::chrono::seconds(n)); //进程睡眠n秒 } } class Thread_functor { public: // functor行为类似函数,C++中的仿函数是通过在类中重载()运算符实现,使你可以像使用函数一样来创建类的对象 void operator()(int n) { std::thread::id this_id = std::this_thread::get_id(); for (int i = 0; i < 5; i++) { { std::lock_guard<std::mutex> lockg(mutex1); cout << "子仿函数线程" << this_id << " 运行: " << i + 1 << endl; } std::this_thread::sleep_for(std::chrono::seconds(n)); //进程睡眠n秒 } } }; int main() { thread mythread1(thread_function, 1); // 传递初始函数作为线程的参数 if (mythread1.joinable()) //判断是否可以成功使用join()或者detach(),返回true则可以,返回false则不可以 mythread1.join(); // 使用join()函数阻塞主线程直至子线程执行完毕 Thread_functor thread_functor; thread mythread2(thread_functor, 3); // 传递初始函数作为线程的参数 if (mythread2.joinable()) mythread2.detach(); // 使用detach()函数让子线程和主线程并行运行,主线程也不再等待子线程 auto thread_lambda = [](int n) { std::thread::id this_id = std::this_thread::get_id(); for (int i = 0; i < 5; i++) { mutex1.lock(); cout << "子lambad线程" << this_id << " 运行: " << i + 1 << endl; mutex1.unlock(); std::this_thread::sleep_for(std::chrono::seconds(n)); //进程睡眠n秒 } }; thread mythread3(thread_lambda, 4); // 传递初始函数作为线程的参数 if (mythread3.joinable()) mythread3.join(); // 使用join()函数阻塞主线程直至子线程执行完毕 unsigned int n = std::thread::hardware_concurrency(); //获取可用的硬件并发核心数 mutex1.lock(); std::cout << n << " 支持并发线程" << endl; mutex1.unlock(); std::thread::id this_id = std::this_thread::get_id(); for (int i = 0; i < 5; i++) { { std::lock_guard<std::mutex> lockg(mutex1); cout << "主线程" << this_id << " 运行: " << i + 1 << endl; } std::this_thread::sleep_for(std::chrono::seconds(1)); } getchar(); return 0; }
多次运行结果:(已解决)