在前一篇文章《C++多线程并发(一)— 线程创建与管理》中解释多线程并发时说到两个比较重要的概念:
如果像前一篇文章中的示例,虽然创建了三个线程,但线程间不需要访问共同的内存分区数据,所以对线程间的执行顺序没有更多要求。但如果多个进程都需要访问相同的共享内存数据,如果都是读取数据还好,如果有读取有写入或者都要写入(数据并发访问或数据竞争),就需要使读写有序(同步化),否则可能会造成数据混乱,得不到我们预期的结果。下面再介绍两个用于理解线程同步的概念:
多个线程对共享内存数据访问的竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。C++标准中对数据竞争的定义是:多个线程并发的去修改一个独立对象,数据竞争是未定义行为的起因。
从上面数据竞争形成的条件入手,数据竞争源于并发修改同一数据结构,那么最简单的处理数据竞争的方法就是对该数据结构采用某种保护机制,确保只有进行修改的线程才能看到数据被修改的中间状态,从其他访问线程的角度看,修改不是已经完成就是还未开始。C++标准库提供了很多类似的机制,最基本的就是互斥量,有一个< mutex >库文件专门支持对共享数据结构的互斥访问。
Mutex全名mutual exclusion(互斥体),是个object对象,用来协助采取独占排他方式控制对资源的并发访问。这里的资源可能是个对象,或多个对象的组合。为了获得独占式的资源访问能力,相应的线程必须锁定(lock) mutex,这样可以防止其他线程也锁定mutex,直到第一个线程解锁(unlock) mutex。mutex类的主要操作函数见下表:
从上表可以看出,mutex不仅提供了常规锁,还为常规锁可能造成的阻塞提供了尝试锁(带时间的锁需要带时间的互斥类timed_mutex支持,具体见下文)。下面先给出一段示例代码:
// mutex1.cpp 通过互斥体lock与unlock保护共享全局变量
#include
#include
#include
#include
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是排他的,两线程并不共享,不会产生数据竞争,所以不需要锁保护。另一个全局变量job_shared是两线程共享的,会引起数据竞争,因此需要锁保护。线程thread_1持有互斥锁lock的时间较长,线程thread_2为免于空闲等待,使用了尝试锁try_lock,如果获得互斥锁则操作共享变量job_shared,未获得互斥锁则操作排他变量job_exclusive,提高多线程效率。
但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的区别如下:
从上面两个支持的操作函数表对比来看,unique_lock功能丰富灵活得多。如果需要实现更复杂的锁策略可以用unique_lock,如果只需要基本的锁功能,优先使用更严格高效的lock_guard。两种锁的简单概述与策略对比见下表:
类模板 | 描述 | 策略 |
---|---|---|
std::lock_guard | 严格基于作用域(scope-based)的锁管理类模板,构造时是否加锁是可选的(不加锁时假定当前线程已经获得锁的所有权—使用std::adopt_lock策略),析构时自动释放锁,所有权不可转移,对象生存期内不允许手动加锁和释放锁 | std::adopt_lock |
std::unique_lock | 更加灵活的锁管理类模板,构造时是否加锁是可选的,在对象析构时如果持有锁会自动释放锁,所有权可以转移。对象生命期内允许手动加锁和释放锁 | std::adopt_lock std::defer_lock std::try_to_lock |
如果将上面的普通锁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);
}
}
}
前面介绍的互斥量mutex提供了普通锁lock/unlock和智能锁lock_guard/unique_lock,基本能满足我们大多数对共享数据资源的保护需求。但在某些特殊情况下,我们需要更复杂的功能,比如某个线程中函数的嵌套调用可能带来对某共享资源的嵌套锁定需求,mutex在一个线程中却只能锁定一次;再比如我们想获得一个锁,但不想一直阻塞,只想等待特定长度的时间,mutex也没提供可设定时间的锁。针对这些特殊需求,< mutex >库也提供了下面几种功能更丰富的互斥类,它们间的区别见下表:
类模板 | 描述 |
---|---|
std::mutex | 同一时间只可被一个线程锁定。如果它被锁住,任何其他lock()都会阻塞(block),直到这个mutex再次可用,且try_lock()会失败。 |
std::recursive_mutex | 允许在同一时间多次被同一线程获得其lock。其典型应用是:函数捕获一个lock并调用另一函数而后者再次捕获相同的lock。 |
std::timed_mutex | 额外允许你传递一个时间段或时间点,用来定义多长时间内它可以尝试捕获一个lock。为此它提供了try_lock_for(duration)和try_lock_until(timepoint)。 |
std::recursive_timed_mutex | 允许同一线程多次取得其lock,且可指定期限。 |
不同互斥类所支持的互斥锁类型总结如下表:
继续用前面的例子,将mutex替换为timed_mutex,将job_2的尝试锁tyr_lock()替换为带时间的尝试锁try_lock_for(duration)。由于改变了尝试锁的时间,所以在真正获得锁之前的尝试次数也有变化,该变化体现在尝试锁失败后对排他变量job_exclusive的最终修改结果或修改次数上。更新后的代码如下所示:
#include
#include
#include
#include
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;
}
前一篇文章中thread1.cpp程序运行结果可能会出现某行与其他行交叠错乱的情况,主要是由于不止一个线程并发访问了std::cout显示终端资源导致的,解决方案就是对cout << “somethings” << endl语句加锁,保证多个线程对cout资源的访问同步。为了尽可能降低互斥锁对性能的影响,应使用微粒锁,即只对cout资源访问语句进行加锁保护,cout资源访问完毕尽快解锁以供其他线程访问该资源。添加互斥锁保护后的代码如下:
//thread2.cpp 增加对cout显示终端资源并发访问的互斥锁保护
#include
#include
#include
#include
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 << "Child function thread " << this_id<< " running : " << 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 << "Child functor thread " << this_id << " running: " << 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 << "Child lambda thread " << this_id << " running: " << 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 << " concurrent threads are supported." << 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 << "Main thread " << this_id << " running: " << i+1 << endl;
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
getchar();
return 0;
}
我们在使用mutex进行排他性的共享数据访问时,一般都会期望加锁不要阻塞,总是能立刻拿到锁,然后尽快访问数据,用完之后尽快解锁,这样才能不影响并发性和性能。但如果要等待某个条件的成立,在等待期间就不得不阻塞线程,常用的判断某条件是否成立的方法是不断轮询该条件是否成立,但如果轮询周期太短则太浪费CPU资源,如果轮询周期太长又可能会导致延误。有没有什么办法解决这个难题呢?可以参考下一篇文章《C++多线程并发(三)—线程同步之条件变量》