TCP并发ECHO服务器多进程版:从socket到fork的完整实现
发布时间:2026/10/14 6:15:06 锦皓数字建站

1. 从一个“能同时陪聊”的服务器说起很多人第一次接触网络编程写出来的第一个像样的东西就是 ECHO 服务器——客户端发一句服务器原样回一句。单客户端跑起来没问题可一旦你开两个终端同时连上去就会发现第二个连接要么被阻塞、要么干脆连不上。原因很简单accept之后服务器就一头扎进recv/send循环里把主线程彻底占死了根本回不到accept去接待下一位客人。“TCP并发ECHO服务器——多进程版”要解决的就是这个问题。它的核心目标很明确让服务器在同一时刻能够服务多个客户端每个客户端都有独立的处理逻辑互不干扰。实现手段是多进程——每来一个连接就fork出一个子进程专门伺候它父进程继续守在accept上等下一个。这篇内容适合谁看如果你已经会写最基础的单连接 socket 程序能看懂socket/bind/listen/accept/recv/send这套调用但还没搞明白“并发”到底怎么落地那这篇就是给你准备的。我会把设计思路、进程模型、代码实现、参数细节、踩坑经验全部摊开讲代码可以直接抄去改。如果你已经写过多线程版本也可以对照着看理解多进程和多线程在并发模型上的本质差异。需要先说明一点多进程只是并发服务器的一种经典实现方式它有自己的适用边界。我不会吹它是“最优解”而是会把它的优势、代价、以及什么场景下该选它、什么场景下该换别的模型都讲清楚。这样你拿到的不只是一段能跑的代码而是一套能迁移到其他网络服务上的判断能力。2. 为什么是“多进程”而不是别的并发模型2.1 并发服务器的几种主流思路对比在动手写代码之前先把可选方案摆到台面上。并发处理多个连接业界常见的有这么几种路子它们各有各的脾气。模型核心机制优点代价多进程每个连接 fork 一个子进程隔离性好一个崩了不影响别人进程创建开销大进程间共享数据麻烦多线程每个连接一个线程开销比进程小共享内存方便需要处理锁和线程安全问题IO 多路复用select/poll/epoll 单进程管理多连接资源占用低能扛海量连接编程模型复杂业务逻辑要拆成状态机线程池/进程池预创建一批 worker 复用避免频繁创建销毁的开销池大小和调度策略需要调优多进程版 ECHO 服务器选的是第一行。为什么在“学习并发”这个阶段多进程往往是第一个被拿出来讲的因为它把“并发”这件事的复杂度压到了最低——你不需要理解事件循环不需要处理锁不需要设计状态机。你只需要理解一件事fork之后父子进程各自跑各自的子进程专心处理它那一个连接处理完就退出。逻辑是线性的跟单连接版本几乎一模一样只是多套了一层进程外壳。2.2 多进程模型的核心优势隔离与简单多进程最大的价值在于隔离性。每个子进程有独立的地址空间一个客户端触发了段错误导致子进程崩溃父进程和其他子进程完全不受影响服务器整体依然存活。这一点在多线程模型里是要额外花力气去保证的——一个线程写越界可能把整个进程的内存搞坏。另一个优势是编程简单。子进程里的代码就是标准的“收数据-回数据-关连接”三段式不需要考虑临界区、不需要加锁。对于 ECHO 这种无状态的服务多进程几乎是零心智负担。2.3 必须正视的代价fork 开销与僵尸进程天下没有免费的午餐。多进程的代价主要有两个必须在设计阶段就想清楚。第一是fork 的系统开销。每次fork都要复制父进程的页表、文件描述符表等内核数据结构。虽然现代操作系统用写时复制Copy-On-Write优化了内存复制但进程创建本身仍然比线程创建重得多。如果服务器每秒要接受成千上万个短连接频繁 fork 会成为瓶颈。ECHO 这种教学场景连接数不多问题不大但心里要有数。第二是僵尸进程。子进程退出后如果父进程不去回收它的退出状态它就会变成僵尸zombie占用进程表项。连接一多僵尸堆积最终可能耗尽系统资源。这是多进程服务器最经典的坑处理方式后面会专门讲。提示选多进程还是多线程本质是在“隔离性/简单性”和“资源开销/共享便利”之间做权衡。ECHO 这种无共享状态的服务多进程的隔离优势能充分发挥共享劣势几乎不暴露所以是个很合适的教学载体。3. 核心细节拆解从 socket 到 fork 的每一步3.1 服务器端的标准生命周期一个 TCP 服务器的骨架无论并发与否都逃不开这几个阶段顺序不能乱socket()创建监听套接字setsockopt()设置地址复用强烈建议bind()绑定 IP 和端口listen()进入监听状态设置等待队列accept()阻塞等待客户端连接处理连接收数据、回数据close()关闭连接多进程版在第 5 步和第 6 步之间插入forkaccept返回一个新的已连接套接字connfd父进程立刻fork子进程拿着connfd去处理父进程关掉自己手里的connfd副本回到accept继续等。3.2 为什么父进程要关掉 connfd这是新手最容易困惑的点明明connfd是父进程accept出来的为什么 fork 之后父进程要把它close掉关键在于引用计数。accept返回的connfd是一个文件描述符它指向内核里一个打开的连接对象。fork之后父子进程各有一份描述符表的拷贝两个connfd指向同一个连接对象内核的引用计数变成 2。父进程如果不关这个引用计数就一直挂着即使子进程处理完关闭了它自己的那份连接对象也不会真正释放因为父进程那份还开着。结果就是连接迟迟不进入TIME_WAIT或彻底关闭文件描述符泄漏。所以规矩是谁不用谁就关。父进程不参与这个连接的数据收发fork 完立刻close(connfd)子进程不负责监听它拿到connfd后可以把监听套接字listenfd也关掉虽然子进程退出时会自动关但显式关闭是好习惯。3.3 子进程的处理逻辑与退出子进程的世界很单纯它只有一个任务就是服务好connfd这一个连接。典型流程是循环recv收到数据就send回去直到recv返回 0对端关闭或返回 -1出错然后close(connfd)最后exit(0)。这里有个细节子进程处理完必须用exit()而不是return。如果子进程是从main函数里return出来的它会一路返回到调用栈顶端可能触发父进程后续代码的重复执行造成诡异 bug。exit()会干净地终止当前进程还会刷新标准 IO 缓冲区是正确选择。3.4 信号处理SIGCHLD 与僵尸回收前面提到的僵尸进程问题标准解法是处理SIGCHLD信号。子进程终止时内核会向父进程发送SIGCHLD。父进程在信号处理函数里调用waitpid回收子进程。但这里有个经典陷阱信号会合并。如果同时有多个子进程退出父进程可能只收到一个SIGCHLD。如果信号处理函数里只waitpid一次就会漏掉其他僵尸。正确写法是用while循环配合WNOHANG选项把所有已退出的子进程一次性收干净while (waitpid(-1, NULL, WNOHANG) 0);这行的意思是回收任意一个已退出的子进程不阻塞只要还有已退出的就继续收直到没有为止。注意信号处理函数里能安全调用的函数有限waitpid是其中之一属于 async-signal-safe。不要在里面做printf之类的操作虽然很多教程这么写但严格来说不安全。4. 完整实操手写一个能扛住多连接的 ECHO 服务器4.1 环境与编译说明代码用标准 C 写依赖 POSIX 接口Linux 和 macOS 都能跑。编译命令很简单gcc -o echo_server echo_server.c gcc -o echo_client echo_client.c不需要额外链接库socket 相关函数都在 libc 里。如果你在 Windows 上需要改用 Winsock接口差异较大这篇以类 Unix 环境为准。4.2 服务器端完整代码与逐段解析先上完整代码然后逐段拆。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include signal.h #include sys/wait.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define BACKLOG 128 #define BUF_SIZE 4096 void sigchld_handler(int sig) { (void)sig; while (waitpid(-1, NULL, WNOHANG) 0); } int main(void) { int listenfd, connfd; struct sockaddr_in servaddr, cliaddr; socklen_t clilen; struct sigaction sa; pid_t pid; listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_ANY); servaddr.sin_port htons(PORT); if (bind(listenfd, (struct sockaddr *)servaddr, sizeof(servaddr)) 0) { perror(bind); exit(1); } if (listen(listenfd, BACKLOG) 0) { perror(listen); exit(1); } sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGCHLD, sa, NULL); printf(echo server listening on port %d\n, PORT); for (;;) { clilen sizeof(cliaddr); connfd accept(listenfd, (struct sockaddr *)cliaddr, clilen); if (connfd 0) { perror(accept); continue; } pid fork(); if (pid 0) { perror(fork); close(connfd); continue; } if (pid 0) { close(listenfd); char buf[BUF_SIZE]; ssize_t n; while ((n recv(connfd, buf, BUF_SIZE, 0)) 0) { ssize_t sent 0; while (sent n) { ssize_t s send(connfd, buf sent, n - sent, 0); if (s 0) break; sent s; } } close(connfd); exit(0); } else { close(connfd); } } return 0; }逐段说明setsockopt那几行设置SO_REUSEADDR作用是服务器重启时能立刻重新绑定端口不用等TIME_WAIT超时。开发阶段反复重启服务器没有这个选项会频繁遇到 “Address already in use”非常影响效率。htonl(INADDR_ANY)表示监听本机所有网卡地址。如果你只想监听某个特定网卡可以换成具体的 IP。htons(PORT)把主机字节序转成网络字节序端口号必须做这个转换否则大端小端机器上行为不一致。listen的第二个参数BACKLOG是等待队列长度。它决定内核最多能挂起多少个已完成三次握手但还没被accept取走的连接。设太小高并发时客户端会收到连接被拒设太大意义也不大因为内核有上限。128 是个稳妥的中间值。SA_RESTART标志让被信号打断的系统调用自动重启。没有它accept可能因为收到SIGCHLD而返回EINTR虽然代码里用continue兜住了但加上SA_RESTART更省心。子进程里的send用了循环因为send不保证一次把n字节全发出去尤其在缓冲区满的时候。虽然 ECHO 场景数据量小但养成“send 要循环”的习惯很重要这是写健壮网络代码的基本功。4.3 客户端测试代码客户端更简单连上之后从标准输入读一行发一行把服务器回显打印出来。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define BUF_SIZE 4096 int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, usage: %s server_ip\n, argv[0]); exit(1); } int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { perror(socket); exit(1); } struct sockaddr_in servaddr; memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_port htons(PORT); if (inet_pton(AF_INET, argv[1], servaddr.sin_addr) 0) { perror(inet_pton); exit(1); } if (connect(sockfd, (struct sockaddr *)servaddr, sizeof(servaddr)) 0) { perror(connect); exit(1); } char buf[BUF_SIZE]; while (fgets(buf, BUF_SIZE, stdin) ! NULL) { size_t len strlen(buf); if (send(sockfd, buf, len, 0) ! (ssize_t)len) { perror(send); break; } ssize_t n recv(sockfd, buf, BUF_SIZE - 1, 0); if (n 0) { printf(server closed\n); break; } buf[n] \0; printf(echo: %s, buf); } close(sockfd); return 0; }4.4 实测验证多客户端并发验证并发是否真的生效最直接的办法是开多个终端。第一步启动服务器./echo_server第二步开三个终端分别运行客户端连到本机./echo_client 127.0.0.1第三步在三个客户端里分别输入不同内容观察每个客户端都能独立收到自己的回显互不串扰。再在其中一个客户端里按 CtrlC 断开另外两个依然正常工作——这就证明每个连接确实由独立进程处理一个断开不影响其他。想看得更直观可以在服务器端用ps观察进程ps -ef | grep echo_server每建立一个连接就会多出一个子进程。客户端断开后子进程消失且不会留下僵尸因为SIGCHLD处理函数回收了。提示测试时如果发现子进程变成defunct状态说明僵尸回收没生效回头检查sigaction是否注册成功、waitpid循环是否写对。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因排查方向第二个客户端连不上没 fork主进程被第一个连接占死检查 accept 后是否 fork服务器重启报 Address already in use没设 SO_REUSEADDR加 setsockopt大量 defunct 进程SIGCHLD 未处理或 waitpid 只调一次用 while WNOHANG 循环回收客户端收不到回显send 没循环或 recv 返回值没判断检查 send/recv 返回值处理连接关闭后端口迟迟不释放父进程没关 connfd引用计数未归零fork 后父进程立即 close(connfd)accept 返回 -1 且 errno 为 EINTR被信号打断且没设 SA_RESTART加 SA_RESTART 或判断 EINTR 后 continue5.2 几个容易忽略的实操心得关于 fork 失败的处理。fork在进程数达到系统上限时会失败返回 -1。这时候connfd已经建立如果不处理这个连接就悬着。正确做法是close(connfd)然后continue让服务器继续服务其他连接而不是直接退出。生产环境里 fork 失败往往意味着负载过高可以考虑给客户端回一个错误提示再关闭。关于子进程继承的监听套接字。子进程 fork 出来时天然继承了listenfd。虽然子进程不accept但持有这个描述符意味着监听套接字的引用计数不会因为父进程的某些操作而归零。显式close(listenfd)是好习惯能让资源管理更清晰。关于缓冲区大小。BUF_SIZE设 4096 对 ECHO 足够。但要注意TCP 是字节流协议recv一次返回的不一定是一条完整“消息”。ECHO 场景下我们只是原样回传不关心消息边界所以没问题。如果你要做的是“按行回显”或“按协议包回显”就必须自己处理粘包和拆包这是另一个话题了。关于测试时的端口占用。反复调试时如果上一个服务器进程没退干净新进程 bind 会失败。用lsof -i :8888或netstat -tlnp | grep 8888找到占用进程kill掉再重启。这也是为什么 SO_REUSEADDR 值得一开始就加上。关于性能的直觉。多进程 ECHO 服务器在几十上百个并发连接下表现良好。但如果你压到几千个并发fork 的开销和进程切换的代价就会显现CPU 大量时间花在调度上而非数据搬运上。这时候就该考虑 IO 多路复用或线程池了。知道当前方案的边界在哪比盲目优化更重要。5.3 一个隐蔽的坑标准输出缓冲子进程如果往标准输出打印调试信息可能会遇到输出错乱或丢失。原因是多个进程共享同一个终端且标准输出默认是行缓冲或全缓冲fork 时缓冲区内容会被复制。如果父进程在 fork 前有未刷新的输出子进程会重复打印一遍。调试时建议用write(STDERR_FILENO, ...)直接写文件描述符或者每次打印后fflush。这个坑不常遇到但一旦遇到会让人一头雾水。5.4 如何确认僵尸回收真的生效写个简单的压测脚本快速建立和断开大量连接然后观察进程状态for i in $(seq 1 200); do (echo hello | ./echo_client 127.0.0.1 /dev/null) done wait ps -ef | grep echo_server | grep defunct | wc -l如果最后一行输出是 0说明僵尸都被回收了。如果是个正数说明回收逻辑有漏洞重点检查waitpid的循环条件。我在实际调试这套代码时最常犯的错误就是一开始只写了一次waitpid压测时偶尔冒出几个僵尸排查半天才想起来信号合并这回事。改成while循环之后就再没出现过。这个细节看起来小但它是多进程服务器能否长期稳定运行的关键之一。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。