通过HTTP提供文件的三种方式:同步、epoll和io_uring
摘要
一篇技术文章,比较了通过HTTP提供文件的三种方法:同步的每个请求一个线程、基于epoll的异步I/O和io_uring,并附有C语言代码示例。
<p><a href="https://lobste.rs/s/h1wgii/serving_files_over_http_three_ways">评论</a></p>
查看缓存全文
缓存时间: 2026/05/25 19:11
# 通过三种方式通过 HTTP 提供文件:同步、epoll 和 io_uring
来源:https://theconsensus.dev/p/2026/05/18/serving-files-three-ways.html
The Consensus 标志 (https://theconsensus.dev/)
关于软件基础设施。
## 通过三种方式通过 HTTP 提供文件:同步、epoll 和 io_uring
我们通过 HTTP 文件服务器的视角,从同步的每请求一线程服务器作为基线开始,探索 epoll 和 io_uring。
作者:Phil Eaton·2026年5月18日
作为订阅者,您正在提前阅读本文。您的支持使得此类文章成为可能。谢谢。
文件服务器是探索 IO 方法的一个很好的途径,因为我们可以编写一个相对简单的程序,同时处理网络和磁盘 IO。而且,尽管在本文中我们不会这样做,但我们可以花无尽的时间来增强和优化它(即时压缩、完整性检查、keep-alive、上传、内存使用等)。
让我们从一些跨 IO 方法的共享代码开始。无论采用哪种 IO 方法,我们都需要能够监听一个端口。
```c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <pthread.h>
#define DOCROOT "./"
#define MAX_REQ_BUF 4096
#define IO_BUF 16384
static inline int listen_socket(uint16_t port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
exit(1);
}
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
if (listen(fd, 128) < 0) {
perror("listen");
exit(1);
}
return fd;
}
```
`common.h`
我们需要能够从 GET HTTP 请求中提取路径。
```c
static inline int parse_http_get(const char *req, size_t len, char *out, size_t n) {
if (len < 5 || strncmp(req, "GET ", 4) != 0)
return -1;
const char *p = req + 4;
const char *sp = memchr(p, ' ', len - 4);
if (!sp)
return -1;
size_t plen = (size_t)(sp - p);
if (plen == 0 || plen >= n)
return -1;
if (p[0] != '/')
return -1;
memcpy(out, p, plen);
out[plen] = 0;
if (plen == 1) {
if (n < sizeof("/index.html"))
return -1;
memcpy(out, "/index.html", sizeof("/index.html"));
}
return 0;
}
```
`common.h`
我们需要能够为请求的文件扩展名映射 MIME 类型。
```c
static inline const char *mime_for(const char *path) {
const char *dot = strrchr(path, '.');
if (!dot)
return "application/octet-stream";
if (!strncmp(dot, ".html", sizeof(".html")))
return "text/html";
if (!strncmp(dot, ".css", sizeof(".css")))
return "text/css";
if (!strncmp(dot, ".js", sizeof(".js")))
return "application/javascript";
if (!strncmp(dot, ".json", sizeof(".json")))
return "application/json";
if (!strncmp(dot, ".png", sizeof(".png")))
return "image/png";
if (!strncmp(dot, ".jpg", sizeof(".jpg")) || !strncmp(dot, ".jpeg", sizeof(".jpeg")))
return "image/jpeg";
if (!strncmp(dot, ".txt", sizeof(".txt")))
return "text/plain";
return "application/octet-stream";
}
```
`common.h`
我们还需要能够格式化 HTTP 响应,包括成功和错误的。
```c
static inline int build_ok_headers(char *buf, size_t n, const char *mime) {
return snprintf(buf, n, "HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Connection: close\r\n"
"\r\n", mime);
}
static inline int build_404(char *buf, size_t n) {
return snprintf(buf, n, "HTTP/1.1 404 Not Found\r\n"
"Content-Type: text/plain\r\n"
"Connection: close\r\n"
"\r\n"
"404 Not Found\n");
}
```
有了这些,我们可以构建一个简单的同步、每请求一线程的文件服务器。(有许多边界情况我不去担心。)让我们从底层开始,逐步向上构建。我们创建一个服务器,并为每个新接受的连接创建自己的线程。
```c
int main(int argc, char **argv) {
signal(SIGPIPE, SIG_IGN);
uint16_t port = (argc > 1) ? (uint16_t)atoi(argv[1]) : 8080;
int sfd = listen_socket(port);
fprintf(stderr, "sync (thread per request) server on :%u\n", port);
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
for (;;) {
int conn = accept(sfd, NULL, NULL);
if (conn < 0) {
if (errno == EINTR)
continue;
perror("accept");
break;
}
pthread_t tid;
if (pthread_create(&tid, &attr, serve, (void *)(intptr_t)conn) != 0) {
perror("pthread_create");
close(conn);
}
}
pthread_attr_destroy(&attr);
return 0;
}
```
`sync.c`
`serve` 方法读取并解析请求,构建响应并写入。
```c
static void *serve(void *arg) {
int conn = (int)(intptr_t)arg;
char path[1024];
int rc = parse_request(conn, path, sizeof(path));
if (rc == 0)
send_response(conn, path);
else if (rc == -1)
// 连接提前关闭,不做任何操作。
{}
else if (rc == -2)
// 请求格式错误,发送 404。
send_response(conn, NULL);
close(conn);
return NULL;
}
```
`sync.c`
`parse_request` 方法读取所有头部,并填充请求的 `path`。如果连接提前关闭则返回 -1,如果头部无法解析则返回 -2。
```c
static int parse_request(int conn, char *path, size_t path_size) {
char req_buf[MAX_REQ_BUF];
size_t offset = 0;
while (offset < sizeof(req_buf)) {
ssize_t r = read(conn, req_buf + offset, sizeof(req_buf) - offset);
if (r < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (r == 0)
return -1;
offset += (size_t)r;
if (offset >= 4 && memmem(req_buf, offset, "\r\n\r\n", 4))
break;
}
if (parse_http_get(req_buf, offset, path, path_size) < 0)
return -2;
return 0;
}
```
`sync.c`
`send_response` 方法从磁盘读取请求的文件,并通过连接写回。
```c
static void send_response(int conn, const char *path) {
char headers[512];
int file_fd = -1;
if (path != NULL) {
char full[2048];
snprintf(full, sizeof(full), "%s%s", DOCROOT, path);
file_fd = open(full, O_RDONLY);
}
if (file_fd < 0) {
int header_length = build_404(headers, sizeof(headers));
write_all(conn, headers, header_length);
return;
}
int header_length = build_ok_headers(headers, sizeof(headers), mime_for(path));
if (write_all(conn, headers, header_length) < 0) {
close(file_fd);
return;
}
char body_buf[IO_BUF];
for (;;) {
ssize_t r = read(file_fd, body_buf, sizeof(body_buf));
if (r < 0) {
if (errno == EINTR)
continue;
break;
}
if (r == 0)
break;
if (write_all(conn, body_buf, r) < 0)
break;
}
close(file_fd);
}
```
`sync.c`
最后,我们有了头文件和 `write_all` 辅助函数。
```c
#include "common.h"
#include <pthread.h>
static int write_all(int fd, const void *buf, size_t towrite) {
const char *p = buf;
while (towrite) {
ssize_t w = write(fd, p, towrite);
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
p += w;
towrite -= (size_t)w;
}
return 0;
}
```
`sync.c`
编译并尝试运行它。
```bash
sudo apt-get update -y && sudo apt-get install clang
clang sync.c -o sync_file_server
./sync_file_server &
curl localhost:8080/common.h
kill %1
```
同步 I/O 非常简单!虽然我们可以不用线程编写这个服务器,但那样我们就无法接受多个并发连接。每个新连接将等待 `accept`,直到现有的连接完成。我们本可以使用 `pread`(https://man7.org/linux/man-pages/man2/pread.2.html) 或 `readv`(https://man7.org/linux/man-pages/man2/readv.2.html) 来读取文件,或者使用 `sendfile`(https://man7.org/linux/man-pages/man2/sendfile.2.html) 来最小化拷贝次数,但同步操作的变化很少,它们看起来都很相似。同步服务器并不那么奇特。
当我们在 Linux 上审视我们的选择时,事情会变得有趣一些。我们将只关注两种:epoll 和 io_uring。
## epoll
(https://theconsensus.dev/p/2026/05/18/serving-files-three-ways.html#epoll)
使用 epoll,您可以注册文件描述符,epoll 会在文件描述符准备好读取数据或有空间可写时通知您。这只适用于某些文件描述符(如套接字),而不适用于磁盘上的实际文件描述符,因为 epoll 会返回 `EPERM`(https://man7.org/linux/man-pages/man2/epoll_ctl.2.html#:~:text=EPERM%20%20The%20target%20file%20fd%20does%20not%20support%20epoll.%20%20This%20error%20can%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20occur%20if%20fd%20refers%20to%2C%20for%20example%2C%20a%20regular%20file%20or%20a%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20directory.):
> 目标文件 fd 不支持 epoll。如果 fd 指向例如普通文件或目录,则可能出现此错误。
我们可以通过尝试为 `common.h` 注册监听器来亲眼看到这一点。
```c
#include <sys/epoll.h>
#include <stdio.h>
#include <fcntl.h>
int main(int argc, char **argv) {
int file_fd = open("common.h", O_RDONLY);
if (file_fd < 0)
return 1;
int epoll_fd = epoll_create1(0);
if (epoll_fd < 0)
return 2;
struct epoll_event ev = { .events = EPOLLIN, .data.fd = file_fd };
int rc = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, file_fd, &ev);
if (rc < 0) {
printf("epoll add common.h failed\n");
return 3;
}
printf("epoll add common.h ok\n");
return 0;
}
```
`epoll_check.c`
编译并运行它。
```bash
$ clang epoll_check.c
$ ./a.out
epoll add common.h failed
```
这一切都很重要,因为正如内核开发者 Jens Axboe 在《使用 io_uring 实现高效 IO》(https://kernel.dk/io_uring.pdf) 中所说:
> 即使您满足 IO 为异步的所有约束,有时它仍然不是异步的。IO 提交可能以多种方式最终阻塞——如果执行 IO 需要元数据,提交将等待该元数据。对于存储设备,存在固定数量的请求槽。如果这些槽当前全部被使用,提交将等待一个槽变为可用。这些不确定性意味着依赖提交始终为异步的应用程序仍然被迫卸载该部分。
因此,虽然我们可以合理确定,使用 epoll 时我们的 `send`/`recv` 调用不会长时间阻塞,但对于 `read`/`write` 调用,我们没有这样的保证。即使使用 epoll,我们也必须使用线程池之类的东西来实现磁盘的非阻塞 I/O。
无论如何,让我们看看移植到 epoll 的文件服务器,再次从顶层开始。我们创建一个套接字,绑定并监听一个端口。但不再直接接受连接,而是将套接字文件描述符传递给 epoll 并永远循环(处理新连接和现有连接的逻辑将放在那里)。
```c
int main(int argc, char **argv) {
uint16_t port = (argc > 1) ? (uint16_t)atoi(argv[1]) : 8080;
int socket_fd = listen_socket(port);
int sfl = fcntl(socket_fd, F_GETFL, 0);
fcntl(socket_fd, F_SETFL, sfl | O_NONBLOCK);
epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
perror("epoll_create1");
return 1;
}
struct epoll_event lev = {
.events = EPOLLIN,
.data.ptr = NULL, // 这是我们自己的指示,表示这是 socket_fd 而不是 conn_fd。
};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket_fd, &lev);
fprintf(stderr, "epoll server on :%u\n", port);
for (;;)
epoll_step(socket_fd);
}
```
`epoll.c`
我们传入自己的用户数据。对于*套接字*,我们将了解是否准备好进行 `accept` 以接受新请求。对于现有的*连接*,我们将了解是否准备好从它进行 `recv` 或向它进行 `send`。在 `epoll_step` 中,我们将控制权交给 epoll 以等待就绪事件。当控制权返回时,我们遍历就绪事件,并且:1)如果事件引用的是*套接字*(事件用户数据为 NULL),则进行 `accept`,并告诉 epoll 跟踪新连接;2)否则,从必定是*连接*的对象中读取或写入。
```c
static void epoll_step(int socket_fd) {
struct epoll_event evs[64];
int n = epoll_wait(epoll_fd, evs, 64, -1);
for (int i = 0; i < n; i++) {
if (evs[i].data.ptr == NULL) {
// 事件引用套接字。接受所有等待的连接。
for (;;) {
int conn_fd = accept(socket_fd, NULL, NULL);
if (conn_fd < 0) {
// 套接字是非阻塞的,所以 <0 表示错误或没有更多等待的连接。
break;
}
int cfl = fcntl(conn_fd, F_GETFL, 0);
fcntl(conn_fd, F_SETFL, cfl | O_NONBLOCK);
struct conn *c = calloc(1, sizeof(*c));
c->conn_fd = conn_fd;
c->file_fd = -1;
struct epoll_event ev = {
.events = EPOLLIN,
.data.ptr = c
};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
}
continue;
}
// 事件引用一个连接。读取、写入或关闭连接。
struct conn *c = evs[i].data.ptr;
if (evs[i].events & EPOLLIN)
on_readable(c);
else if (evs[i].events & EPOLLOUT)
on_writable(c);
else
conn_close(c);
}
}
```
`epoll.c`
关于 epoll 的一个有趣的事情是,我们可以注册读取通知(`.events = EPOLLIN`)、写入通知(`.events = EPOLLOUT`),或两者(`.events = EPOLLIN | EPOLLOUT`)。在 HTTP 服务器的上下文中,我们可以通过仅先注册读取来优化通知。当读取完成后,我们将告诉 epoll 只切换到关于该连接的可写性通知。正如您所见,系统已经是事件驱动的,并且将继续如此。
在 `on_readable` 方法中,我们将不断累积数据,直到到达头部结束为止。然后启动响应的处理。
```c
static void on_readable(struct conn *c) {
while (c->req_offset < sizeof(c->req)) {
ssize_t r = read(c->conn_fd, c->req + c->req_offset, sizeof(c->req) - c->req_offset);
if (r == 0) {
conn_close(c);
return;
}
if (r < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
return;
conn_close(c);
return;
}
c->req_offset += (size_t)r;
if (memmem(c->req, c->req_offset, "\r\n\r\n", 4)) {
start_response(c);
return;
}
}
conn_close(c);
}
```
`epoll.c`
在 `start_response` 中,既然我们已经至少读取了所有头部,我们将解析请求并尝试打开引用的文件。我们还将把 epoll 的就绪检查从读取切换到写入。
```c
static void start_response(struct conn *c) {
char path[1024];
int rc = parse_http_get(c->req, c->req_offset, path, sizeof(path));
if (rc < 0) {
c->out_len = build_404(c->out, sizeof(c->out));
c->out_offset = 0;
c->file_eof = 1;
} else {
char full[2048];
snprintf(full, sizeof(full), "%s%s", DOCROOT, path);
c->file_fd = open(full, O_RDONLY);
if (c->file_fd < 0) {
c->out_len = build_404(c->out, sizeof(c->out));
c->out_offset = 0;
c->file_eof = 1;
} else {
c->out_len = build_ok_headers(c->out, sizeof(c->out), mime_for(path));
c->out_offset = 0;
}
}
struct epoll_event ev = {
.events = EPOLLOUT,
.data.ptr = c
};
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c->conn_fd, &ev);
}
```
`epoll.c`
我们实际上还没有写入任何内容,因为连接不一定准备好写入。这将在 `on_writable` 中发生,我们实际上读取文件(epoll 对此无能为力)并写入,直到被告知写入会被阻塞(这是因为我们之前将*连接*设置为非阻塞所致)。
```c
static void on_writable(struct conn *c) {
for (;;) {
if (c->out_offset >= c->out_len) {
if (c->file_eof) {
conn_close(c);
return;
}
ssize_t r = read(c->file_fd, c->out, sizeof(c->out));
if (r <= 0) {
conn_close(c);
return;
}
c->out_offset = 0;
c->out_len = (size_t)r;
}
ssize_t w = write(c->conn_fd, c->out + c->out_offset, c->out_len - c->out_offset);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
return;
conn_close(c);
return;
}
c->out_offset += (size_t)w;
}
}
```
`epoll.c`
最后是辅助函数、全局变量、结构体和包含文件。
```c
#include "common.h"
#include <sys/epoll.h>
#include <fcntl.h>
struct conn {
int conn_fd;
int file_fd;
char req[MAX_REQ_BUF];
size_t req_offset;
char out[IO_BUF];
size_t out_offset, out_len;
int file_eof;
};
static int epoll_fd;
static void conn_close(struct conn *c) {
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, c->conn_fd, NULL);
close(c->conn_fd);
if (c->file_fd >= 0)
close(c->file_fd);
free(c);
}
```
`epoll.c`
编译并尝试运行它。
```bash
clang epoll.c -o epoll_file_server
./epoll_file_server &
curl localhost:8080/common.h
kill %1
```
现在让我们看看 io_uring。
相似文章
Zig 0.16 中的异步 I/O:今日视角
Zig 0.16 推出了新的 std.Io 接口,用于跨平台 I/O。zio 库通过栈式协程和操作系统级异步 API 提供了完整的异步实现,无需每个任务一个线程即可实现高效的并发任务。
Silk: 开源协作式纤程调度器
Silk 是一个面向 Linux 的开源协作式纤程调度器,具有每 CPU 调度线程、io_uring 集成和拓扑感知的工作窃取功能,专为低开销下的高并发而设计。
FediMeteo、HAProxy 与不浪费 snac 线程的艺术
作者介绍了在 FediMeteo 服务中使用 HAProxy 缓存来减少 snac 线程上的不必要负载,此前已用 nginx 做过类似优化。该方法旨在通过让反向代理吸收重复的公共请求,保持轻量级 ActivityPub 服务器的高效。
Ursula:基于线程每核心、多Raft架构的HTTP事件流Rust运行时
Ursula是一个开源、自托管的分布式服务器,用于可重放、仅追加的事件时间线,运行于HTTP和SSE之上,采用线程每核心、多Raft架构,并搭配S3存储以实现低延迟和持久性。
Show HN: Rapel – 不稳定网络中的分块可续传下载
Rapel 是一个支持续传的分块 HTTP 下载工具,专为不稳定网络设计。它具备并发下载、JSON 状态管理、优雅关闭及跨平台支持等特点。