条款三十六:如果有异步的必要请指定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 storage,TLS),不可能预测到哪个线程的变量被访问:

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