Linux网络IO模型浅析(三)多路复用IO
多路复用IO(IO multiplexing)
提到IO multiplexing,可能大部分人会感觉到陌生,但实际上我们平时用的select,epoll等就是多路复用IO的实例。
因为IO多路复用本质上是内核一旦发现某进程指定的一个或多个IO产生事件,就通知该进程,所以有些时候我们也称这种IO方式为事件驱动IO(event driven IO)。
多路复用的意义
应用程序如果同时处理多路输入输出流:
- 如果采用阻塞模式,将得不到预期的目的
- 如果采用非阻塞模式,对多个输入进行轮询,又太浪费时间
- 如果设置多个进程或进程,分别处理一条数据通路的数据,将产生同步互斥问题,让程序变得复杂。
- 与多进程多线程相比,IO多路复用技术的最大优势是系统开销小(系统不必创建进程/线程,也不必维护这些进程线程,从而大大减少系统开销)
多路复用基本思想:
1. 构建一张有关文件描述符的表,然后调用函数将要监控的文件fd加入到表中
2. 当文件描述符的表中一个或多个已经就绪准备好了才返回
3. 函数返回时候告诉进程哪个文件描述符就绪,我们就可以对其直接进行IO操作了
IO多路复用基本流程如下:
上图以select为例,调用select后,线程将被阻塞,之后kernel监听注册到文件描述符列表里的socket,当一个或多个socket产生了事件(数据可读),select则返回。此时用户空间再调用read函数将数据从kernel拷贝到用户空间。
在响应一次网络请求的过程中,多路复用模型会经历两次阻塞:
- select函数调用后的阻塞
- read函数调用后的阻塞
对比阻塞IO的过程来看,甚至还多了一次阻塞,所以我们可以大胆推测,在连接数不多的情况下,使用阻塞IO + 多线程去处理请求,整体表现很可能比多路复用要更好 。所以多路复用的优势从来不是单连接时可以处理的更快,而是单线程可以处理多个连接。
select相关介绍
select优劣分析
优点:
- 跨平台性强:select在多种操作系统上都有实现,支持跨平台编程。
- 简单易用:select接口简单易用,使用方便,占用的CPU资源也较少。
- 支持多种I/O事件:select不仅能够监控读写事件,还可以监控异常事件。这使得select可以应对更加复杂的I/O场景。
劣势:
- 效率较低:当文件描述符数量很大时,每次调用select都需要将所有文件描述符从用户空间复制到内核空间并轮询状态,这会导致一定的开销,阻塞时间随着监听的fd个数增加而增大。
- 可扩展性受限:select有一个最大文件描述符数量的限制,如果需要监控的文件描述符数量超出限制,则需要重新编写程序。
简而言之,当需求的响应模型比较简单,服务的网络请求较少完全可以使用select模型,但是当需要服务一个10K级的海量接入,selcet显然无力迎战,更不要提100K,1000K的接入了。所以当我们需求需要一个高效的网络服务模型去处理海量接入时,我们可以采用epoll(Linux)或kqueue(FreeBSD和Mac OS X);
select接口介绍
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
//该函数准许进程指示内核等待多个事件中任何一个发送,并只在一个或多个事件发生后唤醒
int select(int maxfd,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
/*参数:
int maxfd 指定待测试的最大文件描述符+1(因为fd从0开始的)
fd_set *readfds 指定我们要内核测试读文件描述符的集合
fd_set *writefds 指定我们要内核测试写文件描述符的集合
fd_set *exceptfds指定我们要内核测试异常文件描述符的集合
struct timeval *timeout //超时设置 1、填NULL:一直阻塞,直到有文件描述符就绪或出错
2、填充时间结构体timeval
时间值为0:非阻塞,仅仅检测文件描述符集合的状态,直接返回
设置的时间不为0:在设置的时间内,如果没有产生事件,就会超时返回,如果产生了事件正常返回。
返回值:
出错:返回-1
没有产生事件,时间却到了,返回0
有事件产生(一个或多个),返回>0
struct timeval{
long tv_sec;
long tv_usec;
}
//操作集合函数
返回值:
-1出错
>0表示有多个事件产生*/
void FD_CLR(int fd, fd_set *set); //将文件描述符fd从set集合中删除(从集合中删除)
int FD_ISSET(int fd, fd_set *set);//判断fd是否在set集合中
void FD_SET(int fd, fd_set *set); //把fd设置进set集合中(加入集合)
void FD_ZERO(fd_set *set); //清空集合
select编程demo
#include<stdio.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAXLNE 1024
int main(int argc, char **argv) {
int listenfd, connfd, n;
struct sockaddr_in servaddr = {0};
char buff[MAXLNE] = {0};
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
if (listen(listenfd, 10) == -1) {
printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
fd_set rfds, rset, wfds, wset; ///< set 用来注册 fds用来操作
FD_ZERO(&rfds);
FD_ZERO(&wfds);
FD_SET(listenfd, &rfds);
int max_fd = listenfd;
while (1) {
rset = rfds;
wset = wfds;
int nready = select(max_fd + 1, &rset, &wset, NULL, NULL); ///< 阻塞监听读写事件,直至返回
if (FD_ISSET(listenfd, &rset)) { ///< 判断监听的套接字是否在可读集合中
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
FD_SET(connfd, &rfds);
if (connfd > max_fd) max_fd = connfd;
if (--nready == 0) continue;
}
int i = 0;
for (i = listenfd + 1 ; i <= max_fd ; i ++) { ///< 跳过监听的套接字进行读写
if (FD_ISSET(i, &rset)) { //
n = recv(i, buff, MAXLNE, 0);
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
FD_CLR(i, &rfds); ///< 之后本fd不可读
FD_SET(i, &wfds);
}
else if (n == 0) { //
FD_CLR(i, &rfds);
//printf("disconnect\n");
close(i);
}
if (--nready == 0) break;
}
else if (FD_ISSET(i, &wset)) {
buff[n] = 10; ///< 换行符
buff[n + 1] = 0; ///< 结束符
send(i, buff, n + 1, 0);
FD_CLR(i, &wfds); ///< 之后本fd不可写
FD_SET(i, &rfds); ///< 之后本fd可读
}
}
}
}
上述是一个简单的但是勉强能展现效果的使用实例,可用来简单展示select的使用方法。
为了节约时间笔者使用了网络助手进行数据传输测试:
epoll相关介绍
epoll的优劣分析
优势:
- 高效:epoll采用了事件驱动的方式,只有在IO事件发生时才会被唤醒,相比于select和poll等多路复用机制,可以避免不必要的系统调用。
- 可伸缩性好:epoll支持一个进程监听多个文件描述符,同时也支持多个进程监听同一个文件描述符,能够满足高并发、高连接数的需求。
- 没有文件描述符数量限制:epoll没有最大连接数的限制,它所支持的连接数与内存大小有关,能够处理成千上万个并发连接。
- 支持边沿触发和水平触发:epoll可以通过设置事件类型为边沿触发或水平触发来适应不同场景的需要(实际编程场景中,二者差别不大)。
劣势:
- 对代码质量要求高:epoll使用起来相对复杂,对代码质量和程序设计要求较高。
- 兼容性差:epoll只能在Linux系统下使用,不具备跨平台的优势。
epoll接口介绍
epoll主要有以下几个接口:
int epoll_create(int size);
//参数size指定了epoll实例中能够注册的文件描述符最大数量,返回一个epoll实例的句柄。实际上传一个正数即可
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//向epoll实例中添加、修改或删除一个文件描述符,op参数值为EPOLL_CTL_ADD、EPOLL_CTL_MOD或EPOLL_CTL_DEL,分别表示添加、修改和删除操作。event参数是一个epoll_event结构体指针,用来指定事件类型和对应的文件描述符。
//int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待文件描述符上的事件发生并返回就绪的文件描述符信息,maxevents参数指定最多返回的事件数量,timeout参数指定等待时间。调用成功返回返回就绪的文件描述符数量,如果超时则返回0,如果出错则返回-1。
struct epoll_event;
//epoll_event结构体用于指定事件类型和对应的文件描述符,包含以下几个字段:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* epoll 事件 */
epoll_data_t data; /*用户数据 */
};
//其中events字段指定关注的事件类型,可以是EPOLLIN、EPOLLOUT、EPOLLERR、EPOLLHUP和EPOLLET等;data字段用于存储用户自定义的数据,可以是指针、文件描述符或者整数等。
epoll编程实例
#include<stdio.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAXLNE 4096
#define POLL_SIZE 1024
int main(int argc, char **argv)
{
int listenfd, connfd, n;
struct sockaddr_in servaddr;
char buff[MAXLNE];
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
int reuse = 1;
setsockopt(listenfd ,SOL_SOCKET ,SO_REUSEADDR, &reuse, sizeof(reuse));
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
if (listen(listenfd, 10) == -1) {
printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
int epfd = epoll_create(1); ///< 创建epoll句柄
struct epoll_event events[POLL_SIZE] = {0};
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
while (1) {
int nready = epoll_wait(epfd, events, POLL_SIZE, 5);
if (nready == -1) {
continue;
}
int i = 0;
for (i = 0 ; i < nready ; i ++) {
int clientfd = events[i].data.fd;
if (clientfd == listenfd) {
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
printf("accept\n");
ev.events = EPOLLIN;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}
else if (events[i].events & EPOLLIN) {
n = recv(clientfd, buff, MAXLNE, 0);
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
buff[n] = 10;
buff[n + 1] = 0;
send(clientfd, buff, n + 1, 0);
}
else if (n == 0) {
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
close(clientfd);
}
}
}
}
close(listenfd);
return 0;
}
同样也是一个简单的epoll编程实例,只实现了简单的回传功能,仅作参考。
经测试效果如下:
写到最后
有上述可知,IO多路复用机制选型并不存在绝对的优劣,不同的应用场景可以选用合适的多路复用模型。同时有很多事件驱动库将众多模型集成,同时简化接口。常见的事件驱动库有libevent 库,还有作为libevent 替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。
本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以去零声官网查看详细的服务: