阶段 0 教程:从 socket 到 epoll echo server
两节课、两份能跑的代码。先写一个只能服务一个客户端的阻塞版服务器,再用 epoll 改成单线程同时服务上万个连接。
三周学习清单
目标是用大约 3 周、每天 2 小时,写出一个能同时服务 500 个客户端的 epoll echo server。ACM 刷题照常进行,这部分是额外的。每一项的详细内容见左侧目录里对应的笔记。
第 1 周:工具和现代 C++
- 装好 Linux 环境。Windows 用户装 WSL2 + Ubuntu,编辑器用 VS Code 的 WSL 插件。(Linux 命令行入门)
- 学会 git 的基本操作(init / add / commit / push),在 GitHub 上建一个仓库放这些练习。
- 学会用 g++ 在命令行编译、用 gdb 打断点调试;认识 CMake,写一个最简单的 CMakeLists.txt。(编译、调试和版本管理)
- 补现代 C++:RAII、unique_ptr / shared_ptr、移动语义、lambda、std::function。(现代 C++)
第 2 周:socket 编程
- 搞懂 TCP 三次握手、四次挥手,以及 IP + 端口的含义。(TCP/IP 基础)
- 跑通下面的「第一课」,亲眼看到它一次只能服务一个客户端。(socket API)
第 3 周:I/O 多路复用和 epoll
- 理解阻塞和非阻塞的区别,以及 select、poll、epoll 各自的优缺点。(I/O 模型)
- 跑通下面的「第二课」,用测试脚本验证 500 个客户端都能收到回显。
- 不看代码,自己从零重写一遍 epoll echo server。做到这一步,阶段 0 就过关了。
第一课:服务器就是一个“开店接客”的循环
一个 TCP 服务器只做六件事:要插座、定地址、开门营业、接待客人、听客人说话、回话。对应的系统调用分别是 socket、bind、listen、accept、read、write。
在 Linux 里一切皆文件:每个连接都是一个整数编号,叫 fd(文件描述符)。用 read 和 write 读写它,就像读写文件一样。
// blocking_echo.cpp 最简单的阻塞 echo server,一次只能服务一个客户端
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
int main() {
// 1. socket:向系统要一个“插座”(fd)
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. bind:绑定到 本机任意IP:8888
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);
if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
// 3. listen:开始营业
listen(listen_fd, 128);
printf("listening on 8888\n");
while (true) {
// 4. accept:接待一个客人,拿到他专属的 conn_fd(没人来就卡在这)
int conn_fd = accept(listen_fd, nullptr, nullptr);
printf("new client fd=%d\n", conn_fd);
char buf[1024];
while (true) {
// 5. read:等客人说话(不说话就一直卡在这)
ssize_t n = read(conn_fd, buf, sizeof(buf));
if (n <= 0) break; // 0 = 客人挂断,<0 = 出错
write(conn_fd, buf, n); // 6. write:原样说回去
}
close(conn_fd);
printf("client fd=%d closed\n", conn_fd);
}
}
问题在哪: read 是阻塞的。如果第一个客人连上来却不说话,程序就卡在第 5 步,根本回不到 accept 去接第二个客人。实测时同时连 3 个客户端,只有 1 个收到了回显。
最笨的解法是每来一个客人就开一个线程,但一万个连接就是一万个线程,内存和切换开销都扛不住。这正是第二课 epoll 要解决的问题。
第二课:epoll 就是一个“前台”,谁有事就叫你
解决思路分两步。第一步,把所有 fd 设成非阻塞,没数据时 read 立刻返回,不会卡住;第二步,把所有 fd 交给 epoll 盯着,程序只在 epoll_wait 处等待,哪个 fd 有事就只处理哪个。这样一个线程就能服务上万个连接。
这个“等事件 → 分发处理 → 再等”的循环就是 Reactor 模式。Python 的 asyncio、Node.js、Redis、Nginx 底层都是它。
| 函数 | 作用 |
|---|---|
| epoll_create1 | 创建一个 epoll 实例(开一个前台) |
| epoll_ctl | 添加、修改、删除要盯的 fd |
| epoll_wait | 等到有 fd 就绪,返回就绪的列表 |
// epoll_echo.cpp epoll 版 echo server,单线程同时服务成千上万个客户端
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
// 把 fd 设成非阻塞:read/accept 没数据时立刻返回,而不是卡住
static void set_nonblock(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);
if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
listen(listen_fd, 1024);
set_nonblock(listen_fd);
// 1. 创建 epoll 实例:一个“前台”,帮你盯住所有 fd
int epfd = epoll_create1(0);
// 2. 把监听 fd 交给前台盯着:有新连接时通知我(EPOLLIN = 可读)
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
printf("listening on 8888 (epoll)\n");
epoll_event events[1024];
char buf[4096];
while (true) {
// 3. epoll_wait:没事就睡,一旦有 fd 就绪就醒来,返回就绪的个数
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 4a. 监听 fd 就绪 = 有新客人:把排队的连接全部接进来
while (true) {
int conn_fd = accept(listen_fd, nullptr, nullptr);
if (conn_fd < 0) break; // EAGAIN:已经接完了
set_nonblock(conn_fd);
epoll_event cev{};
cev.events = EPOLLIN;
cev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
}
} else {
// 4b. 普通连接就绪 = 客人说话了:读出来再写回去
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r); // 简化:假设一次写得完,阶段 1 用 Buffer 解决
} else if (r == 0 || errno != EAGAIN) {
// 客人挂断或出错:从 epoll 里移除并关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}
}
实测同时连 500 个客户端,500 个全部收到回显。
这份代码故意留了两个坑,阶段 1 会解决: 一是 write 可能一次写不完(对方接收慢的时候),剩下的数据要先存进输出 Buffer,等 EPOLLOUT 事件再写;二是所有逻辑都堆在 main 里,阶段 1 要把它拆成 EventLoop、Channel、Poller 这几个类。
怎么编译、运行和测试
在 Linux 或 WSL 里执行(Windows 原生环境没有 epoll)。三个文件都可以在 示例代码 页面下载。
# 编译
g++ -std=c++17 -O2 -o epoll_echo epoll_echo.cpp
# 终端 1:启动服务器
./epoll_echo
# 终端 2:手动测试,输入任意文字回车,应该原样返回
nc 127.0.0.1 8888
# 终端 2:压力测试,同时连 500 个客户端
python3 test_clients.py epoll 500
测试脚本会先建立 N 个连接,再逐个发消息并检查回显。实测结果:
| 版本 | 同时连接数 | 收到回显 |
|---|---|---|
| 阻塞版 | 3 | 1 |
| epoll 版 | 500 | 500 |
课后练习
- 用 nc 连上阻塞版,再开第二个 nc,亲眼看看第二个为什么没反应。
- 给 epoll 版加上日志:连接建立和断开时,打印 fd 以及客户端的 IP:端口(提示:accept 的第二、三个参数)。
- 把 echo 改成群聊:一个客户端发的消息,转发给其他所有客户端。
- 解决“写不完”的坑:给每个连接加一个 std::string 输出缓冲,write 没写完就把剩余数据存起来,并关注 EPOLLOUT。
- 不看代码,从零重写 epoll 版。
自测
阻塞 I/O 和非阻塞 I/O 有什么区别?
阻塞 fd 上没数据时,read 会一直等;非阻塞 fd 会立刻返回 -1,errno 为 EAGAIN。详见 I/O 模型。
为什么处理 listen_fd 时要在循环里反复 accept,直到返回负数?
一次 EPOLLIN 通知时,队列里可能已经有好几个连接在排队。只 accept 一个的话,其余的要等下一轮 epoll_wait 才处理(ET 模式下甚至会一直收不到通知)。listen_fd 是非阻塞的,接完时 accept 返回 -1、errno 为 EAGAIN,循环就结束了。
read 返回 0 和返回 -1 且 errno 为 EAGAIN,分别代表什么?
返回 0 表示对方关闭了连接,应该 close;返回 -1 且 errno 为 EAGAIN 表示暂时没有数据,等下次通知再读即可,连接仍然正常。
SO_REUSEADDR 是干什么的?去掉它,重启服务器会发生什么?
它允许绑定处于 TIME_WAIT 状态的地址。去掉之后,服务器刚关掉就马上重启,bind 会报 Address already in use,要等一两分钟才能启动。详见 TCP/IP 基础。
select、poll、epoll 各有什么缺点?epoll 为什么能支持更多连接?
select 有 1024 个 fd 的上限,并且和 poll 一样,每次调用都要把整个 fd 集合拷进内核,再遍历全部找出就绪的。epoll 只需注册一次,内核直接返回就绪列表,开销只和活跃连接数有关。
epoll 的 LT 和 ET 有什么区别?本教程的代码用的是哪种?
LT 只要还有数据没读完,就会一直通知;ET 只在状态变化时通知一次,必须循环读到 EAGAIN。本教程用的是默认的 LT。
面试常问
什么是 C10K 问题?
参考回答: 指单台服务器同时处理一万个连接的问题。早期“一个连接一个线程(或进程)”的模型做不到,因为每个线程要占几 MB 的栈,线程切换的开销也会随数量暴涨。解决思路就是非阻塞 I/O 加 I/O 多路复用(epoll),用少量线程管理大量连接,也就是 Reactor 模型。现在讨论的已经是 C10M 了,要靠内核旁路(DPDK)之类的技术。
单线程 epoll 服务器的瓶颈在哪?怎么扩展?
参考回答: 所有连接都在一个线程里处理,只能用满一个 CPU 核;而且只要某个请求处理得慢(比如做了一次耗时计算或阻塞调用),所有连接都会被拖住。扩展方式是多 Reactor:一个主线程只负责 accept,把新连接轮流分给几个 I/O 线程,每个 I/O 线程有自己的 epoll 循环(one loop per thread),耗时的业务逻辑再交给单独的线程池。这是阶段 1 要做的事。
可能的追问: Redis 为什么用单线程还这么快?因为数据在内存里,每个命令都很快,瓶颈在网络 I/O 而不是 CPU;单线程还省掉了锁和线程切换。Redis 6 开始把网络读写放到了多个 I/O 线程里,命令执行仍是单线程。
如果一个客户端只发不收(或者收得很慢),服务器会怎样?
参考回答: 服务器给它的回复写不进对方的接收窗口,内核发送缓冲区满了之后,write 只能写一部分或者返回 EAGAIN,剩下的数据会堆在这个连接的输出缓冲区里,越堆越多,最后可能把内存吃光。所以要给输出缓冲区设一个上限(高水位),超过之后停止读这个客户端的新请求,或者直接断开它。Redis 的 client-output-buffer-limit 就是做这个的。
介绍一下你写的这个服务器(项目介绍题)
参考回答思路: 按“做了什么、怎么做的、结果如何、遇到什么问题”四段来讲,每段一两句,控制在一分钟内,把细节留给面试官追问。例如:“我用 C++ 和 epoll 写了一个单线程 Reactor 的 echo server,所有 socket 都是非阻塞的,epoll 用 LT 模式。我写了一个测试脚本,同时连 500 个客户端都能正确回显,而阻塞版本只能服务一个。过程中遇到过 write 写不完、对端关闭导致 SIGPIPE 退出这些问题,后来分别用输出缓冲区和忽略 SIGPIPE 解决。”
提醒: 只说自己真正做过、能讲清楚的内容。面试官一定会顺着你说的每个词追问,比如你说了 LT,就会问为什么不用 ET。
做完这些,就可以进入阶段 1,把这堆代码拆成一个真正的 Reactor 网络库。