select、poll、epoll的编程接口与使用方法详解
一、select 编程接口与使用方法
1.1 select 函数原型
select
函数是 Unix 系统中用于多路复用 I/O 的系统调用,其函数原型如下:
#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。这是为了遍历所有可能的文件描述符,因为
fd_set
是一个位图表示,需要知道最大的位位置来进行有效的遍历。 - readfds:指向
fd_set
结构的指针,用于检查可读性的文件描述符集合。 - writefds:指向
fd_set
结构的指针,用于检查可写性的文件描述符集合。 - exceptfds:指向
fd_set
结构的指针,用于检查异常条件的文件描述符集合。 - timeout:指向
struct timeval
结构的指针,用于设置select
等待的最长时间。如果设为NULL
,则select
会一直阻塞,直到有事件发生;如果设为{0, 0}
,则select
会立即返回,不等待任何事件。
1.2 fd_set 数据结构操作
fd_set
是一个文件描述符集合的数据结构,在使用select
时,需要对其进行相应的操作。相关的操作宏定义如下:
- FD_ZERO(fd_set *set):清空
set
集合。 - FD_SET(int fd, fd_set *set):将文件描述符
fd
添加到set
集合中。 - FD_CLR(int fd, fd_set *set):将文件描述符
fd
从set
集合中移除。 - FD_ISSET(int fd, fd_set *set):检查文件描述符
fd
是否在set
集合中。
1.3 代码示例
下面是一个简单的使用select
实现的服务器端代码示例,用于监听客户端连接并处理客户端发送的数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.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};
// 创建套接字
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_set read_fds;
fd_set tmp_fds;
FD_ZERO(&read_fds);
FD_ZERO(&tmp_fds);
FD_SET(server_fd, &read_fds);
int activity, new_socket_fd;
while (1) {
tmp_fds = read_fds;
activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);
if (activity < 0) {
perror("select error");
} else if (activity > 0) {
if (FD_ISSET(server_fd, &tmp_fds)) {
if ((new_socket_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
perror("accept");
continue;
}
FD_SET(new_socket_fd, &read_fds);
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket_fd, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
}
for (int 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';
send(i, buffer, strlen(buffer), 0);
}
}
}
}
}
}
return 0;
}
在上述代码中,首先创建了一个 TCP 套接字并进行绑定和监听。然后使用select
函数来监听服务器套接字和已连接客户端套接字的可读事件。当有新连接到达时,接受连接并将新的套接字添加到read_fds
集合中。当客户端有数据可读时,读取数据并回显给客户端。如果客户端关闭连接,相应的套接字从read_fds
集合中移除。
1.4 select 的局限性
- 文件描述符数量限制:在传统的实现中,
fd_set
的大小是固定的,这限制了可监听的文件描述符数量。虽然可以通过修改系统参数来增加这个限制,但这不是一个优雅的解决方案。 - 线性扫描:
select
在返回后需要线性扫描所有的文件描述符来确定哪些发生了事件,这在文件描述符数量较多时效率较低。 - 数据拷贝:每次调用
select
时,需要将文件描述符集合从用户空间拷贝到内核空间,返回时又要从内核空间拷贝回用户空间,这增加了系统开销。
二、poll 编程接口与使用方法
2.1 poll 函数原型
poll
函数也是 Unix 系统中用于多路复用 I/O 的函数,其函数原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds:一个指向
struct pollfd
数组的指针,该数组包含了要监视的文件描述符及其相关事件。 - nfds:
fds
数组中的元素个数。 - timeout:等待事件发生的最长时间,单位为毫秒。如果为
-1
,则poll
会一直阻塞,直到有事件发生;如果为0
,则poll
会立即返回,不等待任何事件。
2.2 pollfd 数据结构
struct pollfd
数据结构定义如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 发生的事件 */
};
- fd:要监视的文件描述符。如果
fd
为-1
,则该pollfd
结构将被忽略。 - events:指定要监视的事件,常用的事件有
POLLIN
(可读)、POLLOUT
(可写)、POLLERR
(错误)等。 - revents:返回实际发生的事件,其值是
events
中指定事件的子集。
2.3 代码示例
下面是一个使用poll
实现的服务器端代码示例,功能与上述select
示例类似:
#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 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};
// 创建套接字
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);
}
struct pollfd fds[MAX_CLIENTS + 1];
fds[0].fd = server_fd;
fds[0].events = POLLIN;
int nfds = 1;
for (int i = 1; i <= MAX_CLIENTS; i++) {
fds[i].fd = -1;
}
int activity;
while (1) {
activity = poll(fds, nfds, -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");
continue;
}
for (int i = 1; i <= MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = new_socket;
fds[i].events = POLLIN;
nfds++;
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 (int i = 1; i < nfds; i++) {
if (fds[i].revents & (POLLIN | POLLERR)) {
valread = read(fds[i].fd, buffer, 1024);
if (valread == 0) {
struct sockaddr_in client_addr;
socklen_t client_addrlen = sizeof(client_addr);
getpeername(fds[i].fd, (struct sockaddr *)&client_addr, &client_addrlen);
printf("Host disconnected, ip %s, port %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(fds[i].fd);
fds[i].fd = -1;
nfds--;
} else {
buffer[valread] = '\0';
send(fds[i].fd, buffer, strlen(buffer), 0);
}
}
}
}
}
return 0;
}
在这段代码中,首先创建并设置服务器套接字。然后使用poll
来监听服务器套接字和已连接客户端套接字的事件。当有新连接到达时,将新的套接字添加到fds
数组中并设置要监听的事件。当客户端有数据可读或发生错误时,进行相应的处理。
2.4 poll 的优势与不足
- 优势:
- 没有文件描述符数量限制:
poll
通过nfds
参数指定要监视的文件描述符数组的大小,理论上没有像select
那样固定的文件描述符数量限制。 - 更灵活的事件定义:
poll
使用pollfd
结构中的events
和revents
来定义和返回事件,比select
中使用fd_set
更为灵活。
- 没有文件描述符数量限制:
- 不足:
- 线性扫描:与
select
类似,poll
在返回后仍需要线性扫描所有的pollfd
结构来确定哪些文件描述符发生了事件,在文件描述符数量较多时效率不高。 - 数据拷贝:每次调用
poll
时,同样需要将pollfd
数组从用户空间拷贝到内核空间,返回时又要从内核空间拷贝回用户空间,存在一定的系统开销。
- 线性扫描:与
三、epoll 编程接口与使用方法
3.1 epoll 概述
epoll
是 Linux 内核为处理大规模并发连接而设计的高性能 I/O 多路复用技术。它采用事件驱动的方式,克服了select
和poll
在处理大量文件描述符时的性能瓶颈。
3.2 epoll 相关函数
- epoll_create(int size):创建一个
epoll
实例,size
参数在内核 2.6.8 之后已被忽略,但仍需提供一个大于 0 的值。该函数返回一个epoll
文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
- epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):用于控制
epoll
实例,向其中添加、修改或删除要监视的文件描述符及其相关事件。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epfd:
epoll_create
返回的epoll
文件描述符。 - op:操作类型,常用的有
EPOLL_CTL_ADD
(添加文件描述符)、EPOLL_CTL_MOD
(修改文件描述符的事件)、EPOLL_CTL_DEL
(删除文件描述符)。 - fd:要操作的文件描述符。
- event:指向
struct epoll_event
结构的指针,用于指定要监视的事件。 - epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):等待
epoll
实例上的事件发生。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- epfd:
epoll_create
返回的epoll
文件描述符。 - events:一个
struct epoll_event
类型的数组,用于存储发生的事件。 - maxevents:
events
数组的大小。 - timeout:等待事件发生的最长时间,单位为毫秒。如果为
-1
,则epoll_wait
会一直阻塞,直到有事件发生;如果为0
,则epoll_wait
会立即返回,不等待任何事件。
3.3 epoll_event 数据结构
struct 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
(错误)等,还可以使用一些标志位如EPOLLET
(边缘触发模式)。 - data:可以是一个指向用户自定义数据的指针,也可以是一个文件描述符等。
3.4 代码示例
下面是一个使用epoll
实现的服务器端代码示例:
#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_CLIENTS 100
#define EVENTS_MAX 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};
// 创建套接字
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);
}
int epoll_fd = epoll_create(1);
if (epoll_fd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
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);
}
struct epoll_event events[EVENTS_MAX];
while (1) {
int n = epoll_wait(epoll_fd, events, EVENTS_MAX, -1);
if (n == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) == -1) {
perror("accept");
continue;
}
event.data.fd = new_socket;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) == -1) {
perror("epoll_ctl: new_socket");
close(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) {
struct sockaddr_in client_addr;
socklen_t client_addrlen = sizeof(client_addr);
getpeername(client_fd, (struct sockaddr *)&client_addr, &client_addrlen);
printf("Host disconnected, ip %s, port %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(client_fd);
if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL) == -1) {
perror("epoll_ctl: del client_fd");
}
} else {
buffer[valread] = '\0';
send(client_fd, buffer, strlen(buffer), 0);
}
}
}
}
close(epoll_fd);
return 0;
}
在上述代码中,首先创建服务器套接字并进行绑定和监听。然后使用epoll_create
创建一个epoll
实例,并通过epoll_ctl
将服务器套接字添加到epoll
实例中,监听其可读事件。在主循环中,使用epoll_wait
等待事件发生。当有新连接到达时,接受连接并将新的套接字添加到epoll
实例中,设置为边缘触发模式。当客户端有数据可读时,读取数据并回显给客户端。如果客户端关闭连接,则从epoll
实例中删除相应的套接字。
3.5 epoll 的工作模式
- 水平触发(LT, Level Triggered):这是
epoll
的默认工作模式。在这种模式下,当一个文件描述符上有未处理的事件时,epoll_wait
会一直返回该文件描述符,直到该事件被处理。 - 边缘触发(ET, Edge Triggered):在边缘触发模式下,
epoll_wait
只有在文件描述符状态发生变化时才会返回。这意味着应用程序需要在一次事件通知中尽可能多地处理数据,否则可能会丢失后续的数据。边缘触发模式通常能提供更高的性能,但编程复杂度也相对较高。
3.6 epoll 的优势
- 事件驱动:
epoll
采用事件驱动的方式,只有在文件描述符上有事件发生时才会通知应用程序,避免了线性扫描所有文件描述符的开销,适用于处理大量并发连接。 - 内核缓存:
epoll
在内核中维护一个事件表,避免了每次调用时将文件描述符集合从用户空间拷贝到内核空间的开销。 - 支持边缘触发模式:边缘触发模式可以减少不必要的系统调用,提高 I/O 效率,特别适合高并发、低延迟的应用场景。
通过对select
、poll
和epoll
的编程接口与使用方法的详细介绍,我们可以根据不同的应用场景选择合适的 I/O 多路复用技术,以提高程序的性能和可扩展性。在处理少量连接时,select
和poll
可能已经足够;而在处理大规模并发连接时,epoll
则是更好的选择。