14 CHN 16 协程
ProTankerAlfa edited this page 2024-05-20 01:29:57 -03:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

English | 简体中文

Drogon从1.4版本开始支持C++ coroutines(协程)。 它提供了扁平化异步执行控制流的方法, 比如,避免著名的回调地狱callback hell. 通过协程, 异步编程将像同步编程一样简单(同时保持了异步程序的高性能)。

术语

本文无意于解释什么是协程或它是如何工作的而是向大家介绍如何在Drogon中使用协程。有很多术语普通的例程也使用但是在协程里意义稍有不同为了避免引起不必要的混淆我们列举了一些常用术语。

协程Coroutine 是能暂停执行以在之后恢复的函数.
Return 对普通函数来说意味着结束执行并返回一个值。 而协程需要返回一个包含promise_type类型的对象本文中称作_resumable_类型用来恢复这个协程的执行。
(co_)yield意思是协程暂停执行并返回一个值。
co_return意思是协程结束并返回一个值(如果有值的话)。
(co_)await意思是当前的协程正在等待一个结果,如果结果没有立即准备好,比如需要发起网络请求,则当前协程被暂停执行,当前线程将执行其它任务。当结果准备好时,当前协程将被恢复执行(不一定在当前线程恢复)

使能协程

协程特性在Drogon中是header-only的这意味着即使构建drogon库的编译器不支持协程用户也可以 使用协程。如何使能协程和编译器有关,对版本>=10.0的GCC来说可以通过-std=c++20 -fcoroutines编译参数使能协程。对MSVC来说(MSVC 19.25测试通过)需要设置/std:c++latest并且不能设置/await。例如可以通过如下cmake命令使能drogon的协程(GCC)

cmake .. -DCMAKE_CXX_FLAGS="-fcoroutines"

注意截至clang12.0, Drogon的协程实现还不能在clang上工作。 而GCC11在c++20标准开启时是默认支持协程的也就是说如果编译器是GCC11则编译Drogon应用程序不需要做任何特别设置。而GCC 10虽然能编译并执行协程但它有一个编译器bug导致嵌套的协程帧不会被释放进而导致内存泄漏。

使用协程

协程的性能和内在逻辑和异步接口相当不过它的接口确是同步形式的所以基本上Drogon中的每个协程函数或者可以在协程中被co_await的函数接口均相当于对应的同步接口改成了Coro后缀。 比如db->execSqlSync()对应于 db->execSqlCoro(),而client->sendRequest()对应于client->sendRequestCoro()等等。所有上述函数均返回一个_awaitable_对象co_await它将马上或者将来恢复协程时得到一个结果等待结果的过程中当前线程将被框架用于执行其它处理IO等操作这就是协程的美妙之处它的代码看起来是同步的但实际上它是异步的。

比如,我们想返回数据库中用户的个数:

app.registerHandler("/num_users",
    [](HttpRequestPtr req, std::function<void(const HttpResponsePtr&)> callback) -> Task<>
    //                                       返回值必须是某种resumable类型框架已封装好 ^^^
{
    auto sql = app().getDbClient();
    try
    {
        auto result = co_await sql->execSqlCoro("SELECT COUNT(*) FROM users;");
        size_t num_users = result[0][0].as<size_t>();
        auto resp = HttpResponse::newHttpResponse();
        resp->setBody(std::to_string(num_users));
        callback(resp);
    }
    catch(const DrogonDbException &err)
    {
        // 异常也可以像同步接口那样正常工作
        auto resp = HttpResponse::newHttpResponse();
        resp->setBody(err.base().what());
        callback(resp);
    }
    co_return; // 该语句不是必须的因为它位于协程的结束处。因为返回值是Task<void>类型,这里不需要返回任何值
}

几个重要的需要注意的地方:

  1. 任何使用了co_await的handler方法它自身就成为一个协程它的返回值就不能是void类型了必须更换成框架封装好的Task模板。
  2. 普通函数中的return在协程中必须换成co_return
  3. 协程的参数要用值传递。不能是引用。

Task模板遵循了c++ coroutine标准用户不要太关心它的细节只需要知道如果希望协程生成T类型的结果那么返回的类型就是Task<T>

通过值传递参数是协程作为异步执行的一个约束编译器会自动值拷贝或者move这些参数到协程帧上以便协程恢复时可以正常使用对于引用参数协程帧只拷贝它的引用地址所以除非确知该参数的生命周期在整个协程执行的期间都有效请使用值类型作为参数类型。

有的用户可能更希望返回response而不是使用callback这在使用协程的时候是可以使用co_return简单做到的。Drogon支持使用co_return返回response对象不过这可能导致最多8%左右的性能损失和callback方案相比请根据自己的应用特点考虑是否容忍这种性能损失。上面的例子可以改写如下

app.registerHandler("/num_users",
    [](HttpRequestPtr req) -> Task<HttpResponsePtr>)
    //               这里返回response对象 ^^^
{
    auto sql = app().getDbClient();
    try
    {
        auto result = co_await sql->execSqlCoro("SELECT COUNT(*) FROM users;");
        size_t num_users = result[0][0].as<size_t>();
        auto resp = HttpResponse::newHttpResponse();
        resp->setBody(std::to_string(num_users));
        co_return resp;
    }
    catch(const DrogonDbException &err)
    {
        // 异常也可以像同步接口那样正常工作
        auto resp = HttpResponse::newHttpResponse();
        resp->setBody(err.base().what());
        co_return resp;
    }
}

目前websocket控制器还不支持协程如果您有需求请在github上发issue。

常见缺陷

在使用协程时,您可能会遇到一些常见的陷阱。

  • 从函数中使用带有lambda捕获的协程

    Lambda捕获和协程具有不同且独立的生命周期。协程会一直存在直到协程帧被破坏。但匿名lambda通常在调用后立即销毁。因此由于协程的异步性质协程的leftime可能比lambda长得多。例如在下面SQL的执行中。 lambda在开始等待SQL完成后立即销毁返回到事件循环以处理其他事件。而协程帧在等待SQL。导致当SQL刚完成时lambda捕获早就被破坏。

    app().getLoop()->queueInLoop([num] -> AsyncTask {
        auto db = app().getDbClient();
        co_await db->execSqlCoro("DELETE FROM customers WHERE last_login < CURRENT_TIMESTAMP - INTERVAL $1 DAY". std::to_string(num));
        // The lambda object, thus captures destruct right at awaiting. They are destructed at this point
        LOG_INFO << "Remove old customers that have no activity for more than " << num << "days"; // use-after-free
    });
    // BAD, This will crash
    

    Drogon提供了 async_func 来包裹lambda以确保它的生命周期

    app().getLoop()->queueInLoop(async_func([num] -> Task<void> {
    //                             ^^^^^^^^^^^^^^^^^^^^^^^^^ wrap with async_func and return a Task<>
        auto db = app().getDbClient();
        co_await db->execSqlCoro("DELETE FROM customers WHERE last_login < CURRENT_TIMESTAMP - INTERVAL $1 DAY". std::to_string(num));
        LOG_INFO << "Remove old customers that have no activity for more than " << num << "days";
    }));
    // Good
    
  • 在函数中将引用传递/捕获到协程

    在C++中通过引用传递对象以减少不必要的复制是一个很好的习惯。然而通过引用从函数传递到协程通常会导致问题。这是由于协程实际上是异步的,并且与一般函数相比具有更长的生命周期。例如下面的代码

    void removeCustomers(const std::string& customer_id)
    {
        async_run([&customer_id] {
            //      ^^^^ DO NOT pass/capture objects by reference into a coroutine
            // Unless you are sure the object has a longer lifetime than the coroutine
    
            auto db = app().getDbClient();
            co_await db->execSqlCoro("DELETE FROM customers WHERE customer_id = $1", customer_id);
            // `customer_id` goes out of scope right at awaiting SQL. Crashes here
            co_await db->execSqlCoro("DELETE FROM orders WHERE customer_id = $1", customer_id);
        }
    }
    

    但是,将来自协程的对象作为引用传递是可以的

    Task<> removeCustomers(const std::string& customer_id)
    {
        auto db = app().getDbClient();
        co_await db->execSqlCoro("DELETE FROM customers WHERE customer_id = $1", customer_id);
        co_await db->execSqlCoro("DELETE FROM orders WHERE customer_id = $1", customer_id);
    }
    
    Task<> findUnwantedCustomers()
    {
        auto db = app().getDbClient();
        auto list = co_await db->execSqlCoro("SELECT customer_id from customers "
            "WHERE customer_score < 5;");
        for(const auto& customer : list)
            co_await removeCustomers(customer["customer_id"].as<std::string>());
            //                               ^^^^^^^^^^^^^^^^^
            // This is perfectly fine and preferred although it's a const reference
            // since we are calling it from a coroutine
    }
    

17 Redis