协程
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中的每个协程函数(或者可以在协程中被coawait的函数)接口均相当于对应的同步接口改成了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>类型,这里不需要返回任何值
}
几个重要的需要注意的地方:
- 任何使用了co_await的handler方法,它自身就成为一个协程,它的返回值就不能是void类型了,必须更换成框架封装好的Task
模板。 - 普通函数中的
return
在协程中必须换成co_return
。 - 协程的参数要用值传递。不能是引用。
TaskTask<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
}