CCIA 读书笔记 001
「C++ Concurrency in Action」读书笔记第一节,对应原书 Chapter 01 - 02
个人笔记,仅记录我没见过的概念、用法等,详情见原书。
C++11 引入了多线程概念, i.e. 标准库开始提供跨平台的多线程 API 了, C++14 、 C++17 在此基础上提供了更丰富的多线程支持,这本书使用 C++17 为标准。
1. 一些概念
在单线程处理器上实现的所谓并发(软件并发,通过 task switch 实现)与在多线程处理器上的真正的并发(硬件并发)有一些区别,比如内存模型(后期会深入讲)
1.1. 多进程并发
缺点:
- 进程间通信可能很难实现、或很慢、或又难实现又慢,因为操作系统需要保证一个进程不会修改另一个进程的数据
- 启动多个进程可能很慢,因为操作系统需要为每个进程分配资源
优点:
- 也因为操作系统的保护,使用多进程更容易写出正确的并发程序
- 多进程甚至可以通过网络进行通信,所以这些进程可以来自多个计算机
此外,多进程间通信大多需要平台特定的 API
1.2. 多线程并发
多进程间一般共享一块内存,如何保证里面数据的安全是一个难点,这本书也主要讲多线程并发
1.3. 并行 VS 并发
大体上都是同时运行多个任务。但并行更侧重于极致性能,压榨硬件资源;并发更侧重于关注点的分离和响应性
2. 为啥要并发
提高性能、分离关注点、提高程序的响应速度,以及「因为我会并发,所以我用并发」
2.1. 关注点的分离
比如一个带有用户界面的音乐播放器,它需要播放音乐,同时接收用户的“播放”和“暂停”之类的指令。如果程序不使用并发,它需要在播放音乐的代码里混入检测用户输入的代码,导致两个功能混在了一起。如果使用并发,可以把用户界面和播放音乐分离成两个线程。这样也提高了程序的响应性。
这种情况一般是需要开一条线程跑某个循环跑到死,使用多线程可以分离不同的逻辑,使开发变得简单。这种情况不需要考虑硬件线程的数量。
2.2. 提高性能
最早 CPU 厂商的侧重点是单核性能,程序员可以看到自己的程序随着 CPU 的迭代跑得更快,但后来单核性能的提升快到极限了, CPU 厂商开始侧重多核性能,正如 Herb Sutter 所说「The free lunch is over.」,开发者需要使用并发来完整利用硬件资源。一种很容易想到的并行方式:
- 任务并行:把一个任务分成多个子任务并行执行,难点在与子任务之间可能有依赖关系
- 数据并行:多个线程对一块数据的多个部分进行相同的处理
另一种并行为多个线程处理多块数据,与上面的数据并行类似,但这种并行提高了同时处理的数据量
2.3. 啥时候不应该并发
- 简而言之,别没活硬整,并发对开发者的负担更重,共容易出 BUG ,如果关注点的分离不是很明确或者获得的性能提高不明显,别用并发
- 再比如,开线程同样消耗操作系统资源,如果开出的线程执行的任务还不如操作系统开线程这个操作复杂,别用并发
- 还有,操作系统能提供的线程数是有限的,太多线程可能会拉低整个操作系统的性能,严重可能会爆栈(尤其是 32 位操作系统有 4GiB 内存大小的限制),这种情况可以用线程池(后面会详细讲)
- 最后,因为操作系统在 context switching 时需要额外花一些时间,所以最好进程的线程数不超过硬件线程数,这种软件线程数超过硬件线程的行为叫做 oversubscription
2.4. C++ 多线程的历史
1998 年 C++ 标准里没有「线程」的概念,编译器都添加自己的扩展以实现多线程,基本上靠封装 POSIX C 标准或 M$ Windows API ,往上还有 MFC 之类应用框架、 Boost 和 ACE 之类的 C++ 库封装这些 C 平台 API 提供更高级的机制,这些实现细节有所不同(尤其是线程的启动相关),但大体上的理念都是 RAII 实现自动锁
C++11 后,标准库开始提供一套线程工具,简单来说就是把 Boost 的线程库搬过来了; C++14 带来了一种新的 mutex 来保护共享数据; C++17 加入了一整套并行算法。支持了原子操作后 C++ 更是可以写出平台无关的多线程程序,编译器还可以对这些操作进行优化
设计上,标准库遵循了一个准则:直接使用平台特定的 API 不会比使用标准库的快很多,但标准库也为很多对象提供了 native_handle() 方法用来调用平台特定的 API
3. Hello World
1 |
|
C++ 运行时会为每个程序启动一条线程,每个线程都有自己的 initial function ,对于这个线程来说是 main ,对于上面的 t 来说是 hello 。 std::thread 被初始化后会开始执行,初始化只需要传入一个可调用的对象,比如重载了 operator() 的类对象、 lambda 表达式等。 std::thread 在被析构之前需要决定好是 join() 还是 detach() ,否则 std::thread 的析构函数会调用 std::terminate() 结束整个程序;反过来,线程可能在被 join() / detach() 之前就结束了,这种情况不被认为是错误。
3.1. join() / detach()
- join: 父线程会被阻塞至子进程结束,很多情况下不会这么用
- detach: 顾名思义,实际的线程与这个
std::thread不再有任何联系,归 C++ 运行时管了,即使这个std::thread析构了,实际的线程也会一直运行
一个 std::thread 只能 join() / detach() 一次(可以用 std::thread::joinable 检查),多次调用会产生 std::system_error: Invalid argument ,同理,这种代码也会产生错误:
1 | std::thread t{foo}; |
这里 t 没有 join() 或 detach() 就被 move assign 了。
更精细的控制可以由 condition variable 和 future 实现。
3.2. 主线程异常时的清理
1 | try { |
大意:主进程异常可能会越过 t.join() ,所以放进 catch 里,但我主观认为没啥用,因为我不喜欢异常,如果有异常我更喜欢让程序直接崩
或者遵循 RAII 理念:
1 | class ThreadGuard { |
3.3. 参数的传递
如果是这样:
1 | void foo(std::string& str); |
传递参数的话需要注意的是,这种方式下参数会被复制进 std::thread t 里,子线程中参数会以 rvalue 的方式传给 foo ,从而导致编译错误。解决方式是使用 std::reference_wrapper :
1 | std::thread t(foo, std::ref(str)); |
没错,类似
std::bind,同理也可以传成员函数,比如std::thread t(&Foo::bar, &foo, std::ref(str));。因为参数是std::move给 initial function 的,所以还有一种情况很适合,比如std::unique_ptr之类只能被std::move而不能被复制的东西:
1
2 std::unique_ptr<std::string> p_str{new std::string{"foo"}};
std::thread t(foo, std::move(p_str));
3.4. 线程数的选择
unsigned int std::thread::hardware_concurrency() 会返回硬件线程数(未定义或不可计算时返回 0 )
3.5. 线程标识
标准库提供了 std::thread::id 作为线程标识,可以通过 std::thread::get_id() 获取 std::thread 对应的线程标识,或者通过 std::this_thread::get_id() 获取当前线程的标识。如果线程没有用 initial function 进行初始化,它的标识会是一个默认初始化的 std::thread::id 。这个标识是可以用流输出的:
1 | std::cout << "this thread: " << std::this_thread::get_id() << '\n'; |
但 std::thread::id 底层具体是什么是由实现决定的,标准库能确保的是两个标识相同的 std::thread 一定来自相同的线程或两个没有初始化的线程,两个标识不同的线程一定来自两个不同的线程或其中一个线程没有初始化