关于TCP并发服务器的构建,之前已经介绍了select,今天本文将深入探讨两种主流的I/O多路复用机制------poll和epoll,并通过完整代码示例演示如何构建高并发TCP服务器。
1. I/O模型概述
Linux下主要有四种I/O模型:
-
阻塞I/O :进程调用
recvfrom等系统调用后,数据未准备好时进程阻塞,直至数据到达内核并复制到用户空间。 -
非阻塞I/O :通过设置文件描述符为非阻塞,调用后立即返回,若数据未准备好则返回
EAGAIN错误,需要轮询。 -
I/O多路复用 :使用
select/poll/epoll同时监视多个描述符,当其中任何一个就绪时通知应用程序,再由应用程序调用相应的I/O操作。 -
异步I/O:应用程序发起操作后立即返回,内核完成所有操作(数据拷贝到用户空间)后通知进程。
其中,I/O多路复用是构建高并发网络服务器的核心技术。
2. poll函数详解
2.1 函数原型与数据结构
c
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
fds:指向pollfd结构体数组的指针,每个结构体描述一个需要监视的文件描述符及其关注的事件。 -
nfds:fds数组的元素个数。 -
timeout:超时时间(毫秒)。-1表示永远阻塞;0表示立即返回;大于0表示等待指定的毫秒数。
struct pollfd定义如下:
c
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 关注的事件位掩码 */
short revents; /* 返回的就绪事件位掩码 */
};
常用事件:
-
POLLIN:有数据可读(包括普通数据和紧急数据)。 -
POLLOUT:可写数据。 -
POLLERR:发生错误(仅输出)。 -
POLLHUP:挂起(仅输出)。
2.2 poll的特点
-
优点:没有最大文件描述符数量的限制(基于链表存储,受系统内存限制)。
-
缺点:
-
每次调用
poll都需要将fds数组从用户态拷贝到内核态,开销随监视描述符数量线性增长。 -
内核检测就绪描述符后,返回时仍需遍历整个数组查找哪些
revents非零,时间复杂度O(n)。 -
仅支持水平触发(LT),即如果文件描述符就绪后不处理,下次
poll会再次通知。
-
2.3 使用poll实现TCP并发服务器
下面是一个使用poll的TCP回显服务器示例。它监听一个端口,接受多个客户端连接,并将客户端发送的数据原样返回。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <poll.h>
#include <fcntl.h>
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024
int main(int argc, char *argv[]) {
int listen_fd, conn_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
char buffer[BUFFER_SIZE];
int i, nready, ret;
// 创建监听套接字
if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 监听
if (listen(listen_fd, 10) < 0) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 初始化pollfd数组
struct pollfd fds[MAX_CLIENTS];
for (i = 0; i < MAX_CLIENTS; i++) {
fds[i].fd = -1;
}
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
int maxi = 0; // fds数组当前最大下标
printf("Poll echo server started on port 8888\n");
while (1) {
nready = poll(fds, maxi + 1, -1); // 永远阻塞
if (nready < 0) {
perror("poll");
break;
}
// 检查监听套接字是否有新连接
if (fds[0].revents & POLLIN) {
client_len = sizeof(client_addr);
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
printf("New client connected: %s:%d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 将新连接添加到fds数组
for (i = 1; i < MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = conn_fd;
fds[i].events = POLLIN;
if (i > maxi) maxi = i;
break;
}
}
if (i == MAX_CLIENTS) {
printf("Too many clients, closing new connection\n");
close(conn_fd);
}
if (--nready <= 0) continue; // 如果没有其他就绪事件,继续下一次循环
}
// 检查现有客户端是否有数据可读
for (i = 1; i <= maxi; i++) {
int fd = fds[i].fd;
if (fd == -1) continue;
if (fds[i].revents & POLLIN) {
memset(buffer, 0, BUFFER_SIZE);
ret = read(fd, buffer, BUFFER_SIZE - 1);
if (ret < 0) {
perror("read");
close(fd);
fds[i].fd = -1;
} else if (ret == 0) { // 客户端关闭
printf("Client %d closed\n", fd);
close(fd);
fds[i].fd = -1;
} else {
printf("Received %d bytes from %d: %s", ret, fd, buffer);
// 回显数据
write(fd, buffer, ret);
}
if (--nready <= 0) break;
}
}
}
close(listen_fd);
return 0;
}
代码说明:
-
使用一个
pollfd数组管理所有套接字(监听套接字+已连接套接字)。 -
监听套接字始终关注
POLLIN事件。 -
当
poll返回后,首先检查监听套接字,若有新连接则accept并将新套接字加入数组。 -
然后遍历数组,检查每个客户端套接字是否就绪可读,读取数据并回显。
-
注意处理客户端关闭(
read返回0)或出错的情况,及时从数组中移除。
3. epoll函数详解
epoll是Linux特有的I/O多路复用机制,在2.6内核中引入,专门为处理大量文件描述符而设计,具有更高的性能和灵活性。
3.1 核心函数
c
#include <sys/epoll.h>
// 创建一个epoll实例,返回一个文件描述符(epoll句柄)
int epoll_create(int size); // size参数在Linux 2.6.8后忽略,但需大于0
// 控制epoll实例中的事件:添加、修改、删除
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);
struct epoll_event定义:
c
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 关注的事件掩码 */
epoll_data_t data; /* 用户数据 */
};
常用事件:
-
EPOLLIN:可读。 -
EPOLLOUT:可写。 -
EPOLLET:设置为边沿触发模式(默认是水平触发)。 -
EPOLLRDHUP:对方关闭连接(需2.6.17以上内核)。 -
EPOLLERR:错误。 -
EPOLLHUP:挂起。
3.2 epoll的特点
-
无上限:监视的描述符数量理论上只受系统内存限制。
-
高效:
-
通过
epoll_ctl注册事件,内核维护一棵红黑树,每次只需拷贝事件到内核一次,后续epoll_wait无需重复拷贝所有描述符。 -
内核通过回调机制将就绪描述符添加到就绪链表,
epoll_wait直接返回就绪事件,无需遍历所有描述符,时间复杂度O(1)。
-
-
支持两种触发模式:
-
水平触发(LT) :默认模式,只要文件描述符还有数据可读,每次
epoll_wait都会返回该事件。与poll行为一致,编程简单。 -
边沿触发(ET) :仅当描述符状态发生变化时(如从未就绪变为就绪)才通知一次。若未将数据全部读写完,下次不会再通知,因此通常需要配合非阻塞I/O循环读写直到
EAGAIN。ET模式效率更高,但编程复杂。
-
3.3 使用epoll实现TCP并发服务器(LT模式)
下面是一个基于epoll(LT模式)的TCP回显服务器,逻辑与poll版本类似,但更简洁。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#define MAX_EVENTS 1024
#define BUFFER_SIZE 1024
int main(int argc, char *argv[]) {
int listen_fd, conn_fd, epfd, nfds, i;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
char buffer[BUFFER_SIZE];
struct epoll_event ev, events[MAX_EVENTS];
// 创建监听套接字
if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 10) < 0) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 创建epoll实例
epfd = epoll_create(1);
if (epfd < 0) {
perror("epoll_create");
close(listen_fd);
exit(EXIT_FAILURE);
}
// 将监听套接字加入epoll事件表
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
perror("epoll_ctl add listen_fd");
close(listen_fd);
close(epfd);
exit(EXIT_FAILURE);
}
printf("Epoll (LT) echo server started on port 8888\n");
while (1) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds < 0) {
perror("epoll_wait");
break;
}
for (i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接
client_len = sizeof(client_addr);
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
printf("New client connected: %s:%d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 将新连接套接字加入epoll,使用LT模式(默认)
ev.events = EPOLLIN;
ev.data.fd = conn_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) < 0) {
perror("epoll_ctl add conn_fd");
close(conn_fd);
}
} else {
// 客户端套接字有数据可读
int fd = events[i].data.fd;
memset(buffer, 0, BUFFER_SIZE);
int ret = read(fd, buffer, BUFFER_SIZE - 1);
if (ret < 0) {
perror("read");
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else if (ret == 0) {
printf("Client %d closed\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else {
printf("Received %d bytes from %d: %s", ret, fd, buffer);
write(fd, buffer, ret);
}
}
}
}
close(listen_fd);
close(epfd);
return 0;
}
3.4 边沿触发(ET)模式的改进
ET模式能减少事件被重复触发的次数,提高并发性能。使用ET模式需要注意:
-
文件描述符必须设置为非阻塞,以避免读写操作阻塞在最后一次操作。
-
当
epoll_wait返回EPOLLIN事件后,应循环调用read直到返回EAGAIN(表示暂时无数据),确保本次数据全部读完。 -
同样,写操作可能需要循环直到
EAGAIN。
下面给出一个ET模式下的回显服务器片段(仅展示修改部分):
c
// 将文件描述符设为非阻塞
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 在accept后设置新连接为非阻塞
conn_fd = accept(...);
set_nonblocking(conn_fd);
ev.events = EPOLLIN | EPOLLET; // 加入ET标志
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
// 处理客户端数据时,循环读取直到EAGAIN
if (events[i].events & EPOLLIN) {
while (1) {
memset(buffer, 0, BUFFER_SIZE);
ret = read(fd, buffer, BUFFER_SIZE - 1);
if (ret < 0) {
if (errno == EAGAIN) {
// 数据已经读完
break;
} else {
perror("read");
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
} else if (ret == 0) {
printf("Client %d closed\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
} else {
printf("Received %d bytes from %d: %s", ret, fd, buffer);
// 注意:此处write也可能阻塞,但在ET模式下,应该也循环写入,或者关注EPOLLOUT事件。
write(fd, buffer, ret); // 简化处理,实际应用应使用非阻塞写入
}
}
}
4. poll与epoll对比
| 特性 | poll |
epoll |
|---|---|---|
| 最大连接数 | 受系统内存限制(链表存储) | 受系统内存限制(红黑树+链表) |
| I/O效率 | 每次调用需将所有描述符从用户态拷贝到内核态,返回后遍历所有描述符查找就绪项,时间复杂度O(n) | 通过epoll_ctl注册,每次epoll_wait只返回就绪描述符,无需遍历全部,时间复杂度O(1) |
| 数据拷贝 | 每次调用poll都会拷贝所有pollfd结构 |
调用epoll_ctl时拷贝注册的事件,epoll_wait仅拷贝就绪事件,效率高 |
| 触发模式 | 仅支持水平触发(LT) | 支持水平触发(LT)和边沿触发(ET) |
| 编程复杂度 | 较低,需维护一个数组 | 稍高,尤其是ET模式需处理非阻塞和循环读写 |
| 平台支持 | POSIX标准,广泛支持 | Linux特有 |
5. 总结与选型建议
-
连接数较少(几百以内) 且代码需要跨平台时,使用
poll足够简单,移植性好。 -
连接数较大(成千上万) 且运行在Linux平台时,
epoll是当之无愧的首选,它能够提供线性扩展的性能。 -
对于追求极致性能 的场景,建议采用
epoll的ET模式,但务必正确处理好非阻塞I/O和资源回收,避免因编程疏忽导致数据丢失或死循环。
I/O多路复用是Linux高并发编程的基石,理解poll和epoll的原理与使用,能够帮助开发者设计出高效的网络服务。希望本文的示例代码能够为你动手实践提供帮助。如果你有任何问题或想法,欢迎在评论区交流讨论