C++雾中风景12:聊聊C++中的Mutex,以及拯救生产力的Boost

笔者近期在工作之中编程实现一个Cache结构的封装,需要使用到C++之中的互斥量Mutex,于是花了一些时间进行了调研。(结果对C++标准库很是绝望....)最终还是通过利用了Boost库的shared_mutex解决了问题。借这个机会来聊聊在C++之中的多线程编程的一些“坑”

1.C++多线程编程的困扰

C++从11开始在标准库之中引入了线程库来进行多线程编程,在之前的版本需要依托操作系统本身提供的线程库来进行多线程的编程。(其实本身就是在标准库之上对底层的操作系统多线程API统一进行了封装,笔者本科时进行操作系统实验是就是使用的pthread或<windows.h>来进行多线程编程的

提供了统一的多线程固然是好事,但是标准库给的支持实在是有限,具体实践起来还是让人挺困扰的:

  • C++本身的STL并不是线程安全的。所以缺少了类似与Java并发库所提供的一些高性能的线程安全的数据结构。(Doug Lea大神亲自操刀完成的并发编程库,让JDK5成为Java之中里程碑式的版本)
  • 如果没有线程安全的数据结构,退而求其次,可以自己利用互斥量Mutex来实现。C++的标准库支持如下的互斥量的实现:

互斥量

版本

作用

mutex

C++11

最基本的互斥量

timed_mutex

C++11

有超时机制的互斥量

recursive_mutex

C++11

可重入的互斥量

recursive_timed_mutex

C++11

结合 2,3 特点的互斥量

shared_timed_mutex

C++14

具有超时机制的可共享互斥量

shared_mutex

C++17

共享的互斥量

由上述表格可见,C++是从14之后的版本才正式支持共享互斥量,也就是实现读写锁的结构。由于笔者的公司仅支持C++11的版本,所以就没有办法使用共享互斥量来实现读写锁了。所以最终笔者只好求助与boost的库,利用boost提供的读写锁来完成了所需完成的工作。(所以对工具不足时可以考虑求助于boost库,确实是解放生产力的大杀器,C++的标准库实在太简陋了~~)

2.标准库互斥量的剖析

虽然吐槽了一小节,但并不影响继续去学习C++标准库给我们提供的工具.........(但愿公司能再推动升级一波C++的版本~~不过看起来是遥遥无期了)接下来笔者就要来带领大家简单剖析一些C++标准库之中互斥量。

mutex

mutex的中文翻译就是互斥量,很多人喜欢称之其为锁。其实不是太准确,因为多线程编程本质上应该通过互斥量之上加锁,解锁的操作,来实现多线程并发执行时对互斥资源线程安全的访问。 我们来看看mutex类的使用方法:

long num = 0;
std::mutex num_mutex;

void numplus() {
    num_mutex.lock();
    for (long i = 0; i < 1000000; ++i) {
        num++;
    }
    num_mutex.unlock();
};

void numsub() {
    num_mutex.lock();
    for (long i = 0; i < 1000000; ++i) {
        num--;
    }
    num_mutex.unlock();
}

int main() {
    std::thread t1(numplus);
    std::thread t2(numsub);
    t1.join();
    t2.join();
    std::cout << num << std::endl;
}

调用线程从成功调用lock()或try_lock()开始,到unlock()为止占有mutex对象。当存在某线程占有mutex时,所有其他线程若调用lock则会阻塞,而调用try_lockh会得到false返回值。由上述代码可以看到,通过mutex加锁的方式,来确保只有单一线程对临界区的资源进行操作。

time_mutex与recursive_mutex的使用也是大同小异,两者都是基于mutex来实现的。( 本质上是基于recursive_mutex实现的,mutex为recursive_mutex的特例)

time_mutex则是进行加锁时可以设置阻塞的时间,若超过对应时长,则返回false。

recursive_mutex则让单一线程可以多次对同一互斥量加锁,同样,解锁时也需要释放相同多次的锁。

以上三种类型的互斥量都是包装了操作系统底层的pthread_mutex_t:

pthread_mutex_t结构

在C++之中并不提倡我们直接对锁进行操作,因为在lock之后忘记调用unlock很容易造成死锁。而对临界资源进行操作时,可能会抛出异常,程序也有可能break,return 甚至 goto,这些情况都极容易导致unlock没有被调用。所以C++之中通过RAII来解决这个问题,它提供了一系列的通用管理互斥量的类:

互斥量管理

版本

作用

lock_graud

C++11

基于作用域的互斥量管理

unique_lock

C++11

更加灵活的互斥量管理

shared_lock

C++14

共享互斥量的管理

scope_lock

C++17

多互斥量避免死锁的管理

创建互斥量管理对象时,它试图给给定mutex加锁。当程序离开互斥量管理对象的作用域时,互斥量管理对象会析构并且并释放mutex。所以我们则不需要担心程序跳出或产生异常引发的死锁了。

对于需要加锁的代码段,可以通过{}括起来形成一个作用域。比如上述代码的栗子,可以进行如下改写(推荐):

long num = 0;
std::mutex num_mutex;

void numplus() {
    std::lock_guard<std::mutex> lock_guard(num_mutex);
    for (long i = 0; i < 1000000; ++i) {
        num++;
    }
};
void numsub() {
    std::lock_guard<std::mutex> lock_guard(num_mutex);
    for (long i = 0; i < 1000000; ++i) {
        num--;
    }
}

int main() {
    std::thread t1(numplus);
    std::thread t2(numsub);
    t1.join();
    t2.join();
    std::cout << num << std::endl;
}

由上述代码可以看到,代码结构变得更加明晰了,对于锁的管理也交给了程序本身来进行处理,减少了出错的可能。

shared_mutex

C++14的版本之后提供了共享互斥量,它的区别就在于提供更加细粒度的加锁操作:lock_sharedlock_shared是一个获取共享锁的操作,而lock是一个获取排他锁的操作,通过这种方式更加细粒度化锁的操作。shared_mutex也是基于操作系统底层的读写锁pthread_rwlock_t的封装:

pthread_rwlock_t的结构

这里有个事情挺奇怪的,C++14提供了shared_timed_mutex 而在C++17提供了shared_mutex。其实shared_timed_mutex涵盖了shard_mutex的功能。(不知道是不是因为名字被diss了,所以后续在C++17里将shared_mutex**加了回来)。共享互斥量适用与读多写少的场景,举个栗子:

long num = 0;
std::shared_mutex num_mutex;

// 仅有单个线程可以写num的值。
void numplus() {
    std::unique_lock<std::shared_mutex> lock_guard(num_mutex);
    for (long i = 0; i < 1000000; ++i) {
        num++;
    }
};

// 多个线程同时读num的值。
long numprint() {
    std::shared_lock<std::shared_mutex> lock_guard(num_mutex);
    return num;
}

简单来说:

  • shared_lock是读锁。被锁后仍允许其他线程执行同样被shared_lock的代码
  • unique_lock是写锁。被锁后不允许其他线程执行被shared_lock或unique_lock的代码。它可以同时限制unique_lock与share_lock

不得不说,C++11没有将共享互斥量集成进来,在很多读多写少的应用场合之中,标准库本身提供的锁机制显得很鸡肋,也从而导致了笔者最终只能求助与boost的解决方案。(其实也可以通过标准库的mutex来实现一个读写锁,这也是面试笔试之中常常问到的问题。不过太麻烦了,还得考虑和互斥量管理类兼容什么的,果断放弃啊)

多锁竞争

还剩下最后一个要写的内容:scope_lock ,当我们要进行多个锁管理时,很容易出现问题,由于加锁的先后顺序不同导致死锁。(其实本来不想写了,好累。这里就简单用例子做解释吧,偷个懒~~)

如下栗子,加锁顺序不当导致死锁:

std::mutex m1, m2;
// thread 1
{
  std::lock_guard<std::mutex> lock1(m1);
  std::lock_guard<std::mutex> lock2(m2);
}
// thread 2
{
  std::lock_guard<std::mutex> lock2(m2);
  std::lock_guard<std::mutex> lock1(m1);
}

而通过C++17提供的scope_lock就可以很简单解决这个问题了:

std::mutex m1, m2;
// thread 1
{
  std::scope_lock lock(m1, m2);
}
// thread 2
{
  std::scope_lock lock(m1, m2);
}

好吧,妈妈再也不用担心我会死锁了~~

3.小结

算是简单的梳理完C++标准库之中的mutex了,也通过一些栗子比较完整的展现了使用方式。笔者上述关于标准库的内容,在boost库之中都能找到对应的实现,不过如果能够使用标准库,尽量还是不要引用boost了。(走投无路的时候记得求助boost,真香~~)希望大家在实践之中可以很好的运用好这些C++互斥量来更好的确保线程安全了。后续笔者还会继续深入的探讨有关C++多线程的相关内容,欢迎大家多多指教。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏牛客网

网易云音乐Java面经(共三面)

【每日一语】很多人都无从得知自己的天赋,因为找不到相信他们的老师。于是他们深信自己很笨。——《心灵捕手》

5531
来自专栏木宛城主

浅谈 SOLID 原则的具体使用

SOLID 是面向对象设计5大重要原则的首字母缩写,当我们设计类和模块时,遵守 SOLID 原则可以让软件更加健壮和稳定。那么,什么是 SOLID 原则呢?本...

2179
来自专栏王清培的专栏

.NET项目开发—浅谈面向接口编程、可测试性、单元测试、迭代重构(项目小结)

阅读目录: 1.开篇介绍 2.迭代测试、重构(强制性面向接口编程,要求代码具有可测试性) 2.1.面向接口编程的两个设计误区 2.1.1.接口的依赖倒置 ...

2009
来自专栏机器之心

放弃Python转向Go语言:我们找到了以下9大理由

选自Stream 作者:Thierry Schellenbach 机器之心编译 参与:黄小天、李亚洲 转用一门新语言通常是一项大决策,尤其是当你的团队成员中只有...

57911
来自专栏Java Web

线程和进程基础——翻译文

前言 所有的内容均来自:http://www.qnx.com/developers/docs/6.4.1/neutrino/getting_started/s...

3095
来自专栏海说

[转]程序员你为什么这么累?

大家一提到程序员,首先想到的是以下标签:苦逼,加班,熬夜通宵。但是,但凡工作了的同学都知道,其实大部分程序员做的事情都很简单,代码CRUD可以说毫无技术含量,就...

952
来自专栏HappenLee的技术杂谈

C++雾中风景12:聊聊C++中的Mutex,以及拯救生产力的Boost

C++从11开始在标准库之中引入了线程库来进行多线程编程,在之前的版本需要依托操作系统本身提供的线程库来进行多线程的编程。(其实本身就是在标准库之上对底层的操作...

1691
来自专栏Leetcode名企之路

【工程】在线诊断系统设计与实现

本文分享一些在线问题诊断的经验,主要是业务层面,服务层面的在线问题诊断一般需要依赖服务监控系统和报警系统来辅助定位问题。

1012
来自专栏互联网杂技

关于Java面试,你应该准备这些知识点

马老师说过,员工的离职原因很多,只有两点最真实: 钱,没给到位 心,受委屈了 当然,我是想换个平台,换个方向,想清楚为什么要跳槽,如果真的要跳槽,想要拿到一个理...

3787
来自专栏程序员互动联盟

【编程基础】聊聊C语言-磨刀不误砍柴工

看到上篇讲的《程序和编程语言》引发了大家的热议,小编很是激动。不过被人评论说是不懂编程,小编还真是郁闷了一下下,在此声明小编可是货真价实的“程序猿”哦。言归正传...

3929

扫码关注云+社区