目录
条款35:优先使用基于任务而非基于线程的程序设计
要点速记:
条款36:如果异步是必要的,则指定std::launch::async
要点速记:
参考:EffectiveModernCppChinese/src/7.TheConcurrencyAPI/Item35.md at master · CnTransGroup/EffectiveModernCppChinese (github.com)
基于任务的方法通常比基于线程的方法更优,原因之一上面的代码已经表明,基于任务的方法代码量更少。我们假设调用doAsyncWork
的代码对于其提供的返回值是有需求的。基于线程的方法对此无能为力,而基于任务的方法就简单了,因为std::async
返回的future提供了get
函数(从而可以获取返回值)。如果doAsycnWork
发生了异常,get
函数就显得更为重要,因为get
函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork
抛出了异常,程序会直接终止(通过调用std::terminate
)。
基于线程与基于任务最根本的区别在于,基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了“thread”的三种含义:
std::thread
是C++执行过程的对象,并作为软件线程的句柄(handle)。有些std::thread
对象代表“空”句柄,即没有对应软件线程,因为它们处在默认构造状态(即没有函数要执行);有些被移动走(移动到的std::thread
就作为这个软件线程的句柄);有些被join
(它们要运行的函数已经运行完);有些被detach
(它们和对应的软件线程之间的连接关系被打断)。auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者
这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。你会想:“怎么可能?如果我要求比系统可以提供的更多的软件线程,创建std::thread
和调用std::async
为什么会有区别?”确实有区别,因为以这种形式调用(即使用默认启动策略——见Item36)时,std::async
不保证会创建新的软件线程。然而,他们允许通过调度器来将特定函数(本例中为doAsyncWork
)运行在等待此函数结果的线程上(即在对fut
调用get
或者wait
的线程上),合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。
如果考虑自己实现“在等待结果的线程上运行输出结果的函数”,之前提到了可能引出负载不均衡的问题,这问题不那么容易解决,因为应该是std::async
和运行时的调度程序来解决这个问题而不是你。遇到负载不均衡问题时,对机器内发生的事情,运行时调度程序比你有更全面的了解,因为它管理的是所有执行过程,而不仅仅个别开发者运行的代码。
有了std::async
,GUI线程中响应变慢仍然是个问题,因为调度器并不知道你的哪个线程有高响应要求。这种情况下,你会想通过向std::async
传递std::launch::async
启动策略来保证想运行函数在不同的线程上执行
对比基于线程的编程方式,基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用std::thread
会更有优势:
std::thread
对象提供了native_handle
的成员函数,而std::future
(即std::async
返回的东西)没有这种能力。这些都是在应用开发中并不常见的例子,大多数情况,开发者应该优先采用基于任务的编程方式。
std::thread
API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。std::async
进行基于任务的编程方式会解决大部分问题。当你调用std::async
执行函数时(或者其他可调用对象),你通常希望异步执行函数。但是这并不一定是你要求std::async
执行的操作。你事实上要求这个函数按照std::async
启动策略来执行。有两种标准策略,每种都通过std::launch
这个限域enum
的一个枚举名表示(关于枚举的更多细节参见Item10)。假定一个函数f
传给std::async
来执行:
std::launch::async
启动策略意味着f
必须异步执行,即在不同的线程。std::launch::deferred
启动策略意味着f
仅当在std::async
返回的future上调用get
或者wait
时才执行。这表示f
推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。(这是个简化说法。关键点不是要在其上调用get
或wait
的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future
支持移动,也可以用来构造std::shared_future
,并且因为std::shared_future
可以被拷贝,对共享状态——对f
传到的那个std::async
进行调用产生的——进行引用的future对象,有可能与std::async
返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async
返回的future上调用get
或wait
。)可能让人惊奇的是,std::async
的默认启动策略——你不显式指定一个策略时它使用的那个——不是上面中任意一个。相反,是求或在一起的。下面的两种调用含义相同:
auto fut1 = std::async(f); //使用默认启动策略运行f
auto fut2 = std::async(std::launch::async | //使用async或者deferred运行f
std::launch::deferred,
f);
因此默认策略允许f
异步或者同步执行。如同Item35中指出,这种灵活性允许std::async
和标准库的线程管理组件承担线程创建和销毁的责任,避免资源超额,以及平衡负载。这就是使用std::async
并发编程如此方便的原因。
但是,使用默认启动策略的std::async
也有一些有趣的影响。给定一个线程t
执行此语句:
auto fut = std::async(f); //使用默认启动策略运行f
f
是否会与t
并发运行,因为f
可能被安排延迟运行。f
是否会在与某线程相异的另一线程上执行,这个某线程在fut
上调用get
或wait
。如果对fut
调用函数的线程是t
,含义就是无法预测f
是否在异于t
的另一线程上执行。f
是否执行,因为不能确保在程序每条路径上,都会不会在fut
上调用get
或者wait
。默认启动策略的调度灵活性导致使用thread_local
变量比较麻烦,因为这意味着如果f
读写了线程本地存储(thread-local storage,TLS),不可能预测到哪个线程的变量被访问。
这还会影响到基于wait
的循环使用超时机制,因为在一个延时的任务(参见Item35)上调用wait_for
或者wait_until
会产生std::launch::deferred
值。意味着,以下循环看似应该最终会终止,但可能实际上永远运行:
using namespace std::literals; //为了使用C++14中的时间段后缀;参见条款34
void f() //f休眠1秒,然后返回
{
std::this_thread::sleep_for(1s);
}
auto fut = std::async(f); //异步运行f(理论上)
while (fut.wait_for(100ms) != //循环,直到f完成运行时停止...
std::future_status::ready) //但是有可能永远不会发生!
{
…
}
这些各种考虑的结果就是,只要满足以下条件,std::async
的默认启动策略就可以使用:
get
或wait
的线程并行执行。thread_local
变量没什么问题。std::async
返回的future上调用get
或wait
,或者该任务可能永远不会执行也可以接受。wait_for
或wait_until
编码时考虑到了延迟状态。如果上述条件任何一个都满足不了,你可能想要保证std::async
会安排任务进行真正的异步执行。进行此操作的方法是调用时,将std::launch::async
作为第一个实参传递:
auto fut = std::async(std::launch::async, f); //异步启动f的执行
std::async
的默认启动策略是异步和同步执行兼有的。thread_local
s的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait
的程序逻辑。std::launch::async
。