线程学习五:线程加锁(1)

1.锁:mutex(互斥量)

锁,是生活中应用十分广泛的一种工具。锁的本质属性是为事物提供“访问保护”,例如:大门上的锁,是为了保护房子免于不速之客的到访;
自行车的锁,是为了保护自行车只有owner才可以使用;保险柜上的锁,是为了保护里面的合同和金钱等重要东西……

在c++等高级编程语言中,锁也是用来提供“访问保护”的,不过被保护的东西不再是房子、自行车、金钱,而是内存中的各种变量。
此外,计算机领域对于“锁”有个响亮的名字——mutex(互斥量),学过操作系统的同学对这个名字肯定很熟悉。

Mutex,互斥量,就是互斥访问的量。这种东东只在多线程编程中起作用,在单线程程序中是没有什么用处的。
从c++11开始,c++提供了std::mutex类型,对于多线程的加锁操作提供了很好的支持。下面看一个简单的例子,对于mutex形成一个直观的认识。

Demo1——无锁的情况

// 假定有一个全局变量counter,启动两个线程,每个都对该变量自增10000次,最后输出该变量的值。在第一个demo中,我们不加锁
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>

int counter = 0;
void increase(int time) {
    for (int i = 0; i < time; i++) {
        // 当前线程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
    }
}

int main(int argc, char** argv) {
    std::thread t1(increase, 10000);
    std::thread t2(increase, 10000);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}
// 编译方法: g++ -g test.cpp -o test -std=c++11 -lpthread

PS: 为了显示多线程竞争导致结果不正确的现象,在每次自增操作的时候都让当前线程休眠1毫秒

如果没有多线程编程的相关经验,我们可能想当然的认为最后的counter为20000,如果这样想的话,那就大错特错了。下面是两次实际运行的结果:

出现上述情况的原因是:

自增操作"counter++"不是原子操作,而是由多条汇编指令完成的。多个线程对同一个变量进行读写操作就会出现不可预期的操作。

以上面的demo1作为例子:假定counter当前值为10,线程1读取到了10,线程2也读取到了10,    分别执行自增操作,线程1和线程2分别将自增的结果写回counter,不管写入的顺序如何,counter都会是11,

但是线程1和线程2分别执行了一次自增操作,我们期望的结果是12!!!!

Demo2——加锁的情况

// 定义一个std::mutex对象用于保护counter变量。对于任意一个线程,如果想访问counter,首先要进行"加锁"操作,如果加锁成功,则进行counter的读写,读写操作完成后释放锁(重要!!!); 如果“加锁”不成功,则线程阻塞,直到加锁成功。
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>

int counter = 0;
std::mutex mtx; // 保护counter

void increase(int time) {
    for (int i = 0; i < time; i++) {
        mtx.lock();
        // 当前线程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
        mtx.unlock();
    }
}

int main(int argc, char** argv) {
    std::thread t1(increase, 10000);
    std::thread t2(increase, 10000);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

看几次运行结果:

这次运行结果和我们预想的一致,原因就是“利用锁来保护共享变量”,在这里共享变量就是counter(多个线程都能对其进行访问,所以就是共享变量啦)。

1.1 mutex类总结

定义于头文件 <mutex>
		
class mutex; (C++11 起)

mutex 类是能用于保护共享数据免受从多个线程同时访问的同步原语。

mutex 提供排他性非递归所有权语义:

  • 调用方线程从它成功调用 lock 或 try_lock 开始,到它调用 unlock 为止占有 mutex 。

  • 线程占有 mutex 时,所有其他线程若试图要求 mutex 的所有权,则将阻塞(对于 lock 的调用)或收到 false 返回值(对于 try_lock ).

  • 调用方线程在调用 lock 或 try_lock 前必须不占有 mutex 。

若 mutex 在仍为任何线程所占有时即被销毁,或在占有 mutex 时线程终止,则行为未定义。 mutex 类满足互斥体 (Mutex) 和标准布局类型 (StandardLayoutType) 的全部要求。

std::mutex 既不可复制亦不可移动。

其他类型的mutex参考https://zh.cppreference.com/w/cpp/thread

mutex类的成员介绍

构造函数: 构造互斥(公开成员函数)

析构函数: 销毁互斥(公开成员函数)

operator=[被删除]: 不可复制赋值(公开成员函数)

/*************** 锁定 **********************/
lock: 锁定互斥,若互斥不可用则阻塞(公开成员函数)

try_lock: 尝试锁定互斥,若互斥不可用则返回(公开成员函数)

unlock: 解锁互斥(公开成员函数)

/*************** 原生句柄  **********************/
native_handle: 返回底层实现定义的原生句柄(公开成员函数)

注意

通常不直接使用 std::mutex, std::unique_lock 、 std::lock_guard 或 std::scoped_lock (C++17 起)以更加异常安全的方式管理锁定。 

2. lock_guard

虽然std::mutex可以对多线程编程中的共享变量提供保护,但是直接使用std::mutex的情况并不多。因为仅使用std::mutex有时候会发生死锁。

回到上边的例子,考虑这样一个情况:假设线程1上锁成功,线程2上锁等待。但是线程1上锁成功后,抛出异常并退出,没有来得及释放锁,导致线程2“永久的等待下去”(线程2:我的心在等待永远在等待……),此时就发生了死锁。

Demo3——死锁的情况(仅仅为了演示,不要这么写代码哦)

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>

int counter = 0;
std::mutex mtx; // 保护counter

void increase_proxy(int time, int id) {
    for (int i = 0; i < time; i++) {
        mtx.lock();
        // 线程1上锁成功后,抛出异常:未释放锁
        if (id == 1) {
            throw std::runtime_error("throw excption....");
        }
        // 当前线程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
        mtx.unlock();
    }
}

void increase(int time, int id) {
    try {
        increase_proxy(time, id);
    }
    catch (const std::exception& e){
        std::cout << "id:" << id << ", " << e.what() << std::endl;
    }
}

int main(int argc, char** argv) {
    std::thread t1(increase, 10000, 1);
    std::thread t2(increase, 10000, 2);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

执行后,结果如下图所示:

程序并没有退出,而是永远的“卡”在那里了,也就是发生了死锁。

那么这种情况该怎么避免呢? 这个时候就需要std::lock_guard登场了。std::lock_guard只有构造函数和析构函数。

简单的来说:当调用构造函数时,会自动调用传入的对象的lock()函数,而当调用析构函数时,自动调用unlock()函数(这就是所谓的RAII,读者可自行搜索)。我们修改一下demo3。

Demo4——避免死锁,lock_guard

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <chrono>
#include <stdexcept>

int counter = 0;
std::mutex mtx; // 保护counter

void increase_proxy(int time, int id) {
    for (int i = 0; i < time; i++) {
        // std::lock_guard对象构造时,自动调用mtx.lock()进行上锁
        // std::lock_guard对象析构时,自动调用mtx.unlock()释放锁
        std::lock_guard<std::mutex> lk(mtx);
        // 线程1上锁成功后,抛出异常:未释放锁
        if (id == 1) {
            throw std::runtime_error("throw excption....");
        }
        // 当前线程休眠1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        counter++;
    }
}

void increase(int time, int id) {
    try {
        increase_proxy(time, id);
    }
    catch (const std::exception& e){
        std::cout << "id:" << id << ", " << e.what() << std::endl;
    }
}

int main(int argc, char** argv) {
    std::thread t1(increase, 10000, 1);
    std::thread t2(increase, 10000, 2);
    t1.join();
    t2.join();
    std::cout << "counter:" << counter << std::endl;
    return 0;
}

执行上述代码,结果为:

结果符合预期。所以,推荐使用std::mutex和std::lock_guard搭配使用,避免死锁的发生。

std::lock_guard的第二个构造函数

实际上,std::lock_guard有两个构造函数,参考https://en.cppreference.com/w/cpp/thread/lock_guard/lock_guard

在demo4中我们使用了第1个构造函数,第3个为拷贝构造函数,定义为删除函数。这里我们来重点说一下第2个构造函数。

第2个构造函数有两个参数,其中第二个参数类型为:std::adopt_lock_t。这个构造函数假定:当前线程已经上锁成功,所以不再调用lock()函数。这里不再给出具体的例子,如果想了解这种构造函数是如何工作的,
可以参考 https://en.cppreference.com/w/cpp/thread/lock_tag_t

3. unique_lock

3.1 unique_lock取代lock_guard

unique_lock是个类模板,工作中,一般lock_guard(推荐使用);lock_guard取代了mutex的lock()和unlock();

unique_lock比lock_guard灵活很多,效率上差一点,内存占用多一点。

3.2 unique_lock的第二个参数

unique_lock可以带第二个参数:

std::unique_lock<std::mutex> lock(g_mtx, std::adopt_lock); // // std::adopt_lock标记作用;

3.2.1 std::adopt_lock

表示这个互斥量已经被lock了(你必须要把互斥量提前lock了 ,否者会报异常);

std::adopt_lock标记的效果就是假设调用一方已经拥有了互斥量的所有权(已经lock成功了);通知lock_guard不需要再构造函数中lock这个互斥量了。

PS:std::lock_guard也可以带std::adopt_lock标记,含义相同

3.2.2 std::try_to_lock

我们会尝试用mutex的lock()去锁定这个mutex,但如果没有锁定成功,我也会立即返回,并不会阻塞在那里;

用这个try_to_lock的前提是你自己不能先lock。实例代码如下:

3.2.3 std::defer_lock

用std::defer_lock的前提是,你不能自己先lock,否则会报异常

std::defer_lock的意思就是并没有给mutex加锁:初始化了一个没有加锁的mutex。

3.3 unique_lock的成员函数

3.3.1 lock()/unlock()

lock(): 加锁

unlock(): 解锁

defer_lock、lock()与unlock() 实例代码 如下:

void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; i++)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::unique_lock<std::mutex> sbguard(my_mutex, std::defer_lock);//没有加锁的my_mutex
        sbguard.lock();//咱们不用自己unlock
        
        // 处理共享代码...

        // 因为有一些非共享代码要处理
        sbguard.unlock();
        
        // 处理非共享代码...

        sbguard.lock();
        
        // 处理共享代码...

        msgRecvQueue.push_back(i);
        // ...
        // 其他处理代码
        sbguard.unlock();// 画蛇添足,但也可以
    }
}

3.3.2 try_lock()

尝试给互斥量加锁,如果拿不到锁,返回false,如果拿到了锁,返回true,这个函数是不阻塞的;实例代码如下:
void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; i++)
    {
        std::unique_lock<std::mutex> sbguard(my_mutex, std::defer_lock);// 没有加锁的my_mutex
        
        if (sbguard.try_lock() == true)//返回true表示拿到锁了
        {
            msgRecvQueue.push_back(i);
            // ...
            // 其他处理代码
        }
        else
        {
            //没拿到锁
            cout << "inMsgRecvQueue()执行,但没拿到锁头,只能干点别的事" << i << endl;
        }
    }
}

3.3.3 release()

返回它所管理的mutex对象指针,并释放所有权;也就是说,这个unique_lock和mutex不再有关系。严格区分unlock()与release()的区别,不要混淆。

如果原来mutex对像处于加锁状态,你有责任接管过来并负责解锁。(release返回的是原始mutex的指针)。实例代码如下:
void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; i++)
    {
        std::unique_lock<std::mutex> sbguard(my_mutex);
        std::mutex *ptx = sbguard.release(); // 现在你有责任自己解锁了

        msgRecvQueue.push_back(i);

        ptx->unlock(); //自己负责mutex的unlock了
    }
}
为什么有时候需要unlock();因为你lock()锁住的代码段越少,执行越快,整个程序运行效率越高。有人也把锁头锁住的代码多少成为锁的粒度,粒度一般用粗细来描述;

a)锁住的代码少,这个粒度叫细,执行效率高;

b)锁住的代码多,这个粒度叫粗,执行效率低;

要学会尽量选择合适粒度的代码进行保护,粒度太细,可能漏掉共享数据的保护,粒度太粗,影响效率。选择合适的粒度是高级程序员能力和实力的体现;

3.4 unique_lock所有权的传递

std::unique_lock<std::mutex> sbguard(my_mutex);//所有权概念

sbguard拥有my_mutex的所有权;sbguard可以把自己对mutex(my_mutex)的所有权转移给其他的unique_lock对象;

所以unique_lock对象这个mutex的所有权是可以转移,但是不能复制。
std::unique_lock<std::mutex> sbguard1(my_mutex);

std::unique_lock<std::mutex> sbguard2(sbguard1);//此句是非法的,复制所有权是非法的

std::unique_lock<std::mutex> sbguard2(std::move(sbguard)); // 移动语义,现在先当与sbguard2与my_mutex绑定到一起了
// 现在sbguard1指向空,sbguard2指向了my_mutex
方法1 :std::move()

方法2:return std:: unique_lock<std::mutex>  代码如下:
std::unique_lock<std::mutex> rtn_unique_lock()
{
    std::unique_lock<std::mutex> tmpguard(my_mutex);
    return tmpguard;//从函数中返回一个局部的unique_lock对象是可以的。三章十四节讲解过移动构造函数。
    //返回这种举报对象tmpguard会导致系统生成临时unique_lock对象,并调用unique_lock的移动构造函数
}

void inMsgRecvQueue()
{
    for (int i = 0; i < 10000; i++)
    {
        std::unique_lock<std::mutex> sbguard1 = rtn_unique_lock();

        msgRecvQueue.push_back(i);
    }
}

注:该文是C++11并发多线程视频教程笔记,详情学习:https://study.163.com/course/courseMain.htm?courseId=1006067356

总结一下,就是lock_guard 配合 adopt_lock使用,unique_lock配合 defer_lock使用。在两种情况都能使用时,推荐使用lock_guard,因为它更快并且使用的内存空间更少。unique_lock支持所有权的传递,所以更加灵活。

以上来自于
https://zh.cppreference.com/w/cpp/thread
https://zhuanlan.zhihu.com/p/91062516
https://blog.csdn.net/u012507022/article/details/85909567

posted on 2022-03-03 17:49  JJ_S  阅读(297)  评论(0编辑  收藏  举报