概述
- 大部分程序使用的I/O模型(传统的阻塞式I/O模型)都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输。
- 但是,有些场景需要以非阻塞的方式检查文件描述符上是否可进行I/O操作。 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作。对应的解决方法是使用I/O多路复用技术。
- I/O多路复用的目标:就是同时检查多个文件描述符的状态,查看I/O系统调用是否可以非阻塞地执行。文件描述符就绪状态的转化是通过一些I/O事件来触发的,而同时检查多个文件描述符的操作,不会执行实际的I/O操作,它只是告诉进程某个文件描述符已经处于就绪状态了,需要调用其他的系统调用来完成实际的I/O操作。
select接口
- int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval * timeout);
- 函数作用:通过select实现多路复用
- 参数
- nfds:委托内核检测的这三个集合中最大的文件描述符 + 1
- 内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
- 在Window中这个参数是无效的,指定为-1即可
- readfds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的读缓冲区
- 传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据
- writefds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的写缓冲区
- 传入传出参数,如果不需要使用这个参数可以指定为NULL
- exceptfds:文件描述符的集合, 内核检测集合中文件描述符是否有异常状态
- 传入传出参数,如果不需要使用这个参数可以指定为NULL
- timeout:超时时长,用来强制解除select()函数的阻塞的
-
struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */ };
- NULL:函数检测不到就绪的文件描述符会一直阻塞。
- 等待固定时长(秒):函数检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,函数返回0
- 不等待:函数不会阻塞,直接将该参数对应的结构体初始化为0即可。
-
- nfds:委托内核检测的这三个集合中最大的文件描述符 + 1
- void FD_CLR(int fd, fd_set *set);
- 函数作用:将文件描述符fd从set集合中删除(将fd对应的标志位设置为0)
- int FD_ISSET(int fd, fd_set *set);
- 函数作用 :判断文件描述符fd是否在set集合中(读一下fd对应的标志位到底是0还是1)
- void FD_SET(int fd, fd_set *set);
- 函数作用 : 将文件描述符fd添加到set集合中(将fd对应的标志位设置为1)
- void FD_ZERO(fd_set *set);
- 函数作用:将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
epoll接口
-
int epoll_create(int size);
- 函数作用 : 创建一个红黑树模型的实例,用于管理待检测的文件描述符的集合。
- 参数
- size : 创建文件描述符的个数,在Linux内核2.6.8版本以后,这个参数是被忽略的,只需要指定一个大于0的数即可。
- 返回值
- 失败:返回 -1
- 成功:返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll实例了
-
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 函数作用 : 管理红黑树实例上的节点,可以进行添加、删除、修改操作。
- 参数
- epfd:文件描述符,为epoll_create返回的参数
- op : 枚举值,枚举值,控制通过该函数执行什么操作
- EPOLL_CTL_ADD:往epoll模型中添加新的节点
- EPOLL_CTL_MOD:修改epoll模型中已经存在的节点
- EPOLL_CTL_DEL:删除epoll模型中的指定的节点
- fd :文件描述符,即要添加/修改/删除的文件描述符
- event : epoll事件,用来修饰第三个参数对应的文件描述符的,指定检测这个文件描述符的什么事件
-
// 联合体, 多个变量共用同一块内存 typedef union epoll_data { void *ptr; int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可 uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
- events:委托epoll检测的事件
- EPOLLIN:读事件, 接收数据, 检测读缓冲区,如果有数据该文件描述符就绪
- EPOLLOUT:写事件, 发送数据, 检测写缓冲区,如果可写该文件描述符就绪
- EPOLLERR:异常事件
- data:用户数据变量,这是一个联合体类型,通常情况下使用里边的fd成员,用于存储待检测的文件描述符的值,在调用epoll_wait()函数的时候这个值会被传出
-
- 返回值
- 失败:返回-1
- 成功:返回0
-
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- 函数作用 : 检测创建的epoll实例中有没有就绪的文件描述符
- 函数参数
- epfd:epoll_create() 函数的返回值, 通过这个参数找到epoll实例
- events:传出参数, 这是一个结构体数组的地址, 里边存储了已就绪的文件描述符的信息
- maxevents:修饰第二个参数, 结构体数组的容量
- timeout:如果检测的epoll实例中没有已就绪的文件描述符,该函数阻塞的时长, 单位毫秒
- 0:函数不阻塞,不管epoll实例中有没有就绪的文件描述符,函数被调用后都直接返回
- 大于0:如果epoll实例中没有已就绪的文件描述符,函数阻塞对应的毫秒数再返回
- -1:函数一直阻塞,直到epoll实例中有已就绪的文件描述符之后才解除阻塞
- 函数返回值
- 成功:
- 等于0:函数是阻塞被强制解除了, 没有检测到满足条件的文件描述符
- 大于0:检测到的已就绪的文件描述符的总个数
- 失败:返回-1
- 成功:
epoll工作模式
- epoll有两种工作模式:水平模式和边沿模式
- 水平模式(Level Triggered) :
- 默认工作模式。
- 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理,下次调用epoll_wait时会再次响应应用程序并通知此事件。
- 比如客户端发送了1000个字节的数据,服务端只读取到500字节,下次调用epoll_wait时,读事件会继续触发,我们就可以继续读取剩余数据。
- 边沿模式(Edge-Triggered) :
- 当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_event时,不会通知此事件。所以此模式下,需循环接收数据,直到接收完所有数据。
- 比如客户端发送了1000个字节的数据,服务端只读取到500字节,下次调用epoll_wait时,读事件不会被触发。
- 边沿触发模式很大程度上减少了epoll事件被触发的次数,效率比水平触发模式高。边沿触发模式,必须使用非阻塞接口。
select代码
-
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <arpa/inet.h> int main() { // 1. 创建监听的fd int lfd = socket(AF_INET, SOCK_STREAM, 0); // 2. 绑定 struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(9999); addr.sin_addr.s_addr = INADDR_ANY; bind(lfd, (struct sockaddr*)&addr, sizeof(addr)); // 3. 设置监听 listen(lfd, 128); // 将监听的fd的状态检测委托给内核检测 int maxfd = lfd; // 初始化检测的读集合 fd_set rdset; fd_set rdtemp; // 清零 FD_ZERO(&rdset); // 将监听的lfd设置到检测的读集合中 FD_SET(lfd, &rdset); // 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据 // 如果有数据, select解除阻塞返回 // 应该让内核持续检测 while(1) { // 默认阻塞 // rdset 中是委托内核检测的所有的文件描述符 rdtemp = rdset; int num = select(maxfd+1, &rdtemp, NULL, NULL, NULL); // rdset中的数据被内核改写了, 只保留了发生变化的文件描述的标志位上的1, 没变化的改为0 // 只要rdset中的fd对应的标志位为1 -> 缓冲区有数据了 // 判断 // 有没有新连接 if(FD_ISSET(lfd, &rdtemp)) { // 接受连接请求, 这个调用不阻塞 struct sockaddr_in cliaddr; int cliLen = sizeof(cliaddr); int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &cliLen); // 得到了有效的文件描述符 // 通信的文件描述符添加到读集合 // 在下一轮select检测的时候, 就能得到缓冲区的状态 FD_SET(cfd, &rdset); // 重置最大的文件描述符 maxfd = cfd > maxfd ? cfd : maxfd; } // 没有新连接, 通信 for(int i=0; i<maxfd+1; ++i) { // 判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据 if(i != lfd && FD_ISSET(i, &rdtemp)) { // 接收数据 char buf[10] = { 0}; // 一次只能接收10个字节, 客户端一次发送100个字节 // 一次是接收不完的, 文件描述符对应的读缓冲区中还有数据 // 下一轮select检测的时候, 内核还会标记这个文件描述符缓冲区有数据 -> 再读一次 // 循环会一直持续, 知道缓冲区数据被读完位置 int len = read(i, buf, sizeof(buf)); if(len == 0) { printf("客户端关闭了连接...\n"); // 将检测的文件描述符从读集合中删除 FD_CLR(i, &rdset); close(i); } else if(len > 0) { // 收到了数据 // 发送数据 write(i, buf, strlen(buf)+1); } else { // 异常 perror("read"); } } } } return 0; }
epoll代码
水平触发模式
-
#include <stdio.h> #include <ctype.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/epoll.h> // server int main(int argc, const char* argv[]) { // 创建监听的套接字 int lfd = socket(AF_INET, SOCK_STREAM, 0); if(lfd == -1) { perror("socket error"); exit(1); } // 绑定 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(9999); serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地多有的IP // 设置端口复用 int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 绑定端口 int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); if(ret == -1) { perror("bind error"); exit(1); } // 监听 ret = listen(lfd, 64); if(ret == -1) { perror("listen error"); exit(1); } // 现在只有监听的文件描述符 // 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll // 创建一个epoll模型 int epfd = epoll_create(100); if(epfd == -1) { perror("epoll_create"); exit(0); } // 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符 struct epoll_event ev; ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据 ev.data.fd = lfd; ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); if(ret == -1) { perror("epoll_ctl"); exit(0); } struct epoll_event evs[1024]; int size = sizeof(evs) / sizeof(struct epoll_event); // 持续检测 while(1) { // 调用一次, 检测一次 int num = epoll_wait(epfd, evs, size, -1); for(int i=0; i<num; ++i) { // 取出当前的文件描述符 int curfd = evs[i].data.fd; // 判断这个文件描述符是不是用于监听的 if(curfd == lfd) { // 建立新的连接 int cfd = accept(curfd, NULL, NULL); // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了 ev.events = EPOLLIN; // 读缓冲区是否有数据 ev.data.fd = cfd; ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); if(ret == -1) { perror("epoll_ctl-accept"); exit(0); } } else { // 处理通信的文件描述符 // 接收数据 char buf[5] = { 0}; int len = recv(curfd, buf, sizeof(buf), 0); if(len == 0) { printf("客户端已经断开了连接\n"); // 将这个文件描述符从epoll模型中删除 epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); } else if(len > 0) { printf("客户端say: %s\n", buf); send(curfd, buf, len, 0); } else { perror("recv"); exit(0); } } } } return 0; }
边沿触发模式
-
#include <stdio.h> #include <ctype.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <string.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/epoll.h> #include <fcntl.h> #include <errno.h> // server int main(int argc, const char* argv[]) { // 创建监听的套接字 int lfd = socket(AF_INET, SOCK_STREAM, 0); if(lfd == -1) { perror("socket error"); exit(1); } // 绑定 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(9999); serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地多有的IP // 127.0.0.1 // inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr); // 设置端口复用 int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 绑定端口 int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); if(ret == -1) { perror("bind error"); exit(1); } // 监听 ret = listen(lfd, 64); if(ret == -1) { perror("listen error"); exit(1); } // 现在只有监听的文件描述符 // 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll // 创建一个epoll模型 int epfd = epoll_create(100); if(epfd == -1) { perror("epoll_create"); exit(0); } // 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符 struct epoll_event ev; ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据 ev.data.fd = lfd; ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); if(ret == -1) { perror("epoll_ctl"); exit(0); } struct epoll_event evs[1024]; int size = sizeof(evs) / sizeof(struct epoll_event); // 持续检测 while(1) { // 调用一次, 检测一次 int num = epoll_wait(epfd, evs, size, -1); printf("==== num: %d\n", num); for(int i=0; i<num; ++i) { // 取出当前的文件描述符 int curfd = evs[i].data.fd; // 判断这个文件描述符是不是用于监听的 if(curfd == lfd) { // 建立新的连接 int cfd = accept(curfd, NULL, NULL); // 将文件描述符设置为非阻塞 // 得到文件描述符的属性 int flag = fcntl(cfd, F_GETFL); flag |= O_NONBLOCK; fcntl(cfd, F_SETFL, flag); // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了 // 通信的文件描述符检测读缓冲区数据的时候设置为边沿模式 ev.events = EPOLLIN | EPOLLET; // 读缓冲区是否有数据 ev.data.fd = cfd; ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); if(ret == -1) { perror("epoll_ctl-accept"); exit(0); } } else { // 处理通信的文件描述符 // 接收数据 char buf[5] = { 0}; // 循环读数据 while(1) { int len = recv(curfd, buf, sizeof(buf), 0); if(len == 0) { // 非阻塞模式下和阻塞模式是一样的 => 判断对方是否断开连接 printf("客户端断开了连接...\n"); // 将这个文件描述符从epoll模型中删除 epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); break; } else if(len > 0) { // 通信 // 接收的数据打印到终端 write(STDOUT_FILENO, buf, len); // 发送数据 send(curfd, buf, len, 0); } else { // len == -1 if(errno == EAGAIN) { printf("数据读完了...\n"); break; } else { perror("recv"); exit(0); } } } } } } return 0; }