一个 Echo Server 能跑,为什么离高性能服务器还很远?

第一次写 TCP 服务端时,我们通常很快就能得到一个“可用”的程序:socket() 创建套接字,bind() 绑定端口,listen() 开始监听,accept() 得到客户端,然后在循环里 recv()send()

本机执行 nc 127.0.0.1 8080,输入什么就返回什么。功能正确,代码不长,看起来已经掌握了服务器编程。

但只要把它放到真实环境,问题会接连出现:第二个客户端为什么一直连不上业务处理?一次 recv() 为什么拿不到完整请求?客户端断开后服务为什么被 SIGPIPE 结束?切成非阻塞后为什么数据只发送了一部分?加了 epoll,吞吐为什么仍然没有提高?

“高性能服务器”不是某一个 API,而是一条从协议语义、I/O 状态机、并发模型、资源治理到可观测性的完整链路。本专题的 10 篇笔记会围绕这些故障逐层展开。本文先建立一个正确但刻意简单的阻塞 Echo Server,再用它说明每篇笔记到底解决哪一层问题。

1. 我们真正要解决的是什么问题?

假设服务端代码包含下面的逻辑:

1
2
3
char buffer[4096];
const ssize_t size = ::recv(client_fd, buffer, sizeof(buffer), 0);
::send(client_fd, buffer, static_cast<std::size_t>(size), 0);

它至少隐含了四个未经证明的假设:

  1. recv() 一定成功且返回正数;
  2. 一次读取正好对应一个应用层消息;
  3. 一次 send() 能发送全部字节;
  4. 对端不会在发送前后断开。

TCP 实际提供的是可靠、有序、双向的字节流,不保留应用层记录边界。recv() 可能只返回当前可用的一部分字节,也可能返回 0 表示对端完成有序关闭,或以 EINTR 等错误中断。send() 成功返回的字节数也可能小于请求长度。

所以服务器编程的第一个核心能力不是记住函数顺序,而是把每个连接写成一个可推进、可暂停、可失败、可关闭的状态机。

2. “高性能”为什么不能只看 QPS?

一个服务即使平均吞吐很高,也可能在高峰期耗尽文件描述符、让慢客户端拖垮输出缓冲,或在发布时无法优雅停止。高性能至少要同时考虑:

维度 需要回答的问题 常见证据
正确性 半包、断连、超时和异常是否处理正确? 协议测试、故障注入、抓包
延迟 普通请求和尾部请求要等多久? P50、P95、P99
吞吐 单位时间完成多少有效请求? requests/s、bytes/s
扩展性 连接数、线程数增加后是否仍有收益? 并发曲线、CPU 与锁争用
资源 fd、内存、队列和缓冲是否有上限? RSS、fd 数、队列深度
可恢复性 过载、依赖失败和发布退出时怎样收敛? 错误率、丢弃数、优雅退出时间
可观测性 线上变慢时能否定位协议、系统还是业务? 日志、指标、trace、系统工具

这意味着学习路线也不能从“先背 epoll”开始。协议边界错误时,更多并发只会更快地产生错误;没有背压时,事件循环越快,内存可能增长得越快。

3. 如何写出一个最小可运行的阻塞基线?

下面的程序只处理 IPv4、监听 0.0.0.0:8080,一次服务一个连接。它不是高性能实现,但刻意处理了资源释放、EINTR、TCP 关闭、部分写和 Linux SIGPIPE,适合作为后续改造的正确性基线。

3.1 运行条件

  • 平台:Linux;
  • 语言标准:C++17;
  • I/O 模式:阻塞;
  • 并发能力:串行处理客户端;
  • 协议:收到多少字节就回写多少字节,不定义应用层消息格式;
  • 退出方式:示例由终端信号结束,尚未实现生产级优雅退出。

将代码保存为 blocking_echo_server.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>

#include <array>
#include <cerrno>
#include <cstddef>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <system_error>

class UniqueFd {
public:
explicit UniqueFd(int fd) noexcept : fd_(fd) {}

~UniqueFd() {
if (fd_ >= 0) {
::close(fd_);
}
}

UniqueFd(const UniqueFd&) = delete;
UniqueFd& operator=(const UniqueFd&) = delete;

int get() const noexcept { return fd_; }

private:
int fd_;
};

[[noreturn]] void throw_system_error(const char* operation) {
throw std::system_error(errno, std::generic_category(), operation);
}

bool send_all(int fd, const char* data, std::size_t size) {
std::size_t sent = 0;

while (sent < size) {
const ssize_t result = ::send(
fd, data + sent, size - sent, MSG_NOSIGNAL);

if (result > 0) {
sent += static_cast<std::size_t>(result);
continue;
}

if (result < 0 && errno == EINTR) {
continue;
}

if (result < 0) {
std::cerr << "send failed: " << std::strerror(errno) << '\n';
} else {
std::cerr << "send returned zero before completion\n";
}
return false;
}

return true;
}

int main() {
try {
UniqueFd listener(::socket(AF_INET, SOCK_STREAM, 0));
if (listener.get() < 0) {
throw_system_error("socket");
}

int reuse_address = 1;
if (::setsockopt(listener.get(), SOL_SOCKET, SO_REUSEADDR,
&reuse_address, sizeof(reuse_address)) < 0) {
throw_system_error("setsockopt");
}

sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(8080);

if (::bind(listener.get(),
reinterpret_cast<const sockaddr*>(&address),
sizeof(address)) < 0) {
throw_system_error("bind");
}

if (::listen(listener.get(), SOMAXCONN) < 0) {
throw_system_error("listen");
}

std::cout << "listening on 0.0.0.0:8080\n";
std::array<char, 4096> buffer{};

for (;;) {
int client_fd;
do {
client_fd = ::accept(listener.get(), nullptr, nullptr);
} while (client_fd < 0 && errno == EINTR);

if (client_fd < 0) {
throw_system_error("accept");
}

UniqueFd client(client_fd);

for (;;) {
const ssize_t received =
::recv(client.get(), buffer.data(), buffer.size(), 0);

if (received > 0) {
if (!send_all(client.get(), buffer.data(),
static_cast<std::size_t>(received))) {
break;
}
continue;
}

if (received == 0) {
break; // 对端完成有序关闭
}

if (errno == EINTR) {
continue;
}

std::cerr << "recv failed: " << std::strerror(errno) << '\n';
break;
}
}
} catch (const std::exception& error) {
std::cerr << "server failed: " << error.what() << '\n';
return 1;
}
}

在 Linux 上编译:

1
2
3
clang++ -std=c++17 -O2 -Wall -Wextra \
blocking_echo_server.cpp -o blocking_echo_server
./blocking_echo_server

另开一个终端连接:

1
printf 'hello\n' | nc 127.0.0.1 8080

预期客户端输出:

1
hello

服务端会一直停在 accept/recv 循环,按 Ctrl+C 结束。这只是最小试验;生产服务需要显式处理终止信号、停止接收新连接、等待或取消在途请求,并在退出期限到达后执行既定策略。

4. 这段基线代码解决了什么,又故意没解决什么?

4.1 文件描述符由谁释放?

socket()accept() 返回文件描述符。UniqueFdclose() 绑定到作用域:客户端正常断开、读写失败或异常离开时,连接 fd 都会被释放。

这个小类只服务当前示例,没有提前实现移动、release()reset() 等暂时用不到的接口。真实项目优先复用已有且经过测试的 fd RAII 类型。

4.2 为什么 recv() 不等于“读取一个请求”?

Echo 协议不解释内容,所以每收到一段字节就可以立即回写。HTTP、自定义 RPC 或长度前缀协议则必须把多次读取放入输入缓冲,并按协议解析完整消息:

1
TCP bytes -> connection input buffer -> frame parser -> complete request

一次 recv() 可能包含半个消息、一个消息,也可能把多个消息拼在一起。所谓“粘包/半包”不是 TCP 把包弄坏,而是应用层尚未定义并实现消息边界。

4.3 为什么 send_all() 必须循环?

send() 的返回值是本次成功接受的字节数,不承诺等于请求长度。阻塞套接字在常见小消息下经常一次发完,但正确代码不能依赖这种经验。

本示例遇到 EINTR 时重试,其他错误结束当前连接,并用 Linux 的 MSG_NOSIGNAL 防止对断开连接发送时由本次调用触发 SIGPIPE 终止整个进程。这个标志是 Linux 接口;其他平台的处理方式需要结合目标系统验证。

4.4 为什么第二个客户端仍可能等待?

外层 accept() 得到连接后,程序会进入该客户端的 recv() 循环,直到它断开才重新 accept。一个连接长时间不发送数据,就能占住整个服务。

这正是基线的价值:它把“阻塞调用将执行流绑定到一个连接”的问题暴露得很清楚。后续可以比较线程/进程模型、I/O 多路复用和事件驱动状态机,而不是从一个巨大框架开始猜每层行为。

4.5 为什么这里还不能叫生产服务器?

它没有应用层帧格式、读写超时、连接上限、输出背压、过载策略、非阻塞状态机、优雅退出、日志上下文、指标和 TLS。accept() 的某些瞬时网络错误也需要按 Linux 语义分类重试,而不是像示例一样直接结束进程。

这些缺口不是要求一开始堆出庞大架构,而是后续每篇文章要逐一回答的问题。

5. 应该按什么问题顺序阅读这 10 篇笔记?

第一阶段:先理解内核到底提供了什么语义

  1. TCP/IP 协议族、IP 与 TCP 协议详解

    解决“连接为什么处于 TIME_WAIT / CLOSE_WAIT”“握手、重传、流量与拥塞控制如何影响应用”的问题。读完后应能把 socket 错误和抓包现象放回协议状态机,而不是只调 API。

  2. Socket 基础与 TCP 编程

    解决 socket/bind/listen/accept/connect/send/recv 的完整生命周期,以及字节序、阻塞/非阻塞、半包、部分读写和常用 socket option。

  3. UDP 通信

    通过与 TCP 对比理解报文边界、丢失、乱序、重复和 MTU。重点不是宣判 UDP“不可靠所以不能用”,而是判断可靠性、时序和重传应该由哪一层承担。

第二阶段:减少不必要的数据搬运,建立进程规范

  1. 高级 I/O 函数与零拷贝

    readv/writevsendfilemmapspliceteefcntl 分析系统调用与数据路径。所谓“零拷贝”必须说明省掉哪一次复制、适用于哪种文件/套接字路径。

  2. Linux 服务器程序规范

    解决权限、日志、资源限制、工作目录、pidfile、信号和优雅退出。服务能在终端运行,与它能由服务管理器可靠托管,是两个阶段。

第三阶段:从阻塞循环演进为事件驱动状态机

  1. [1] libevent 服务端

    使用 event_baseevconnlistenerbuffereventevbuffer 构建服务端,重点理解回调、输入缓冲、消息解析、水位线、超时和输出背压。

  2. [2] libevent 客户端

    解决异步连接、DNS、超时、重连和客户端状态机。连接失败不是“再调用一次 connect”这么简单,需要退避、截止时间和生命周期约束。

第四阶段:选择并发模型并形成完整工程

  1. 多进程、多线程与进程池线程池

    对比 fork、pthread、IPC、进程池、线程池和半同步/半反应堆模型,重点回答连接归属、队列上限、任务取消和退出时谁等待谁。

  2. 高性能服务器编程综合笔记

    epoll 的 LT/ET、Reactor、非阻塞 I/O、timerfd/eventfd/signalfd、线程池、HTTP 解析和背压拼成一个可落地模型。

  3. 服务器调试、测试与系统监测

    最后学习如何用 gdb、stracesstcpdumplsofvmstat 等证据区分协议、代码、CPU、内存和网络瓶颈,并建立压测闭环。

6. 每一层容易出现什么故障?

现象 先追问什么 对应笔记
recv() 偶尔少一截 应用层如何定义消息边界? Socket 与 TCP
大量 TIME_WAIT 哪一端主动关闭,连接模型是否合理? TCP/IP 详解
UDP 偶尔缺消息 是否定义序号、重传、去重与 MTU 策略? UDP
静态文件吞吐低 数据经过了哪些用户态/内核态复制? 高级 I/O
发布时连接被硬断 停止接收、排空和退出期限如何定义? 服务器规范
单个慢客户端让内存上涨 输出缓冲上限和背压策略在哪里? libevent 服务端
依赖恢复后客户端同时重连 是否有指数退避、抖动和总截止时间? libevent 客户端
线程增加但吞吐下降 锁、队列、任务粒度还是内存带宽? 进程池/线程池
epoll 一直报告可写 是否无条件监听写事件? 综合工程笔记
压测数字高但线上仍慢 输入、连接模型和尾延迟是否一致? 调试与监测

表格只提供调查入口,不替代证据。例如 TIME_WAIT 本身是 TCP 正常状态,不能看到数量多就直接修改内核参数;必须先确认主动关闭方、连接复用策略和实际资源压力。

7. 从阻塞基线走向 Reactor,要保持哪些不变量?

切成非阻塞和 epoll 后,调用返回 EAGAIN 不代表连接失败,只表示当前无法继续。每个连接需要保留跨事件状态:

1
2
3
4
5
6
7
connection
├── input buffer 已读但尚未形成完整请求的字节
├── parser state 当前解析到哪里
├── output buffer 尚未成功发送的字节
├── deadlines 连接/读取/写入/业务超时
├── lifecycle state open / half-closed / closing
└── ownership 属于哪个事件循环或 worker

事件到来时只推进能完成的部分;读到 EAGAIN 就等待下次可读,发送缓冲未清空就保留偏移并按需关注可写事件。不能把阻塞版本的 send_all() 原封不动放进事件循环,否则慢客户端会阻塞整个 Reactor。

无论底层换成原生 epoll 还是 libevent,TCP 字节流、部分读写、协议状态和背压这些不变量都不会消失,框架只是帮助管理事件与缓冲。

8. 常见误区

8.1 误区:使用 epoll 就是高性能服务器

epoll 解决大量 fd 的就绪通知,不优化业务算法、锁竞争、内存分配、协议解析和慢依赖。事件循环中执行阻塞数据库调用,仍会拖住所有连接。

8.2 误区:非阻塞 I/O 不会阻塞,所以代码更简单

系统调用会更快返回,但未完成工作必须存入连接状态,等待后续事件继续。复杂度从线程阻塞转移到了显式状态机。

8.3 误区:一次 send() 成功就代表消息发完

返回正数只表示这部分字节被内核接受。必须根据返回值推进偏移;非阻塞模式遇到 EAGAIN 时还要保存剩余输出并稍后继续。

8.4 误区:线程越多,并发能力越强

线程会消耗栈、调度、缓存和同步资源。I/O 密集、CPU 密集和混合负载需要不同分层,线程数应通过吞吐与尾延迟曲线决定。

8.5 误区:UDP 不可靠,所以只适合玩具程序

UDP 提供保留报文边界的无连接数据报语义。实时媒体、游戏和自定义传输可以根据需求选择丢弃、重传和时序;关键是可靠性是否由应用正确实现,而不是协议标签本身。

8.6 误区:压测 QPS 越高,线上表现越好

短连接/长连接、请求大小、业务命中率、客户端 think time 和错误处理都会改变结果。必须记录输入模型、错误率、资源曲线和 P99,而不是只保留峰值数字。

9. 什么时候适合使用这套学习路线?

它适合希望系统理解 Linux TCP/UDP 服务端、事件驱动 I/O、libevent、进程/线程池和线上排障的 C/C++ 开发者。最好已经掌握基本 C++ 资源管理和编译方式,并拥有 Linux 环境进行 socket、epoll 和系统工具实验。

如果目标只是调用成熟 HTTP/RPC 框架完成普通业务,不需要先手写完整 Reactor;理解消息边界、超时、背压和观测接口后,优先使用团队已有且经过生产验证的框架更可靠。

本专题也不是分布式系统、安全协议或内核网络栈的完整教程。TLS、服务发现、一致性、负载均衡和跨机容错应在掌握单机连接生命周期后,按真实需求继续学习。

10. 总结

一个 Echo Server 能跑,只证明最短成功路径成立。高性能服务器真正困难的是:当数据只到一半、对端很慢、资源不足、线程争用、依赖超时和进程退出时,系统仍能保持边界、控制损失并提供证据。

  1. TCP 是字节流,应用层必须自己定义消息边界,并处理部分读写和断连。
  2. 阻塞基线有助于理解 API,但串行连接模型无法承载真实并发。
  3. epoll 和 libevent 管理就绪事件,不会自动解决协议、背压和业务阻塞。
  4. 并发模型必须同时定义连接所有权、队列上限、超时和退出路径。
  5. 性能结论需要吞吐、尾延迟、错误率和资源证据共同支撑。

最直接的实践建议是:先编译并运行本文的阻塞基线,分别测试半包、客户端中途断开和第二个长连接等待;记录现象后再进入 TCP/IP 与 Socket 两篇笔记。带着真实问题阅读,比从 epoll API 清单开始更容易建立完整模型。

参考资料