头条焦点:《C++并发编程实战》读书笔记(1):并发、线程管控

点击上方“C语言与CPP编程”,选择“关注/置顶/星标公众号”

干货福利,第一时间送达!

你好,我是飞宇。


(资料图片)

昨天在朋友圈分享了一下自己关于《C++并发编程实战》这本书的读书笔记,收到不少点赞,今天就在公众号上分享一下自己以前的读书笔记,今天先更新第一部分,后续的读书笔记会慢慢再慢慢更新好了。

这里也顺便放一下自己的个人联系方式的二维码,听说以后公众号后不可以放二维码了,我也会经常在微信上分享一些计算机学习经验以及工作体验,还有一些内推工作机会,一般不闲聊,欢迎来做点赞之交。

C++国内并发编程的书少得可怜,唯一本质量还稍微靠谱点的就这本了《C++并发编程实战》,算是矮子里了挑将军挑出来的吧,我看的是这本

《C++并发编程实战》是关于C++新标准涉及的并发与多线程功能的深度指南,从std::thread、std::mutex和std::async的基本使用方法开始,一直到复杂的内存模型和原子操作。

第二版的英文原版与中译本都很容易购买。此外,github上也有其中文翻译https://github.com/xiaoweiChen/CPP-Concurrency-In-Action-2ed-2019以及示例代码汇总https://github.com/anthonywilliams/ccia_code_samples

第1章 你好,C++并发世界

计算机系统中的并发包括任务切换与硬件并发,往往同时存在,关键因素是硬件支持的线程数。不论何种,本书谈论的技术都适用。

采用并发的理由主要是分离关注点与提升性能。但并发使得代码复杂、难懂、易错,不值得时无需采用并发。

并发的方式包括多进程与多线程。前者采用多个进程,每个进程只含一个线程,开销更大,通过昂贵的进程间通信来传递信息,但更安全并且可利用网络连接在不同计算机上并发。后者采用单一进程,内含多个线程,额外开销更低,但难以驾驭,往往暗含隐患。本书专攻多线程并发。

并发与并行都指可调配的硬件资源同时运行多个任务,但并行更强调性能,而并发更强调分离关注点或相应能力。

以一个简单的例子开启本书:

第2章 线程管控

2.1线程的基本管控

每个C++程序都含有至少一个线程,即main函数所在线程。随后,程序可通过std::thread启动更多线程;它需要头文件,可以通过任何可调用类型(函数、伪函数、lambda等)发起线程。

启动线程后需要决定是与之汇合(join)还是与之分离(detach)。如果线程销毁时还没决定,那么线程会调用std::terminate终止整个程序。只有存在关联的执行线程时,即t.joinable()返回true,才能调用join/detach。

detach成员函数表示程序不等待线程结束,令线程在后台运行,归属权与控制权转交给C++运行时库。使用detach需确保所访问的外部数据始终正确有效,避免持有主线程的局部变量的指针/引用,否则主线程退出后该线程可能持有空悬指针/空悬引用。解决办法是将数据复制到新线程内部而非共享,或者使用join而非detach。

join成员函数的作用是等待线程的执行结束并回收线程资源;只能调用一次,之后就不再joinable。为了防止抛出异常时跳过join,导致程序崩溃有,可以实现一个RAII类,在析构函数中保证已经汇合过。

2、向线程函数传递参数

直接向std::thread的构造函数添加更多参数即可给线程函数传递参数。不过参数是先按默认方式复制到线程内部存储空间,再被当成临时变量以右值形式传给线程函数。

例如下面的字符串字面量hello,先以const char*形式传入,再转化为std::string类型。

但如果实参是指针,那么传入指针后构造string时,指针可能已经空悬。解决办法是传参时直接转换为string。

如果线程函数的形参是左值引用,直接传入实参会被转化为右值再传入,导致错误。解决办法是用std::ref加以包装。

想要使用成员函数作为线程函数的话,还需传入对象指针。例如下面的线程函数实际上调用w.f(i)。

对于只能移动不能拷贝的参数,例如unique_ptr,若实参是临时变量则自动移动,若实参是具名变量则需使用move。

2.3 移交线程归属权

thread掌握资源,像unique_ptr一样只能移动不能拷贝;此外当thread关联一个线程时向其移动赋值会导致程序终止。支持移动操作的容器,例如vector,可以装载std::thread对象。

可以改进前文的thread_guard,使其支持构建并掌管线程,确保离开所在作用域前线程已完结。

2.4在运行时选择线程数量、线程ID

可以通过std::thread::hardware_concurrency()来获取可真正并发的线程数量,硬件信息无法获取时返回0。当用多线程分解任务时,该值是有用的指标。

以下是并行版accumulate的简易实现,根据硬件线程数计算实际需要运算的线程数,随后将任务分解到各个线程处理,最后汇总得到结果。

线程ID的类型是std::thread::id,可随意复制或比较。可以通过thread的get_id()成员函数获取,也可以通过std::this_thread::get_id()获取当前线程ID。

第3章 在线程间共享数据

3.1 线程间共享数据的问题

并发编程中操作由多个线程负责,争先让线程执行各自的操作,结果取决于它们执行的相对顺序,这就是条件竞争。恶性条件竞争会导致未定义行为。很经典的两个线程各自递增一个全局变量十万次的例子,理想情况下最后变量变为二十万,然而实际情况是这样:

3.2 用互斥保护共享数据

可以利用名为互斥的同步原语。C++线程库保证了一旦由线程锁住某个互斥,其他线程试图加锁时必须等待,直到原先加锁的线程将其解锁。注意应以合适的粒度加锁,仅在访问共享数据期间加锁,处理数据时尽可能解锁。

C++中通过构造std::mutex的实例来创建互斥,通过lock/unlock成员函数来加锁解锁。并不推荐直接调用成员函数,应使用其RAII类lock_guard,构造时加锁、析构时解锁。

然而仍可能出现未被保护的指针/引用,或者成员函数调用了不受掌控的其他函数,因此不能向锁所在的作用域之外传递受保护数据的指针/引用。然而即使用互斥保护,有些接口仍存在固有的条件竞争。例如对于栈来说:线程1判断栈非空,随后线程2取出元素,栈空,随后线程1取出元素时出错。下面是一个解决办法的示例:‍

最后,死锁是指两个线程都需要锁住两个互斥锁才能继续运行,而目前都只锁住一个,并苦苦等待对方解锁。以下是一些防范死锁的准则:1、如果已经持有锁,就不要获取第二个锁;确实需要获取多个锁时使用std::lock来一次性获取所有锁。2、一旦持锁,避免调用用户提供的程序接口避免嵌套锁。3、依从固定顺序获取锁。4、按层级加锁。5、事实上任何同步机制的循环等待都会导致死锁。

例如swap函数需要同时获取双方的锁时:

unique_lock比lock_guard更灵活,不占有与之关联的互斥锁,但占用更多空间并且更慢。它提供了lock/try_lock/unlock成员函数;构造函数第二个参数传入adopt_lock表示互斥锁已上锁,传入defer_lock表示构造时无需上锁。unique_lock可移动不可复制,可以在不同作用域间转移互斥所有权,用途是让程序在同一个锁的保护下执行其他操作。

3.3 保护共享数据的其他工具

可以通过once_flag类和call_once函数来在初始化过程中保护共享数据。

C++11还规定了静态数据只会初始化一次。那么单例模板类可以这样实现:

对于读多写少的数据结构,C++14提供了shared_timed_mutex,C++17提供了功能更多的shared_mutex,那么写锁即lock_guard或unique_lock,读锁即shared_lock

递归锁recursive_mutex允许同一线程对它多次加锁,释放所有锁后其他线程才可获取该锁。

根据公众号最新规定,文章中含有商品链接的需要标注"广告"二字

你好,我是飞宇,本硕均于某中流985 CS就读,先后于百度搜索以及字节跳动电商等部门担任Linux C/C++后端研发工程师。

同时,我也是知乎博主@韩飞宇,日常分享C/C++、计算机学习经验、工作体会,欢迎点击此处查看我以前的学习笔记&经验&分享的资源。

我组建了一些社群一起交流,群里有大牛也有小白,如果你有意可以一起进群交流。

欢迎你添加我的微信,我拉你进技术交流群。此外,我也会经常在微信上分享一些计算机学习经验以及工作体验,还有一些内推机会。

加个微信,打开另一扇窗

推荐DIY文章
活佛济公之天鹅梦是第几集 看完天鹅的整个故事会有何印象转变
驾驶高手教你开车技巧 现在开车是几乎每个人都必须知道的技能
优酷与土豆合并后有什么电视剧 优酷五大院线最新片单一览|报道
妥当的读音是什么 什么是正确的发音 正确的拼音其实是tuǒ dàng
补肾固元膏:又名阿胶核桃膏,是具有悠久养血养颜传统的佳品|全球简讯
东航婴儿座位是怎样的 为何全世界的航空公司空都不卖婴儿票
精彩新闻

超前放送