C++多线程并发(二)---线程同步之互斥锁

文章目录

  • 一、何为线程同步
  • 二、如何处理数据竞争
    • 2.1 lock与unlock保护共享资源
    • 2.2 lock_guard与unique_lock保护共享资源
    • 2.3 timed_mutex与recursive_mutex提供更强大的锁
    • 2.4 前章答疑
  • 更多文章:

一、何为线程同步

在前一篇文章《C++多线程并发(一)— 线程创建与管理》中解释多线程并发时说到两个比较重要的概念:

  • 多线程并发:在同一时间段内交替处理多个操作,线程切换时间片是很短的(一般为毫秒级),一个时间片多数时候来不及处理完对某一资源的访问;
  • 线程间通信:一个任务被分割为多个线程并发处理,多个线程可能都要处理某一共享内存的数据,多个线程对同一共享内存数据的访问需要准确有序。

如果像前一篇文章中的示例,虽然创建了三个线程,但线程间不需要访问共同的内存分区数据,所以对线程间的执行顺序没有更多要求。但如果多个进程都需要访问相同的共享内存数据,如果都是读取数据还好,如果有读取有写入或者都要写入(数据并发访问或数据竞争),就需要使读写有序(同步化),否则可能会造成数据混乱,得不到我们预期的结果。下面再介绍两个用于理解线程同步的概念:

  • 同步:是指在不同进程之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。如果用对资源的访问来定义的话,同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。
  • 互斥:是指散布在不同进程之间的若干程序片断,当某个进程运行其中一个程序片段时,其它进程就不能运行它们之中的任一程序片段,只能等到该进程运行完这个程序片段后才可以运行。如果用对资源的访问来定义的话,互斥某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。

多个线程对共享内存数据访问的竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。C++标准中对数据竞争的定义是:多个线程并发的去修改一个独立对象,数据竞争是未定义行为的起因。

二、如何处理数据竞争

从上面数据竞争形成的条件入手,数据竞争源于并发修改同一数据结构,那么最简单的处理数据竞争的方法就是对该数据结构采用某种保护机制,确保只有进行修改的线程才能看到数据被修改的中间状态,从其他访问线程的角度看,修改不是已经完成就是还未开始。C++标准库提供了很多类似的机制,最基本的就是互斥量,有一个< mutex >库文件专门支持对共享数据结构的互斥访问。

2.1 lock与unlock保护共享资源

Mutex全名mutual exclusion(互斥体),是个object对象,用来协助采取独占排他方式控制对资源的并发访问。这里的资源可能是个对象,或多个对象的组合。为了获得独占式的资源访问能力,相应的线程必须锁定(lock) mutex,这样可以防止其他线程也锁定mutex,直到第一个线程解锁(unlock) mutex。mutex类的主要操作函数见下表:
C++多线程并发(二)---线程同步之互斥锁_第1张图片
从上表可以看出,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;
}

C++多线程并发(二)---线程同步之互斥锁_第2张图片
从上面的代码看,创建了两个线程和两个全局变量,其中一个全局变量job_exclusive是排他的,两线程并不共享,不会产生数据竞争,所以不需要锁保护。另一个全局变量job_shared是两线程共享的,会引起数据竞争,因此需要锁保护。线程thread_1持有互斥锁lock的时间较长,线程thread_2为免于空闲等待,使用了尝试锁try_lock,如果获得互斥锁则操作共享变量job_shared,未获得互斥锁则操作排他变量job_exclusive,提高多线程效率。

2.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操作函数
C++多线程并发(二)---线程同步之互斥锁_第3张图片
从上面两个支持的操作函数表对比来看,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);
        }
    }
}

2.3 timed_mutex与recursive_mutex提供更强大的锁

前面介绍的互斥量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,且可指定期限。

不同互斥类所支持的互斥锁类型总结如下表:
C++多线程并发(二)---线程同步之互斥锁_第4张图片
继续用前面的例子,将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;
}

C++多线程并发(二)---线程同步之互斥锁_第5张图片

2.4 前章答疑

前一篇文章中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++多线程并发(三)—线程同步之条件变量》

更多文章:

  • 《C++多线程并发—本章GitHub源码》
  • 《C++多线程并发(一)— 线程创建与管理》
  • 《C++多线程并发(三)—线程同步之条件变量》
  • 《C++多线程并发(四)—异步编程》
  • 《C++多线程并发(五)—原子操作与无锁编程》
  • 《C++ Concurrency in Action》
  • 《C++线程支持库》

你可能感兴趣的:(C++,流云的博客)