I/O多路复用(multiplexing)的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。
select,poll,epoll本质上都是同步I/O,因为它们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间
IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者,本文重点讲解网络IO。
对于一个network IO,它会涉及到两个系统对象:
它们经历的两个交互过程是:
I/O多路复用(multiplexing)是网络编程中最常用的模型,最常用的select
、poll
、epoll
都属于这种模型。以select
为例:
看起来它与blocking I/O很相似,两个阶段都阻塞。但它与blocking I/O的一个重要区别就是它可以等待多个数据报就绪(datagram ready),即可以处理多个连接。这里的select
相当于一个"代理",调用select
以后进程会被select
阻塞,这时候在内核空间内select
会监听指定的多个datagram (如socket
连接),如果其中任意一个数据就绪了就返回。此时程序再进行数据读取操作,将数据拷贝至当前进程内。由于select
可以监听多个socket
,可以用它来处理多个连接。
在select
模型中每个socket
一般都设置成non-blocking,虽然等待数据阶段仍然是阻塞状态,但是它是被select
调用阻塞的,而不是直接被I/O阻塞的。select
底层通过轮询机制来判断每个socket
读写是否就绪。
select
也有一些缺点,比如底层轮询机制会增加开销、支持的文件描述符数量过少等。为此,Linux引入了epoll
作为select
的改进版本。
函数原型:
#include
#include
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
函数参数:
maxfdp1
:指定待测试的描述符个数
readset、writeset、exceptset
:指定要让内核监控的可写、可读、异常的描述符,如果对某一个的条件不感兴趣,可以把它设为空指针。struct fd_set
可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset);
:清空集合void FD_SET(int fd, fd_set *fdset);
:将一个给定的文件描述符加入集合之中void FD_CLR(int fd, fd_set *fdset);
:将一个给定的文件描述符从集合中删除int FD_ISSET(int fd, fd_set *fdset);
:检查集合中指定的文件描述符是否可以读写timeout
:告知内核等待所指定描述字中的任何一个就绪可花多少时间
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
};
NULL
timeval
结构,而且其中的定时器值必须为0。返回值:
select
函数监控3类文件描述符,调用select
函数后会阻塞,直到描述符fd准备就绪(有数据可读、可写、异常)或者超时,函数便返回。 当select
函数返回后,可通过遍历描述符集合,找到就绪的描述符。
SOCKADDR_IN addrSrv;
int reuse = 1;
SOCKET sockSrv,connsock;
SOCKADDR_IN addrClient;
pool pool;
int len=sizeof(SOCKADDR);
/*创建TCP*/
sockSrv=socket(AF_INET,SOCK_STREAM,0);
/*地址、端口的绑定*/
addrSrv.sin_addr.S_un.S_addr=htonl(INADDR_ANY);
addrSrv.sin_family=AF_INET;
addrSrv.sin_port=htons(port);
if(bind(sockSrv,(SOCKADDR*)&addrSrv,sizeof(SOCKADDR))<0)
{
fprintf(stderr,"Failed to bind");
return ;
}
if(listen(sockSrv,5)<0)
{
fprintf(stderr,"Failed to listen socket");
return ;
}
setsockopt(sockSrv,SOL_SOCKET,SO_REUSEADDR,(const char*)&reuse,sizeof(reuse));
init_pool(sockSrv,&pool);
while(1)
{
/*通过selete设置为异步模式*/
pool.ready_set=pool.read_set;
pool.nready=select(pool.maxfd+1,&pool.ready_set,NULL,NULL,NULL);
if(FD_ISSET(sockSrv,&pool.ready_set))
{
connsock=accept(sockSrv,(SOCKADDR *)&addrClient,&len);
//loadDeal()/*连接处理*/
add_client(connsock,&pool);//添加到连接池
}
/*检查是否有事件发生*/
check_client(&pool);
}
上面是一个服务器代码的关键部分,设置为异步的模式,然后接受到连接将其添加到连接池中。监听描述符上使用select
,接受客户端的连接请求,在check_client
函数中,遍历连接池中的描述符,检查是否有事件发生。
每次调用select
,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
同时每次调用select
都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set
集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)
select
函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket
,以及调用select
函数的额外操作,效率更差。但是,使用select
以后最大的优势是用户可以在一个线程内同时处理多个socket
的IO请求。用户可以注册多个socket
,然后不断地调用select
读取被激活的socket
,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的#include
/**
* fds:要监视的描述符
* nfds:fds中描述符的总数量
* 返回值:返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
**/
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
// 表示监视的描述符
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch 监视的请求事件 */
short revents; /* returned events witnessed 已发生的事件 */
};
poll
的使用和select
基本类似,相比select
,poll
解决了单个进程能够打开的文件描述符数量有限制这个问题,和select
函数一样,当poll
函数返回后,可以通过遍历描述符集合,找到就绪的描述符。
select
和poll
共同具有的一个很大的缺点就是包含大量fd的数组被整体复制于用户态和内核态地址空间之间,开销会随着fd(文件描述符)数量增多而线性增大。
此外,select
和poll
都需要在返回后,通过遍历文件描述符来获取已经就绪的socket
。同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其性能会线性下降。
目前,epoll
是Linux2.6下最高效的IO复用方式,也是Nginx、Node的IO实现方式。epoll
的出现,解决了select
、poll
的缺点:
epoll_wait
只返回就绪的fd(文件描述符)epoll
使用nmap内存映射技术避免了内存复制的开销epoll
的fd(文件描述符)数量上限是操作系统的最大文件句柄数目,这个数目一般和内存有关,通常远大于1024#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
size
表明内核要监听的描述符数量。调用成功时返回一个epoll
句柄描述符,失败时返回-1注册要监听的事件类型,参数解释如下:
epfd
表示epoll句柄
op
表示fd操作类型,有如下3种
fd
是要监听的描述符
event
表示要监听的事件
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。
epfd
是epoll句柄events
表示从内核得到的就绪事件集合maxevents
告诉内核events的大小timeout
表示等待的超时事件水平触发(Level Triggered,LT):当就绪的fd(文件描述符)未被用户进程处理,下一次查询依旧会返回,这也是select和poll的触发方式
边缘触发(Edge Triggered,ET):无论就绪的fd(文件描述符)是否被处理,下一次不再返回。理论上性能更高,但是实现相当复杂,并且任何意外的丢失事件都会造成请求处理错误。epoll默认使用水平触发,通过相应选项可以使用边缘触发
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 红黑树 |
IO效率 | 每次调用都进行线性遍历,时间复杂度为O(n) |
每次调用都进行线性遍历,时间复杂度为O(n) |
事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList 里面,时间复杂度O(1) |
最大连接数 | 1024(x86)或2048(x64) | 无上限 | 无上限 |
fd拷贝 | 每次调用select ,都需要把fd集合从用户态拷贝到内核态 |
每次调用poll ,都需要把fd集合从用户态拷贝到内核态 |
调用epoll_ctl 时拷贝进内核并保存,之后每次epoll_wait 不拷贝 |
epoll
是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select
和poll
。目前流行的高性能web服务器Nginx正式依赖于epoll
提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。
一文读懂阻塞、非阻塞、同步、异步
IO多路复用的三种机制
select/poll/epoll对比分析
select、poll、epoll之间的区别总结
IO多路复用之select总结
IO多路复用之poll总结
IO多路复用之epoll总结