1. 概述
I/O 多路复用是一种在单个线程中处理多个I/O操作的机制。
I/O 多路复用通过操作系统提供的特定系统调用, 在一个线程中监听多个I/O事件。当其中任何一个I/O事件就绪(可读、可写或异常)时,线程就会被唤醒,并且可以针对就绪的事件执行相应的操作,而不需要阻塞等待。
1.1. select
1.2. select API
#include <sys/select.h>
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval * timeout);
函数参数:
nfds:委托内核检测的这三个集合中最大的文件描述符 + 1
内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
readfds:文件描述符的集合,内核只检测这个集合中文件描述符对应的读缓冲区
传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据
writefds:文件描述符的集合,内核只检测这个集合中文件描述符对应的写缓冲区
传入传出参数,如果不需要使用这个参数可以指定为 NULL
exceptfds:文件描述符的集合,内核检测集合中文件描述符是否有异常状态
传入传出参数,如果不需要使用这个参数可以指定为 NULL
timeout:超时时长,用来强制解除 select () 函数的阻塞的
NULL:函数检测不到就绪的文件描述符会一直阻塞。
等待固定时长(秒):函数检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,函数返回 0
不等待:函数不会阻塞,直接将该参数对应的结构体初始化为 0 即可。
函数返回值:
大于 0:成功,返回集合中已就绪的文件描述符的总个数
等于 -1:函数调用失败
// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
1.3. select 流程图
1.4. select 示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <arpa/inet.h>
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_sockets[MAX_CLIENTS], max_clients = MAX_CLIENTS;
fd_set readfds;
int max_sd, sd, activity, i, valread;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// Create server socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// Set socket options
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
// Bind server socket to address and port
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(9999);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// Listen
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// Initialize client sockets
for (i = 0; i < max_clients; i++) {
client_sockets[i] = 0;
}
while (1) {
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
max_sd = server_fd;
// Add client sockets to set
for (i = 0; i < max_clients; i++) {
sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &readfds);
}
if (sd > max_sd) {
max_sd = sd;
}
}
// Wait for activity on any of the sockets
activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
perror("select error");
}
// If activity on server socket, it's a new connection
if (FD_ISSET(server_fd, &readfds)) {
if ((sd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// Add new socket to array of client sockets
for (i = 0; i < max_clients; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = sd;
break;
}
}
}
// Check client sockets for activity
for (i = 0; i < max_clients; i++) {
sd = client_sockets[i];
if (FD_ISSET(sd, &readfds)) {
// If client is sending data
if ((valread = read(sd, buffer, BUFFER_SIZE)) == 0) {
// Client disconnected
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);
client_sockets[i] = 0;
} else {
// Echo received data back to client
send(sd, buffer, valread, 0);
}
}
}
}
return 0;
}
poll 实现 IO多路复用的机制本质上和 select 是一样的,那么下面我们主要从 poll 的特点、poll 的 API、以及实现一个简单的 poll 的代码示例来更好的理解并使用它。
2. poll
2.1. poll 的特点
相同点:
1. 内核对文件描述符的集合检测的方式都是线性轮询的方式
2. 文件描述符集合都需要频繁在内核和用户态之间进行拷贝
不同点:
1. 参数接口不同,参数被封装到结构体中作为 poll 的参数。
2. select 检测的文件描述符上限是 1024,poll 没有限制。
2.2. poll API
#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数:
fds: 这是一个 struct pollfd 类型的数组,里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
fd:委托内核检测的文件描述符
events:委托内核检测的 fd 事件(输入、输出、错误),每一个事件有多个取值
revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数 1 数组的元素总个数)
timeout: 指定 poll 函数的阻塞时长
-1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
大于 0:阻塞指定的毫秒(ms)数之后,解除阻塞
函数返回值:
失败: 返回 - 1
成功:返回一个大于 0 的整数,表示检测的集合中已就绪的文件描述符的总个数
2.3. poll 使用示例
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
#include <errno.h>
#define MAXFD 1024
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(2048);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(sockfd);
return -1;
}
listen(sockfd, 10);
struct pollfd events[MAXFD];
events[0].fd = sockfd;
events[0].events = POLLIN;
for (int i = 1; i < MAXFD; ++i) {
events[i].fd = -1;
}
char buff[1024] = {0};
while (1) {
int readynum = poll(events, MAXFD, -1);
if (readynum < 0) {
perror("poll");
break;
}
for (int i = 0; i < MAXFD; ++i) {
if (events[i].revents & POLLIN) {
if (i == 0) {
int clientfd = accept(sockfd, NULL, NULL);
if (clientfd < 0) {
perror("accept");
continue;
}
// 添加新事件
for (int j = 1; j < MAXFD; ++j) {
if (events[j].fd == -1) {
events[j].fd = clientfd;
events[j].events = POLLIN;
break;
}
}
} else {
// 接收数据
int n = recv(events[i].fd, buff, sizeof(buff), 0);
if (n == 0) {
// 连接关闭
printf("exit fd[%d]\n", i);
close(events[i].fd);
events[i].fd = -1;
} else if (n < 0) {
// 出错
perror("recv");
} else {
// 正常接收到数据
printf("fd[%d] recv: %s\n", i, buff);
send(events[i].fd, buff, n, 0);
}
}
}
}
}
close(sockfd);
return 0;
}
3. epoll
3.1. 概念
epoll 全称 eventpoll, 基于事件触发的通知机制。当文件描述符读缓冲区不为空时,触发读事件。当文件描述符写缓冲区可写时,则触发写事件。
3.2. 原理
epoll 的核心数据结构是:一个红黑树和一个链表。
1. 红黑树用于存储文件描述符,方便快速索引到文件描述符。
2. 链表负责存放准备就绪的文件描述符。
3. 当调用 epoll_wait() 函数时,仅仅观察链表中是否有数据即可。
4. 链表的数据通过mmap的传递数据,减少复制的开销。
3.3. 特点
1. 不再通过遍历的方式,查找文件描述符。
2. 只需要传递链表中准备就绪的文件描述符即可。
3. 内核和用户空间通过共享内存传递数据。
3.4. epoll API
struct epoll_event {
uint32_t events; // 表示关注的事件类型,可以是 EPOLLIN、EPOLLOUT、EPOLLERR、EPOLLHUP 等组合。
epoll_data_t data; // 用户数据,可以是文件描述符或指针等。
};
typedef union epoll_data {
void *ptr; // 用于指定指针类型的用户数据
int fd; // 用于指定文件描述符类型的用户数据
uint32_t u32; // 用于指定 32 位无符号整数类型的用户数据
uint64_t u64; // 用于指定 64 位无符号整数类型的用户数据
} epoll_data_t;
事件宏
EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT: 表示对应的文件描述符可以写;
EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR: 表示对应的文件描述符发生错误;
EPOLLHUP: 表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式(默认为水平触发),这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
#include <sys/epoll.h>
int epoll_create(int size);
//作用: 创建一个新的 epoll 实例
//参数:
size 支持的最大文件描述符数目(Linux 2.6.8 之后这个参数已经被忽略, 大于 0 即可)
//返回值:
成功:新的 epoll 文件描述符,用于后续的 epoll 操作
失败:-1
---------------------------------------------------------------------------------
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//作用:控制 epoll 文件描述符上的事件
//参数:
epfd: epoll 文件描述符,由 epoll_create 返回
op: 指定操作类型:
EPOLL_CTL_ADD //添加事件
EPOLL_CTL_MOD //修改事件
EPOLL_CTL_DEL //删除事件
fd: 需要关注的文件描述符
event:指定事件类型和其他控制参数的结构体
//返回值:
成功:0
失败: -1
---------------------------------------------------------------------------------
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//作用:等待 epoll 文件描述符上的事件发生
//参数:
epfd: epoll 文件描述符,由 epoll_create 返回
events: 用于存放发生事件的结构体数组
maxevents: 数组的大小,即最多处理多少个事件
timeout: 等待事件的超时时间,以毫秒为单位。-1 阻塞、0 非阻塞
//返回值:
成功:发生事件的文件描述符数量
失败: -1
---------------------------------------------------------------------------------
3.5. epoll 的工作模式
3.5.1. ET 边缘触发
3.5.2. 工作原理
当被监听的文件描述符上的事件状态发生变化时,epoll 只通知一次,直到下一次状态变化再次通知。
也就是说,只有在文件描述符状态由无数据可读/写 变为有数据可读/写时才通知。
3.5.3. 设置方法
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监听可读事件的边缘触发
// 或
ev.events = EPOLLOUT | EPOLLET; // 监听可写事件的边缘触发
3.5.4. 适用场景
1. 服务器监听 socket 上的连接,当有新连接到来时,epoll_wait 返回一次可读事件,服务器读取数据直至缓冲区为空,再次触发需要新的可读事件。
2. 从文件中读取数据到内存缓冲区,当文件可读时,epoll_wait 返回一次可读事件,读取文件内容直至读完,再次触发需要新的可读事件。
3. 通过管道进行进程间通信,当管道可读时,epoll_wait 返回一次可读事件,读取管道内容直至为空,再次触发需要新的可读事件.
3.6. LT 水平触发模式
3.6.1. 工作原理
当被监听的文件描述符上的事件状态发生变化时,epoll 会一直通知,直到状态恢复为无数据可读/写。
也就是说,只要文件描述符上有数据可读/写,就会一直通知。
3.6.2. 设置方法
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件的水平触发
// 或
ev.events = EPOLLOUT; // 监听可写事件的水平触发
3.6.3. 适用场景
1. 一个多线程程序,每个线程负责处理一个文件的数据。主线程通过 epoll_wait 监听文件描述符上的可读事件,然后将任务派发给空闲的工作线程。线程读取文件数据直至完成,然后继续监听可读事件。
3.7. 代码示例
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#define MAX_EVENTS 10
#define MAX_BUFF_SIZE 1024
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(2048);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(sockfd);
return -1;
}
listen(sockfd, 10);
// 创建 epoll 句柄
int epollfd = epoll_create(MAX_EVENTS);
if (epollfd == -1) {
perror("epoll_create");
close(sockfd);
return -1;
}
// 添加监听套接字 sockfd 到 epoll
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &event) == -1) {
perror("epoll_ctl");
close(epollfd);
close(sockfd);
return -1;
}
struct epoll_event events[MAX_EVENTS];
char buff[MAX_BUFF_SIZE];
while (1) {
// 监听事件
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
break;
}
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == sockfd) {
// 有新连接
int clientfd = accept(sockfd, NULL, NULL);
if (clientfd == -1) {
perror("accept");
continue;
}
// 添加新连接到 epoll
event.events = EPOLLIN;
event.data.fd = clientfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &event) == -1) {
perror("epoll_ctl");
close(clientfd);
continue;
}
} else {
// 已连接套接字可读
int n = recv(events[i].data.fd, buff, sizeof(buff), 0);
if (n <= 0) {
if (n == 0) {
// 连接关闭
printf("exit fd[%d]\n", events[i].data.fd);
} else {
// 出错
perror("recv");
}
// 从 epoll 中移除套接字
epoll_ctl(epollfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
} else {
// 正常接收到数据
printf("fd[%d] recv: %s\n", events[i].data.fd, buff);
send(events[i].data.fd, buff, n, 0);
}
}
}
}
close(epollfd);
close(sockfd);
return 0;
}