摘要:在 Linux 网络编程中,TCP 服务端默认是单循环阻塞服务器,同一时间只能服务一个客户端。本文讲解 5 种主流 TCP 并发模型:多进程、多线程、select、poll、epoll,分析各自优缺点,附带核心示例代码,适合 Linux 网络编程入门学习。
前言
在学习 Linux 网络 socket 编程的时候,最简单的 TCP 服务器是单循环服务器:socket→bind→listen→accept→recv/send→close。这种服务器有致命缺陷:同一时刻只能处理一个客户端连接。当一个客户端连接上来之后,服务端阻塞在recv,其他客户端无法接入,这在实际项目完全不可用。
为了解决同时服务多个客户端的需求,就需要实现TCP 并发服务器。常见实现分为两大类:
- 多任务模型:多进程、多线程(创建子进程 / 子线程处理客户端业务,主进程继续等待新连接)
- IO 多路复用模型:select、poll、epoll(单进程,监听多个文件描述符 IO 事件,无频繁创建销毁进程线程开销)
学习前提:掌握 TCP 三次握手、四次挥手、socket 基础 API,理解文件描述符。
一、多进程版 TCP 并发服务器
原理
- 主进程调用
accept()阻塞等待新客户端连接; - 一旦获取到通信套接字
connfd,调用fork()创建子进程; - 子进程负责和该客户端收发数据,业务处理完毕关闭套接字,子进程退出;
- 父进程关闭通信套接字(父子进程文件描述符拷贝,父不需要使用 connfd),继续循环调用
accept等待下一个客户端。
注意:子进程退出会产生僵尸进程,父进程需要回收子进程资源;可以使用
SIGCHLD信号捕获,在信号处理函数内部调用waitpid(WNOHANG)非阻塞回收僵尸子进程。
核心伪代码
int main(int argc, char *argv[]) { int sockfd = socket(AF_INET, SOCK_STREAM, 0); bind(sockfd, ...); listen(sockfd, 10); while(1) { int connfd = accept(sockfd, NULL, NULL); pid_t pid = fork(); if(pid > 0) { //父进程:不需要通信套接字,关闭,继续等待新连接 close(connfd); } else if(pid == 0) { //子进程:处理客户端业务 close(sockfd); //子进程不需要监听套接字 char buf[1024]={0}; recv(connfd, buf, sizeof(buf), 0); send(connfd, buf, strlen(buf),0); close(connfd); exit(0); //子进程结束 } else { perror("fork"); close(connfd); } } close(sockfd); return 0; }优缺点
✅优点:
- 进程之间资源完全隔离,一个客户端异常崩溃,不会影响其他客户端和主服务;
- 逻辑简单,代码容易写。
❌缺点:
fork()开销大,每来一个客户端就要创建一个完整进程;进程数量受系统限制,不适合高并发上万连接场景;- 需要处理僵尸进程回收,增加信号处理代码;
- 进程间通信复杂,如果多个客户端之间要交换数据,需要使用 IPC(管道、共享内存、消息队列)。
二、多线程版 TCP 并发服务器
原理
进程开销大,线程是轻量级进程,共享进程堆、全局数据区,只拥有独立栈。
- 主线程
accept获取新连接connfd; - 调用
pthread_create创建子线程,把通信套接字传入线程函数; - 子线程专门处理这个客户端读写业务;
- 线程设置为分离属性
pthread_detach,线程结束后操作系统自动回收资源,不需要主线程调用pthread_join阻塞等待; - 主线程继续循环等待下一个客户端。
注意:传参坑点:不要直接把局部变量
connfd地址传给线程,多个线程会竞争读取同一个变量,出现值错乱;可以动态 malloc 申请堆内存存放 fd,线程内部用完 free 释放。
核心伪代码
void *client_task(void *arg) { int connfd = *(int *)arg; free(arg); char buf[1024]={0}; recv(connfd, buf, sizeof(buf),0); send(connfd, buf, strlen(buf),0); close(connfd); return NULL; } int main(int argc, char *argv[]) { int sockfd = socket(AF_INET, SOCK_STREAM, 0); bind(sockfd,...); listen(sockfd,10); while(1) { int *p_fd = malloc(sizeof(int)); *p_fd = accept(sockfd, NULL, NULL); pthread_t tid; pthread_create(&tid, NULL, client_task, p_fd); pthread_detach(tid); //设置分离属性,自动回收线程资源 } close(sockfd); return 0; }编译需要增加链接库:
gcc server.c -o server -pthread
✅优点:
- 线程创建销毁开销远小于进程;
- 线程共享进程全局资源,线程间数据交互简单,直接读写全局变量(需要互斥锁保护临界资源)。
❌缺点:
- 一个线程发生段错误崩溃,整个进程全部崩溃,所有客户端全部断开;
- 大量线程会消耗内存(每个线程默认 8M 栈),线程数量上限受限;
- 多线程访问共享资源必须加互斥锁,锁处理不当会产生死锁。
三、IO 多路复用 select 模型
IO 多路复用:单进程,同时监听多个文件描述符是否可读 / 可写事件,当某个 fd 事件就绪,才去执行读写操作。避免阻塞在某一个 fd 上。
select 核心特点
- 使用位图
fd_set存储要监听的文件描述符,最大监听 1024 个 fd; - 用户态的 fd 集合每次调用 select 都会拷贝到内核;
- 内核返回整个位图集合,应用程序必须循环遍历所有 fd 判断哪个事件就绪;
- 只支持水平触发。
核心工作流程
FD_ZERO清空文件描述符集合;FD_SET把监听套接字sockfd加入集合;- 调用
select()阻塞等待 IO 事件; - 如果
sockfd就绪:代表有新客户端连接,调用accept拿到connfd,把 connfd 加入监听集合; - 如果普通
connfd就绪:代表客户端发来数据,执行recv/send;客户端断开则调用 close,并且从 fd 集合清除该 fd。
核心伪代码
fd_set rdfds; int maxfd = sockfd; while(1) { FD_ZERO(&rdfds); FD_SET(sockfd, &rdfds); //遍历保存所有已经存在的connfd,加入rdfds //...... select(maxfd+1, &rdfds, NULL, NULL, NULL); if(FD_ISSET(sockfd, &rdfds)) { //有新客户端连接 int connfd = accept(sockfd,NULL,NULL); FD_SET(connfd, &rdfds); if(connfd>maxfd) maxfd = connfd; } //遍历判断各个connfd是否就绪 for(int i=sockfd+1; i<=maxfd;i++) { if(FD_ISSET(i,&rdfds)) { //读写业务逻辑 int ret = recv(i, buf, sizeof(buf),0); if(ret <=0) { close(i); FD_CLR(i,&rdfds); } } } }✅优点:单进程,没有进程 / 线程创建销毁开销,相比多进程多线程,支持更多并发连接。 ❌缺点:最大 1024 个 fd 上限;每次调用 select 都拷贝集合;返回之后必须遍历全部 fd,效率随着连接数上升下降。
四、poll 模型
poll 解决 select 位图 1024 的数量限制,使用结构体数组保存需要监听的 fd 事件。
- 无文件描述符数量硬编码上限;
- 依然需要把数组拷贝到内核,返回后需要遍历数组查找就绪事件;
- 只支持水平触发。
在高连接场景性能依然有瓶颈,工业开发中优先 epoll。
五、epoll 模型(Linux 高性能 IO 多路复用)
epoll 是 Linux 独有的 IO 多路复用,高性能网络编程主流方案。
三大核心函数
epoll_create(size):创建 epoll 对象,返回 epoll 文件描述符;epoll_ctl(epfd, op, fd, event):向内核添加、修改、删除要监听的 fd 和事件;epoll_wait()阻塞等待事件,直接返回已经就绪的事件数组,不需要遍历全部 fd。
epoll 特点
- 使用内核红黑树保存监听 fd,没有 1024 上限;
- 用户态和内核只拷贝就绪事件,不需要拷贝全部 fd 集合;
- 直接返回就绪的 fd 集合,程序只处理就绪的 fd;
- 支持水平触发 LT和边沿触发 ET两种模式;边沿触发效率更高。
✅优点:海量并发连接场景,性能不会随着连接数量增加明显下降;Nginx 底层就是 epoll。 ❌缺点:Linux 平台专有 API,跨平台性差。
六、五种并发模型对比总结
| 模型 | 资源开销 | 最大连接 | 稳定性 | 跨平台 | 适用场景 |
|---|---|---|---|---|---|
| 多进程 | 很高 | 少,几百 | 高,进程隔离 | 跨平台 | 客户端数量少,安全性优先 |
| 多线程 | 中等 | 几千 | 低,线程崩溃整个进程挂 | 跨平台 | 中等并发,业务逻辑简单 |
| select | 低 | 最大 1024 | 高 | 跨平台 | 连接数小于 1024,简单小程序 |
| poll | 低 | 无硬上限 | 高 | 跨平台 | 中等并发,兼容多系统 |
| epoll | 很低 | 十万百万级别 | 高 | 仅 Linux | 高并发网络服务器 (Nginx) |
七、拓展:TCP 粘包问题
只要是 TCP 流式套接字,就会存在粘包现象:多条业务数据在内核缓冲区粘连到一起,应用层读取无法区分数据包边界。
解决粘包三种方案:
- 固定结构体收发:直接发送固定大小结构体;
- 特殊分隔符:数据包末尾加换行符
\n等标记; - 自定义帧协议:帧头 + 数据长度 + 业务数据 + 校验 + 帧尾(工业项目最常用)。
下一篇博客我们讲解 SQLite3 数据库 C 语言 API 实战,实现词典程序。