Update Item35.md

This commit is contained in:
猫耳堀川雷鼓 2021-03-05 17:29:05 +08:00 committed by GitHub
parent f3c8ace132
commit dc47a11efd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,72 +1,74 @@
C++11的伟大标志之一是将并发整合到语言和库中。熟悉其他线程API比如pthreads或者Windows threads的开发者有时可能会对C++提供的斯巴达式译者注应该是简陋和严谨的意思功能集感到惊讶这是因为C++对于并发的大量支持是在编译器的约束层面。由此产生的语言保证意味着在C++的历史中开发者首次通过标准库可以写出跨平台的多线程程序。这位构建表达库奠定了坚实的基础并发标准库tasks, futures, threads, mutexes, condition variables, atomic objects等仅仅是成为并发软件开发者丰富工具集的基础。
# 第7章 并发API
在接下来的Item中记住标准库有两个futures的模板`std::future和std::shared_future`。在许多情况下,区别不重要,所以我们经常简单的混于一谈为*futures*。
**CHAPTER 7 The Concurrency API**
# 优先基于任务编程而不是基于线程
C++11的伟大成功之一是将并发整合到语言和库中。熟悉其他线程API比如pthreads或者Windows threads的开发者有时可能会对C++提供的斯巴达式译者注应该是简陋和严谨的意思功能集感到惊讶这是因为C++对于并发的大量支持是在对编译器作者约束的层面。由此产生的语言保证意味着在C++的历史中,开发者首次通过标准库可以写出跨平台的多线程程序。这为构建表达库奠定了坚实的基础,标准库并发组件(任务*tasks*,期望*futures*,线程*threads*,互斥*mutexes*,条件变量*condition variables*,原子对象*atomic objects*等)仅仅是成为并发软件开发者丰富工具集的基础。
在接下来的条款中,记住标准库有两个*future*的模板:`std::future`和`std::shared_future`。在许多情况下,区别不重要,所以我们经常简单的混于一谈为*futures*。
## 条款三十五:优先考虑基于任务的编程而非基于线程的编程
**Item 35: Prefer task-based programming to thread-based**
如果开发者想要异步执行`doAsyncWork`函数,通常有两种方式。其一是通过创建`std::thread`执行`doAsyncWork`,这是应用了**基于线程***thread-based*)的方式:
如果开发者想要异步执行 `doAsyncWork` 函数,通常有两种方式。其一是通过创建 `std::thread` 执行 `doAsyncWork` 比如
```cpp
int doAsyncWork();
std::thread t(doAsyncWork);
```
其二是将 `doAsyncWork` 传递给 `std::async` 一种基于任务的策略:
其二是将`doAsyncWork`传递给`std::async`,一种**基于任务***task-based*的策略:
```cpp
auto fut = std::async(doAsyncWork); // "fut" for "future"
auto fut = std::async(doAsyncWork); //“fut”表示“future”
```
这种方式中,函数对象作为一个任务传递给 `std::async`
这种方式中,传递给`std::async`的函数对象被称为一个**任务***task*
基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设唤醒`doAsyncWork`的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法可以简单地获取`std::async`返回的`future`提供的`get`函数获取这个返回值。如果`doAsycnWork`发生了异常,`get`函数就显得更为重要,因为`get`函数可以提供抛出异常的访问,而基于线程的方法,如果`doAsyncWork`抛出了异常,线程会直接终止(通过调用`std::terminate`)。
基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设调用`doAsyncWork`的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法就简单了,因为`std::async`返回的*future*提供了`get`函数(从而可以获取返回值)。如果`doAsycnWork`发生了异常,`get`函数就显得更为重要,因为`get`函数可以提供抛出异常的访问,而基于线程的方法,如果`doAsyncWork`抛出了异常,程会直接终止(通过调用`std::terminate`)。
基于线程与基于任务最根本的区别在于抽象层次的高低。基于任务的方式使得开发者从线程管理的细节中解放出来对此在C++并发软件中总结了'thread'的三种含义:
基于线程与基于任务最根本的区别在于,基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来对此在C++并发软件中总结了“*thread*”的三种含义:
- 硬件线程Hardware threads是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
- 软件线程Software threads也被称为系统线程是操作系统管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程,因为当软件线程被比如 I/O、同步锁或者条件变量阻塞的时候,操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
- `std::threads`是C++执行过程的对象,并作为软件线程的handle(句柄)。`std::threads`存在多种状态1. `null`表示空句柄,因为处于默认构造状态(即没有函数来执行),因此不对应任何软件线程。 2. moved from (moved-to的`std::thread`就对应软件进程开始执行) 3. `joined`(连接唤醒与被唤醒的两个线程) 4. `detached`(将两个连接的线程分离)
- **硬件线程**hardware threads是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
- **软件线程**software threads也被称为系统线程OS threads、system threads是操作系统假设有一个操作系统。有些嵌入式系统没有。管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程,因为当软件线程被阻塞的时候(比如 I/O、同步锁或者条件变量,操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
- **`std::thread`**是C++执行过程的对象,并作为软件线程的句柄(*handle*)。有些`std::thread`对象代表“空”句柄,即没有对应软件线程,因为它们处在默认构造状态(即没有函数要执行);有些被移动走(移动到的`std::thread`就作为这个软件线程的句柄);有些被`join`(它们要运行的函数已经运行完);有些被`detach`(它们和对应的软件线程之间的连接关系被打断)。
软件线程是有限的资源。如果开发者试图创建大于系统支持的硬件线程数量,会抛出`std::system_error`异常。即使你编写了不抛出异常的代码,这仍然会发生,比如下面的代码,即使 `doAsyncWork``noexcept`
软件线程是有限的资源。如果开发者试图创建大于系统支持的线程数量,会抛出`std::system_error`异常。即使你编写了不抛出异常的代码,这仍然会发生,比如下面的代码,即使 `doAsyncWork``noexcept`
```cpp
int doAsyncWork() noexcept; // see Item 14 for noexcept
int doAsyncWork() noexcept; //noexcept见条款14
```
这段代码仍然会抛出异常
这段代码仍然会抛出异常
```cpp
std::thread t(doAsyncWork); // throw if no more
// threads are available
std::thread t(doAsyncWork); //如果没有更多线程可用,则抛出异常
```
设计良好的软件必须有效地处理这种可能性(软件线程资源耗尽),一种有效的方法是在当前线程执行`doAsyncWork`但是这可能会导致负载不均而且如果当前线程是GUI线程可能会导致响应时间过长的问题;另一种方法是等待当前运行的线程结束之后再创建新的线程,但是仍然有可能当前运行的线程在等待`doAsyncWork`的结果(例如操作得到的变量或者条件变量的通知)。
设计良好的软件必须有效地处理这种可能性,但是怎样做?一种方法是在当前线程执行`doAsyncWork`但是这可能会导致负载不均而且如果当前线程是GUI线程可能会导致响应时间过长的问题。另一种方法是等待某些当前运行的软件线程结束之后再创建新的`std::thread`,但是仍然有可能当前运行的线程在等待`doAsyncWork`的动作(例如产生一个结果或者报告一个条件变量)。
即使没有超出软件线程的限额,仍然可能会遇到资源超额的麻烦。如果当前准备运行的软件线程大于硬件线程的数量系统的线程调度程序会将硬件核心的时间切片当一个软件线程的时间片执行结束会让给另一个软件线程即发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销并且如果发生了硬件核心漂移这个开销会更高具体来说如果发生了硬件核心漂移1CPU cache中关于上次执行线程的数据很少需要重新加载指令2新线程的cache数据会覆盖老线程的数据如果将来会再次覆盖老线程的数据显然频繁覆盖增加很多切换开销
即使没有超出软件线程的限额,仍然可能会遇到**资源超额***oversubscription*的麻烦。这是一种当前准备运行的即未阻塞的软件线程大于硬件线程的数量的情况。情况发生时线程调度器操作系统的典型部分会将软件线程时间切片分配到硬件上。当一个软件线程的时间片执行结束会让给另一个软件线程此时发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销当软件线程安排到与上次时间片运行时不同的硬件线程上这个开销会更高。这种情况下1CPU缓存对这个软件线程很冷淡即几乎没有什么数据也没有有用的操作指南2“新”软件线程的缓存数据会“污染”“旧”线程的数据旧线程之前运行在这个核心上而且还有可能再次在这里运行
避免资源超额是困难的因为软件线程之于硬件线程的最佳比例取决于软件线程的执行频率比如一个程序从IO密集型变成计算密集型执行频率是会改变的而且比例还依赖上下文切换的开销以及软件线程对于CPU cache的使用效率。此外硬件线程的数量和CPU cache的速度取决于机器的体系结构即使经过调校软件比例在某一种机器平台取得较好效果,换一个其他类型的机器这个调校并不能提供较好效果的保证。
避免资源超额很困难因为软件线程之于硬件线程的最佳比例取决于软件线程的执行频率那是动态改变的比如一个程序从IO密集型变成计算密集型执行频率是会改变的。而且比例还依赖上下文切换的开销以及软件线程对于CPU缓存的使用效率。此外硬件线程的数量和CPU缓存的细节比如缓存多大相应速度多少取决于机器的体系结构即使经过调校在某一种机器平台避免了资源超额而仍然保持硬件的繁忙状态,换一个其他类型的机器这个调校并不能提供较好效果的保证。
而使用`std::async`可以将调校最优比例这件事隐藏于标准库中,在应用层面不需过多考虑
如果你把这些问题推给另一个人做,你就会变得很轻松,而使用`std::async`就做了这件事:
```cpp
auto fut = std::async(doAsyncWork); // onus of thread mgmt is
// on implement of
// the Standard Library
auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者
```
这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额的异常,为何这么说调用`std::async`并不保证开启一个新的线程,只是提供了执行函数的保证,具体是否创建新的线程来运行此函数,取决于具体实现,比如可以通过调度程序来将`AsyncWork`运行在等待此函数结果的线程上,调度程序的合理性决定了系统是否会抛出资源超额的异常,但是这是库开发者需要考虑的事情了
这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。你会想:“怎么可能?如果我要求比系统可以提供的更多的软件线程,创建`std::thread`和调用`std::async`为什么会有区别?”确实有区别,因为以这种形式调用(即使用默认启动策略——见[Item36](https://github.com/kelthuzadx/EffectiveModernCppChinese/blob/master/7.TheConcurrencyAPI/item36.md))时,`std::async`不保证会创建新的软件线程。然而,他们允许通过调度器来将特定函数(本例中为`doAsyncWork`)运行在等待此函数结果的线程上(即在对`fut`调用`get`或者`wait`的线程上),合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度
如果考虑自己实现在等待结果的线程上运行输出结果的函数,之前提到了可能引出负载不均衡的问题,`std::async`运行时的调度程序显然比开发者更清楚调度策略的制定,因为运行时调度程序管理的是所有执行过程,而不仅仅个别开发者运行的代码。
如果考虑自己实现在等待结果的线程上运行输出结果的函数,之前提到了可能引出负载不均衡的问题,这问题不那么容易解决,因为应该是`std::async`和运行时的调度程序来解决这个问题而不是你。遇到负载不均衡问题时,对机器内发生的事情,运行时调度程序比你有更全面的了解,因为它管理的是所有执行过程,而不仅仅个别开发者运行的代码。
如果在GUI程序中使用`std::async`会引起响应变慢的问题,还可以通过`std::launch::async`向`std::async`传递调度策略来保证运行函数在不同的线程上执行
有了`std::async`GUI线程中响应变慢仍然是个问题因为调度器并不知道你的哪个线程有高响应要求。这种情况下你会想通过向`std::async`传递`std::launch::async`启动策略来保证想运行函数在不同的线程上执行(见[Item36](https://github.com/kelthuzadx/EffectiveModernCppChinese/blob/master/7.TheConcurrencyAPI/item36.md)
最前沿的线程调度算法使用线程池来避免资源超额的问题并且通过窃取算法来提升了跨硬件核心的负载均衡。C++标准实际上并不要求使用线程池或者`work-stealing`算法,而且这些技术的实现难度可能比你想象中更有挑战。不过,库开发者在标准库实现中采用了这些前沿的技术,这使得采用基于任务的方式编程的开发者在这些技术发展中持续获得回报,相反如果开发者直接使用`std::thread`编程,处理资源耗竭,负责均衡问题的责任就压在了应用开发者身上,更不说如何使得开发方案跨平台使用
最前沿的线程调度器使用系统级线程池(*thread pool*)来避免资源超额的问题,并且通过工作窃取算法(*work-stealing algorithm*来提升了跨硬件核心的负载均衡。C++标准实际上并不要求使用线程池或者工作窃取实际上C++11并发规范的某些技术层面使得实现这些技术的难度可能比想象中更有挑战。不过库开发者在标准库实现中采用了这些技术也有理由期待这个领域会有更多进展。如果你当前的并发编程采用基于任务的方式在这些技术发展中你会持续获得回报。相反如果你直接使用`std::thread`编程,处理线程耗尽、资源超额、负责均衡问题的责任就压在了你身上,更不用说你对这些问题的解决方法与同机器上其他程序采用的解决方案配合得好不好了
对比基于线程的开发方式,基于任务的设计为开发者避免了线程管理的痛苦,并且自然提供了一种获取异步执行的结果的方式。当然,仍然存在一些场景直接使用`std::thread`会更有优势:
对比基于线程的编程方式,基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用`std::thread`会更有优势:
- **需要访问非常基础的线程API**。C++并发API通常是通过操作系统提供的系统级API(pthreads 或者 windows threads)来实现的系统级API通常会提供更加灵活的操作方式举个例子C++并发API没有线程优先级和affinities的概念。为了提供对底层系统级线程API的访问`std::thread`对象提供了`native_handle`的成员函数,而在高层抽象的比如`std::futures`没有这种能力。
- **需要优化应用的线程使用**。举个例子,只在特定系统平台运行的软件可以调教地比使用C++并行API更好的程序性能
- **需要实现C++并发API之外的线程技术**。举例来说,自行实现线程池技术
- **需要访问非常基础的线程API**。C++并发API通常是通过操作系统提供的系统级APIpthreads或者Windows threads来实现的系统级API通常会提供更加灵活的操作方式举个例子C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问`std::thread`对象提供了`native_handle`的成员函数,而`std::future`(即`std::async`返回的东西)没有这种能力。
- **需要且能够优化应用的线程使用**。举个例子,你要开发一款已知执行概况的服务器软件,部署在有固定硬件特性的机器上,作为唯一的关键进程
- **需要实现C++并发API之外的线程技术**比如C++实现中未支持的平台的线程池
这些都是在应用开发中并不常见的例子,大多数情况,开发者应该优先采用基于任务的编程方式。
**请记住:**
## 记住
- `std::thread`API不能直接访问异步执行的结果如果执行函数有异常抛出代码会终止执行
- 基于线程的编程方式关于解决资源超限,负载均衡的方案移植性不佳
- 基于任务的编程方式`std::async`会默认解决上面两条问题
- `std::thread` API不能直接访问异步执行的结果如果执行函数有异常抛出代码会终止执行。
- 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理。
- 通过带有默认启动策略的`std::async`进行基于任务的编程方式会解决大部分问题。