前言
GitHub: xiaoyang-sde/co-uring-http
前段时间我在实现 rust-kernel-riscv (使用 Rust 无栈协程进行上下文切换的操作系统内核) 时, 跟进了一些 Linux Kernel 的特性, 其中印象最深的就是 io_uring. io_uring 作为最新的高性能异步 I/O 框架, 支持普通文件与网络套接字的异步读写, 解决了传统 AIO 的许多问题. 在 Linux 通过隔离内核页表来应对 Meltdown 攻击后, 系统调用的开销是不可忽略的, 而 io_uring 通过映射一段在用户态与内核态共享的内存区域, 显著减少系统调用的次数, 缓解了刷新缓存开销. 关于 io_uring 的使用方法可以参考迟先生的博客: io_uring 的接口与实现.
C++ 20 引入的无栈协程让编写异步程序容易了不少, 之前通过回调函数实现的功能可以全部通过类似同步代码的写法来实现. 协程的性能很优秀, 创建的开销几乎可以忽略不记, 但是当前的标准只提供了基础功能, 还并没有实现易于使用的协程高级库, 导致我尝试自己封装了一套协程原语, 例如 task<T> 与 sync_wait<task<T>>.
为了体验这些特性, 我用 C++ 20 协程与 io_uring 重新实现了一个烂大街项目: HTTP 服务器. 鉴于以前没用过 C++ 写项目, 再加上 GitHub 常见的 HTTP 服务器项目是基于 Reactor 模式与 epoll 实现的, 以至于我在开发的过程中能借鉴 (指复制) 的机会并不多, 希望各位包容一下我的逆天代码. 我会持续维护这个项目, 争取添加更多特性并进一步优化性能.
主要特性
- 使用 C++ 20 协程简化服务端与客户端的异步交互
- 使用
io_uring管理异步 I/O 请求, 例如accept(),recv(),send() - 使用 ring-mapped buffers 减少内存分配的次数, 减少数据在内核态与用户态之间拷贝的次数 (Linux 5.19 新特性)
- 使用 multishot accept 减少向
io_uring提交accept()请求的次数 (Linux 5.19 新特性) - 实现线程池进行协程调度, 充分利用 CPU 的所有核心
- 使用 RAII 类管理
io_uring, 文件描述符, 以及线程池的生命周期
编译环境
.devcontainer/Dockerfile 提供了基于 ubuntu:lunar 的容器镜像, 已经配置好了编译环境, 可以直接在 Linux 或者 WSL 上使用. WSL 用户可以参考 Update WSL Kernel 的步骤将 Linux Kernel 升级到 6.3, 但是 Docker Desktop on Mac 用户似乎没办法升级.
- Linux Kernel 6.3 或更高版本
- CMake 3.10 或更高版本
- Clang 14 或更高版本
- libstdc++ 11.3 或更高版本 (只要装 GCC 就可以)
- liburing 2.3 或更高版本
cmake -DCMAKE\_BUILD\_TYPE=Release -DCMAKE\_C\_COMPILER:FILEPATH=/usr/bin/clang -DCMAKE\_CXX\_COMPILER:FILEPATH=/usr/bin/clang++ -B build -G "Unix Makefiles"
make -C build -j$(nproc)
./build/co\_uring\_http
性能测试
为了测试 co-uring-http 在高并发情况的性能, 我用 hey 这个工具向它建立 1 万个客户端连接, 总共发送 100 万个 HTTP 请求, 每次请求大小为 1 KB 的文件. co-uring-http 每秒可以 88160 的请求, 并且在 0.5 秒内处理了 99% 的请求.
测试环境是 WSL (Ubuntu 22.04 LTS, Kernel 版本 6.3.0-microsoft-standard-WSL2), i5-12400 (6 核 12 线程), 16 GB 内存, PM9A1 固态硬盘.
./hey -n 1000000 -c 10000 <http://127.0.0.1:8080/1k>
Summary:
Total: 11.3429 secs
Slowest: 1.2630 secs
Fastest: 0.0000 secs
Average: 0.0976 secs
Requests/sec: 88160.9738
Total data: 1024000000 bytes
Size/request: 1024 bytes
Response time histogram:
0.000 [1] |
0.126 [701093] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.253 [259407] |■■■■■■■■■■■■■■■
0.379 [24843] |■
0.505 [4652] |
0.632 [678] |
0.758 [1933] |
0.884 [1715] |
1.010 [489] |
1.137 [5111] |
1.263 [78] |
设计文档
组件简介
-
task(task.hpp):task类表示一个协程, 在被co_await之前不会启动. -
thread_pool(thread_pool.hpp):thread_pool类实现了一个线程池来调度协程. -
file_descriptor(file_descriptor.hpp):file_descriptor类持有一个文件描述符.file_descriptor.hpp文件封装了一些支持file_descriptor类的系统调用,例如open()、pipe()与splice(). -
server_socket(socket.hpp):server_socket类扩展了file_descriptor类, 表示可接受客户端的监听套接字. 它提供了一个accept()方法, 记录是否在io_uring中存在现有的 multishot accept 请求,并在不存在时提交一个新的请求. -
client_socket(socket.hpp):client_socket类扩展了file_descriptor类, 表示与客户端进行通信的套接字. 它提供了一个send()方法, 用于向io_uring提交一个send请求, 以及一个recv()方法, 用于向io_uring提交一个recv请求. -
io_uring(io_uring.hpp):io_uring类是一个thread_local单例, 持有io_urin的提交队列与完成队列. -
buffer_ring(buffer_ring.hpp):buffer_ring类是一个thread_local单例, 向io_uring提供一组固定大小的缓冲区. 当收到一个 HTTP 请求时,io_uring从buffer_ring中选择一个缓冲区用于存放收到的数据. 当这组数据被处理完毕后,buffer_ring会将缓冲区还给io_uring, 允许缓冲区被重复使用. 缓冲区的数量与大小的常量定义于constant.hpp, 可以根据 HTTP 服务器的预估工作负载进行调整. -
http_server(http_server.hpp):http_server类为thread_pool中的每个线程创建一个thread_worker任务, 并等待这些任务执行完毕. (其实这些任务是个无限循环, 根本不会执行完毕.) -
thread_worker(http_server.hpp):thread_worker类提供了一些可以与客户端交互的协程. 它的构造函会启动thread_worker::accept_client()和thread_worker::event_loop()这两个协程. -
thread_worker::event_loop()协程在一个循环中处理io_uring的完成队列中的事件, 并继续运行等待该事件的协程. -
thread_worker::accept_client()协程在一个循环中通过调用server_socket::accept()来提交一个 multishot accept 请求到io_uring. (由于 multishot accept 请求的持久性,server_socket::accept()只有当之前的请求失效时才会提交新的请求到io_uring.) 当新的客户端建立连接后, 它会启动thread_worker::handle_client()协程处理该客户端发来的 HTTP 请求. -
thread_worker::handle_client()协程调用client_socket::recv()来接收 HTTP 请求, 并且用http_parser(http_parser.hpp) 解析 HTTP 请求. 等请求解析完毕后, 它会构造一个http_response(http_message.hpp) 并调用client_socket::send()将响应发给客户端.
// `thread_worker::handle_client()` 协程的简化版代码逻辑
// 省略了许多用于处理 `http_request` 并构造 `http_response` 的代码
// 实现细节请参考源代码
auto thread_worker::handle_client(client_socket client_socket) -> task<> {
http_parser http_parser;
buffer_ring &buffer_ring = buffer_ring::get_instance();
while (true) {
const auto [recv_buffer_id, recv_buffer_size] = co_await client_socket.recv(BUFFER_SIZE);
const std::span<char> recv_buffer = buffer_ring.borrow_buffer(recv_buffer_id, recv_buffer_size);
// ...
if (const auto parse_result = http_parser.parse_packet(recv_buffer); parse_result.has_value()) {
const http_request &http_request = parse_result.value();
// ...
std::string send_buffer = http_response.serialize();
co_await client_socket.send(send_buffer, send_buffer.size());
}
buffer_ring.return_buffer(recv_buffer_id);
}
}
工作流程
http_server为thread_pool中的每个线程创建一个thread_worker任务.- 每个
thread_worker任务使用SO_REUSEPORT选项创建一个套接字来监听相同的端口, 并启动thread_worker::accept_client()与thread_worker::event_loop()协程. - 当新的客户端建立连接后,
thread_worker::accept_client()协程会启动thread_worker::handle_client()协程来处理该客户端的 HTTP 请求. - 当
thread_worker::accept_client()或thread_worker::handle_client()协程等待异步 I/O 请求时, 它会暂停执行并向io_uring的提交队列提交请求, 然后把控制权还给thread_worker::event_loop(). thread_worker::event_loop()处理io_uring的完成队列中的事件. 对于每个事件, 它会识别等待该事件的协程, 并恢复其执行.