C++ 异步编程的奇妙之旅「下」

172 阅读6分钟

以下内容为本人的学习笔记,如需要转载,请将本段内容(无删改)张贴于文章顶部:微信公众号「ENG八戒」mp.weixin.qq.com/s/vCKVYZYdq…,更多无限制精彩内容欢迎查阅我的个人博客站点 ENG八戒

图片

话说上文从线程聊到 std::promise 和 std::future,今天继续聊异步编程。

std::packaged_task

在应用 std::promise 和 std::future 快速实现跨线程异步计算的实例代码里,其实有一个很不爽快的问题,任务函数的计算结果没有直观地通过 return 语句返回。

由于 std::thread 不能接受线程函数的返回值,导致需要将 std::promise 对象传递进入线程执行函数(任务函数)中,再利用这个 std::promise 对象传递计算结果给调用线程的 std::future 对象。

虽然目的是转存计算结果,但是任务函数变得冗余。简直就像,让你发现显示屏上多了根头发,怎么看怎么难受!

既然不喜欢这样的写法,那么我们就想办法优化。

任务函数的计算结果应该是直观地通过函数 return 语句返回:

int compute(int a, int b) {
    int sum = a + b;
    // 模拟耗时
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return sum;
}

在创建 std::thread 对象时,原应传递的任务函数指针改为传递一个函数对象,其余参数照旧。这个函数对象应该内部调用了任务函数,并接收返回值,再通过 std::promise 传递到调用线程的 std::future 对象。

为了创建这个函数对象,可以封装一个类 my_packaged_task。这个类 my_packaged_task 也应该提供方法 get_future() 供调用线程获取与 std::promise 对象关联的 std::future 对象。

基于这种思路,原先的启动异步计算的过程可以如下:

int main() {
    my_packaged_task<int(int, int)> task(compute);
    std::future<int> f = task.get_future();
    std::thread t(std::move(task), 3, 4);
    t.detach();
    std::cout << "get compute result:"
                << f.get() << std::endl;

    return 0;
}

这样,只要实现了类 my_packaged_task,std::promise 就可以被隐藏起来了,再也不用关心它到底如何赋值,在哪里赋值。

为了适应任务函数的多样性,包括返回值的类型、型参的类型等,my_packaged_task 应该被定义为模版类。

template <typename Func>
class my_packaged_task;

template <typename Ret, typename... Args>
class my_packaged_task<Ret(Args...)> {
  //...
};

先声明 my_packaged_task 的通用模板,再定义模板的特例化。Ret 代表任务函数的返回值类型,Args 代表任务函数的可变长参数的类型。

判断模版定义是不是特例化,可以依据模板类型后边是否有 <> 来决定。

实现 my_packaged_task 模板的特例化时,需要定义被隐藏的 std::promise 对象成员:

template <typename Ret, typename... Args>
class my_packaged_task<Ret(Args...)> {
private:
  std::promise<Ret> promise_;
  //...
};

为了能调用任务函数,也需要定义一个可调用对象成员:

template <typename Ret, typename... Args>
class my_packaged_task<Ret(Args...)> {
private:
  //...
  std::function<Ret(Args...)> func_;

public:
  my_packaged_task(std::function<Ret(Args...)> func)
      : func_(std::move(func)) {}
  //...
};

如上,任务函数可以在实例化 my_packaged_task 类时指定。

std::thread 对象在启动线程执行函数(任务函数)时,会调用 my_packaged_task 函数对象的 () 操作符。所以可以在重写 my_packaged_task 类的 () 操作符时,调用任务函数,然后获得任务函数的返回值,再调用 std::promise 对象赋值。

template <typename Ret, typename... Args>
class my_packaged_task<Ret(Args...)> {
//...
public:
  //...
  void operator()(Args&&... args) {
    try {
      promise_.set_value(func_(std::forward<Args&&>(args)...));
    } catch (...) {
      promise_.set_exception(std::current_exception());
    }
  }
  //...
};

与 std::promise 对象关联的 std::future 对象需要通过成员 get_future() 返回:

template <typename Ret, typename... Args>
class my_packaged_task<Ret(Args...)> {
  //...
public:
  //...
  std::future<Ret> get_future() {
    return promise_.get_future();
  }
};

从以上的优化思路来看,my_packaged_task 就是一个任务函数和 std::thread 的适配器。

是的,在以上的过程中,我们实现了标准库里 std::packaged_task 的简化版。如果在 main 函数里直接把 my_packaged_task 替换成 std::packaged_task,可以直接编译,并且运行结果是一致的,所以我们可以直接使用 std::packaged_task 来简化任务函数的结果返回。

std::async

到目前为止,为了启动异步计算的过程还是需要创建一些和计算过程无关的对象,比如 std::packaged_task 和 std::thread 对象等,而真正有意义的就只有用于获取异步计算结果的 std::future 对象。

所以,我们可以大胆地设想一下,能不能继续优化,比如用一个函数或类对象就把 std::packaged_task 和 std::thread 对象等封装起来,对于用户来讲,无须关心它们,仅需要通过这个函数或者类获取 std::future 对象即可。

假设我们用函数 my_async 实现了上面的猜想,启动异步计算的过程就可简化成这样:

int main() {
    std::future<int> f = my_async(compute, 3, 4);
    std::cout << "get compute result:"
                << f.get() << std::endl;
    return 0;
}

任务函数 compute 和前一个例子无异,已经是非常干净俐落的了,不打算再深究它。那么这个 my_async 到底该如何实现?

从最终简化的目标来看,启动异步计算的过程省略了这部份的代码:

std::packaged_task<int(int, int)> task(compute);
std::future<int> f = task.get_future();
std::thread t(std::move(task), 3, 4);
t.detach();

那么是否可以直接把这段代码封装起来?应该是可以的,但是基于 compute 的形式是可变化的,比如上面的这段代码里 compute 的类型为 int(int, int),如果换成其它类型又如何?可见 my_async 应该是用模版函数实现:

template <typename Func, typename... Args>
std::future<typename std::result_of<Func(Args...)>::type>
my_async(Func&& func, Args&&... args) {
    using Result_t = typename std::result_of<Func(Args...)>::type;
    std::packaged_task<Result_t(Args...)> task(func);
    std::future<Result_t> future = task.get_future();
    std::thread t(std::move(task), args...);
    t.detach();
    return future;
}

这里为什么必须要用 t.detach() 呢?是否可换成 t.join()?

my_async 的目标是启动异步计算,那么调用 my_async 的时候我们假设也是不能阻塞的,所以线程应该被放飞而不是等待线程函数执行返回。

事实上,标准库也提供了 std::async() 模版函数实现以上的优化,但是 std::async() 实现的功能更完善,不仅仅是提供立刻执行的异步处理,还提供了延迟的同步处理。

std::async() 提供的立刻执行的异步处理就和上面我们实现的 my_async 功能一样,使用形式还需要添加指明使用的策略 std::launch。

策略有三个:

  1. std::launch::async,保证行为是异步的,任务函数是在子线程中被执行。

  2. std::launch::deferred,不是异步行为,任务函数会被延迟执行,比如调用 std::future::get()std::future::wait() 时,才触发执行,而且不会创建新线程。总结起来,就是需要结果时才执行计算。

  3. 默认值,类似于 std::launch::async | std::launch::deferred,是否是异步执行,依赖于系统负载,用户无法控制。

更多

比较新的 C++ 20 还提供了 std::jthread 和协程(coroutine),边幅有限,暂不继续展开了,有空再聊这个话题。