select、poll、epoll的内存占用与效率对比
一、概述
在后端开发的网络编程中,select
、poll
和epoll
是三种重要的I/O多路复用机制,它们允许程序同时监控多个文件描述符(如套接字)的状态变化,以实现高效的并发处理。在实际应用中,内存占用和效率是评估这些机制的关键指标。了解它们在这两方面的特性,对于优化网络应用程序的性能至关重要。
二、select
2.1 select原理
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);
select
通过轮询方式检查readfds
、writefds
和exceptfds
这三个描述符集合中是否有文件描述符准备好进行相应的I/O操作(读、写或异常)。nfds
是需要检查的文件描述符集合中最大文件描述符加1。timeout
参数用于设置等待的超时时间。
2.2 内存占用
select
的内存占用主要体现在它所使用的文件描述符集合。在Linux系统中,fd_set
是一个固定大小的数组,通常表示为一个unsigned long
类型的数组。其大小在不同系统中可能有所不同,但一般限制了可监控的文件描述符数量,常见的上限为1024。这意味着,当需要监控大量文件描述符时,select
可能无法满足需求,并且其内存占用相对固定,与实际监控的文件描述符数量无关。
2.3 效率
select
的效率较低,主要原因在于:
- 轮询方式:每次调用
select
时,内核需要遍历所有被监控的文件描述符,检查其状态。这使得时间复杂度为O(n),当监控的文件描述符数量较多时,性能会显著下降。 - 内核态与用户态数据拷贝:
select
需要将用户态的文件描述符集合拷贝到内核态,在返回时又将结果从内核态拷贝回用户态,这增加了额外的开销。 - 文件描述符限制:由于
fd_set
的大小限制,无法处理大量的文件描述符,进一步限制了其在高并发场景下的应用。
2.4 代码示例
下面是一个简单的使用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 1024
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 < MAX_CLIENTS; i++) {
val = i;
if (FD_ISSET(val, &tmp_fds)) {
if ((valread = read(val, buffer, 1024)) == 0) {
// 连接关闭
getpeername(val, (struct sockaddr *)&address, (socklen_t *)&addrlen);
printf("Host disconnected, ip %s, port %d \n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
close(val);
FD_CLR(val, &read_fds);
} else {
buffer[valread] = '\0';
send(val, buffer, strlen(buffer), 0);
}
}
}
}
}
return 0;
}
三、poll
3.1 poll原理
poll
函数的原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
通过一个struct pollfd
类型的数组来监控文件描述符。struct pollfd
结构体定义如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 发生的事件 */
};
poll
会遍历这个数组,检查每个文件描述符的状态。与select
不同,poll
没有文件描述符数量的硬限制,通过调整数组大小可以监控更多的文件描述符。
3.2 内存占用
poll
的内存占用主要取决于struct pollfd
数组的大小。由于每个struct pollfd
结构体包含文件描述符、等待事件和发生事件等信息,随着监控的文件描述符数量增加,内存占用也会线性增长。但相对select
来说,它更灵活,不受固定大小的fd_set
限制。
3.3 效率
poll
在效率上相对于select
有一定提升:
- 无文件描述符数量限制:
poll
没有像select
那样的文件描述符数量硬限制,理论上可以监控更多的文件描述符,适用于更高并发的场景。 - 轮询方式改进:虽然仍然是轮询,但
poll
在实现上避免了select
中一些固定大小数组带来的限制,减少了不必要的开销。不过,其时间复杂度仍然是O(n),在高并发场景下,随着文件描述符数量的增加,性能仍会受到影响。 - 内核态与用户态数据拷贝:
poll
同样需要在用户态和内核态之间拷贝数据,这一点与select
类似,也会带来一定的性能开销。
3.4 代码示例
下面是一个使用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 1024
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 nfds = 1;
// 创建套接字
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数组
fds[0].fd = server_fd;
fds[0].events = POLLIN;
while (1) {
int activity = poll(fds, nfds, -1);
if (activity < 0) {
perror("poll error");
break;
} 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数组
fds[nfds].fd = new_socket;
fds[nfds].events = POLLIN;
nfds++;
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 (int i = 1; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
valread = read(fds[i].fd, buffer, 1024);
if (valread == 0) {
// 连接关闭
close(fds[i].fd);
for (int j = i; j < nfds - 1; j++) {
fds[j] = fds[j + 1];
}
nfds--;
i--;
printf("Host disconnected\n");
} else {
buffer[valread] = '\0';
send(fds[i].fd, buffer, strlen(buffer), 0);
}
}
}
}
}
return 0;
}
四、epoll
4.1 epoll原理
epoll
是Linux特有的I/O多路复用机制,它提供了比select
和poll
更高效的方式来处理大量并发连接。epoll
有两种工作模式:水平触发(LT, Level Triggered)和边缘触发(ET, Edge Triggered)。
epoll
使用三个系统调用:epoll_create
、epoll_ctl
和epoll_wait
。
- epoll_create:用于创建一个
epoll
实例,返回一个epoll
文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
- epoll_ctl:用于控制
epoll
实例,添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
其中,op
可以是EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)或EPOLL_CTL_DEL
(删除)。event
结构体定义了要监控的事件。
3. epoll_wait:用于等待事件发生,返回发生事件的文件描述符集合。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
4.2 内存占用
epoll
的内存占用相对较为灵活。epoll_create
创建的epoll
实例占用一定的内核内存,用于存储监控的文件描述符和相关事件信息。随着添加的文件描述符数量增加,内存占用会相应增长,但它采用了更高效的数据结构(红黑树)来管理文件描述符,相比poll
的线性数组结构,在内存管理上更具优势。特别是在监控大量文件描述符时,epoll
的内存占用增长更为平缓。
4.3 效率
epoll
在效率方面具有显著优势:
- 事件驱动机制:
epoll
采用事件驱动的方式,当有文件描述符就绪时,内核会通过回调机制将其加入就绪队列。epoll_wait
只需要检查就绪队列,而不需要像select
和poll
那样轮询所有文件描述符,时间复杂度为O(1),大大提高了在高并发场景下的效率。 - 内核态与用户态数据拷贝优化:
epoll
在内核态和用户态之间的数据拷贝采用了内存映射(mmap)技术,减少了数据拷贝的次数,进一步提高了性能。 - 支持边缘触发模式:边缘触发模式下,只有当文件描述符状态发生变化时才会触发事件,这在一些高性能场景下可以减少不必要的系统调用,提高效率。但边缘触发模式需要开发者更加小心地处理数据,以避免数据丢失。
4.4 代码示例
下面是一个使用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 1024
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 epoll_fd, events_num;
struct epoll_event events[MAX_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实例
epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 将服务器套接字添加到epoll实例
struct epoll_event event;
event.data.fd = server_fd;
event.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
perror("epoll_ctl: server_fd");
exit(EXIT_FAILURE);
}
while (1) {
// 等待事件发生
events_num = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (events_num == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < events_num; i++) {
if (events[i].data.fd == server_fd) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) == -1) {
perror("accept");
continue;
}
// 将新连接的套接字添加到epoll实例
event.data.fd = new_socket;
event.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
perror("epoll_ctl: new_socket");
}
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
} else {
int client_fd = events[i].data.fd;
valread = read(client_fd, buffer, 1024);
if (valread == 0) {
// 连接关闭
if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL) == -1) {
perror("epoll_ctl: del client_fd");
}
close(client_fd);
printf("Host disconnected\n");
} else {
buffer[valread] = '\0';
send(client_fd, buffer, strlen(buffer), 0);
}
}
}
}
return 0;
}
五、内存占用对比总结
- select:内存占用相对固定,受限于
fd_set
的大小,一般为1024,不适用于监控大量文件描述符的场景。 - poll:内存占用随监控的文件描述符数量线性增长,通过调整
struct pollfd
数组大小可以监控更多文件描述符,但在大量文件描述符场景下内存开销较大。 - epoll:内存占用相对灵活,采用红黑树结构管理文件描述符,在监控大量文件描述符时内存增长更为平缓,内存管理效率较高。
六、效率对比总结
- select:效率较低,时间复杂度为O(n),轮询所有文件描述符,且存在内核态与用户态数据拷贝开销,文件描述符数量有限制,不适合高并发场景。
- poll:相对于
select
有一定提升,无文件描述符数量硬限制,但仍然是轮询方式,时间复杂度为O(n),在高并发场景下性能仍会受到影响。 - epoll:效率显著高于
select
和poll
,采用事件驱动机制,时间复杂度为O(1),并且优化了内核态与用户态数据拷贝,支持边缘触发模式,非常适合高并发网络编程场景。
在实际的后端开发网络编程中,应根据具体的应用场景和需求来选择合适的I/O多路复用机制。如果是低并发、文件描述符数量较少的场景,select
或poll
可能已经足够;而对于高并发、大量文件描述符的场景,epoll
无疑是更好的选择。