第 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 适合迭代(recvfrom 即处理);TCP echo 因客户端可发任意长度 → 并发。

      fork-per-client 模型

      父进程 accept + fork;子进程 close lfd、父进程 close cfd;SIGCHLD handler 配合 waitpid(-1, NULL, WNOHANG) 收割 zombie。简单通用但 fork 开销大。

      §60.3;TLPI Listing 60-4 is_echo_sv.cSA_RESTART 让 accept 不被信号打断;可加 child 数上限防 fork bomb。

      Preforked/Prethreaded 服务器池

      启动时预创建固定数量子进程/线程组成 pool;每个空闲 worker 在 listen fd 上 accept(内核串行化);父进程按负载伸缩 pool 大小;负载低时收缩,避免闲置进程浪费资源。

      §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;配置文件 /etc/inetd.conf 字段:service-name, socket-type (stream/dgram), protocol (tcp/udp), flags (wait/nowait), user, server-program, args;xinetd 是扩展版(多数新 Linux 发行版默认)。

      二、详细笔记

      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 argsargv[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 模型。

      三、关键图表

      并发服务器模型对照
      模型 适用负载 核心机制

      迭代

      短请求、低 QPS

      单进程 accept + 处理 + 循环

      fork-per-client

      中等并发

      每个 client 一个 fork,子进程退出

      Preforked pool

      高 QPS

      启动时预 fork N 子,全部 accept

      Prethreaded pool

      高 QPS

      同上但用线程

      单进程 I/O 复用

      C10K

      select/poll/epoll reactor

      服务器农场 (DNS RR)

      横向扩展

      DNS 把单名映射到多 IP

      L4/L7 负载均衡

      横向扩展

      专用 LB 调度请求

      inetd /etc/inetd.conf 字段
      字段 取值 含义

      service-name

      echo / http / …​

      查 /etc/services 找端口

      socket-type

      stream / dgram

      TCP / UDP

      protocol

      tcp / udp

      协议

      flags

      wait / nowait

      TCP nowait / UDP wait

      user[.group]

      root / nobody

      exec 后进程凭证

      server-program

      /usr/sbin/tcpd / internal

      服务程序或 internal

      server-args

      in.telnetd …​

      argv[0..]

      四、思维导图

      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 负载均衡

      五、重点与易错点

      1. fork 后父子要关对端 fd——子关 lfd、父关 cfd;否则 fd 表会耗尽、socket 引用计数永远 > 0、连接永不关闭。

      2. SIGCHLD 处理必备 WNOHANG——不带 WNOHANG 的 wait 会阻塞父进程,无法接受新连接;while (waitpid(-1, NULL, WNOHANG) > 0) continue 才能收割所有 zombie。

      3. SIGCHLD handler 内保存并恢复 errno——handler 可能修改 errno,影响主线 syslog/errExit;TLPI 示例保存 savedErrno

      4. fork-per-client 高并发下资源爆炸——每客户端一进程,描述符、内存、调度开销都很大;fork bomb 风险需用 child 上限防御。

      5. preforked 必须在 listen 之后 fork——子继承 listen fd;所有子都能在同一个 listen fd 上 accept,内核保证一次只唤醒一个子。

      6. preforked pool 大小需动态调整——固定大小闲置或不够;典型 NGINX 用 channel 或共享计数同步。

      7. TCP 选 nowait,UDP 选 wait——wait 让 inetd 在 execed 服务退出前不再 select 同一 socket,防止并发 exec;nowait 让 inetd 继续 listen。

      8. xinetd 是 inetd 主流替代——现代 Linux 多数发行版已用 xinetd,提供 per-service access control、连接速率限制等;阅读 xinetd.org 文档。

      9. 服务器农场不解决 server affinity——客户端连续请求可能被分到不同 server;session sticky cookie(应用层)或 L4 持久连接选项解决。

      10. 服务器农场要监控每节点健康——DNS round-robin 客户端缓存可能导致请求仍打到死的节点;L4 LB 主动健康检查 + 自动 fail-out 是工业实践。

      11. 现代互联网服务倾向于 reactor/proactor——预先 fork N 个 worker,每 worker 跑单进程 epoll + 多客户端;NGINX/Envoy/HAProxy 都是此模式。

      12. C10K / C10M——「同时维持一万 / 一百万连接」是经典 benchmark;fork-per-client 完全不可行,必须 epoll/io_uring + 异步。

      13. 跨章衔接:第 59 章 Internet 域 API 是基础;第 61 章 shutdown、SO_REUSEADDR、TCP 状态机帮助理解 fork-per-client 中的连接生命周期;第 63 章详述备选 I/O(select/poll/epoll);第 37 章 becomeDaemon 在 server 示例中频繁出现。