第 60 章 Sockets: 服务器设计 (Sockets: Server Design)
核心结论
-
迭代 vs 并发服务器:迭代服务器一次处理一个客户端(适合请求-响应快、简单的服务,如 echo);并发服务器同时服务多个客户端(适合长会话 / 重计算)。
-
fork-per-client 是并发服务器的最简实现:父进程
accept、fork 子进程处理;父需SIGCHLD处理器waitpid(-1, NULL, WNOHANG)循环收割 zombie;父子共享 fd 需各自关闭用不到的一份。 -
高负载服务器的非传统设计:preforked/prethreaded 进程池、单进程多客户端 (I/O 复用 / epoll)、DNS round-robin 或 L4 负载均衡;fork-per-client 资源开销大(每客户端一进程),高 QPS 场景下无法承受。
-
TCP 服务器常规并发模型(一种 preforked):父先 listen 后 fork,所有子同时在监听 fd 上
accept,内核负责唤醒唯一子(accept 在多数现代 UNIX 上原子)。 -
inetd 是 Internet superserver:单进程 select 监控多 service-port,
accept后 fork+exec 服务程序;fd 复制到 0/1/2、关闭其他 fd;inetd 自身处理 SIGCHLD 与nowait/wait区分 TCP/UDP。 -
设计选择取决于 (请求时长, 并发数, 资源约束):长会话、高并发 → 进程池 / epoll;短请求、低并发 → fork-per-client 可接受;想省开发 → inetd (现多为 xinetd)。
|
本章主旨
本章关注 socket 服务器的「结构选择」——不是单个 socket API,而是「一个进程怎么服务多个客户端?」这一架构问题。读者需建立:(1) 迭代与并发的 trade-off;(2) fork-per-client 是默认并发模型;高负载场景下用 preforked server pool 或单进程 + I/O 复用(第 63 章);(3) inetd 帮开发者省略 boilerplate(bind/listen/accept/fd duplicate/close-on-exec)。同时引入「服务器农场」概念,预告横向扩展 (scale-out) 的模式。 |
一、核心概念
本章围绕 5 个核心概念:迭代 vs 并发、fork-per-client 并发、preforked pool、单进程多客户端 (I/O 复用)、inetd / xinetd。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
迭代 vs 并发服务器 |
迭代 = 一次一个客户端(要求请求处理快);并发 = 同时多个客户端(fork/线程/I/O 复用)。模式选择决定资源消耗与延迟。 |
§60.1;UDP echo 适合迭代( |
fork-per-client 模型 |
父进程 |
§60.3;TLPI Listing 60-4 |
Preforked/Prethreaded 服务器池 |
启动时预创建固定数量子进程/线程组成 pool;每个空闲 worker 在 listen fd 上 |
§60.4;参考 Stevens UNPv1 第 30 章;与 fork-per-client 比,预 fork 避免每请求的 fork 开销;监听 fd 的 accept 在多数系统上原子,旧系统需要文件锁。 |
单进程多客户端模型 |
一个进程同时服务多客户端,必须用 I/O 多路复用:select/poll(§63.2)、signal-driven I/O(§63.3)、epoll(§63.4)。自己负责公平调度,防止一个客户端独占。 |
§60.4 + 第 63 章;适用场景:HTTP server,单线程 reactor/event loop。 |
inetd (Internet Superserver) |
单 daemon 用 select 监控多个 service-port;事件到达后 fork+exec 服务;TCP 服务前完成 accept、把 socket 复制到 fd 0/1/2、关闭其他 fd;省去每个服务程序的 boilerplate。 |
§60.5;配置文件 |
二、详细笔记
60.1 迭代 vs 并发:何时用哪种
What:两种 socket 服务器结构——迭代服务器一次只服务一个客户端,处理完才接下一个;并发服务器同时服务多个客户端。
Why:选择错误会让服务器吞吐受限或响应延迟爆炸。请求时长决定延迟约束;并发数决定资源消耗。
How:
| 维度 | 迭代 | 并发(fork-per-client) |
|---|---|---|
请求时长 |
必须短(否则阻塞后续客户端) |
任意 |
实现复杂度 |
简单 |
需 SIGCHLD / fd 清理 / 进程数上限 |
资源消耗 |
1 进程 |
N 进程(N = 并发客户端数) |
典型场景 |
DNS query-response、UDP echo、简单 TCP 命令 |
长会话、文件传输、HTTP |
When:(1) 单次请求服务端处理 < 几 ms —— 迭代;(2) 多客户端长 keepalive —— 并发;(3) 高并发 —— 考虑 preforked / 复用。
Example:TLPI 用 UDP echo(Listing 60-2,id_echo_sv.c)和 TCP echo(Listing 60-4,is_echo_sv.c)作为两种典型 demo:
// 摘自《The Linux Programming Interface》 第 60 章 Listing 60-2 (UDP echo, iterative)
#include <syslog.h>
#include "id_echo.h"
#include "become_daemon.h"
int main(int argc, char *argv[]) {
int sfd;
ssize_t numRead;
socklen_t addrlen, len;
struct sockaddr_storage claddr;
char buf[BUF_SIZE];
char addrStr[IS_ADDR_STR_LEN];
if (becomeDaemon(0) == -1) errExit("becomeDaemon");
sfd = inetBind(SERVICE, SOCK_DGRAM, &addrlen);
if (sfd == -1) { syslog(LOG_ERR, "..."); exit(EXIT_FAILURE); }
for (;;) {
len = sizeof(struct sockaddr_storage);
numRead = recvfrom(sfd, buf, BUF_SIZE, 0,
(struct sockaddr *) &claddr, &len);
if (numRead == -1) errExit("recvfrom");
if (sendto(sfd, buf, numRead, 0,
(struct sockaddr *) &claddr, len) != numRead)
syslog(LOG_WARNING, "Error echoing response to %s (%s)",
inetAddressStr(...), strerror(errno));
}
}
60.2 并发 TCP 服务器:fork-per-client
What:父进程 bind+listen 后死循环 accept;每收到 connection 调用 fork,子进程在 cfd 上读写;父立即关 cfd、子立即关 lfd。
Why:理解并发服务器的最经典实现。下文 SIGCHLD 处理让父不会积累 zombie;fd 双向清理避免 fd 泄漏与父进程 fd 表耗尽。
How:TLPI Listing 60-4 关键结构(去注释版):
// 摘自《The Linux Programming Interface》 第 60 章 Listing 60-4
#include <signal.h>
#include <syslog.h>
#include <sys/wait.h>
#include "become_daemon.h"
#include "inet_sockets.h"
#include "tlpi_hdr.h"
#define SERVICE "echo"
#define BUF_SIZE 4096
static void grimReaper(int sig) {
int savedErrno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) continue;
errno = savedErrno;
}
static void handleRequest(int cfd) {
char buf[BUF_SIZE]; ssize_t numRead;
while ((numRead = read(cfd, buf, BUF_SIZE)) > 0)
if (write(cfd, buf, numRead) != numRead) { syslog(...); exit(EXIT_FAILURE); }
if (numRead == -1) { syslog(...); exit(EXIT_FAILURE); }
}
int main(int argc, char *argv[]) {
int lfd, cfd;
struct sigaction sa;
if (becomeDaemon(0) == -1) errExit("becomeDaemon");
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = grimReaper;
if (sigaction(SIGCHLD, &sa, NULL) == -1) ...
lfd = inetListen(SERVICE, 10, NULL);
if (lfd == -1) ...
for (;;) {
cfd = accept(lfd, NULL, NULL);
if (cfd == -1) { syslog(LOG_ERR, "accept fail"); exit(EXIT_FAILURE); }
switch (fork()) {
case -1: syslog(LOG_ERR, "fork"); close(cfd); break; /* try next */
case 0: close(lfd); handleRequest(cfd); _exit(EXIT_SUCCESS);
default: close(cfd); break; /* loop to accept next */
}
}
}
要点: . SIGCHLD + SA_RESTART:收割 zombie 同时不让 accept/recv 等被 EINTR 打断。 . waitpid(-1, …, WNOHANG):非阻塞 loop 直到没有 zombie;保存恢复 errno。 . 父子各自关闭对端不需要的 fd:防止 fd 用尽、防止 socket 永不关闭(dup 引用计数)。 . 真实场景需加 child 数量上限:防 fork-bomb 攻击。
When:(1) 客户端请求时长不可预测;(2) 每客户端资源消耗可估算;(3) 中等并发(数十 ~ 数百)。
Example:父进程用 fork() → 子进程调 handleRequest → 子 close lfd、close cfd 后 exit;父 close cfd 回到 accept。
60.3 高负载服务器的其它设计
What:除 fork-per-client 之外的并发模型——preforked server pool(启动时预创建 N 子进程/线程)、单进程多客户端(I/O 复用 + 调度)、服务器农场(DNS round-robin / L4 load balancer)。
Why:fork-per-client 在数千并发 / 每秒数千请求时会因 fork 开销变成瓶颈;预先创建 worker 省掉每请求 fork + 初始化。
How:
| 模型 | 核心思路 | 优 / 缺点 |
|---|---|---|
Preforked processes |
父启动时 fork N 个子,全部阻塞在 accept;空闲 worker 数 ≥ 1 时接受新连接,否则扩缩 pool 大小 |
省 fork 开销;需 pool 动态调整;Linux/BSD accept 原子 |
Prethreaded |
同上但用线程;每线程 accept 或主线程 accept 后 push 给空闲 worker |
线程比进程更轻;共享地址空间,错误影响范围更大 |
单进程多客户端 |
一个进程 + select/poll/epoll;进程负责「自己公平调度」 |
极致轻量;但服务器要自己防止一个客户端独占 |
服务器农场 (server farm) |
DNS round-robin 把单一名字映射到多 IP;或专门的 L4 负载均衡器 |
横向扩展;远程 DNS 缓存会破坏 round-robin;负载不均;典型 web 部署 |
DNS round-robin:权威 DNS 把同一域名映射到多个 A 记录;不同查询返回顺序不同。客户端缓存可能使 round-robin 失效。
When:(1) QPS 高、客户端长连接 → preforked;(2) 超高并发 + 单机 → 单进程 epoll reactor;(3) 跨机器水平扩展 → DNS RR + L4 balancer。
Example:Nginx/HAProxy/Envoy 等生产服务器普遍采用「preforked + 共享 accept fd(多 worker) + epoll」混合模式。
60.4 inetd 与 /etc/inetd.conf
What:inetd 是「Internet 超级守护进程」——单 daemon 用 select 监控多个 service-port,事件到达时(TCP 已 accept、UDP 已 recv datagram)fork + exec 服务程序,把 socket 复制到 fd 0/1/2 并关闭其他 fd,让服务程序把 socket 当 stdin/stdout 用。
Why:(1) 减少 inetd 之前每服务一 daemon 造成的进程表浪费(多数低频服务只在等待请求);(2) 把 boilerplate(bind/listen/accept/fd dup、close-on-exec、daemonize)抽到 inetd。
How:/etc/inetd.conf 一行一个服务(列分隔):
# service socket-type protocol flags user server-program args
echo stream tcp nowait root internal
echo dgram udp wait root internal
ftp stream tcp nowait root /usr/sbin/tcpd in.ftpd
telnet stream tcp nowait root /usr/sbin/tcpd in.telnetd
-
flags =
wait/nowait:UDP 通常wait(让 execed 服务读完所有 datagram 后退出),TCP 通常nowait(只服务一次连接);wait时 inetd 把 socket 从 select 集合中移除直到 SIGCHLD。 -
internal表示 inetd 内置实现(如 echo)。 -
user用.可指定 group,运行时用 setuid/setgid。 -
server-program args:argv[0]通常是 basename。
修改 inetd.conf 后 killall -HUP inetd 重新读取。
inetd 调用服务的简化步骤(TCP nowait): . fork . close inherited fds (除 socket fd) . dup socket fd 到 0、1、2(dup2 序列,关闭原 socket fd) . exec 服务程序
When:(1) 实现「用 stdin/stdout 通信」的简单网络服务 → inetd 最简;(2) 现代 Linux 多用 xinetd,提供 ACL、访问速率限制、日志;(3) 服务很重 / 高并发 → 不用 inetd,自己起 daemon。
Example:TLPI Listing 60-6 — 被 inetd 启动的 TCP echo 服务只剩几行:
// 摘自《The Linux Programming Interface》 第 60 章 Listing 60-6
#include <syslog.h>
#include "tlpi_hdr.h"
#define BUF_SIZE 4096
int main(int argc, char *argv[]) {
char buf[BUF_SIZE]; ssize_t numRead;
while ((numRead = read(STDIN_FILENO, buf, BUF_SIZE)) > 0) {
if (write(STDOUT_FILENO, buf, numRead) != numRead) {
syslog(LOG_ERR, "write failed: %s", strerror(errno));
exit(EXIT_FAILURE);
}
}
if (numRead == -1) { syslog(...); exit(EXIT_FAILURE); }
exit(EXIT_SUCCESS);
}
60.5 服务器设计的实用判断
What:选择服务器结构没有「最佳」,是 trade-off——开发成本、运维成本、单进程性能、单机能撑的并发、横向扩展能力、安全性。
How:决策树(简化):
-
请求时长 ≤ 1ms,QPS ≤ 1k → 迭代或 fork-per-client。
-
QPS 1k-10k,客户端长连接 → preforked + 共享 accept fd。
-
QPS > 10k 或长连接数 > 10k → epoll/event-loop 或线程池 reactor。
-
多机 → L4/L7 负载均衡(HAProxy、Envoy、Nginx upstream)。
-
短生命周期的命令类服务 → inetd。
When:(1) 起步先写 fork-per-client;(2) profiling 发现瓶颈 → 迁移;(3) 互联网服务多已转向 epoll + 协程/异步。
Example:C10K / C10M 问题——「一台机器同时维持 10k / 10M 连接」。epoll + event-loop 正是答案;TLPI 第 63 章详细讨论备选 I/O 模型。
三、关键图表
|
并发服务器模型对照
|
|
inetd
/etc/inetd.conf 字段
|
四、思维导图
mindmap
root((第 60 章 服务器设计))
迭代 vs 并发
请求时长影响
UDP echo 迭代
TCP echo 并发
fork per client
父 accept
SIGCHLD 收割
父子 close fd
进程数上限
替代模型
Preforked pool
Prethreaded
单进程 多客户端
服务器农场
DNS round robin
inetd
一步启动服务
select 监控多端口
fd dup 到 0 1 2
wait nowait 区分
xinetd 替代
设计权衡
fork 开销
C10K C10M
epoll reactor
L4 负载均衡
五、重点与易错点
-
fork 后父子要关对端 fd——子关
lfd、父关cfd;否则 fd 表会耗尽、socket 引用计数永远 > 0、连接永不关闭。 -
SIGCHLD 处理必备 WNOHANG——不带 WNOHANG 的
wait会阻塞父进程,无法接受新连接;while (waitpid(-1, NULL, WNOHANG) > 0) continue才能收割所有 zombie。 -
SIGCHLD handler 内保存并恢复 errno——handler 可能修改 errno,影响主线
syslog/errExit;TLPI 示例保存savedErrno。 -
fork-per-client 高并发下资源爆炸——每客户端一进程,描述符、内存、调度开销都很大;fork bomb 风险需用 child 上限防御。
-
preforked 必须在 listen 之后 fork——子继承 listen fd;所有子都能在同一个 listen fd 上
accept,内核保证一次只唤醒一个子。 -
preforked pool 大小需动态调整——固定大小闲置或不够;典型 NGINX 用 channel 或共享计数同步。
-
TCP 选 nowait,UDP 选 wait——
wait让 inetd 在 execed 服务退出前不再 select 同一 socket,防止并发 exec;nowait让 inetd 继续 listen。 -
xinetd 是 inetd 主流替代——现代 Linux 多数发行版已用 xinetd,提供 per-service access control、连接速率限制等;阅读 xinetd.org 文档。
-
服务器农场不解决 server affinity——客户端连续请求可能被分到不同 server;session sticky cookie(应用层)或 L4 持久连接选项解决。
-
服务器农场要监控每节点健康——DNS round-robin 客户端缓存可能导致请求仍打到死的节点;L4 LB 主动健康检查 + 自动 fail-out 是工业实践。
-
现代互联网服务倾向于 reactor/proactor——预先 fork N 个 worker,每 worker 跑单进程 epoll + 多客户端;NGINX/Envoy/HAProxy 都是此模式。
-
C10K / C10M——「同时维持一万 / 一百万连接」是经典 benchmark;fork-per-client 完全不可行,必须 epoll/io_uring + 异步。
-
跨章衔接:第 59 章 Internet 域 API 是基础;第 61 章
shutdown、SO_REUSEADDR、TCP 状态机帮助理解 fork-per-client 中的连接生命周期;第 63 章详述备选 I/O(select/poll/epoll);第 37 章becomeDaemon在 server 示例中频繁出现。