Understanding Drogon’s threading model

drogon 是一个快速的 C++ Web 应用程序框架,部分原因是没有抽象化底层线程模型并把它们包裹起来。 然而这也常引发一些用户的疑惑。 社群中经常会看到一些问题和讨论,为什么响应只在一些阻塞调用之后发送、为什么在同一个事件循环块上调用阻塞网络函数会导致死锁等等。本文的目的在解释导致它们的确切条件和如何避免它们。

事件循环和线程

Drogon 在线程池上运行,其中每个线程都有自己的事件循环。事件循环是 Drogon 的核心。且每个 drogon 应用至少有 2 个事件循环。一个主循环和一个工作循环。一般来说, 主循环总是在主线程(启动 main 的线程)上运行。它负责启动所有工作循环。以 hello world 为例。 app().run() 在主线程上启动主循环。这进而产生 3 个工作线程/循环。

  1. #include <drogon/drogon.h>
  2. using namespace drogon;
  3. int main()
  4. {
  5. app().registerHandler("/", [](const HttpRequest& req
  6. , std::function<void (const HttpResponsePtr &)> &&callback) {
  7. auto resp = HttpResponse::newHttpResponse();
  8. resp->setBody("Hello wrold");
  9. callback(resp);
  10. });
  11. app().addListener("0.0.0.0", 80800);
  12. app().setNumThreads(3);
  13. app().run();
  14. }

线程结构看起来像这样

  1. .-------------.
  2. | app().run() | <main thread>
  3. :-------------:
  4. |
  5. .-----v------.
  6. | MAIN LOOP |
  7. :------------:
  8. | Spawns
  9. .-----------+--------------.
  10. | | |
  11. .-----v----. .--v-------. .---v----.
  12. | Worker 1 | | Worker 2 | | etc... |
  13. :----------: :----------: :--------:
  14. <thread 1> <thread 2> <thread ...>

工作循环的数量取决于许多变量,包括为 HTTP 服务器指定了多少线程、创建了多少非快速 DB 和 NoSQL 连接等等,稍后再讨论快速连接与非快速连接,重要的是 drogon 而不仅仅有 HTTP 服务器线程。每个事件循环本质上都是一个任务队列,它主要处理如下几种事情:

  1. 在事件循环的线程上读取任务队列里的任务并执行它,您可以从任何其他线程提交任务,任务的提交和执行是完全无锁的(感谢无锁数据结构)并且在所有情况下都不会导致数据竞争。事件循环会按顺序一个一个地处理任务。因此,任务具有明确定义的执行顺序。但是,在一个巨大的、长时间运行的任务之后排队的任务也会被延迟;
  2. 当被该事件循环管理的网络资源上有任何注册的事件发生时,事件循环会调用对应的处理程序对该事件进行处理;
  3. 当该事件循环管理的任何定时器到时时,事件循环会调用对应的定时器处理函数(通常由定时器的创建者提供); 当上述任何事件都没有发生时,事件循环/线程处于阻塞挂起的状态。

下面看一个例子:

  1. // queuing two tasks on the main loop
  2. trantor::EventLoop* loop = app().getLoop();
  3. loop->queueInLoop([]{
  4. std::cout << "task1: I'm gonna wait for 5s\n";
  5. std::this_thread::sleep_for(5s);
  6. std::cout << "task1: hello!\n";
  7. });
  8. loop->queueInLoop([]{
  9. std::cout << "task2: world!\n";
  10. });

希望现在你能理解为什么运行上面的代码片段会导致task1: I'm going wait for 5s立即出现。暂停 5 秒钟,然后task1: hellotask2: world!再出现。

重点1:不要在事件循环中调用阻塞IO。这会导致其他任务必须等待该 IO。

网络IO

drogon 中的几乎所有内容都与事件循环相关联。这包括 TCP 、HTTP 客户端、数据库客户端和数据缓存。为避免竞争条件,所有 IO 都在关联的事件循环中完成。如果 IO 调用是从另一个线程进行的,则参数将被存储并作为任务提交给适当的事件循环。这有一些含意。例如,当从 HTTP 程序中进行数据库调用时。来自客户端的回调可能不一定(实际上,通常不会)与原始程序在同一线程上运行。

  1. app().registerHandler("/send_req", [](const HttpRequest& req
  2. , std::function<void (const HttpResponsePtr &)> &&callback) {
  3. // This handler will run on one of the HTTP server threads
  4. // Create a HTTP client that runs on the main loop
  5. auto client = HttpClient::newHttpClient("https://drogon.org", app().getLoop());
  6. auto request = HttpRequest::newHttpRequest();
  7. client->sendRequest(request, [](ReqResult result, const HttpResponse& resp) {
  8. // This callback runs on the main thread
  9. });
  10. });

因此,如果您不知道您的代码实际在做什么,可能会阻塞事件循环。例如在主循环上创建大量 HTTP 客户端并发送所有传出请求, 或者在数据库回调中运行计算量大的函数。延后的其他线程请求的数据库查询。

  1. Worker 1 Main Loop Worker2
  2. .---------. .----------. .---------.
  3. | | | | | |
  4. | req 1-. | |----------| .--+--req 2 |
  5. | :-+----+-> | | | |
  6. | | | send http| | |---------|
  7. |---------| a.-| req 1 | | | |
  8. |other req| s| |----------| | | |
  9. |---------| y| | <---+--: | |
  10. | | n| |send http | | |
  11. | | c| | req 2 |-. | |
  12. | | | |----------| |a |---------|
  13. | | | |http resp1| |s |other req|
  14. | | :-|>compute | |y |---------|
  15. | | | | |n | |
  16. | | .--+-generate | |c | |
  17. | | | | response | | | |
  18. | | | |----------| | | |
  19. | | | |http resp2|<: | |
  20. |---------| | | compute | |---------|
  21. |response<|-: | |-----|> |
  22. |send back| | generate | |send resp|
  23. | | | response | | back |
  24. :---------: :----------: :---------:

同样的原理也适用于 HTTP 服务器。如果响应是从另外的线程生成的(例如:在 DB 回调中)。响应会在关联线程上排队等待发送而不是立即发送。

重点2:注意大量计算的函数。如果不小心,它们也会影响吞吐量。

事件循环死锁

了解 Drogon 的设计方式后。不难看出如何使事件循环死锁。您只需提交一个远端 IO 请求并在同一个循环中等待它。事实上同步接口内部就是如此运最。它提交一个 IO 请求并等待回调(使用者要注意不能在当前循环中使用同步接口)。

  1. app().registerHandler("/dead_lock", [](const HttpRequest& req
  2. , std::function<void (const HttpResponsePtr &)> &&callback) {
  3. auto currentLoop = app().getIOLoops()[app().getCurrentThreadIndex()];
  4. auto client = HttpClient::newHttpClient("https://drogon.org", currentLoop);
  5. auto request = HttpRequest::newHttpRequest();
  6. auto resp = client->sendRequest(resp); // DEADLOCK! calling the sync interface
  7. });

可以将其可视化为

  1. Some loop
  2. .------------.
  3. | new client |
  4. | new request|
  5. |send request|
  6. .->WAIT resp---+-.
  7. | | .... | |
  8. ?| | | |
  9. ?| |------------| |
  10. ?| | | |
  11. ?| | | |
  12. ?| | | |
  13. ?| | | |
  14. | | | |
  15. | | | |
  16. | | | |
  17. | | | |
  18. | | | |
  19. | | | |
  20. | |------------| |
  21. | | read resp | |
  22. :-+- <-+-:
  23. | oops |
  24. | deadlock |
  25. :------------:

其他功能也是如此。数据库、NoSQL ,你能想到的。幸运的是,非快速数据库客户端在它们自己的线程上运行; 每个客户端都有自己的线程。因此,从 HTTP 处理程序进行同步数据库查询是安全的。但是,您不应在同一客户端的回调中运行同步数据库查询。否则同样的事情会发生

重点 3:同步 API 对性能和安全都是不利的,请避开它们。如果必须,请确保在单独的线程上运行客户端。

高速数据库客户端

Drogon 是为性能而设计的,其次是易用性。快速数据库客户端共享 HTTP 服务器线程。这消除向另一个线程提交请求的需要来提高性能,并避免互斥锁和操作系统的上下文切换。然而由于共享线程。您不能对它们使用同步接口。这会使事件循环死锁。

使用协程

Drogon 开发和使用中一个困境是,异步 API 更有效但使用起来很烦人。虽然同步 API 可能存在问题且速度缓慢,但是它们很容易编程。 Lambda 声明可能冗长, 而且语法并不优雅,代码不会从上到下运行,而是充满了回调以及各种嵌套;与此相对,同步 API 比异步更干净,但性能差很多(它会使线程经常处于等待状态从而降低吞吐量)。 下面是异步编排和同步编排的对比:

异步:

  1. // drogon's async DB API
  2. auto db = app().getDbClient();
  3. db->execSqlAsync("INSERT......", [db, callback](auto result){
  4. db->execSqlAsync("UPDATE .......", [callback](auto result){
  5. // Handle success
  6. },
  7. [callback](const DbException& e) {
  8. // handle failure
  9. })
  10. },
  11. [callback](const DbException& e){
  12. // handle failure
  13. })

同步:

  1. // drogon's sync API. Exception can be handled automatically by the framework
  2. db->execSqlSync("INSERT.....");
  3. db->execSqlSync("UPDATE.....");

一定有办法一石二鸟吧? C++20 的协程就是我们的解决之道,本质上,协程是被编译器支持的回调包装器,使您的代码看起来像是同步的,但实际上一直都是异步的。下面是使用协程的相同的代码:

  1. co_await db->execSqlCoro("INSERT.....");
  2. co_await db->execSqlCoro("UPDATE.....");

它与同步 API 的形式完全一样!但几乎在各个方面都更好。可以获得异步的所有好处,但继续使用类似同步的接口。他的原理超出本文的范围。 drogon维护者建议尽可能使用协程(GCC >= 11. MSVC >= 16.25)。然而,它并不是万能魔法,它不会解决阻塞事件循环和竞争条件,但使用协程更容易调试和理解异步代码。

重点 4:尽可能使用协程

总结

  • 尽可能使用 C++20 协程和快速数据库连接
  • 同步 API 可能减慢速度或导致事件循环死锁
  • 如果您必须使用同步 API。确保它们运行在跟当前程序不同的线程上