在网络编程中,TCP 服务器面临的核心挑战之一是如何高效处理多个客户端的并发连接。由于accept()和recv()等核心 IO 接口默认是阻塞的,单线程服务器无法同时处理 "等待新连接" 和 "接收已有连接数据" 两个任务。本文将从 IO 模型底层原理出发,详解 TCP 并发服务器的实现方案,重点剖析多路复用模型的核心逻辑与实践。
一、TCP 并发服务器的核心问题
单线程 TCP 服务器的执行逻辑存在天然缺陷:
accept()会阻塞等待客户端三次握手,期间无法处理已连接客户端的消息recv()会阻塞等待数据到达,期间无法响应新的客户端连接- 阻塞 IO 导致服务器只能串行处理一个客户端的请求,完全无法支撑并发
针对这个问题,主流有两类解决方案:
1. 线程 / 进程模型
- 优点:实现简单,为每个 TCP 连接创建独立线程 / 进程,各自处理阻塞 IO
- 缺点:资源消耗大,连接数增多时线程 / 进程切换开销剧增,性能瓶颈明显
2. 多路复用模型
通过一个核心接口监听多个文件描述符的 IO 事件,仅在事件发生时才进行处理,从根本上解决阻塞 IO 的并发问题,是高性能服务器的首选方案。
二、Linux 系统的 4 种 IO 模型
理解多路复用前,先掌握 Linux 的 4 种基础 IO 模型:
表格
| IO 模型 | 核心特点 | 适用场景 |
|---|---|---|
| 阻塞 IO | 数据未就绪时,进程阻塞等待,不占用 CPU 资源 | 简单场景、低并发连接 |
| 非阻塞 IO | 轮询检查 IO 状态,CPU 空转率高 | 特殊低延迟场景 |
| 异步 IO | 内核主动向应用层上报 IO 事件,完全异步 | 高性能异步框架 |
| 多路复用 IO | 单接口监听多文件描述符,事件驱动 | 高并发 TCP 服务器 |
三、多路复用的三种实现:select/poll/epoll
Linux 提供了select、poll、epoll三种多路复用接口,核心能力和性能差异显著。
1. select
c
运行
#include <sys/select.h>
// 核心函数
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
// 辅助宏
void FD_CLR(int fd, fd_set *set); // 从集合删除fd
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在集合中
void FD_SET(int fd, fd_set *set); // 添加fd到集合
void FD_ZERO(fd_set *set); // 清空集合
核心缺点:
- 文件描述符上限(默认 1024),无法支撑高并发
- 每次调用需拷贝文件描述符集合到内核,开销大
- 仅支持水平触发(LT),需反复处理未完成的事件
- 需遍历所有文件描述符才能找到触发事件的 fd
2. poll
c
运行
#include <poll.h>
struct pollfd {
int fd; // 监听的文件描述符
short events; // 期望监听的事件(POLLIN/POLLOUT等)
short revents; // 实际发生的事件
};
// 核心函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
核心改进:
- 用链表存储监听事件,无文件描述符数量上限
- 其余缺点与 select 一致(内核态 / 用户态拷贝、水平触发、遍历查找)
3. epoll(高性能首选)
epoll 是 Linux 特有的多路复用接口,完美解决 select/poll 的痛点:
c
运行
#include <sys/epoll.h>
struct epoll_event {
uint32_t events; // 监听事件(EPOLLIN/EPOLLOUT/EPOLLET等)
epoll_data_t data; // 关联数据(通常存fd)
};
// 创建内核事件表
int epoll_create(int size);
// 管理事件(添加/修改/删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待事件触发
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
核心优势:
- 无文件描述符数量上限(仅受系统资源限制)
- 事件表存储在内核态,无需反复拷贝
- 支持水平触发(LT,默认)和边沿触发(ET,高性能模式)
- 直接返回触发事件的 fd,无需遍历所有监听对象
四、epoll 实现 TCP 并发服务器示例
以下是基于 epoll 的 TCP 并发服务器完整实现,采用边沿触发模式提升性能:
c
运行
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 1024 // 最大事件数
#define PORT 8888 // 监听端口
#define BUF_SIZE 1024 // 缓冲区大小
// 设置文件描述符为非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL");
return -1;
}
return 0;
}
// 添加fd到epoll监听集合
void add_epoll_fd(int epfd, int fd, int enable_et) {
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN; // 监听读事件
if (enable_et) {
ev.events |= EPOLLET; // 启用边沿触发
}
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
perror("epoll_ctl add");
exit(EXIT_FAILURE);
}
set_nonblocking(fd); // ET模式必须配合非阻塞IO
}
int main() {
int listen_fd, conn_fd, epfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
struct epoll_event events[MAX_EVENTS];
char buf[BUF_SIZE];
// 1. 创建监听socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 2. 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址和端口
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
// 4. 开始监听
if (listen(listen_fd, 128) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
// 5. 创建epoll实例
epfd = epoll_create(MAX_EVENTS);
if (epfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
// 6. 添加监听fd到epoll
add_epoll_fd(epfd, listen_fd, 0); // 监听fd用LT模式即可
printf("TCP server running on port %d...\n", PORT);
// 7. 事件循环
while (1) {
// 等待事件触发,-1表示永久阻塞
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
// 8. 处理所有触发的事件
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 8.1 新客户端连接事件
if (fd == listen_fd) {
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd == -1) {
perror("accept");
continue;
}
printf("New client connected: %s:%d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 添加新连接fd到epoll,启用ET模式
add_epoll_fd(epfd, conn_fd, 1);
}
// 8.2 已有客户端数据可读
else if (events[i].events & EPOLLIN) {
memset(buf, 0, BUF_SIZE);
ssize_t n = read(fd, buf, BUF_SIZE - 1);
// 客户端关闭连接
if (n == 0) {
printf("Client %d disconnected\n", fd);
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
continue;
}
// 读数据出错
if (n < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read");
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
continue;
}
// 成功读取数据
printf("Recv from client %d: %s\n", fd, buf);
// 回显数据给客户端
write(fd, buf, n);
}
}
}
// 关闭资源(实际不会执行到)
close(listen_fd);
close(epfd);
return 0;
}
编译运行
bash
运行
# 编译
gcc tcp_epoll_server.c -o tcp_server
# 运行
./tcp_server
# 测试(新开终端)
telnet 127.0.0.1 8888
# 或使用nc工具
nc 127.0.0.1 8888
五、核心注意事项
- ET 模式必须配合非阻塞 IO :边沿触发仅在状态变化时通知一次,需循环读取直到
EAGAIN,避免数据残留 - 监听 fd 建议用 LT 模式:新连接事件无需频繁处理,LT 模式更简单
- 端口复用 :
SO_REUSEADDR确保服务器重启时可立即绑定端口 - 资源释放:客户端断开连接时,需关闭 fd 并从 epoll 中删除
总结
- TCP 并发服务器的核心是解决阻塞 IO 的并发问题,多路复用模型是高性能首选方案;
- epoll 相比 select/poll 无 fd 数量限制、无需反复拷贝数据、支持 ET 模式,是高并发场景的最优选择;
- 实现 epoll 服务器时,ET 模式需配合非阻塞 IO,且要正确处理客户端断开连接的资源释放逻辑。