EffectiveModernCppChinese/7.TheConcurrencyAPI/item36.md
猫耳堀川雷鼓 81abbcf78a
Update item36.md
2021-03-05 20:12:07 +08:00

9.2 KiB
Raw Blame History

条款三十六:如果有异步的必要请指定std::launch::async

Item 36: Specify std::launch::async if asynchronicity is essential.

当你调用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推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当getwait被调用,f会同步执行,即调用方被阻塞,直到f运行结束。如果getwait都没有被调用,f将不会被执行。(这是个简化说法。关键点不是要在其上调用getwait的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future支持移动,也可以用来构造std::shared_future,并且因为std::shared_future可以被拷贝,对共享状态——对f传到的那个std::async进行调用产生的——进行引用的future对象,有可能与std::async返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async返回的future上调用getwait。)

可能让人惊奇的是,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上调用getwait。如果对fut调用函数的线程是t,含义就是无法预测f是否在异于t的另一线程上执行。
  • 无法预测f是否执行,因为不能确保在程序每条路径上,都会不会在fut上调用get或者wait

默认启动策略的调度灵活性导致使用thread_local变量比较麻烦,因为这意味着如果f读写了线程本地存储thread-local storageTLS不可能预测到哪个线程的变量被访问

auto fut = std::async(f);   //f的TLS可能是为单独的线程建的
                            //也可能是为在fut上调用get或者wait的线程建的

这还会影响到基于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)   //但是有可能永远不会发生!
{
    
}

如果f与调用std::async的线程并发运行(即,如果为f选择的启动策略是std::launch::async),这里没有问题(假定f最终会执行完毕),但是如果f是延迟执行,fut.wait_for将总是返回std::future_status::deferred。这永远不等于std::future_status::ready,循环会永远执行下去。

这种错误很容易在开发和单元测试中忽略,因为它可能在负载过高时才能显现出来。那些是使机器资源超额或者线程耗尽的条件,此时任务推迟执行才最有可能发生。毕竟,如果硬件没有资源耗尽,没有理由不安排任务并发执行。

修复也是很简单的:只需要检查与std::async对应的future是否被延迟执行即可,那样就会避免进入无限循环。不幸的是,没有直接的方法来查看future是否被延迟执行。相反,你必须调用一个超时函数——比如wait_for这种函数。在这个情况中,你不想等待任何事,只想查看返回值是否是std::future_status::deferred所以无须怀疑使用0调用wait_for

auto fut = std::async(f);               //同上

if (fut.wait_for(0s) ==                 //如果task是deferred被延迟状态
    std::future_status::deferred)
{
                                       //在fut上调用wait或get来异步调用f
} else {                                //task没有deferred被延迟
    while (fut.wait_for(100ms) !=       //不可能无限循环假设f完成
           std::future_status::ready) {
                                       //task没deferred被延迟也没ready已准备
                                        //做并行工作直到已准备
    }
                                       //fut是ready已准备状态
}

这些各种考虑的结果就是,只要满足以下条件,std::async的默认启动策略就可以使用:

  • 任务不需要和执行getwait的线程并行执行。
  • 读写哪个线程的thread_local变量没什么问题。
  • 可以保证会在std::async返回的future上调用getwait,或者该任务可能永远不会执行也可以接受。
  • 使用wait_forwait_until编码时考虑到了延迟状态。

如果上述条件任何一个都满足不了,你可能想要保证std::async会安排任务进行真正的异步执行。进行此操作的方法是调用时,将std::launch::async作为第一个参数传递:

auto fut = std::async(std::launch::async, f);   //异步启动f的执行

事实上,对于一个类似std::async行为的函数,但是会自动使用std::launch::async作为启动策略的工具拥有它会非常方便而且编写起来很容易也使它看起来很棒。C++11版本如下

template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params)          //返回异步调用f(params...)得来的future
{
    return std::async(std::launch::async,
                      std::forward<F>(f),
                      std::forward<Ts>(params)...);
}

这个函数接受一个可调用对象f和0或多个参数params,然后完美转发(参见Item25)给std::async,使用std::launch::async作为启动策略。就像std::async一样,返回std::future作为用params调用f得到的结果。确定结果的类型很容易,因为type trait std::result_of可以提供给你。(参见Item9关于type trait的详细表述。)

reallyAsync就像std::async一样使用:

auto fut = reallyAsync(f); //异步运行f如果std::async抛出异常它也会抛出

在C++14中reallyAsync返回类型的推导能力可以简化函数的声明:

template<typename F, typename... Ts>
inline
auto                                        // C++14
reallyAsync(F&& f, Ts&&... params)
{
    return std::async(std::launch::async,
                      std::forward<F>(f),
                      std::forward<Ts>(params)...);
}

这个版本清楚表明,reallyAsync除了使用std::launch::async启动策略之外什么也没有做。

请记住:

  • std::async的默认启动策略是异步和同步执行兼有的。
  • 这个灵活性导致访问thread_locals的不确定性隐含了任务可能不会被执行的意思会影响调用基于超时的wait的程序逻辑。
  • 如果异步执行任务非常关键,则指定std::launch::async