C++并发实战-第一至第三章-总学习笔记第1
[TOC]
C++ 并发实战《C++ Concurrency in Action》的学习笔记1, 记录第一章到第三章的部分.
内容包括 C++ 并发的背景, 线程管理, 线程间的数据共享等.
1. 你好, C++ 的并发世界
C++11 开始支持多线程
1.1 何谓并发
并发, 是指两个或更多独立的活动同时发生.
1.1.1 计算机系统中的并发
计算机领域的并发指的是在单个系统里同时执行多个独立的任务, 而非顺序的进行一些活动.
任务切换得太快造成”并发的假象”其实也是并发.
硬件并发(hardware concurrency): 多个核心并发.
现在的并发一般是硬件并发 + 任务切换组合.
1.1.2 并发的途径
- 多进程并发
将应用程序分为多个独立的进程, 独立的进程可以通过进程间常规的通信渠道传递讯息(信号, 套接字, 文件, 管道等等).
缺点:
- 进程之间的通信通常不是设置复杂, 就是速度慢.
- 需要时间启动进程, 操作系统需要内部资源来管理进程.
优点:
- 更容易编写安全的并发代码.
- 可以使用远程连接(可能需要联网)的方式,在 不同的机器上运行独立的进程.
- 多线程并发
进程中的所有线程都共享地址空间, 并且所有线程访问到大部分数据———全局变量仍然是全局的, 指针, 对象的引用或数据可以在线程之间传递.
优点: 开销小.
缺点: 安全性低.
C++ 标准并未对进程间通信提供任何原生支持, 全是多线程并发.
1.1.3 并发与并行
其区别主要在于关注点和意图方面(差距甚微).
People talk about parallelism when their primary concern is taking advantage of the available hardware to increase the performance of bulk data processing, whereas people talk about concurrency when their primary concern is separation of concerns, or responsiveness.
1.2 为什么使用并发?
1.2.1 为了分离关注点
不同任务的代码分离, 仅需关注自身代码以及任务关联信号.
1.2.2 为了性能
任务并行(task parallelism, 各个部分之间可能存在着依赖)与数据并行(data parallelism, 每个线程在不同的数据部分上执行相同的操作).
可以清楚地分割任务/数据的做法: 易并行(embarrassingly parallel)算法: have good scalability properties(随着可用的 thread 数目增加, 性能可能会线性增加).
1.2.3 什么时候不使用并发
不使用并发的唯一原因就是收益比不上成本.例如,开发维护时间成本.
性能增益:
- 实际执行任务的时间可能要比启动线程的时间小.
- 每个线程都需要一个独立的堆栈空间,所以运行太多的线程也会耗尽进程的可用内存或地址空间.
- 运行越多的线程,操作系统就需要越多的上下文切换.
1.3 C++中的并发和多线程
1.3.1 C++多线程历史
起初: C 语言中流行的多线程 API———POSIX 标准中的 C 标准和 Microsoft Windows API + Boost 和 ACE 平台无关库.
1.3.2 新标准支持并发
C++11 标准: Boost 线程库作为新类库的主要模型.
1.3.3 C++14 和 C++17 对并发和并行的更多支持
1.3.4 C++ 线程库的效率
抽象代价(abstraction penalty):使用高级工具和使用低级工具的开销差.
C++ 实现了底层操作工具与抽象易用工具之间平衡.
1.3.5 平台相关的工具
native_handle()
成员函数,允许通过使用平台相关 API 直接操作底层实现.
1.4 开始入门
initial function: Thread 开始工作的入口, 比如 main
函数.
2. 线程管理
2.1 线程管理的基础
2.1.1 启动线程
构造 std::thread
对象.
可以用可调用类型构造, 将带有函数调用符类型的实例传入 std::thread
类中, 替换默认的构造函数.
注意语法解析:
1 | std::thread my_thread(background_task());//本意是使用产生的一个临时的函数对象, |
2.1.2 等待线程完成
要保证线程在 main
线程 std::terminate()
之前对启动的线程作出处理, 注意也要考虑 exception 的情况下. 有 2 种处理方式.
线程分离
main
线程自主运行:detach()
, 只有执行的函数返回了, 分离出去的线程才会被结束. 此时注意访问已经销毁的未定义行为, 尤其是线程函数还持有函数局部变量的指针或引用的情况下.
解决办法之一: make the thread function self-contained and copy the data into the thread rather than sharing the data. 把数据考进入 callable 里. 还有一种办法就是使用下一种处理方式.main
线程等待其完成, 期间被阻塞:join()
.
只能对一个线程使用一次 join()
. 判断是否 join 过: joinable()
. 原因如下: The act of calling join()
also cleans up any storage associ
ated with the thread, so the std::thread
object is no longer associated with the now-finished thread; it isn’t associated with any thread.
PS. 这一切在 C++ 20 里有了很大的改善.
std::jthread
替代了std::thread
可以通过 stop source/stop token 来控制线程什么时候结束.
2.1.3 Waiting in exceptional circumstances
注意 if an exception is thrown after the thread has been started but before the call to join()
可能会导致线程的资源不能被释放. detach()
没有这个问题.
解决办法:
- uses a
try
/catch
block: 不好. - use the standard Resource Acquisition Is Initialization (RAII) idiom and provide a class that does the
join()
in its destructor.
1 | class thread_guard |
2.1.4 Running threads in the background
detach()
后 ownership and control are passed over to the C++ Runtime Library. detached 线程被叫做 daemon threads.
- detached 线程不能再
join()
. - 注意不能 detach 没有绑定线程的
std::thread
对象. you can’t calldetach()
on astd::thread
object with no associated thread of execution. - 因此也可以通过
t.joinable()
returnstrue
来判断std::thread
是否可以 detach.
2.2 Passing arguments to a thread function
先让我们看看 std::thread
的接口定义:
1 | namespace std { |
后面的注意点可能就比较好懂一些
向线程传递函数参数注意:
the arguments are copied into internal storage. 不管是什么传入的是什么东西都是被 copy 进去的, 不管是 value/pointer/reference.
参数的初始化(因为 copy)是在新开的线程内进行的, 而不是在创造新线程的线程内. 因此当传入参数是指针/引用的时候, 可能会导致深拷贝而导致从外考到
std::thread
内部时的 lifetime 不一致的情况. 有人会说我只 pass-by-value 不就行了. 但是还是有 type decay 的情况. 例如下面的字符数组到std::string
的情形.
1 | void f(int i,std::string const& s); |
- 函数参数是 non-const reference 时, pass-by-value 要注意使用
std::ref
包装一下, 否则无法编译. 原因是传进去的 value 被盲目地 copy 进入构造函数内, 成为了 rvalue(编译实现会先拷贝成 temporary value 然后在通过 implicit/explicit conversion 看看能不能与参数类型匹配), 而 non-const reference 无法匹配 rvalue. 因此我们需要将 pass-by-value 通过std::ref
包装成引用.
1 | void update_data_for_widget(widget_id w,widget_data& data); |
- 类似
std::bind
,std::thread
接受成员函数作为函数指针传参.
1 | class X{ |
- 支持 move semantics: 对于 temporary object, move is automatic. 对于 named value, the transfer must be requested directly by invoking
std::move()
.
1 | void process_big_object(std::unique_ptr<big_object>); |
instances of
std::thread
are movable,but not copyable. This ensures that only one object is associated with a particular thread of execution at any one time.
2.3 Transferring ownership of a thread
resource-owning types: std::ifstream
, std::unique_ptr
, std::thread
.
- you can’t just drop a thread by assigning a new value to the
std::thread
object that manages it.
1 | void some_function(); |
std::thread
可以作为函数的参数以及返回值.
1 | //as return |
- 创建一个
scoped_thread
对std::thread
生命周期进行管理的类.
1 | class scoped_thread{ |
- C++17 未能得到通过的
joining_thread
class 实现了上述scoped_thread
的功能, 也许会在 C++20 看到(可惜还是没有得到完全实现).
1 | class joining_thread{ |
- thread 可以放入容器中, 例如
std::vector<>
.
1 | void do_work(unsigned id); |
2.4 Choosing the number of threads at runtime
std::thread::hardware_concurrency()
成员变量给出能够实现并发的 thread 数目的 hint.
A naïve parallel version of std::accumulate
:
1 | template<typename Iterator,typename T> |
2.5 Identifying threads
thread 的 ID 使用 std::thread::id
类型.
两种获取 id 的方式:
- 成员函数
get_id()
. std::this_thread:: get_id()
.当前 thread 的 id.
接口:
1 | namespace std { |
If the std::thread
object doesn’t have an associated thread of execution, the call to get_id()
returns a default-constructed std::thread::id object
, which indicates “not any thread.”
下面的例子作为匹配任务与 thread 的示意.
1 | std::thread::id master_thread; |
thread 虽然没有语义信息(还是可以 std::cout
打印出来)但可以作为关联容器的 key, 也可以用来比较.
比较时,如果不同代表不同的 thread, 相同代表相同的 thread 或者空 thread.std::hash<std::thread::id>
可以让 id 参与比较, 排序.
3. Sharing data between threads
3.1 Problems with sharing data between threads
- reading 不会导致问题.
- invariants 概念: 如何保证 shared 数据结构在所有时刻是不变的?
作者举的例子是双向链表做删除一个中间节点时, 有些时刻不是 invariants 的. 这是对值改变的进一步抽象.
3.1.1 Race conditions
problematic race condition: 跟多窗口买票一样抢电影座. 尤其是这个问题不好复现, 也具有偶发性(timing 强相关, debugger affects the timing of the program), 因此必须重视(hard to find and hard to duplicate because the window of opportunity is small.). 也可以被叫做 data races => the dreaded undefined behavior.
定义:
In concurrency, a race condition is anything where the outcome depends on the relative ordering of execution of operations on two or more threads; the threads race to perform their respective operations.
3.1.2 Avoiding problematic race conditions
- 方法1: wrap your data structure with a protection mechanism. 对既有的数据结构套一层保护机制(例如锁). 这个应用更加普遍.
- 方法2: lock-free programming, modify the design of your data structure and its invariants. 从根本上改变数据结构, 不用锁也可以的数据结构.
- 方法3: software transactional memory (STM), 不是标准内容, 本书不介绍(但作为 TS 在发展: Technical Specification for C++ Extensions for Transactional Memory). The basic idea of doing something privately and then committing in a single step.
3.2 Protecting shared data with mutexes
mutex: (mutual exclusion) 互斥锁.
3.2.1 Using mutexes in C++
虽然 std::mutex
有成员函数可以实现 lock()
与 unlock()
, 但需要手动准确指定有时不准确/方便.
std::lock_guard
管理 std::mutex
的周期(使用 RAII 方式, 即其在构造函数中对 std::mutex
变量进行锁定, 在其析构函数中对 std::mutex
变量进行解锁, 整个类没有对 std::mutex
进行解锁和加锁的对外接口).
最好把 std::mutex
以及要锁的数据放到一个 class 里,并且注意接口设计不要留出 stray pointer or reference 的可访问 backdoor(std::mutex
也锁不住指针访问).
Any code that has access to that pointer or reference can now access (and potentially modify) the protected data without locking the mutex.
3.2.2 Structuring code for protecting shared data
对 shared data 不要暴露任何形式的指针/引用接口.
Don’t pass pointers and references to protected data outside the scope of the lock, whether by returning them from a function, storing them in externally visible memory, or passing them as arguments to user-supplied functions.
1 | class some_data{ |
3.2.3 Spotting race conditions inherent in interfaces
即便加上锁, 并不意味着万事大吉. 还有两种情况也有可能出现 race condition.
假设一个 stack adaptor 的接口设计如下, 并且保证每次对 stack 的访问都是加锁的:
1 | template<typename T,typename Container=std::deque<T> > |
- 不同成员函数之间可能存在依赖: 例如
size()
,empty()
与pop()
.
1 | stack<int> s; |
一个线程判断 empty()
为 false
的时候, 进入处理程序, 但是另一个程序先进入后 pop()
了唯一的 node 导致第一个线程访问 top()
是空的. 如果你不嫌麻烦/增加成本的话, 可以加 exception.
- 进一步加锁, 不止是对数据加速, 对所有可能访问数据的成员函数也进行加锁. 也有可能出现问题: 执行过程的 interleave(交错).
如下图, 同一个元素被处理了 2 遍, 一个元素被跳过, 并且系统不会报错, 很难查到原因.
很自然地大家就想到了, 那把 pop()
与 top()
函数结合成一个函数不就可以避免这个问题了吗? sometimes naive!
大牛们(Herb Sutter 在设计 exception-safety 时)早就想过这一点,std::stack
的 pop()
与 top()
的分离是有合理的原因的.
比如 std::stack<std::vector<int>>
, 顺序是 pop()
删除 stack 的 top 元素 => copy top 元素给 top()
函数的返回值, 但是如果返回过程中有 exception 的话, 出现了悬空的 copy temporary 元素. 至于是为啥是这个顺序, 书里没介绍, 至少说明这个顺序是可能的.
那有啥办法解决这个悬空的元素的问题?
- OPTION 1: PASS IN A REFERENCE
1 | std::vector<int> result; |
缺点: 浪费构造资源, 也许没有足够的参数在进行 pop()
之前就能构造出对象, 也许有些自定义的类根本就没有这个构造函数(stored type be assignable).
- OPTION 2: REQUIRE A NO-THROW COPY CONSTRUCTOR OR MOVE CONSTRUCTOR
表面上的原因就是 pop
扔了异常出来导致中断悬空, 那我不扔不就完了. 虽然现在很多对象/type 在移动构造函数不会扔异常, 虽然拷贝构造函数还会扔, 但是这样的 type 还是少(通过 std::is_nothrow_copy_constructible
and std::is_nothrow_move_constructible
查看), 很多自定义的对象更不用说了, 不能保证没有.
- OPTION 3: RETURN A POINTER TO THE POPPED ITEM
返回指针呢? 对于简单的对象/type 指针的代价比 value 高.
智能指针 std::shared_ptr
可以采用.
- OPTION 4: PROVIDE BOTH OPTION 1 AND EITHER OPTION 2 OR 3
终极办法: 把上面三个方法揉到一起取长补短. 例如以下例子(option 1 + 3):
1 |
|
这个 stack 几乎只有 push()
与 pop()
功能了, 虽然能保证这两个操作是安全的.
那要是直接做个 global 的 mutex 锁住所有的操作就没这么多考虑了, 但是粒度太粗. 我们需要写出 fine-grained locking schemes.
3.2.4 Deadlock: the problem and a solution
互等都不执行即为死锁. 不要以为把锁与解锁的顺序对上了就一定不会产生死锁.
例如两个类都锁住自己等着 swap. 下面的代码是解决这个问题的例子. 使用 std::lock()
: Locks the given Lockable objects lock1, lock2, …, lockn using a deadlock avoidance algorithm to avoid deadlock. 接口如下.
1 | template< class Lockable1, class Lockable2, class... LockableN > |
1 | class some_big_object{}; |
std::adopt_lock
是一种传入 std::lock_guard
, std::unique_lock
and std::shared_lock
等加锁对象的策略. 除了这种以外还有 std::defer_lock
, std::try_to_lock
. 三者从前往后分别代表加锁对象已经有锁的控制权了, 直接 adopt 即可, 不用重复初始化; 延迟获取锁的控制权, 后面通过 lock()
再锁; 如果没有被阻塞的话尝试获取锁的控制权. 它们都是 empty struct tag types.
对于 exception, If std::lock
has successfully acquired a lock on one mutex and an exception is thrown when it tries to acquire a lock on the other mutex, this first lock is released automatically: std::lock
provides all-or-nothing semantics with regard to locking the supplied mutexes.
C++17 中有 std::scoped_lock
(RAII 对象) 来代替 std::lock
与 std::lock_guard
的操作. 其接口如下:
1 | namespace std { |
1 | class X{ |
本节做法的限制: need to acquire two or more locks together, it doesn’t help if they’re acquired separately. 分开锁的情况下, 需要自己去避免死锁.
3.2.5 Further guidelines for avoiding deadlock
死锁不止会发生在 std::mutex
上也会发生在 thread 的 join()
上. 甚至不仅仅发生一对之间: a cycle of three or more threads will still cause deadlock.
通用原则:
Don’t wait for another thread if there’s a chance it’s waiting for you.
AVOID NESTED LOCKS 避免嵌套锁
don’t acquire a lock if you already hold one.AVOID CALLING USER-SUPPLIED CODE(未知的 code) WHILE HOLDING A LOCK
你无法确定用户代码里的情况,最好不要在有锁的时候调用用户代码, 但是很多时候这么做不太现实.
- ACQUIRE LOCKS IN A FIXED ORDER
acquire them in the same order in every thread.
我觉得这里作者的意思如果更抽象一点的话是根据数据结构已经目的, 所有线程上的操作都是采用一致的加锁策略. 例如双向链表的删除操作可以每次 lock 三个操作包含的 node. 对于 reverse 操作, 每次 lock 住前后 2 个 node 即可. 当然这里要对每个 node 分别加锁. 必须注意 nodes must always be locked in the same order(否则有可能 they could deadlock with each other in the middle of the list), 可能导致的问题如下图(删除中间 node).
- USE A LOCK HIERARCHY
设置 lock 等级, 高级的必须先锁住低级的. 对于同级别的: This does mean that you can’t hold two locks at the same time if they’re the same level in the hierarchy.
下面是自定义的 hierarchical_mutex
类型, 只要满足 lock()
, unlock()
, and try_lock()
接口就可以成为 Lockable 类型.
1 |
|
这种实现 Although detection is a runtime check, it’s at least not timing-dependent.
使用等级 mutex.
1 | hierarchical_mutex high_level_mutex(10000); |
- EXTENDING THESE GUIDELINES BEYOND LOCKS
thread 与 thread 之间也要注意死锁.
3.2.6 Flexible locking with std::unique_lock
接口如下:
1 | namespace std { |
std::unique_lock
比 std::lock_guard
更灵活, 可以控制对锁加锁的时机. 通过 std::std::defer_lock
, std::std::adopt_lock
, std::std::try_to_lock
, 并且多出了一个 flag 控制 mutex 是否属于当前实例(owns_lock()
查询),是的话 destructor 必须 unlock, 不是的话不能 unlock.
为了维护这个 flag 的代价就是 std::unique_lock
更消耗资源(overhead).
进一步地 std::unique_lock
提供了对 mutex 的加锁(lock()
和 try_lock()
)和解锁(unlock()
)操作, 同时可以配合条件变量 condition variable 使用, 使之自由度变高很多. 与 std::scoped_lock
不同的是 std::unique_lock
更严格: If the instance does own the mutex, the destructor must call unlock()
, and if the instance does not own the mutex, it must not call unlock()
. 同时, 也没有 flag 这个成员变量的 overhead. 因此可以考虑在满足需求的前提下, 使用 C++17 的 std::scoped_lock
代替它(毕竟 varaic 也包括 1). 需要使用 std::unique_lock
的两种场景的例子:
- deferred locking.
1 | std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock); |
- the ownership of the lock needs to be transferred from one scope to another.
3.2.7 Transferring mutex ownership between scopes
std::unique_lock
也是 moveable but not copyable(这也许就是 unique 的出处, 与 std::unique_ptr
类似), 因此传递其值时要么 std::move()
, 要么本身是 rvalue.
常用于锁住预处理数据后返回一个 std::unique_lock
的实例给 caller 进行进一步的处理(当然还是在锁住的状态下). 例子如下:
1 | std::unique_lock<std::mutex> get_lock(){ |
这样 std::unique_lock
可以作为一个 gateway, 即访问的大门, 一切想要访问共享数据的操作都可以通过这个 gateway 进行. 并且支持 move, 当然还有销毁.
std::unique_lock
支持在自己被析构之前放弃 mutex (成员函数 unlock()
或者 std::lock()
), 因为有些场景确定需要解锁, 否则可能一直阻塞可以运行的其他线程.
3.2.8 Locking at an appropriate granularity
Lock a mutex only while accessing the shared data; try to do any processing of the data outside the lock.
例子: In particular, don’t do any time-consuming activities like file I/O while holding a lock.
总结下来就是
- 只对需要加锁的数据加锁.
- 尽量保证访问 lock 的时间短. 如果访问有间隔可以考虑使用
std::unique_lock
, 用完 unlock 再次用 lock.
1 | void get_and_process_data() |
还有如果将 int
这种 copy 出来代价不高的操作, 可以考出来操作, 而不是一直锁住所有操作都在锁里进行, 下面是例子代码.
1 | class Y{ |
但是这个策略也有 drawback, 那就是语义上可能没意义了. 因为可能在平行的时间点上两个值从来没有相等过, 但是由于 lock 时间太短导致比较的时间点是不一致的.
3.3 Alternative facilities for protecting shared data
进针对初始化时期的机制: C++ Standard provides a mechanism purely for protecting shared data during initialization.
3.3.1 Protecting shared data during initialization
只初始化时可能造成 race condition 的场景. 例如多个线程都在等一个数据结构初始化, 初始化结束后变成 read-only 不用考虑后面的 race condition 了. 再接着锁没有必要.
Lazy initialization 的单线程思路:
1 | std::shared_ptr<some_resource> resource_ptr; |
多线程下的直接转换, 也就是粗暴全部锁住, 但是缺点是所有的线程都要锁住并检查, 造成大量的阻塞:
1 | std::shared_ptr<some_resource> resource_ptr; |
想当然的改进: double-checked locking pattern, 在锁住之前先 check 有没有被 release, 如果其他线程已经帮他初始化好了, 那就不需要锁住阻塞其他线程了.
1 | void undefined_behaviour_with_double_checked_locking(){ |
但是这是个 Anti-pattern, 表面上看起来 work, 其实是 broken 的. 点在于 resource_ptr.reset(new some_resource);
这个过程不是原子性的. 具体分析可以参考 Scott Meyers 与 Andrei Alexandrescu 关于 DCLP 论述的 paper.
针对这种情况, C++ 标准库添加了 std::once_flag
(状态类, 作为一个 flag 比锁要效率很多) 和 std::call_once
(模板函数) 专门解决这个问题. std::call_once
里通过入参里的 Callable 与 … Args 实现一次性的初始化, 然后将已经完成初始化的状态传给 std::once_flag
实例, 这样其他线程只要去查询这个 std::once_flag
的状态就好了.
接口如下:
1 | namespace std { |
改进后的代码例子:
1 | std::shared_ptr<some_resource> resource_ptr; |
C++11 还解决了一个可能的问题, static 数据初始化多线程都去做这件事导致 race condition, C++11 会保证只有 1 个线程能初始化它: the initialization is defined to happen on exactly one thread, and no other threads will proceed until that initialization is complete. 也就是被标准化了.
1 | class my_class; |
3.3.2 Protecting rarely updated data structures
上面解决的是只需要初始化 1 次就不会变的数据结构, 还有一种情况, 大多数情况下都是 read, 偶尔 write. 典型的例子是 DNS 解析表. 如果能区分读与写, 只在写的时候进行锁, 读的时候不锁就好了. 下面是实现这样功能的 mutex(与 std::mutex
一样的 Lockable 类型).
C++11: boost shared_mutex
.
C++14: std::shared_timed_mutex
std::shared_lock
(不是 Lockable 类型, 与 std::unique_lock
类似, 用于控制 shared_mutex).
C++17: std::shared_mutex
and std::shared_timed_mutex
(在 std::shared_mutex
增加了一些时间控制选项).
接口如下:
1 | namespace std { |
动作机制: Any thread has a shared lock, a thread that tries to acquire an exclusive lock will block until all other threads have released their locks, and likewise if any thread has an exclusive lock, no other thread may acquire a shared or exclusive lock until the first thread has released its lock.
例子代码:
1 | class dns_cache{ |
3.3.3 Recursive locking
如果想要在一个实例里多次 lock, unlock 该怎么办? 首先直接用用 mutex 是非法的. C++ 标准提供了 std::recursive_mutex
可以让一个实例多次 lock. 限制是 You must release all your locks before the mutex can be locked by another thread, so if you call lock()
three times, you must also call unlock()
three times.
应用这个的一个典型场景: 类中一个成员函数已经 lock 了成员数据结构, 但是它里面调用的另一个成员函数也会去 lock, 重复地 lock 是 undefined behavior. 简单直接的解决办法是把 std::mutex
之类的 mutex 换成 std::recursive_mutex
. 但是作者比较排斥使用 std::recursive_mutex
, 因为这会让人停止思考从根本解决这个问题的动力, 例如提供一个 private 成员函数专门用来 lock 其他的成员函数如果想要 lock 就都去调用它即可.
C++并发实战-第一至第三章-总学习笔记第1
https://www.chuxin911.com/Notes_On_C++_Concurrency_in_Action_1_20211108/