
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介
文章目录
- 前言
- 一、单进程服务器为什么不能并发
- 二、`fork` 后谁能看到哪些文件描述符
-
- 2.1 子进程继承父进程的描述符表
- 2.2 不需要的描述符必须关闭
- 2.3 为什么父进程必须关闭通信 fd
- 三、只 fork 一次会出现僵尸进程
-
- 3.1 子进程退出后需要回收
- 3.2 方案一:忽略 `SIGCHLD`
- 3.3 方案二:双重 fork
- 四、完整的多进程服务器
-
- 4.1 `TcpServer.hpp`
- 4.2 为什么 `peer` 可以按值传入孙子进程
- 五、服务器重启为什么可能 bind 失败
-
- 5.1 TCP 连接关闭后还有状态
- 5.2 `SO_REUSEADDR`
- 5.3 不要把选项写成按位或的名称
- 六、多进程模型适合什么场景
- 七、容易写错的地方
-
- 7.1 父进程忘记关闭 `sockfd`
- 7.2 子进程忘记关闭 `listen_sock`
- 7.3 父进程阻塞等待真正干活的子进程
- 7.4 `fork` 失败后没有关闭通信 fd
- 7.5 混淆文件描述符和内核对象
- 总结
前言
上一篇完成了单进程 Echo Server。它能够正常连接、读取和回显,但只能按顺序服务客户端:主线程进入 Service(sockfd) 后,只要当前连接不退出,就不会回到下一轮 accept。
解决方法很直接:主进程只负责获取连接,子进程负责处理连接。
不过一旦加入 fork,文件描述符继承、引用计数、僵尸进程和连接关闭顺序都会跟着出现。本文从单次 fork 开始,最后整理成双重 fork 的多进程 TCP 服务器,并解释服务器重启时为什么可能暂时绑定失败。
一、单进程服务器为什么不能并发
单进程版本的主循环大致如下:
for (;;) {
int sockfd = accept(listen_sock, ...);
Service(sockfd);
close(sockfd);
}
accept 返回客户端 A 的通信 fd 后,程序进入 Service(A)。只要 A 不关闭连接,read(A) 就会不断等待并处理 A 的数据,主线程无法继续执行 accept。
客户端 B 此时仍可能完成三次握手,因为握手由内核协议栈处理。可是应用程序没有再次调用 accept,B 只能停留在连接队列中,迟迟得不到业务响应。
问题不在 TCP,也不在 accept,而在于只有一条执行流承担了两种职责:
获取新连接 + 服务已有连接
把它们拆开以后,主进程就可以快速回到 accept。
二、fork 后谁能看到哪些文件描述符
2.1 子进程继承父进程的描述符表
父进程完成 accept 后,通常同时持有两个文件描述符:
listen_sock:监听套接字
sockfd:当前客户端通信套接字
这时调用 fork,子进程会继承父进程的文件描述符。父子进程中的描述符编号虽然相同,但它们背后的描述符表项共同引用内核文件对象。
所以子进程既能看到 listen_sock,也能看到 sockfd。
2.2 不需要的描述符必须关闭
职责分工应该是:
- 父进程继续 accept,不负责当前客户端通信;
- 子进程只服务当前客户端,不负责接收新连接。
因此:
pid_t id = fork();
if (id == 0) {
close(listen_sock); // 子进程不负责获取新连接
Service(sockfd);
close(sockfd);
exit(0);
}
close(sockfd); // 父进程不负责服务当前客户端
这里的 close 不一定立刻销毁内核 Socket。父子进程都引用通信文件对象时,引用计数至少为 2;父进程关闭自己的 sockfd 后,子进程仍然可以继续使用。只有所有引用都关闭,相关内核资源才会真正释放。
多进程网络程序中,close 的含义是“当前进程放弃这份引用”,不是无条件把其他进程正在使用的连接一起销毁。
2.3 为什么父进程必须关闭通信 fd
如果父进程一直保留每次 accept 返回的通信 fd,即使子进程已经服务完成并关闭连接,内核对象仍然被父进程引用。
后果包括:
- 文件描述符不断泄漏;
- 连接无法按预期彻底关闭;
- 服务器运行时间越长,占用资源越多。
因此,父进程完成 fork 后,应立即关闭自己不使用的通信 fd。
三、只 fork 一次会出现僵尸进程
3.1 子进程退出后需要回收
子进程处理完客户端后会退出。只要父进程仍然存在,已经退出但尚未被 wait 的子进程就会处于僵尸状态。
如果服务器不断接收连接、不断创建子进程,却从不回收,僵尸进程就会累积。
最直接的写法是:
pid_t id = fork();
if (id > 0) {
close(sockfd);
waitpid(id, nullptr, 0);
}
但阻塞式 waitpid 会让父进程等到子进程服务结束,服务器又回到了近似串行状态。
3.2 方案一:忽略 SIGCHLD
在 Linux 上,可以让系统自动处理退出子进程:
signal(SIGCHLD, SIG_IGN);
之后父进程不需要逐个阻塞等待,主循环可以继续执行 accept。
这种写法简洁,但依赖系统对 SIGCHLD 忽略行为的处理。若程序还需要取得子进程退出状态,就不适合直接忽略,而应该安装信号处理器并循环 waitpid(-1, …, WNOHANG)。
3.3 方案二:双重 fork
另一种经典做法是:
代码结构如下:
pid_t id = fork();
if (id == 0) {
close(listen_sock);
pid_t grandchild = fork();
if (grandchild < 0) {
close(sockfd);
exit(1);
}
if (grandchild > 0) {
exit(0); // 中间子进程立即退出
}
Service(sockfd); // 孙子进程服务客户端
close(sockfd);
exit(0);
}
close(sockfd);
waitpid(id, nullptr, 0); // 很快回收中间子进程
这里最容易写错的是“孤儿进程”和“僵尸进程”的关系。
只 fork 一次时,如果父进程仍然运行,子进程退出后会成为僵尸,不会自动变成孤儿。只有父进程先退出,仍在运行的子进程才会成为孤儿。
双重 fork 中,真正干活的是孙子进程。它的直接父进程很快退出,所以它才会被系统接管。
双重 fork 的目的不是“创建更多进程”,而是让真正处理连接的进程脱离原父进程的回收关系,同时让原父进程快速回到 accept。
四、完整的多进程服务器
第一篇中的 Common.hpp、InetAddr.hpp、客户端和 Makefile 可以继续使用,只替换 TcpServer.hpp。
4.1 TcpServer.hpp
#pragma once
#include "Common.hpp"
#include "InetAddr.hpp"
#include <sys/wait.h>
#include <iostream>
class TcpServer : public NoCopy {
public:
explicit TcpServer(uint16_t port)
: port_(port) {}
bool Init() {
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock_ < 0) {
std::cerr << "socket error: " << strerror(errno) << std::endl;
return false;
}
int enable = 1;
if (setsockopt(listen_sock_,
SOL_SOCKET,
SO_REUSEADDR,
&enable,
sizeof(enable)) < 0) {
std::cerr << "setsockopt error: "
<< strerror(errno) << std::endl;
return false;
}
InetAddr local(port_);
if (bind(listen_sock_, local.AddrPtr(), local.Length()) < 0) {
std::cerr << "bind error: " << strerror(errno) << std::endl;
return false;
}
if (listen(listen_sock_, 8) < 0) {
std::cerr << "listen error: " << strerror(errno) << std::endl;
return false;
}
return true;
}
void Run() {
for (;;) {
sockaddr_in peer_addr{};
socklen_t len = sizeof(peer_addr);
int sockfd = accept(
listen_sock_,
reinterpret_cast<sockaddr*>(&peer_addr),
&len);
if (sockfd < 0) {
if (errno == EINTR) {
continue;
}
std::cerr << "accept error: "
<< strerror(errno) << std::endl;
continue;
}
InetAddr peer(peer_addr);
ProcessConnection(sockfd, peer);
}
}
~TcpServer() override {
if (listen_sock_ >= 0) {
close(listen_sock_);
}
}
private:
static bool WriteAll(int sockfd, const std::string& message) {
std::size_t offset = 0;
while (offset < message.size()) {
ssize_t n = write(sockfd,
message.data() + offset,
message.size() – offset);
if (n > 0) {
offset += static_cast<std::size_t>(n);
} else if (n < 0 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}
static void Service(int sockfd, const InetAddr& peer) {
char buffer[1024];
for (;;) {
ssize_t n = read(sockfd, buffer, sizeof(buffer) – 1);
if (n > 0) {
buffer[n] = '\\0';
std::string response = "server echo# ";
response.append(buffer, static_cast<std::size_t>(n));
if (!WriteAll(sockfd, response)) {
break;
}
} else if (n == 0) {
std::cout << peer.StringAddr() << " quit" << std::endl;
break;
} else if (errno != EINTR) {
break;
}
}
}
void ProcessConnection(int sockfd, const InetAddr& peer) {
pid_t id = fork();
if (id < 0) {
std::cerr << "fork error: " << strerror(errno) << std::endl;
close(sockfd);
return;
}
if (id == 0) {
close(listen_sock_);
pid_t grandchild = fork();
if (grandchild < 0) {
close(sockfd);
exit(FORK_ERR);
}
if (grandchild > 0) {
exit(OK);
}
Service(sockfd, peer);
close(sockfd);
exit(OK);
}
close(sockfd);
while (waitpid(id, nullptr, 0) < 0 && errno == EINTR) {
}
}
private:
uint16_t port_;
int listen_sock_ = –1;
};
4.2 为什么 peer 可以按值传入孙子进程
fork 会复制当前进程的用户空间。InetAddr peer 在子进程、孙子进程中各自拥有一份副本,原父进程回到下一轮循环不会改坏孙子进程里的地址对象。
这和线程不同。线程共享同一个地址空间,若把循环局部变量按引用交给线程,就必须考虑变量何时销毁或被下一轮覆盖。
五、服务器重启为什么可能 bind 失败
5.1 TCP 连接关闭后还有状态
测试时经常出现一个现象:关闭服务器以后立刻重启,bind 报“Address already in use”。这不一定表示旧进程仍然存在,也可能是 TCP 连接关闭后仍处于 TIME_WAIT 等状态。
TCP 要保证迟到报文不会污染后续新连接,因此连接关闭后,相关四元组不会马上从系统中彻底消失。
5.2 SO_REUSEADDR
监听 Socket 创建以后、bind 以前,可以设置:
int enable = 1;
setsockopt(listen_sock,
SOL_SOCKET,
SO_REUSEADDR,
&enable,
sizeof(enable));
它允许服务器在符合条件时更快地重新绑定本地地址,常用于服务重启。
SO_REUSEADDR 不是“允许任何两个进程随便绑定同一端口”,也不能解决仍有另一个正常监听进程占用端口的情况。
5.3 不要把选项写成按位或的名称
有些代码会写:
SO_REUSEADDR | SO_REUSEPORT
作为 setsockopt 的单个选项名。这种写法不清晰,也不具备通用含义。需要两个选项时,应分别调用两次 setsockopt:
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(enable));
当前 Echo Server 只设置 SO_REUSEADDR 即可。SO_REUSEPORT 更偏向多个 Socket 共同监听同一端口及负载分配场景,不要因为名字相近就默认一起打开。
六、多进程模型适合什么场景
多进程的优点是隔离性强。某个服务进程崩溃,通常不会直接破坏其他连接;每个进程还有独立地址空间,业务代码相互影响较小。
缺点也明显:
- 创建进程比创建普通任务更重;
- 地址空间独立,进程间共享数据需要额外 IPC;
- 客户端很多时,进程数量会快速增长;
- 调度和内存成本更高。
学习 TCP 时,多进程模型非常适合观察文件描述符继承和并发服务原理。真实项目是否采用,还要结合连接数量、任务时长和隔离需求判断。
七、容易写错的地方
7.1 父进程忘记关闭 sockfd
会造成文件描述符泄漏,并让连接对象长期保留额外引用。
7.2 子进程忘记关闭 listen_sock
子进程并不负责接收新连接,保留监听 fd 只会增加无意义引用,还可能让服务器退出和端口释放行为更难判断。
7.3 父进程阻塞等待真正干活的子进程
这样虽然不会产生僵尸,但父进程会一直等到客户端退出,失去并发效果。双重 fork 中,父进程只等待立即退出的中间子进程。
7.4 fork 失败后没有关闭通信 fd
进程创建失败时,这条连接已经被 accept 取出。若不关闭 sockfd,同样会泄漏。
7.5 混淆文件描述符和内核对象
父子进程中的 fd 数字可能相同,但“关闭某个 fd”只影响当前进程的描述符表项。内核对象何时释放,由全部引用共同决定。
总结
本文把单进程 TCP 服务器改造成了多进程版本:
下一篇继续把进程切换成线程,分析线程为什么不能随意关闭共享 fd,并在多线程 TCP 服务器上实现受限远程命令执行。
往期博客分享 【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器 【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池 【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server
网硕互联帮助中心






评论前必须登录!
注册