Linux C语言select/poll/epoll机制对比与选择
一、I/O 多路复用概述
在深入探讨 select
、poll
和 epoll
机制之前,先来了解一下 I/O 多路复用的概念。在网络编程中,一个服务器往往需要处理多个客户端的连接请求和数据传输。传统的做法是为每个客户端创建一个单独的进程或线程来处理,然而这种方式在面对大量客户端时,会消耗大量的系统资源,导致性能下降。
I/O 多路复用技术就是为了解决这个问题而诞生的。它允许一个进程同时监视多个文件描述符(如套接字)的状态变化,当其中任何一个文件描述符就绪(可读、可写或有异常)时,就通知应用程序进行相应处理。这样,一个进程就可以高效地管理多个 I/O 流,大大提高了系统的并发处理能力。
二、select 机制
2.1 select 原理
select
是最早的 I/O 多路复用机制,它通过一个 fd_set
结构体来表示一组文件描述符。fd_set
本质上是一个位图,每一位对应一个文件描述符。select
函数会阻塞等待,直到 fd_set
中的某个文件描述符就绪,或者超时时间到达。
select
函数的原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds
:需要检查的文件描述符集合中最大文件描述符加 1。readfds
:指向读文件描述符集合的指针。writefds
:指向写文件描述符集合的指针。exceptfds
:指向异常文件描述符集合的指针。timeout
:设置的超时时间,如果为NULL
,则select
会一直阻塞,直到有文件描述符就绪。
当 select
函数返回时,readfds
、writefds
和 exceptfds
会被修改,只包含就绪的文件描述符。应用程序需要遍历这些集合,才能确定哪些文件描述符真正就绪。
2.2 select 示例代码
下面是一个简单的使用 select
实现的服务器端代码示例,监听一个端口,接收客户端连接并处理数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/time.h>
#define PORT 8080
#define MAX_CLIENTS 100
int main(int argc, char const *argv[]) {
int server_fd, new_socket, valread;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
fd_set read_fds;
fd_set tmp_fds;
int activity, i, val;
// 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置套接字选项
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定套接字到地址
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听套接字
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 初始化文件描述符集合
FD_ZERO(&read_fds);
FD_ZERO(&tmp_fds);
FD_SET(server_fd, &read_fds);
while (1) {
tmp_fds = read_fds;
// 等待文件描述符就绪
activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
printf("select error");
} else if (activity > 0) {
if (FD_ISSET(server_fd, &tmp_fds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 将新连接的套接字加入文件描述符集合
FD_SET(new_socket, &read_fds);
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
}
for (i = 0; i <= server_fd; i++) {
if (FD_ISSET(i, &tmp_fds)) {
if (i != server_fd) {
valread = read(i, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(i);
FD_CLR(i, &read_fds);
} else {
buffer[valread] = '\0';
printf("Message from client %d: %s\n", i, buffer);
val = write(i, buffer, strlen(buffer));
}
}
}
}
}
}
return 0;
}
2.3 select 优缺点
优点:
- 几乎所有操作系统都支持
select
,具有良好的跨平台性。
缺点:
fd_set
大小受限,在 Linux 上默认最大为 1024,这限制了可同时处理的文件描述符数量。select
返回后,需要遍历整个fd_set
来确定哪些文件描述符就绪,时间复杂度为 O(n),在文件描述符数量较多时性能较低。select
的fd_set
参数在返回时会被修改,每次调用select
前都需要重新设置。
三、poll 机制
3.1 poll 原理
poll
机制改进了 select
中文件描述符集合大小受限的问题。poll
使用一个 pollfd
结构体数组来表示需要监视的文件描述符及其事件。pollfd
结构体定义如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生的事件 */
};
poll
函数的原型为:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:指向pollfd
结构体数组的指针。nfds
:数组中元素的个数。timeout
:超时时间,单位为毫秒,-1
表示无限期阻塞。
poll
函数会阻塞等待,直到数组中的某个文件描述符就绪,或者超时时间到达。返回时,revents
字段会标记实际发生的事件,应用程序可以通过遍历 pollfd
数组来确定就绪的文件描述符。
3.2 poll 示例代码
下面是使用 poll
实现的类似服务器端代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <poll.h>
#define PORT 8080
#define MAX_CLIENTS 100
int main(int argc, char const *argv[]) {
int server_fd, new_socket, valread;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
struct pollfd fds[MAX_CLIENTS + 1];
int activity, i, val;
// 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置套接字选项
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定套接字到地址
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听套接字
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 初始化 pollfd 数组
for (i = 0; i <= MAX_CLIENTS; i++) {
fds[i].fd = -1;
fds[i].events = 0;
fds[i].revents = 0;
}
fds[0].fd = server_fd;
fds[0].events = POLLIN;
int client_count = 0;
while (1) {
// 等待文件描述符就绪
activity = poll(fds, client_count + 1, -1);
if (activity < 0) {
perror("poll error");
} else if (activity > 0) {
if (fds[0].revents & POLLIN) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 将新连接的套接字加入 pollfd 数组
for (i = 1; i <= MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = new_socket;
fds[i].events = POLLIN;
client_count++;
break;
}
}
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
}
for (i = 1; i <= client_count; i++) {
if (fds[i].revents & (POLLIN | POLLERR)) {
valread = read(fds[i].fd, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
getpeername(fds[i].fd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(fds[i].fd);
fds[i].fd = -1;
client_count--;
} else {
buffer[valread] = '\0';
printf("Message from client %d: %s\n", fds[i].fd, buffer);
val = write(fds[i].fd, buffer, strlen(buffer));
}
}
}
}
}
return 0;
}
3.3 poll 优缺点
优点:
- 没有文件描述符数量的限制,理论上可以处理任意数量的文件描述符。
pollfd
结构体数组在返回时不会被修改,不需要每次重新设置。
缺点:
- 虽然没有文件描述符数量限制,但实际应用中,由于需要传递一个较大的数组,在文件描述符数量非常大时,会消耗较多的内核资源。
- 与
select
类似,poll
返回后需要遍历整个数组来确定就绪的文件描述符,时间复杂度为 O(n),在文件描述符数量较多时性能不高。
四、epoll 机制
4.1 epoll 原理
epoll
是 Linux 特有的 I/O 多路复用机制,它在性能上比 select
和 poll
有了很大提升。epoll
基于事件驱动,采用回调机制,当文件描述符就绪时,内核会主动将其添加到一个就绪队列中,应用程序只需从这个队列中获取就绪的文件描述符,而不需要遍历所有监视的文件描述符。
epoll
有三个主要函数:
- epoll_create:创建一个
epoll
实例,返回一个epoll
文件描述符。
int epoll_create(int size);
参数 size
是创建 epoll
实例时预分配的文件描述符数量,从 Linux 2.6.8 开始,该参数被忽略,但仍需提供一个大于 0 的值。
- epoll_ctl:用于控制
epoll
实例,向其中添加、修改或删除监视的文件描述符及其事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd
:epoll
实例的文件描述符。op
:操作类型,有EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)、EPOLL_CTL_DEL
(删除)。fd
:要操作的文件描述符。event
:指向epoll_event
结构体的指针,用于指定事件类型和数据。epoll_event
结构体定义如下:
struct epoll_event {
uint32_t events; /* 事件类型 */
epoll_data_t data; /* 用户数据 */
};
events
可以是 EPOLLIN
(可读)、EPOLLOUT
(可写)、EPOLLERR
(错误)等事件的组合。data
可以是一个整数、指针或 epoll_data
联合体中的其他成员。
- epoll_wait:等待
epoll
实例中的文件描述符就绪,返回就绪的文件描述符数量。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd
:epoll
实例的文件描述符。events
:用于存储就绪事件的数组。maxevents
:events
数组的大小。timeout
:超时时间,单位为毫秒,-1
表示无限期阻塞。
4.2 epoll 示例代码
下面是使用 epoll
实现的服务器端代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/epoll.h>
#define PORT 8080
#define MAX_EVENTS 10
int main(int argc, char const *argv[]) {
int server_fd, new_socket, valread;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
int epollfd, nfds;
struct epoll_event event;
struct epoll_event *events;
// 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置套接字选项
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定套接字到地址
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听套接字
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 创建 epoll 实例
epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 将监听套接字添加到 epoll 实例中
event.data.fd = server_fd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
// 分配内存用于存储就绪事件
events = calloc(MAX_EVENTS, sizeof(event));
while (1) {
// 等待事件发生
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int n = 0; n < nfds; ++n) {
if (events[n].data.fd == server_fd) {
while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) != -1) {
event.data.fd = new_socket;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
perror("epoll_ctl: new_socket");
close(new_socket);
}
}
if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept");
}
} else {
valread = read(events[n].data.fd, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
close(events[n].data.fd);
if (epoll_ctl(epollfd, EPOLL_CTL_DEL, events[n].data.fd, NULL) == -1) {
perror("epoll_ctl: del");
}
} else {
buffer[valread] = '\0';
printf("Message from client %d: %s\n", events[n].data.fd, buffer);
write(events[n].data.fd, buffer, strlen(buffer));
}
}
}
}
free(events);
close(epollfd);
close(server_fd);
return 0;
}
4.3 epoll 优缺点
优点:
- 支持大量文件描述符,性能不会随着文件描述符数量的增加而显著下降,因为它采用事件驱动,时间复杂度为 O(1)。
- 内核和用户空间之间的数据拷贝只需要一次,而
select
和poll
每次都需要将文件描述符集合从用户空间拷贝到内核空间。 - 提供了边缘触发(Edge Triggered,ET)和水平触发(Level Triggered,LT)两种模式,ET 模式效率更高,适合处理高速流数据。
缺点:
epoll
是 Linux 特有的机制,不具备跨平台性,在其他操作系统上无法使用。
五、select、poll、epoll 对比与选择
-
文件描述符数量限制:
select
受限于fd_set
的大小,默认最大为 1024。poll
理论上没有限制,但在实际应用中,大量文件描述符会消耗较多内核资源。epoll
支持大量文件描述符,非常适合处理高并发场景。
-
性能:
select
和poll
在文件描述符数量较多时,由于需要遍历所有文件描述符来确定就绪的,时间复杂度为 O(n),性能较低。epoll
采用事件驱动,时间复杂度为 O(1),在高并发场景下性能优势明显。
-
跨平台性:
select
几乎支持所有操作系统,具有良好的跨平台性。poll
也有较好的跨平台性,但在不同系统上可能有一些细微差异。epoll
是 Linux 特有的,不具备跨平台性。
-
使用场景选择:
- 如果需要处理的文件描述符数量较少,并且对跨平台性有要求,
select
或poll
可以满足需求。 - 如果需要处理大量文件描述符,特别是在高并发的网络服务器场景下,
epoll
是最佳选择。
- 如果需要处理的文件描述符数量较少,并且对跨平台性有要求,
在实际应用中,应根据具体的需求和场景来选择合适的 I/O 多路复用机制,以达到最优的性能和资源利用效率。同时,对于需要跨平台的应用,要充分考虑不同机制在不同操作系统上的兼容性和性能表现。