文章目录
- [从饭店接客到线程池|TCP Socket 编程进化实战博客](#从饭店接客到线程池|TCP Socket 编程进化实战博客)
-
- 前言
- [一、TCP Socket 核心API总览(打电话/开饭店模型)](#一、TCP Socket 核心API总览(打电话/开饭店模型))
-
- [1. socket() 创建套接字](#1. socket() 创建套接字)
- [2. bind()绑定地址端口](#2. bind()绑定地址端口)
- [3. listen() 开启监听](#3. listen() 开启监听)
- [4. accept() 取已经握手完成的连接](#4. accept() 取已经握手完成的连接)
- [5. connect() 客户端发起连接](#5. connect() 客户端发起连接)
- [read / write vs recv / send](#read / write vs recv / send)
- 二、V1:单进程回声服务器(夫妻老婆店)
- [三、V2:多进程版本服务器(fork + 两次fork)](#三、V2:多进程版本服务器(fork + 两次fork))
- [四、拓展:僵尸进程与 SIGCHLD 信号(两套解决方案)](#四、拓展:僵尸进程与 SIGCHLD 信号(两套解决方案))
-
- 什么僵尸进程
- 方案1:两次fork(上面V2代码)
- [方案2:`signal(SIGCHLD, SIG_IGN);`](#方案2:
signal(SIGCHLD, SIG_IGN);) - 方案3:注册SIGCHLD信号处理函数(工业级推荐)
- 三种方案对比表
- 五、V3:多线程TCP服务器(雇服务员代替开分店)
- 六、V4:线程池版本服务器(提前招聘固定服务员)
- 七、四个服务器模型总对比
- 八、网络编程高频踩坑清单
-
- [1. Address already in use](#1. Address already in use)
- [2. fork之后fd的处理准则](#2. fork之后fd的处理准则)
- [3. read返回0](#3. read返回0)
- [4. SIGPIPE信号](#4. SIGPIPE信号)
- [5. TCP字节流无边界(粘包问题)](#5. TCP字节流无边界(粘包问题))
- [6. 信号打断系统调用EINTR](#6. 信号打断系统调用EINTR)
- 九、后续学习路线
从饭店接客到线程池|TCP Socket 编程进化实战博客
前言
很多同学初学网络编程,上来就死记硬背:socket‑bind‑listen‑accept‑connect 这套调用顺序。
能写出跑通的回声服务器,但是一问底层就懵:
监听套接字和连接套接字到底有啥不一样?
fork 之后为什么要乱七八遭 close 一堆 fd?
两次 fork 意义何在?
SIGCHLD、僵尸进程、SIG_IGN又是什么?为什么
write居然可以发送网络数据?
本篇博客就用饭店经营模型贯穿全文,从最基础 API,一步步迭代 4 版服务器,把网络编程常见底层问题全部讲透。
Linux 哲学:一切皆文件 。Socket 本质也是文件描述符,
read/write可以像读写普通文件一样收发网络数据。
一、TCP Socket 核心API总览(打电话/开饭店模型)
TCP 是面向连接、可靠、字节流协议,通信前必须完成三次握手建立连接。
| 服务端 | 作用 | 客户端 | 作用 |
|---|---|---|---|
| socket() | 创建套接字(买手机) | socket() | 创建套接字 |
| bind() | 绑定IP+端口(绑定门店地址) | --- | 一般不手动bind,内核自动分配随机端口 |
| listen() | 设置为监听套接字(开门营业) | connect() | 拨号发起TCP连接 |
| accept() | 取出完成三次握手的连接(接待客人入座) | --- | --- |
| read/write | 收发业务数据 | read/write | 收发业务数据 |
| close | 关闭套接字,触发四次挥手 | close | 关闭套接字 |
重点区分两个套接字,90%初学者混淆在这里:
- 监听套接字 listen‑fd :
socket()+bind()+listen()得到。只给accept()使用,不能read/write;服务器全程存活,直到程序退出才关闭。- 连接套接字 conn‑fd :
accept()的返回值。专门和单个客户端做读写IO;客户端断开就要close。
饭店比喻:listen‑fd = 门口迎宾,只管接待新客人,不上菜;
conn‑fd = 包间服务员,只负责和一桌客人聊天上菜。
1. socket() 创建套接字
int socket(int domain, int type, int protocol);
AF_INET:IPv4;SOCK_STREAM:TCP字节流;protocol填0自动匹配协议- 返回值:文件描述符fd;失败返回‑1。
2. bind()绑定地址端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
把 socket 和本机IP、端口绑定。
struct sockaddr_in local;
memset(&local,0,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888); // 主机序转网络大端序
local.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有网卡IP
INADDR_ANY:多网卡服务器,全部网卡上监听,不用写死某个IP。- htons / htonl:网络通信必须用大端字节序。
小知识:客户端不是不能bind,只是没必要。connect的时候OS自动分配临时随机端口;手动bind容易端口冲突。
3. listen() 开启监听
int listen(int sockfd, int backlog);
⚠️ listen 不会阻塞! 只是告诉内核:这个fd是被动监听套接字,请为我维护连接队列。
内核维护两条队列:
- 半连接队列(SYN队列):收到SYN,未完成三次握手
- 全连接队列(accept队列) :三次握手全部完成,等待
accept()系统调用取走连接。
backlog 用来限制全连接队列最大长度。
/proc/sys/net/core/somaxconn 是系统上限,程序填写的backlog不能超过它。
坑:服务器如果迟迟不调用accept,全连接队列打满,新来的连接会被拒绝。
4. accept() 取已经握手完成的连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 阻塞行为:全连接队列为空时,accept阻塞等待新连接到来。
- 返回全新的连接套接字conn‑fd,这个fd用来和客户端通信。
- addr是传出参数,输出对端客户端IP和端口;不关心可以传NULL。
重要认知:三次握手是内核完成的,用户进程阻塞在accept的时候,客户端照样完成握手,连接放在内核队列排队。
5. connect() 客户端发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
客户端向服务端发起TCP连接,阻塞直到握手成功或者超时失败。
read / write vs recv / send
Linux一切皆文件,socket是fd,所以
read/write可以直接收发网络数据。
send(fd, buf, len, 0)等价write;recv(fd,buf,len,0)等价read。send/recv多flags参数,可以实现MSG_OOB、MSG_DONTWAIT等高级特性。
⚠️网络IO和普通文件IO行为不一样:会出现短读、短写 ,实际工程需要封装readn/writen循环读写,教学Demo为简化没有处理。
二、V1:单进程回声服务器(夫妻老婆店)
逻辑:循环accept,拿到连接之后,就在主线程做read/write服务客户端。
伪代码:
_listensock = socket(...);
bind(_listensock,...);
listen(_listensock, backlog);
while(_isrunning){
int connfd = accept(_listensock,...);
Service(connfd); // while循环read/write,阻塞在这里
close(connfd);
}
致命缺陷:同一时刻只能服务一个客户端。
主线程陷在Service的IO循环,再也无法执行accept,新的客户端只能在全连接队列排队等待。
饭店比喻:老板既迎宾又上菜,接待一桌客人之后就被困在包间,门口来新顾客没人接待。
解决思路:把「等待新连接accept」和「客户端IO读写」两件事分开,不能放在同一个执行流阻塞。
三、V2:多进程版本服务器(fork + 两次fork)
目标:支持多客户端并发连接。父进程专门accept,每来一个客户端创建进程专门服务。
核心代码片段
int connfd = accept(_listensock, CONV(&peer), &len);
pid_t id = fork();
if(id < 0){
close(connfd);
return;
}
else if(id == 0){
close(_listensock); // ✨子进程关闭监听套接字!
if(fork() > 0) exit(0); // 两次fork
// 孙子进程真正干活
Service(connfd);
close(connfd);
exit(0);
}
else{
close(connfd); // ✨父进程关闭通信套接字
waitpid(id, nullptr,0);
}
🧑💼父进程职责
- 循环阻塞在
accept,接收新客户端连接。 - 拿到connfd之后fork创建第一层子进程。
- 父进程不再业务通信,立刻close(connfd)。
- waitpid回收第一层子进程,防止僵尸。
- 回到循环继续accept下一个连接。
👶第一层子进程(过渡进程,不干活)
close(_listensock):子进程不需要监听fd。>
fork会复制文件描述符,内核socket引用计数+1;不用的fd主动关闭,避免资源泄露;防御性编程,防止子进程误调用accept。
- 再fork,生成孙子进程;自己马上exit退出。
👶👶孙子进程(真正执行业务IO)
继承connfd,执行Service做read/write和客户端通信;业务结束exit退出。
第一层子进程已经退出,孙子变成孤儿进程 ,会被systemd/init收养。init进程会自动wait孤儿,孙子退出自动回收,不会产生僵尸进程。
为什么要两次fork?
假设只fork一次,子进程直接执行业务:
if(id ==0){
close(_listensock);
Service(connfd); exit(0);
}else{
close(connfd);
waitpid(id,nullptr,0); // 阻塞!
}
waitpid(id, nullptr,0)是阻塞调用。
如果客户端长连接一直不关闭,子进程不退出,父进程卡在waitpid,无法继续accept接收新连接,服务器再次废掉。
两次fork巧妙解决:
- 第一层子进程fork完孙子立刻exit;
- 父进程waitpid很快回收第一层子进程,父进程解除阻塞,可以回去继续accept;
- 孙子作为孤儿进程,想运行多久运行多久,由init负责回收。
多进程模型优缺点
✅优点:进程内存完全隔离;某个服务客户端的进程崩溃,不影响父进程和其他客户端。
❌缺点:fork开销大,拷贝页表、PCB;上万并发创建大量进程,系统压力巨大;进程间通信复杂。
四、拓展:僵尸进程与 SIGCHLD 信号(两套解决方案)
什么僵尸进程
子进程已经终止退出,PCB进程控制块还保留在内核,等待父进程wait/waitpid读取退出状态。
PCB保存退出码,是操作系统设计:父有权限知道子进程是正常退出还是异常崩溃。父不调用wait,子就变成僵尸,占用PID资源。
子进程终止,内核发送 SIGCHLD 信号给父进程。
方案1:两次fork(上面V2代码)
孙子变成孤儿,被init收养;init内部会自动wait所有孤儿,不会僵尸。
优点:POSIX标准,跨平台可移植;缺点:代码繁琐。
方案2:signal(SIGCHLD, SIG_IGN);
Linux特有扩展行为,不是POSIX强制标准!
signal(SIGCHLD, SIG_IGN);
父进程设置忽略SIGCHLD信号。内核理解为:父完全不关心子进程退出状态 。
子进程终止,内核直接释放全部资源,不保存PCB,不会产生僵尸进程。
⚠️注意区分:SIGCHLD的默认动作
SIG_DFL也是忽略信号,但不会自动回收子进程,照样产生僵尸!
SIG_IGN触发内核特殊分支,仅此一家,别的信号没有这个待遇。
缺点:无法获取子进程退出状态;Solaris等其他UNIX不支持,可移植性差。
方案3:注册SIGCHLD信号处理函数(工业级推荐)
void sigchld_handler(int sig){
// WNOHANG非阻塞,循环收割所有已经死掉的子进程
while(waitpid(-1, nullptr, WNOHANG) >0);
}
signal(SIGCHLD, sigchld_handler);
收到SIGCHLD信号触发回调,循环非阻塞waitpid收割僵尸。
坑:信号会打断阻塞系统调用。父进程正在accept收到SIGCHLD,accept返回‑1,
errno=EINTR;代码中需要捕获EINTR,continue重新accept。
三种方案对比表
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 两次fork | 子进程fork后退出,孙子成为孤儿由init回收 | 标准可移植;可拿到第一层子进程状态 | 代码复杂,多一次fork |
signal(SIGCHLD,SIG_IGN) |
Linux内核特例,子终止直接释放资源 | 代码一行极简 | Linux专属;拿不到退出码;可移植差 |
| SIGCHLD信号回调+WNOHANG | 收到信号循环非阻塞waitpid | 跨平台,可以拿到子退出状态 | 需要处理EINTR被信号打断的问题 |
五、V3:多线程TCP服务器(雇服务员代替开分店)
进程太重,创建开销大;线程共享进程地址空间,开销更小。
逻辑:主线程循环accept;每来一个连接,pthread_create创建新线程专门处理IO。
关键点
- pthread_create的入口函数必须是static静态函数,C++成员函数隐含this指针,不匹配函数签名。
- 传递参数不能用栈变量!要用new堆上分配结构体,线程内部delete释放,防止主线程覆盖数据。
pthread_detach(pthread_self()):线程分离,退出自动回收资源,不需要pthread_join等待。
伪代码:
struct ThreadData{
int _sockfd;
InetAddr _addr;
};
static void* threadExcute(void* args){
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
Service(*td);
close(td->_sockfd);
delete td;
return nullptr;
}
void ProcessConnection(int sockfd, struct sockaddr_in &peer){
ThreadData* td = new ThreadData(sockfd, peer);
pthread_t tid;
pthread_create(&tid, nullptr, threadExcute, td);
}
✅优点:线程创建销毁开销远小于进程;同进程内共享数据方便。
❌缺点:线程共享地址空间;一个线程崩溃,整个进程直接退出;大量短连接频繁创建销毁线程,有性能损耗。
饭店比喻:不开分店,店内雇佣多名服务员;共用厨房资源,但一个服务员闯大祸整个门店关门。
六、V4:线程池版本服务器(提前招聘固定服务员)
痛点:短连接场景,来一个连接创建销毁一次线程,开销高。
线程池:程序启动预先创建一批工作线程;新连接到来,把业务任务打包扔进任务队列,空闲线程取出任务执行。不需要动态创建线程。
伪代码:
void ProcessConnection(int sockfd, struct sockaddr_in &peer){
InetAddr addr(peer);
auto func = std::bind(&TcpServer::Service, this, sockfd, addr);
ThreadPool::GetInstance()->Push(func);
}
✅优点:避免频繁创建销毁线程;可以限制最大并发数量,防止连接暴涨把系统打崩;响应速度快。
❌缺点:代码复杂度上升;任务队列需要加锁同步;任务积压会造成客户端等待。
饭店比喻:提前招好固定数量服务员,客人来了直接分配,不临时招人;高峰期客人过多只能排队。
七、四个服务器模型总对比
| 模型 | 并发单元 | 创建开销 | 崩溃影响 | 编程难度 | 适用场景 |
|---|---|---|---|---|---|
| V1单进程 | 无 | 极小 | 整个服务卡住 | ★☆☆☆☆ | 教学Demo,仅学习 |
| V2多进程 | 进程 | 大 | 仅故障子进程,主进程无恙 | ★★★☆☆ | 并发量不大,高隔离要求场景 |
| V3多线程 | 线程 | 中等 | 整个进程崩溃 | ★★★☆☆ | 中小并发长连接 |
| V4线程池 | 预创建线程 | 小 | 整个进程崩溃 | ★★★★☆ | 大量短连接,线上业务 |
演进底层逻辑:
- 分离「接收新连接accept」和「业务IO」,不能阻塞在同一个流程。
- 不断追求更轻量的并发单元;频繁创建代价大就预先池化。
八、网络编程高频踩坑清单
1. Address already in use
服务器Ctrl+C重启报端口占用。TCP断开之后会进入TIME_WAIT状态,端口一段时间不能复用。
解决:创建socket之后设置套接字选项
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
开发环境必加。
2. fork之后fd的处理准则
fork复制文件描述符,内核文件引用计数+1。
不用的fd,一定要主动close,不要依赖进程退出自动回收。
多进程服务器记忆口诀:
父进程:保留 listen‑fd,close(conn‑fd)
子进程:close(listen‑fd),保留 conn‑fd
3. read返回0
不是读取0字节数据;代表对端关闭TCP连接(读到EOF),需要关闭本端socket。
4. SIGPIPE信号
已经断开的socket继续write,会触发SIGPIPE,默认杀死整个进程。生产环境一般忽略SIGPIPE。
5. TCP字节流无边界(粘包问题)
TCP是字节流,没有数据包边界。send两次"hello",对方可能一次读到"hellohello"。
解决方案:应用层协议,定长包 / 分隔符 / 头部携带报文长度。Demo没有处理粘包,只能用于学习。
6. 信号打断系统调用EINTR
accept/read等阻塞系统调用,收到信号会返回‑1,errno=EINTR;代码需要判断,重新发起系统调用。
九、后续学习路线
本文全部是每个连接分配一个进程/线程 的阻塞IO模型。真实高并发服务器(Nginx、Redis)不会这么做。
下一步学习:
- IO多路复用 select / poll
- epoll(Linux),Reactor事件驱动模型
- 非阻塞socket
阻塞多进程/多线程模型,理解清楚,是学习epoll的前置基础。万丈高楼平地起。