简介:面向希望打通网络编程与HTTP协议实践的C语言学习者,这份资料围绕一个可运行的简易Web服务器展开,覆盖从socket创建、bind绑定与listen监听,到accept接收连接、recv读取请求的完整链路,并进一步演示如何解析请求行与请求头、拼装状态行与响应头,再借助文件I/O把HTML页面回送客户端。压缩包为7z格式,整体约17.85MB,文件总数与类型明细暂未提供,从内容构成看应包含服务器源码与用于本地测试的示例页面,编译运行后即可对照调试。已有241人学习下载,适合课程设计、练手项目与网络方向面试准备。读者可据此理解请求与响应报文的结构,掌握文件打开失败、连接异常等错误处理方式,并顺着多线程或异步I/O的思路对单连接模型做并发改造,形成对Web服务背后工作机制的直观认识。
1. 一台 1 核 2G 的机器,单进程跑 epoll,扛住八千个长连接不掉线——这不是什么玄学,而是把 webserver 的骨架写对之后的正常表现
这份资源就是一套用 C 语言从零实现的 webserver:自己封装 socket、自己搭 epoll 事件循环、自己解析 HTTP 报文,不依赖任何第三方网络库。适合两类人:一类是刚啃完 socket API,想找个真能跑起来的东西把 bind、listen、accept、read 这一串调用串成一条完整链路的;另一类是想弄明白主流事件驱动服务里常被提到的 Reactor、边缘触发、零拷贝,到底在解决什么具体问题的。下面按我拆这套代码的顺序讲,从目录结构一路讲到怎么往里加路由。
2. 工程骨架与模块划分:从 main 到 epoll_wait 的启动链路
拿到一份网络服务的源码,我一般不看 main 里的业务逻辑,先看它怎么建监听、怎么把 fd 挂进事件循环、失败的时候退到哪一步。这三件事决定了后面所有代码能不能读懂——因为一个 webserver 的复杂度,八成集中在“连接的生命周期管理”上,业务逻辑反而是最简单的那层。
2.1 技术选型:为什么是 epoll + 非阻塞 + 线程池
最朴素的写法是阻塞 socket 加“一连接一线程”:accept 一个连接就 pthread_create 一个线程去 read。教学演示没问题,一旦并发上千,线程栈(默认 8MB 虚拟内存)和上下文切换的开销就把机器吃干了,而且大量线程其实都阻塞在 read 上什么都不干。
再进一步是 select/poll,单线程能管多连接了,但每次调用都要把整份 fd 集合从用户态拷进内核,内核再线性扫一遍,复杂度是 O(n)。连接数上千之后,每轮事件循环光扫描就是纯浪费。
这套代码走的是第三条路:epoll + 非阻塞 fd + 固定大小线程池。epoll 在内核里用红黑树维护注册的 fd、用就绪链表维护“已经有事可做”的 fd,epoll_wait 返回的是就绪链表长度,跟总连接数无关;非阻塞保证任何一次 read/write 都不会把线程卡住;线程池则把“处理已就绪的请求”这件事摊到固定几个核上,线程数可控,不会随连接数膨胀。
| 方案 | 千连接下的主要开销 | 适用场景 |
|---|---|---|
| 阻塞 + 一连接一线程 | 线程栈 + 上下文切换 | 连接数几十、逻辑重 |
| select / poll | 每轮 O(n) 拷贝与扫描 | 连接数百、跨平台 |
| epoll 水平触发 LT | 每次 wait 都重复通知未处理完的 fd | 逻辑简单、怕漏事件 |
| epoll 边缘触发 ET | 必须一次读干,否则丢事件 | 高并发、代码可控 |
选 ET 不是因为它“更高级”,而是因为它逼着你把缓冲区管理写对。LT 下你可以只读 1 个字节就返回,下次 wait 还会通知你;ET 只在你状态变化时通知一次,读不干净就等着连接假死。这个差别在第 5 章会展开讲,是一堆血泪经验的来源。
2.2 目录结构与各模块职责
这份代码没有把所有东西塞进一个 main.c,按职责切成了下面这些文件。看目录基本能猜出整体架构,这也是我判断一份网络代码“能不能改”的第一印象。
| 文件 | 职责 | 改动频率 |
|---|---|---|
main.c | 参数解析、初始化顺序、启动事件循环 | 低 |
config.c/h | 端口、线程数、web 根目录、超时时间的默认值与覆盖 | 中 |
listen.c/h | socket/bind/listen 封装,监听 fd 创建 | 极低 |
epoll_dispatcher.c/h | epoll 实例管理、事件分发、连接注册与注销 | 低 |
connection.c/h | 连接上下文、读写缓冲区、状态机 | 中 |
http_parser.c/h | 请求行、请求头解析,URL 解码与路径归一化 | 中 |
http_response.c/h | 状态码、MIME 表、响应头拼装、静态文件发送 | 中 |
threadpool.c/h | 任务队列、互斥锁与条件变量、worker 循环 | 低 |
timer.c/h | 升序链表定时器,管 keep-alive 与空闲连接 | 低 |
log.c/h | 分级日志,带时间戳与文件行号 | 低 |
Makefile | 编译、依赖、调试与发布两套 CFLAGS | 低 |
改业务基本只碰http_response.c和http_parser.c;改并发策略才动epoll_dispatcher.c和threadpool.c。这种切法有个好处:你想把 ET 换成 LT,改动范围被限制在分发器里,不用满仓库搜EPOLLET。
2.3 启动初始化链路拆解
整个服务的启动是一条线性的、任何一步失败都必须能说清原因的链路。下面这段是main.c的主干,我把注释留得比较密,因为初学者最容易在这里漏掉SO_REUSEADDR或者把线程池建在循环之后。
int main(int argc, char *argv[]) { struct config conf; config_init(&conf); /* 先把默认值填好:8080、4 线程、./www、60s 超时 */ config_parse_args(&conf, argc, argv); /* 日志必须先起来,后面每一步初始化失败都要有地方落字 */ log_init(conf.log_path, LOG_INFO); /* 监听 fd:socket -> setsockopt -> bind -> listen,一次做完 */ int listen_fd = tcp_listen(conf.port, conf.backlog); if (listen_fd < 0) { log_fatal("listen on port %d failed: %s", conf.port, strerror(errno)); return 1; } /* 事件循环的根:一个 epoll 实例,第一件事是把 listen_fd 加进去 */ int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { log_fatal("epoll_create1: %s", strerror(errno)); return 1; } struct epoll_event ev; memset(&ev, 0, sizeof(ev)); ev.events = EPOLLIN | EPOLLET; /* 监听 fd 用 ET,靠 while(accept) 收干 */ ev.data.ptr = NULL; /* NULL 标记这是个监听事件,不是普通连接 */ if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { log_fatal("epoll_ctl add listen_fd: %s", strerror(errno)); return 1; } /* 线程池要在进循环之前建好,避免第一个连接来了才现招人 */ threadpool_t *pool = threadpool_create(conf.threads, conf.queue_size); if (!pool) { log_fatal("threadpool_create failed"); return 1; } event_loop(epfd, listen_fd, pool, &conf); /* 死循环,信号里置退出标志 */ return 0; }tcp_listen里的几个细节值得单独说,因为它们是“能跑”和“能反复跑”的分界线:
int tcp_listen(int port, int backlog) { /* SOCK_NONBLOCK 直接建非阻塞 fd,SOCK_CLOEXEC 防止 fork 出去的子进程继承 */ int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); if (fd < 0) return -1; int on = 1; /* 关键:不加这句,服务重启时旧连接处于 TIME_WAIT,bind 直接报 EADDRINUSE */ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); /* 让内核回收已关闭连接的缓冲区更快一点,压测时有用 */ setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 监听所有网卡 */ addr.sin_port = htons((uint16_t)port); /* 主机序转网络序,别写反 */ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; } if (listen(fd, backlog) < 0) { close(fd); return -1; } return fd; }参数上,backlog传给内核的 accept 队列长度,我一般设成 512 到 1024。它不等于最大连接数,只是“已完成三次握手但用户态还没 accept”的排队上限;设小了,瞬时并发高峰会出现连接被内核直接 RST 的情况,现象是客户端偶发Connection reset by peer,而服务端日志干干净净——这个坑在第 5 章还会碰到一次。
INADDR_ANY监听所有网卡,只做本机测试可以换成htonl(INADDR_LOOPBACK),避免调试接口被外面直接摸到。
event_loop本身很短,一个epoll_wait加一个分发:
void event_loop(int epfd, int listen_fd, threadpool_t *pool, struct config *conf) { struct epoll_event events[MAX_EVENTS]; /* MAX_EVENTS 一般 1024 */ while (!g_stop) { /* 最后一个参数用 1000ms 而不是 -1,给定时器一个兜底心跳 */ int n = epoll_wait(epfd, events, MAX_EVENTS, 1000); if (n < 0) { if (errno == EINTR) continue; /* 被信号打断,正常,继续等 */ log_error("epoll_wait: %s", strerror(errno)); break; } for (int i = 0; i < n; i++) { if (events[i].data.ptr == NULL) { do_accept(listen_fd, epfd, pool); /* 3.1 */ } else { conn_t *c = (conn_t *)events[i].data.ptr; dispatch_conn(c, events[i].events, epfd, pool); /* 3.2 */ } } timer_tick(time(NULL)); /* 4.3,惰性清理过期连接 */ } }这里用data.ptr而不是data.fd存信息,是个挺关键的取舍:fd 只能告诉你“是哪个连接”,而data.ptr直接指到连接上下文,省掉一次哈希查找;连接关闭后也只需要把上下文里的 fd 置-1做标记,不用担心 fd 被复用后事件串到别的连接上。代价是指针指向的内存必须活得比 epoll 注册久,这个约束在第 5 章会变成一个具体的悬垂指针事故。
3. 事件循环与并发模型:Reactor 怎么把连接管起来
Reactor 这个词被讲得太玄,落地到代码上其实就三件事:主线程只负责等事件和分发,不干重活;每个连接的读、写、超时状态都记在自己的上下文里;真正耗 CPU 的解析和文件操作丢给线程池。这一章把这三件事拆开看。
3.1 连接接入:accept 与边缘触发的正确姿势
监听 fd 用的是 ET,意味着epoll_wait告诉你“有新连接了”之后,你必须一直 accept 到返回EAGAIN为止。只 accept 一次的话,剩下排队的连接要等下一次状态变化才会被通知——但内核不会因为你没取完就再通知一遍,那批连接就卡在队列里,客户端看到的是“连上了,但一直没响应”。
static void do_accept(int listen_fd, int epfd, threadpool_t *pool) { for (;;) { struct sockaddr_in peer; socklen_t len = sizeof(peer); /* accept4 直接带 NONBLOCK,省一次 fcntl,也避免中间漏设的窗口 */ int cfd = accept4(listen_fd, (struct sockaddr *)&peer, &len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* 收干了,退出循环 */ if (errno == EINTR) continue; /* 信号打断,再来 */ if (errno == EMFILE || errno == ENFILE) { /* fd 用完,见 5.3 */ log_error("accept: fd exhausted"); usleep(10000); break; } log_error("accept: %s", strerror(errno)); break; } conn_t *c = conn_new(cfd, &peer); /* 分配上下文:缓冲区、状态、时间戳 */ if (!c) { close(cfd); continue; } struct epoll_event ev; memset(&ev, 0, sizeof(ev)); ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.ptr = c; if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev) < 0) { log_error("epoll_ctl add conn: %s", strerror(errno)); conn_free(c); continue; } timer_add(c, g_conf.keepalive_timeout); /* 挂进定时器,超时自动关 */ } }EPOLLONESHOT这个标志值得单独解释。没有它的时候,一个连接的读事件可能被主线程投递两次到线程池,两个 worker 同时去读同一个 fd、同时改同一份缓冲区,数据错乱还是轻的,重则重复响应。加上EPOLLONESHOT之后,fd 触发一次事件就自动从就绪集合里摘掉,处理完必须显式用EPOLL_CTL_MOD重新武装:
/* 处理完一次请求后重新武装,注意 EPOLLOUT 只在有数据待写时才加 */ int conn_rearm(conn_t *c, int epfd, int want_write) { struct epoll_event ev; memset(&ev, 0, sizeof(ev)); ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; if (want_write) ev.events |= EPOLLOUT; ev.data.ptr = c; return epoll_ctl(epfd, EPOLL_CTL_MOD, c->fd, &ev); }conn_new里分配的内存大小是固定的:读缓冲 8KB、写缓冲 4KB。固定大小的好处是不会因为一两个大请求导致内存抖动,坏处是超过 8KB 的请求头会被拒。我一般会加一个“头超过缓冲区就返回 431”的分支,而不是让它静默溢出——这个小判断能挡掉一批畸形请求。
3.2 读写事件处理与用户态缓冲区
读事件的处理函数要区分三种返回值:读到数据、对端关闭、这次读完了。ET 模式下必须循环读到EAGAIN,中途遇到EINTR要重试而不是当错误。
/* >0 读到字节数;0 对端关闭;-1 已读干或待下次;-2 缓冲满;-3 真错误 */ ssize_t conn_read(conn_t *c) { size_t space = c->rbuf.cap - c->rbuf.len; if (space == 0) return -2; /* 交给上层返回 431 */ ssize_t n = read(c->fd, c->rbuf.data + c->rbuf.len, space); if (n > 0) { c->rbuf.len += (size_t)n; c->last_active = time(NULL); /* 有流量就刷新超时,keep-alive 靠它 */ return n; } if (n == 0) return 0; /* 对端发了 FIN */ if (errno == EAGAIN || errno == EWOULDBLOCK) return -1; if (errno == EINTR) return conn_read(c); return -3; }写要麻烦得多,因为非阻塞 fd 上一次write可能只写出去一半。这里我把响应拆成“头部走写缓冲、正文走 sendfile”,两条路径各自处理部分写:
/* 返回 1 全部写完;0 还没写完,需要等 EPOLLOUT;-1 出错 */ int conn_flush(conn_t *c) { while (c->wbuf.off < c->wbuf.len) { /* 先把响应头推完 */ ssize_t n = write(c->fd, c->wbuf.data + c->wbuf.off, c->wbuf.len - c->wbuf.off); if (n > 0) { c->wbuf.off += (size_t)n; continue; } if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return 0; if (n < 0 && errno == EINTR) continue; return -1; } while (c->file_left > 0) { /* 再推文件正文 */ /* sendfile 在用户态不落数据,offset 由内核维护,必须一直传同一个变量 */ ssize_t n = sendfile(c->fd, c->file_fd, &c->file_off, c->file_left); if (n > 0) { c->file_left -= (size_t)n; continue; } if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return 0; if (n < 0 && errno == EINTR) continue; return -1; } return 1; }sendfile的第三个参数是off_t *,是个“进参兼出参”:内核从它指向的位置开始发,发完把新偏移写回去。初学者常犯的错是传NULL又同时设了file_left,结果文件每次都从开头发,客户端收到的是不断重复的内容。另一个坑是file_left的类型——用int在 2GB 以上的文件上会溢出,必须是size_t或off_t。
返回 0 之后要做的事是把EPOLLOUT加进关注集合,等内核缓冲区腾出空间了再继续。这套“返回 0 就等事件、别原地自旋”的写法,是区分“能扛并发”和“压测三分钟 CPU 打满”的分水岭。
3.3 线程池:任务队列与 epoll 怎么配合
线程池的结构很经典,但在这套代码里它有个特殊约束:任务里不能阻塞,也不能持有长时间的锁,因为 worker 数量远小于连接数,一个任务卡住就拖累一批连接。
typedef struct { void (*fn)(void *); void *arg; } task_t; struct threadpool { pthread_mutex_t lock; pthread_cond_t notify; task_t *queue; int cap, head, tail, count; pthread_t *tids; int nthreads; int shutdown; }; void *worker(void *arg) { threadpool_t *p = (threadpool_t *)arg; for (;;) { pthread_mutex_lock(&p->lock); /* 用 while 不用 if:条件变量有虚假唤醒 */ while (p->count == 0 && !p->shutdown) pthread_cond_wait(&p->notify, &p->lock); if (p->shutdown && p->count == 0) { pthread_mutex_unlock(&p->lock); break; } task_t t = p->queue[p->head]; p->head = (p->head + 1) % p->cap; p->count--; pthread_mutex_unlock(&p->lock); t.fn(t.arg); /* 真正干活一定在锁外 */ } return NULL; }投递侧的策略决定了过载时的行为。我的做法是队列满了直接拒绝,而不是阻塞主线程:
int threadpool_submit(threadpool_t *p, void (*fn)(void *), void *arg) { pthread_mutex_lock(&p->lock); if (p->count == p->cap) { /* 队列满,宁可回 503 也不让主线程等 */ pthread_mutex_unlock(&p->lock); return -1; } p->queue[p->tail].fn = fn; p->queue[p->tail].arg = arg; p->tail = (p->tail + 1) % p->cap; p->count++; pthread_cond_signal(&p->notify); /* 只唤醒一个 worker,避免惊群 */ pthread_mutex_unlock(&p->lock); return 0; }主线程收到EPOLLIN之后做的事就很薄了:conn_read把数据读进缓冲,判断是否凑齐一个完整请求(头部的\r\n\r\n出现没有),凑齐了才把http_process打包成任务丢进池子;没凑齐就重新武装 fd 等下一批数据。这样 worker 拿到的一定是一条完整请求,解析逻辑里不需要处理半包——半包状态机被关在读事件那一层,代码量能少一半。
pthread_cond_signal和pthread_cond_broadcast的选择也有讲究:队列里新增一个任务,唤醒一个 worker 就够;用 broadcast 会在连接密集时产生明显的惊群,多个 worker 醒来抢同一把锁,然后又一起睡回去,白白浪费调度。
4. HTTP 解析与响应:一个请求从字节流到文件内容
网络层通了之后,剩下的是协议层。这部分代码不涉及并发,但边界条件特别多,写的时候容易觉得“差不多能跑”,压测或者换浏览器就现原形。
4.1 请求行与头部解析:手写状态机
一个请求进来是一串字节,格式上分三段:请求行、头部若干行、空行,之后可能还有 body。这套代码只处理 GET 和 HEAD,body 直接忽略,所以解析逻辑能压得很短。
/* 把 "GET /index.html HTTP/1.1" 拆成 method、path、version */ static int parse_request_line(char *line, http_request_t *req) { char *sp1 = strchr(line, ' '); if (!sp1) return -1; char *sp2 = strchr(sp1 + 1, ' '); /* 从第二个空格位置往后找第三个 */ if (!sp2) return -1; *sp1 = '\0'; *sp2 = '\0'; req->method = method_from_str(line); /* 只认 GET / HEAD / POST */ req->version = version_from_str(sp2 + 1); /* HTTP/1.0 与 1.1 */ if (req->method == M_UNKNOWN) return 400; /* URL 里可能带 ?query,先按 ? 切开,再对路径做 %XX 解码 */ char *q = strchr(sp1 + 1, '?'); if (q) { *q = '\0'; req->query = q + 1; } if (url_decode(sp1 + 1, req->path, sizeof(req->path)) < 0) return 400; if (normalize_path(req->path) < 0) return 403; /* 挡住 ../ 目录穿越 */ return 0; }normalize_path是安全检查,不是可选优化。请求/static/../../etc/passwd的时候,如果直接把 path 拼到 web 根目录后面去open,就把系统文件读出去了。我的写法是把路径按/切段,遇到..就弹栈,遇到.就跳过,最后重新拼接,任何一步发现栈空还在弹,直接返回 403。
头部解析用一个循环读行、按:切 key/value 就够了,但有两个细节要处理:key 比较必须大小写不敏感(用strcasecmp,因为各家客户端发的Content-Length、content-length都有);value 前后的空格要 trim 掉。头部字段里这套代码真正用到的只有三个:Connection(决定 keep-alive)、Host(虚拟主机扩展用)、Content-Length(POST 时判断 body 长度)。
4.2 静态文件响应与零拷贝
响应拼装的核心是Content-Length必须和实际发送的字节数严格一致,差一个字节浏览器就会一直转圈等后续数据。所以流程是:先open+fstat拿到文件大小,用它拼头部,再发正文。
int build_response_header(conn_t *c, int status, const char *mime, off_t size, int keep_alive) { int n = snprintf(c->wbuf.data, c->wbuf.cap, "HTTP/1.1 %d %s\r\n" "Server: tiny-c-webserver\r\n" "Content-Type: %s\r\n" "Content-Length: %lld\r\n" "Connection: %s\r\n" "\r\n", status, status_reason(status), mime, (long long)size, keep_alive ? "keep-alive" : "close"); /* 头比缓冲区大说明配置或状态码表写错了,宁可断连也别发半截头 */ if (n < 0 || n >= (int)c->wbuf.cap) return -1; c->wbuf.len = (size_t)n; c->wbuf.off = 0; return 0; }MIME 类型用一张静态表做后缀匹配,表里放十几个常见类型就够用,匹配不上的一律application/octet-stream。这里我要提醒一句:查表必须按后缀从长到短匹配,或者干脆精确匹配完整后缀,否则.js会被.s之类的规则抢走,虽然这套代码只支持单点后缀,但换成多段后缀实现时就容易翻车。
正文发送走第 3 章的conn_flush,sendfile直接从文件描述符把数据推进 socket,中间不经过用户态缓冲区。1MB 的静态文件,走read+write要两次拷贝、两次系统调用,走sendfile就只剩内核态内部的 DMA 拷贝。压测时这个差别在带宽上很直观。
4.3 keep-alive 与定时器:升序链表加惰性删除
HTTP/1.1 默认 keep-alive,连接处理完一个请求不能立刻关,得等客户端复用或者超时。如果没有超时机制,一批半死不活的连接会把 fd 耗光——这是最容易被忽略的一类泄漏,因为它不发生在一个请求内,而是发生在请求之间的空隙里。
定时器的实现我选的是“升序链表 + 惰性删除”,不是最小堆也不是时间轮。原因很实在:连接数在万级以内时,插入是 O(n) 但常数极小,而且被删的节点可以在链表里留个cancelled标记,等到它排到队头再真正释放,省掉了“删除时需要先定位前驱”的麻烦。
void timer_tick(time_t now) { while (g_timer_head && g_timer_head->expire <= now) { timer_node_t *n = g_timer_head; g_timer_head = n->next; /* 惰性删除:连接已经正常关闭的节点,这里只释放节点不关 fd */ if (!n->cancelled) { log_info("conn %d idle timeout, closing", n->conn->fd); close_conn(n->conn); } free(n); } }timer_tick挂在事件循环里,epoll_wait超时设 1 秒,所以最坏情况下连接会多活 1 秒——这个精度对 keep-alive 完全够用,不需要timerfd那套精度。每次读事件里把last_active刷新,定时器节点里的expire要不要跟着改?我的做法是不改,因为改节点位置就得删了重插,白白多一次 O(n) 遍历;直接在timer_tick里比对conn->last_active + timeout和当前时间,等于把“是否过期”的判断从插入时推迟到了扫描时。
提示:定时器节点的
expire只用来保证链表有序,真正的过期判断用连接上的last_active,这两者不要混为一谈,否则刷新超时的时候会写出很别扭的代码。
5. 避坑与常见问题排查:这五个坑我逐个踩过
这套代码从头写完大概两天,但调通、能压测、能稳定跑一周,花了差不多三倍时间。下面这五个是我印象最深的,每个都有明确的现象和明确的解法。
5.1 连接假死:ET 模式下没有读干
现象:服务跑起来之后,用浏览器访问正常,但一上压测工具,就有一定比例的连接挂住不动,客户端等到超时,服务端日志里连请求都没记录。并发越高比例越大。
原因:do_accept或者conn_read里只调用了一次就返回了。ET 只在 fd 状态变化时通知一次,内核缓冲区里还剩数据没读走,它不会因为你没读完就再通知一遍。另一种变体是conn_read里遇到EAGAIN之前提前break,看起来读了,实际只读了一部分。
解决:所有 ET 下的读操作统一写成for(;;)循环,退出条件只有EAGAIN、EWOULDBLOCK和真正的错误。accept4同理。我后来的习惯是在每个循环末尾加一行日志(debug 级别),打一次“本轮读了多少字节”,压测时打开看,如果某个连接频繁出现“只读了 1 次就退出”,基本能定位。
5.2 进程莫名消失:SIGPIPE 直接杀掉进程
现象:压测跑到一半,服务进程没了,日志停在某个时间点,dmesg里也没有 OOM 记录,退出码是 141。重跑一次可能不复现,很容易被误判成“偶发崩溃”。
原因:往一个已经被对端关闭的连接写数据,内核会发SIGPIPE,这个信号的默认处理是终止进程。它不是错误返回,是信号,所以write的错误处理里根本抓不到。压测时客户端主动断连非常频繁,这个坑必踩。
解决:两件事一起做。一是在main开头忽略掉这个信号,signal(SIGPIPE, SIG_IGN);二是所有write、sendfile的返回值都要判断EPIPE,返回-1走正常关连接流程。只做第一件不做第二件的话,进程是活下来了,但会把每次断开都当成正常写入,日志里看不出来。
/* main 里尽早设置,早于任何可能写 socket 的代码 */ signal(SIGPIPE, SIG_IGN);5.3 突然不接受新连接:fd 被耗尽
现象:服务跑一段时间后,新连接全部失败,但已有连接还能正常响应,CPU 占用不高,accept也没有明显报错。
原因:fd 泄漏,或者 file-max 太小。常见泄漏点有三个:连接关闭时忘了close(fd);conn_new失败的分支里没关;epoll_ctl失败后只释放了内存没关 fd。压测几分钟就能把 1024 的默认上限吃满。
解决:短期用ulimit -n 65535提高上限,长期靠“谁 open 谁 close”的纪律。还有个技巧是预留一个空的 fd:当accept4返回EMFILE时,close掉预留 fd、accept一个连接、再立刻close,这样能把 accept 队列里堆积的连接清掉,给监控留出反应时间,不至于直接把内核队列撑爆。检查手段是ls /proc/<pid>/fd | wc -l对比连接数,如果数量远大于实际连接,八成是泄漏。
5.4 响应被截断:部分写没处理
现象:浏览器偶尔出现图片加载一半、CSS 只应用了一部分;压测工具报“响应长度与 Content-Length 不一致”。小文件几乎不出现,大文件或者高并发下必现。
原因:write返回值被忽略,或者只判断了> 0就当成写完了。非阻塞 socket 的内核发送缓冲区有限,几 KB 之后就会返回部分写入,默认写法直接丢掉剩下的字节。
解决:所有写路径统一走第 3 章的conn_flush,用一个off记录已写偏移,遇到EAGAIN就注册EPOLLOUT等下次继续。sendfile的offset参数一定要传同一个变量,别图省事传NULL。
5.5 重启报端口占用:TIME_WAIT 与 bind 失败
现象:改完代码重新启动,报bind: Address already in use,等半分钟又好了。压测后重启成功率更低。
原因:主动关闭方会进入TIME_WAIT,持续 2MSL(一般 60 秒),这段时间端口仍被占用。没设SO_REUSEADDR的服务就没法bind。
解决:setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ...)加在bind之前,注意顺序不能反。用的是多进程多实例的话,再加SO_REUSEPORT让多个进程共享同一个端口,交由内核做连接分配。需要说明的是,SO_REUSEADDR解决的是“同一端口上残留 TIME_WAIT”,不是“同一端口同时监听两份”,后者要靠SO_REUSEPORT,这两个选项容易混。
6. 二次开发:加一条路由和一个访问日志钩子
拿到这份代码之后最想做的事,一般是把它从“只会发静态文件”改成“能挂接口”。改动最小的做法是在http_response.c里加一张前缀匹配的路由表,请求进来先查表,命中就走函数指针,没命中才回落到静态文件逻辑。
typedef int (*handler_fn)(const http_request_t *req, conn_t *c, void *ud); typedef struct route { const char *prefix; /* 前缀匹配,"/api/" 这类 */ handler_fn fn; void *ud; /* 透传给 handler,放配置或数据库连接池 */ } route_t; static route_t g_routes[] = { { "/api/ping", handle_ping, NULL }, { "/api/status", handle_status, NULL }, { NULL, NULL, NULL } /* 哨兵,遍历用 */ }; /* 命中返回 1,未命中返回 0,交给静态文件逻辑 */ int route_dispatch(const http_request_t *req, conn_t *c) { for (int i = 0; g_routes[i].prefix; i++) { size_t n = strlen(g_routes[i].prefix); if (strncmp(req->path, g_routes[i].prefix, n) == 0) return g_routes[i].fn(req, c, g_routes[i].ud) == 0; } return 0; }接入点只有一处:http_process里解析完请求头之后、查 MIME 表之前,先调route_dispatch。这样静态文件那条路径完全不动,回归风险最小。
访问日志用钩子实现,别把log_info散落在每个 handler 里。在http_process的开头记一次时间戳,结尾按“客户端 IP + 方法 + 路径 + 状态码 + 耗时 + 响应字节数”打一行。耗时用clock_gettime(CLOCK_MONOTONIC)取两个时间点相减,别用gettimeofday,后者受系统时间调整影响,测出来的耗时会跳变。压测验证也很简单:先用单线程跑一轮确认没崩,再用多线程、逐渐加大并发数,重点盯三个指标——QPS 随并发增长是否在某个点之后变平(说明 worker 不够或无谓锁竞争)、P99 延迟有没有突然抬高(多半是部分写或者定时器扫描太长)、fd 数量是否随连接关闭稳定回落(没回落就是泄漏)。短连接压测看前三项,长连接压测看第四项和内存增长。
最后说个我自己踩出来的教训。这套代码第一版我把缓冲区直接开在conn_t里,按 64KB 分配,想着“大一点省事”,压测两千连接直接把机器内存吃满。从那以后我每次写网络服务,都会先把每个连接的固定开销算一遍,乘以目标并发数,看看是否在机器承受范围内,再决定缓冲区怎么分配、要不要做动态扩容。写 C 的网络服务,内存账算不清,跑得越快崩得越早。希望帮到你。
本文还有配套的精品资源,点击获取