c++20 协程

Posted on February 20, 2022

表面上我是想说 c++20 的协程, 其实我是想说, asio 封装后的 asio::awaitable<> , 这个威力巨大的武器将是未来异步的基石.

asio::awaitable<> 的概念, 是万物皆可 await. 万物皆可 await 的概念实际上来自 nodejs.

当初, nodejs 用回调地狱带火了异步. 既然回调地狱为何又火? 因为 nodejs 万物只能异步. nodejs 没有除了异步以外的 IO. 于是 nodejs 逼迫大家在异步的道路上进行探索, 终于诞生了 Promsie/await 对. Promise 是库, await 是语言关键字. Promise 先出, await 后出. 没有 await 的 Promise 是不完整的 Promsie , 没有 Promise 的 await 是无法使用的废物. 两个天造地设的一对, 居然并不是同时出生.

Promise 的概念就是, 所有的函数, 都不直接返回数据, 而是返回 Promise , 要取得真正的返回值, 就要 await 这个 Promise. 那么, 调用返回 Promise 函数的地方, 就变成了 initiator , 而 await promsie 的地方, 就是 completion handler. 也就是, Promsie/await 的概念, 正好契合了 Proactor 的异步并发模型. 自从有了 promise/await , 原先为了避免回调地狱而 设计为 on(‘data’, lisener) 的 reactor 模式的库纷纷被抛弃, 大家都要选择 Promise 版的替代品.

Proactor 模型中实现的最好的库, 自然就是 boost.asio, 或者说,是 std::net.

asio 最初对异步的支持, 使用的是 initiator + completion handler 的模式. 同 nodejs 一样, 陷入了 回调地狱. 但是很快, asio 发明了 stackless 协程. 实际就是 Duff’s Device 实现的可重入函数. 基本用法是

reenter(this)
{
    yield asio::async_read(socket, *this);
}

reenter 和 yield 都是宏, 展开后, 就是变成了一个 for循环+switch case 嵌套.

但是, 这种使用宏展开变成 Duff’s Device 的可重入函数, 不如语言层面的支持来的利索干净. 因此 asio 作者给标准委员会提案, 干脆把宏变成关键字. 有了编译器的支持, Duff’s Device 带来的一些缺陷就可以规避和修正. 使用上也会更便利.

然而这样的优雅提案, 并没有别接受. 委员会转而支持了微软提交的 co_await 协程.

微软缺乏异步大师, 他们有了 co_await 武器, 只能设计出 winrt::IAsyncResult 这样的不能包含万物的协程.

而 asio 爸爸不一样, 它捣鼓出了 asio::awaitable<>

万物皆可 await, 使用的方法和 nodejs 如出一辙.

在 nodejs 里, Promise/await 的使用方法很简单, 首先是将原先的函数声明为 async , 然后就可以在函数体里使用 await .

声明为 async 的函数, 自身也自动变成了 Promise, 可以被其他函数 await

万物皆可 await,

到了 asio 这里, 只要把原来 T 返回值的函数, 换成 asio::awaitable<T> , 就可以在函数体里使用 co_await, 并能被其他函数 co_await.

万物皆可 co_await 后, 我连程序入口点都改成了 asio::awaitable co_main(int argc, char * argv)

然后写一个简单的 stub main

asio::awaitable<int> co_main(int argc, char * argv)
{
    std::cout << "hello world\n";
    co_return 0; 
}

int main(int argc, char * argv)
{
    int co_main_ret;
    asio::io_context io;

    asio::co_spawn(io, co_main(argc, argv), [&co_main_ret](std::exception_ptr e_ptr, int co_main_return)
    {
        if (e_ptr)
            std::rethrow_exeption(e_ptr);
        co_main_ret = co_main_return;
    });

    io.run();

    return co_main_ret;
}

这样 co_main 就变成了真正的 main.

co_main 因为本身处于协程之中, 因此, 他可以使用 co_await asio::this_coro::executor 获取 executor 从而构造需要的 IO 对象.

比如这样写

asio::awaitable<int> co_main(int argc, char * argv)
{
    std::cout << "hello ";
    
    asio::steady_clock timer(co_await asio::this_coro::executor);

    timer.expires_from_now(1s);

    co_await timer.async_await(asio::use_awaitable);
    
    "world\n";

    co_return 0; 
}

int main(int argc, char * argv)
{
    int co_main_ret;
    asio::io_context io;

    asio::co_spawn(io, co_main(argc, argv), [&co_main_ret](std::exception_ptr e_ptr, int co_main_return)
    {
        if (e_ptr)
            std::rethrow_exeption(e_ptr);
        co_main_ret = co_main_return;
    });

    io.run();

    return co_main_ret;
}

调用原先需要传递 回调函数的 asio异步函数, 只要把回调换成 asio::use_awaitable 占位符, 就可以自动把待调用函数变成 awaitable.

因此, 回调不再是回调了, asio 的文档都已经把 callback handler 改称为 completion token 了. 传递 use_awaitable 当做 completion token, 则 initiator 函数就自动 awaitable.

asio 需要兼容 回调/co_await 两种模式, 所以使用了 completion token 概念, 而我自己的代码, 不需要这个概念, 我只要全部 co_await 话, 把所有的函数, 统统变成 awaitable.

万物皆 awaitable 后, 我发现了新大陆.

原先的设计思路突然被放开了限制, 豁然开朗.

原先我设计的程序主体启动代码是这样的


my_server srv(io, configs);

srv.start();

asio::signal_set s(io, SIGTERM);
s.async_wait([&](auto ec, auto sig){ srv.stop() ;} )

io.run();


变成 awaitable 后, 我的主体代码是这样的


my_server srv(io, configs);
asio::signal_set sighandler(io, SIGTERM);
co_await (  srv.run() || sighandler.async_await(asio::use_awaitable)  ) ;
co_await srv.stop();

而且, srv.stop() 变成 awaitable 后, 更容易实现优雅的退出了, 因为可以等待一些费时的逻辑完成退出后, stop 才会 resolve.

这样, srv 就在干净的状态下析构, co_main 也顺利干净利落的返回.

Comments