Understanding Drogon’s threading model
drogon 是一个快速的 C++ Web 应用程序框架,部分原因是没有抽象化底层线程模型并把它们包裹起来。 然而这也常引发一些用户的疑惑。 社群中经常会看到一些问题和讨论,为什么响应只在一些阻塞调用之后发送、为什么在同一个事件循环块上调用阻塞网络函数会导致死锁等等。本文的目的在解释导致它们的确切条件和如何避免它们。
事件循环和线程
Drogon 在线程池上运行,其中每个线程都有自己的事件循环。事件循环是 Drogon 的核心。且每个 drogon 应用至少有 2 个事件循环。一个主循环和一个工作循环。一般来说, 主循环总是在主线程(启动 main
的线程)上运行。它负责启动所有工作循环。以 hello world 为例。 app().run()
在主线程上启动主循环。这进而产生 3 个工作线程/循环。
#include <drogon/drogon.h>
using namespace drogon;
int main()
{
app().registerHandler("/", [](const HttpRequest& req
, std::function<void (const HttpResponsePtr &)> &&callback) {
auto resp = HttpResponse::newHttpResponse();
resp->setBody("Hello wrold");
callback(resp);
});
app().addListener("0.0.0.0", 80800);
app().setNumThreads(3);
app().run();
}
线程结构看起来像这样
.-------------.
| app().run() | <main thread>
:-------------:
|
.-----v------.
| MAIN LOOP |
:------------:
| Spawns
.-----------+--------------.
| | |
.-----v----. .--v-------. .---v----.
| Worker 1 | | Worker 2 | | etc... |
:----------: :----------: :--------:
<thread 1> <thread 2> <thread ...>
工作循环的数量取决于许多变量,包括为 HTTP 服务器指定了多少线程、创建了多少非快速 DB 和 NoSQL 连接等等,稍后再讨论快速连接与非快速连接,重要的是 drogon 而不仅仅有 HTTP 服务器线程。每个事件循环本质上都是一个任务队列,它主要处理如下几种事情:
- 在事件循环的线程上读取任务队列里的任务并执行它,您可以从任何其他线程提交任务,任务的提交和执行是完全无锁的(感谢无锁数据结构)并且在所有情况下都不会导致数据竞争。事件循环会按顺序一个一个地处理任务。因此,任务具有明确定义的执行顺序。但是,在一个巨大的、长时间运行的任务之后排队的任务也会被延迟;
- 当被该事件循环管理的网络资源上有任何注册的事件发生时,事件循环会调用对应的处理程序对该事件进行处理;
- 当该事件循环管理的任何定时器到时时,事件循环会调用对应的定时器处理函数(通常由定时器的创建者提供); 当上述任何事件都没有发生时,事件循环/线程处于阻塞挂起的状态。
下面看一个例子:
// queuing two tasks on the main loop
trantor::EventLoop* loop = app().getLoop();
loop->queueInLoop([]{
std::cout << "task1: I'm gonna wait for 5s\n";
std::this_thread::sleep_for(5s);
std::cout << "task1: hello!\n";
});
loop->queueInLoop([]{
std::cout << "task2: world!\n";
});
希望现在你能理解为什么运行上面的代码片段会导致task1: I'm going wait for 5s
立即出现。暂停 5 秒钟,然后task1: hello
和task2: world!
再出现。
重点1:不要在事件循环中调用阻塞IO。这会导致其他任务必须等待该 IO。
网络IO
drogon 中的几乎所有内容都与事件循环相关联。这包括 TCP 、HTTP 客户端、数据库客户端和数据缓存。为避免竞争条件,所有 IO 都在关联的事件循环中完成。如果 IO 调用是从另一个线程进行的,则参数将被存储并作为任务提交给适当的事件循环。这有一些含意。例如,当从 HTTP 程序中进行数据库调用时。来自客户端的回调可能不一定(实际上,通常不会)与原始程序在同一线程上运行。
app().registerHandler("/send_req", [](const HttpRequest& req
, std::function<void (const HttpResponsePtr &)> &&callback) {
// This handler will run on one of the HTTP server threads
// Create a HTTP client that runs on the main loop
auto client = HttpClient::newHttpClient("https://drogon.org", app().getLoop());
auto request = HttpRequest::newHttpRequest();
client->sendRequest(request, [](ReqResult result, const HttpResponse& resp) {
// This callback runs on the main thread
});
});
因此,如果您不知道您的代码实际在做什么,可能会阻塞事件循环。例如在主循环上创建大量 HTTP 客户端并发送所有传出请求, 或者在数据库回调中运行计算量大的函数。延后的其他线程请求的数据库查询。
Worker 1 Main Loop Worker2
.---------. .----------. .---------.
| | | | | |
| req 1-. | |----------| .--+--req 2 |
| :-+----+-> | | | |
| | | send http| | |---------|
|---------| a.-| req 1 | | | |
|other req| s| |----------| | | |
|---------| y| | <---+--: | |
| | n| |send http | | |
| | c| | req 2 |-. | |
| | | |----------| |a |---------|
| | | |http resp1| |s |other req|
| | :-|>compute | |y |---------|
| | | | |n | |
| | .--+-generate | |c | |
| | | | response | | | |
| | | |----------| | | |
| | | |http resp2|<: | |
|---------| | | compute | |---------|
|response<|-: | |-----|> |
|send back| | generate | |send resp|
| | | response | | back |
:---------: :----------: :---------:
同样的原理也适用于 HTTP 服务器。如果响应是从另外的线程生成的(例如:在 DB 回调中)。响应会在关联线程上排队等待发送而不是立即发送。
重点2:注意大量计算的函数。如果不小心,它们也会影响吞吐量。
事件循环死锁
了解 Drogon 的设计方式后。不难看出如何使事件循环死锁。您只需提交一个远端 IO 请求并在同一个循环中等待它。事实上同步接口内部就是如此运最。它提交一个 IO 请求并等待回调(使用者要注意不能在当前循环中使用同步接口)。
app().registerHandler("/dead_lock", [](const HttpRequest& req
, std::function<void (const HttpResponsePtr &)> &&callback) {
auto currentLoop = app().getIOLoops()[app().getCurrentThreadIndex()];
auto client = HttpClient::newHttpClient("https://drogon.org", currentLoop);
auto request = HttpRequest::newHttpRequest();
auto resp = client->sendRequest(resp); // DEADLOCK! calling the sync interface
});
可以将其可视化为
Some loop
.------------.
| new client |
| new request|
|send request|
.->WAIT resp---+-.
| | .... | |
?| | | |
?| |------------| |
?| | | |
?| | | |
?| | | |
?| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| |------------| |
| | read resp | |
:-+- <-+-:
| oops |
| deadlock |
:------------:
其他功能也是如此。数据库、NoSQL ,你能想到的。幸运的是,非快速数据库客户端在它们自己的线程上运行; 每个客户端都有自己的线程。因此,从 HTTP 处理程序进行同步数据库查询是安全的。但是,您不应在同一客户端的回调中运行同步数据库查询。否则同样的事情会发生。
重点 3:同步 API 对性能和安全都是不利的,请避开它们。如果必须,请确保在单独的线程上运行客户端。
高速数据库客户端
Drogon 是为性能而设计的,其次是易用性。快速数据库客户端共享 HTTP 服务器线程。这消除向另一个线程提交请求的需要来提高性能,并避免互斥锁和操作系统的上下文切换。然而由于共享线程。您不能对它们使用同步接口。这会使事件循环死锁。
使用协程
Drogon 开发和使用中一个困境是,异步 API 更有效但使用起来很烦人。虽然同步 API 可能存在问题且速度缓慢,但是它们很容易编程。 Lambda 声明可能冗长, 而且语法并不优雅,代码不会从上到下运行,而是充满了回调以及各种嵌套;与此相对,同步 API 比异步更干净,但性能差很多(它会使线程经常处于等待状态从而降低吞吐量)。 下面是异步编排和同步编排的对比:
异步:
// drogon's async DB API
auto db = app().getDbClient();
db->execSqlAsync("INSERT......", [db, callback](auto result){
db->execSqlAsync("UPDATE .......", [callback](auto result){
// Handle success
},
[callback](const DbException& e) {
// handle failure
})
},
[callback](const DbException& e){
// handle failure
})
同步:
// drogon's sync API. Exception can be handled automatically by the framework
db->execSqlSync("INSERT.....");
db->execSqlSync("UPDATE.....");
一定有办法一石二鸟吧? C++20 的协程就是我们的解决之道,本质上,协程是被编译器支持的回调包装器,使您的代码看起来像是同步的,但实际上一直都是异步的。下面是使用协程的相同的代码:
co_await db->execSqlCoro("INSERT.....");
co_await db->execSqlCoro("UPDATE.....");
它与同步 API 的形式完全一样!但几乎在各个方面都更好。可以获得异步的所有好处,但继续使用类似同步的接口。他的原理超出本文的范围。 drogon维护者建议尽可能使用协程(GCC >= 11. MSVC >= 16.25)。然而,它并不是万能魔法,它不会解决阻塞事件循环和竞争条件,但使用协程更容易调试和理解异步代码。
重点 4:尽可能使用协程
总结
- 尽可能使用 C++20 协程和快速数据库连接
- 同步 API 可能减慢速度或导致事件循环死锁
- 如果您必须使用同步 API。确保它们运行在跟当前程序不同的线程上