select、poll、epoll在处理TCP连接时的差异与选择
一、select
1.1 select 基本原理
select 是 UNIX 系统中最早提供的多路复用 I/O 函数。它允许应用程序监视一组文件描述符(file descriptor,简称 fd),当其中任何一个描述符准备好进行 I/O 操作(读、写或异常)时,select 函数就会返回,通知应用程序哪些描述符可以进行相应的操作。
在处理 TCP 连接时,每个 TCP 连接对应的套接字(socket)就是一个文件描述符。select 函数通过检查这些套接字描述符的状态,来判断是否有数据可读、可写或发生异常。
select 的核心数据结构是 fd_set
,它是一个文件描述符集合。应用程序需要手动将感兴趣的文件描述符添加到这个集合中。同时,select 函数还提供了三个 fd_set
类型的参数,分别用于监听读事件(readfds
)、写事件(writefds
)和异常事件(exceptfds
)。
1.2 select 函数原型
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds
:需要检查的文件描述符集合中最大文件描述符的值加 1。这是为了告诉 select 函数需要检查到哪个文件描述符。readfds
:指向读文件描述符集合的指针。writefds
:指向写文件描述符集合的指针。exceptfds
:指向异常文件描述符集合的指针。timeout
:设置 select 函数等待的超时时间。如果为NULL
,则 select 会一直阻塞,直到有文件描述符准备好或发生错误;如果timeout
的时间为 0,则 select 函数不会阻塞,立即返回。
1.3 select 代码示例
以下是一个简单的使用 select 处理 TCP 连接的服务器端代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#define PORT 8080
#define MAX_CLIENTS 10
int main() {
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, sd;
// 创建套接字
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, MAX_CLIENTS) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 初始化文件描述符集合
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
while (1) {
tmp_fds = read_fds;
// 使用 select 等待文件描述符准备好
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 + 1; i++) {
sd = i;
if (FD_ISSET(sd, &tmp_fds)) {
valread = read(sd, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
getpeername(sd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(sd);
FD_CLR(sd, &read_fds);
} else {
buffer[valread] = '\0';
printf("Message from client: %s\n", buffer);
}
}
}
}
}
return 0;
}
1.4 select 的优缺点
优点:
- 跨平台支持:select 在几乎所有的操作系统上都有实现,具有很好的可移植性。
- 简单易用:其接口相对简单,对于简单的网络编程场景容易上手。
缺点:
- 文件描述符数量限制:在许多系统中,
fd_set
能容纳的文件描述符数量有限,通常为 1024 个。这对于大规模并发连接的场景来说是远远不够的。 - 线性扫描:select 函数返回后,应用程序需要线性扫描整个
fd_set
来确定哪些文件描述符准备好,这在文件描述符数量较多时效率较低。 - 数据从用户态到内核态的拷贝:每次调用 select 时,都需要将
fd_set
从用户态拷贝到内核态,当文件描述符数量较多时,这会带来较大的开销。
二、poll
2.1 poll 基本原理
poll 也是一种多路复用 I/O 机制,它改进了 select 的一些缺点。poll 使用一个 pollfd
结构体数组来管理文件描述符及其感兴趣的事件。pollfd
结构体包含文件描述符、感兴趣的事件和实际发生的事件。
与 select 不同,poll 没有文件描述符数量的限制(理论上只受系统资源的限制)。它通过遍历 pollfd
数组来检查每个文件描述符的状态,当有文件描述符准备好时,poll 函数返回,同时在 pollfd
数组中标记出哪些文件描述符发生了相应的事件。
2.2 poll 函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:指向pollfd
结构体数组的指针,数组中的每个元素描述一个文件描述符及其感兴趣的事件。
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 感兴趣的事件 */
short revents; /* 实际发生的事件 */
};
events
可以是以下值的按位或组合:
-
POLLIN
:数据可读。 -
POLLOUT
:数据可写。 -
POLLERR
:发生错误。 -
POLLHUP
:挂起。 -
POLLNVAL
:无效的文件描述符。 -
nfds
:fds
数组中元素的数量。 -
timeout
:设置 poll 函数等待的超时时间,单位为毫秒。如果为 -1,则 poll 会一直阻塞,直到有文件描述符准备好或发生错误;如果为 0,则 poll 函数不会阻塞,立即返回。
2.3 poll 代码示例
以下是使用 poll 处理 TCP 连接的服务器端代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <poll.h>
#define PORT 8080
#define MAX_CLIENTS 10
int main() {
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, sd;
// 创建套接字
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, MAX_CLIENTS) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 初始化 pollfd 数组
for (i = 0; i <= MAX_CLIENTS; i++) {
fds[i].fd = -1;
}
fds[0].fd = server_fd;
fds[0].events = POLLIN;
while (1) {
// 使用 poll 等待文件描述符准备好
activity = poll(fds, MAX_CLIENTS + 1, -1);
if (activity < 0) {
printf("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 < 0) {
fds[i].fd = new_socket;
fds[i].events = POLLIN;
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 <= MAX_CLIENTS; i++) {
sd = fds[i].fd;
if (sd > 0 && (fds[i].revents & POLLIN)) {
valread = read(sd, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
getpeername(sd, (struct sockaddr *)&address, (socklen_t *)&addrlen);
printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(sd);
fds[i].fd = -1;
} else {
buffer[valread] = '\0';
printf("Message from client: %s\n", buffer);
}
}
}
}
}
return 0;
}
2.4 poll 的优缺点
优点:
- 无文件描述符数量限制:理论上可以处理任意数量的文件描述符,克服了 select 的文件描述符数量限制问题。
- 事件驱动:通过
revents
直接标记出发生事件的文件描述符,不需要像 select 那样线性扫描整个集合,提高了效率。
缺点:
- 线性遍历:虽然通过
revents
可以直接获取发生事件的文件描述符,但 poll 函数内部仍然需要线性遍历pollfd
数组来检查每个文件描述符的状态,当文件描述符数量非常大时,性能会受到影响。 - 数据从用户态到内核态的拷贝:每次调用 poll 时,同样需要将
pollfd
数组从用户态拷贝到内核态,当文件描述符数量较多时,这会带来较大的开销。
三、epoll
3.1 epoll 基本原理
epoll 是 Linux 特有的多路复用 I/O 机制,它在性能上相对于 select 和 poll 有了很大的提升。epoll 基于事件驱动,通过一个 epoll 实例来管理大量的文件描述符。
epoll 有两种工作模式:水平触发(Level Triggered,简称 LT)和边缘触发(Edge Triggered,简称 ET)。
- 水平触发(LT):只要文件描述符对应的缓冲区还有数据可读(或可写空间),epoll 就会不断通知应用程序。这是一种比较传统的触发方式,与 select 和 poll 的触发方式类似。
- 边缘触发(ET):只有当文件描述符对应的缓冲区状态发生变化时(例如,从无数据可读变为有数据可读),epoll 才会通知应用程序。这种触发方式更为高效,但要求应用程序在接收到通知后尽可能多地处理数据,因为后续 epoll 可能不会再次通知,直到缓冲区状态再次发生变化。
epoll 通过三个系统调用实现:epoll_create
、epoll_ctl
和 epoll_wait
。
3.2 epoll 相关函数原型
#include <sys/epoll.h>
// 创建一个 epoll 实例,返回一个 epoll 句柄
int epoll_create(int size);
// 操作 epoll 实例,添加、修改或删除文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待 epoll 实例上的事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epoll_create
: -
size
:指定 epoll 实例能够处理的最大文件描述符数量。虽然这个参数现在已经被忽略,但仍然需要提供一个大于 0 的值。 -
epoll_ctl
: -
epfd
:epoll 实例的句柄,由epoll_create
返回。 -
op
:操作类型,可以是以下值之一:EPOLL_CTL_ADD
:将文件描述符fd
添加到 epoll 实例中。EPOLL_CTL_MOD
:修改文件描述符fd
的事件掩码。EPOLL_CTL_DEL
:将文件描述符fd
从 epoll 实例中删除。
-
fd
:要操作的文件描述符。 -
event
:指向epoll_event
结构体的指针,用于指定感兴趣的事件和关联的数据。
struct epoll_event {
uint32_t events; /* 感兴趣的事件 */
epoll_data_t data; /* 关联的数据 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events
可以是以下值的按位或组合:
-
EPOLLIN
:数据可读。 -
EPOLLOUT
:数据可写。 -
EPOLLERR
:发生错误。 -
EPOLLHUP
:挂起。 -
EPOLLET
:设置为边缘触发模式,默认是水平触发模式。 -
epoll_wait
: -
epfd
:epoll 实例的句柄。 -
events
:指向epoll_event
结构体数组的指针,用于存储发生事件的文件描述符及其事件信息。 -
maxevents
:events
数组的大小,即最多能返回多少个发生事件的文件描述符。 -
timeout
:设置等待的超时时间,单位为毫秒。如果为 -1,则 epoll_wait 会一直阻塞,直到有事件发生;如果为 0,则 epoll_wait 不会阻塞,立即返回。
3.3 epoll 代码示例
以下是使用 epoll 处理 TCP 连接的服务器端代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define PORT 8080
#define MAX_EVENTS 10
int main() {
int server_fd, new_socket, valread;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
struct epoll_event event;
struct epoll_event *events;
int epollfd;
int i, nfds;
// 创建套接字
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, MAX_EVENTS) < 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);
}
// 分配内存用于存储发生事件的 epoll_event 结构体
events = calloc(MAX_EVENTS, sizeof(event));
while (1) {
// 使用 epoll_wait 等待事件发生
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (i = 0; i < nfds; i++) {
if (events[i].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[i].data.fd, buffer, 1024);
if (valread == 0) {
// 客户端关闭连接
close(events[i].data.fd);
} else if (valread > 0) {
buffer[valread] = '\0';
printf("Message from client: %s\n", buffer);
} else {
perror("read");
close(events[i].data.fd);
}
}
}
}
free(events);
close(epollfd);
close(server_fd);
return 0;
}
3.4 epoll 的优缺点
优点:
- 高效的事件通知:epoll 采用基于事件驱动的方式,当有事件发生时,通过回调机制直接将事件通知给应用程序,避免了线性遍历文件描述符集合,大大提高了效率。
- 支持大量文件描述符:理论上可以处理大量的文件描述符,适合高并发场景。
- 内核与用户空间数据共享:epoll 通过共享内存的方式,避免了每次调用都将数据从用户态拷贝到内核态,减少了开销。
缺点:
- 平台依赖性:epoll 是 Linux 特有的机制,在其他操作系统上无法使用,可移植性不如 select。
- 编程复杂度:相对于 select 和 poll,epoll 的接口较为复杂,特别是在处理边缘触发模式时,需要开发者更加小心地处理数据读写,以避免数据丢失等问题。
四、select、poll、epoll 的差异与选择
4.1 差异
- 文件描述符数量限制:select 通常有文件描述符数量限制(如 1024 个),poll 理论上无限制,epoll 同样支持大量文件描述符,在处理高并发连接时,poll 和 epoll 更具优势。
- 事件通知方式:select 和 poll 采用线性扫描的方式,需要应用程序遍历整个文件描述符集合来确定哪些文件描述符准备好;而 epoll 采用事件驱动,通过回调机制直接通知应用程序发生事件的文件描述符,效率更高。
- 数据拷贝方式:select 和 poll 每次调用都需要将文件描述符集合从用户态拷贝到内核态,当文件描述符数量较多时开销较大;epoll 通过共享内存的方式,减少了这种数据拷贝的开销。
- 工作模式:select 和 poll 只有水平触发模式;epoll 支持水平触发和边缘触发两种模式,边缘触发模式更为高效,但编程复杂度也更高。
4.2 选择
- 简单场景与可移植性:如果应用程序是简单的网络编程,对可移植性要求较高,并且预计连接数较少,select 是一个不错的选择。其简单的接口和跨平台特性可以满足需求。
- 中等规模并发:对于中等规模的并发连接场景,poll 是一个比较合适的选择。它克服了 select 的文件描述符数量限制问题,并且在事件通知方式上比 select 更高效。
- 高并发场景:在高并发场景下,epoll 是最佳选择。它的高效事件通知机制、对大量文件描述符的支持以及减少数据拷贝的特性,使其在处理大量并发连接时性能卓越。但需要注意其平台依赖性和编程复杂度,开发者需要仔细处理事件的触发模式和数据读写逻辑。
在实际的后端开发中,根据应用程序的具体需求和场景,合理选择 select、poll 或 epoll 来处理 TCP 连接,可以显著提高网络编程的性能和效率。同时,对于大规模高并发的网络应用,还需要综合考虑其他因素,如内存管理、负载均衡等,以构建稳定、高效的网络服务。