概述
为什么需要I/O复用?
知乎上的一个回答:
举一个例子,模拟一个进程同时处理30个I/O:
假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:
1.按顺序逐个检查,先是A,然后B,然后C。。。这中间有一个学生卡住,全班都会被耽误;
类比:在回射服务器版本1客户端中,客户端的工作流程是先从标准输入fgets上读取一行数据,紧接着write到服务端,再read服务端返回的数据,然后再fputs到标准输出,这样编程存在一个隐患:不能及时察觉到服务端的终止(服务端终止时会发送一个FIN),例如有这么一个情形:
当客户端阻塞在fgets上面等待输入时,服务端进程意外终止并向客户端发送了一个FIN字段,而此时服务端阻塞在fgets上面,没能及时察觉到FIN的到来,也就是没能意识到服务器已经提前终止了;
由于没能意识到服务器已经提前终止,客户端会fgets读取一行数据,并调用write写到服务器,这会导致RST包的发送等一系列问题;
2.创建30个分身,每个分身检查一个学生的答案是否正确,这类似于为I/O创建一个进程或线程;
类比,在回射服务器版本1服务端中,服务器主(父)进程负责监听,对于每个客户套接字都fork一个子进程进行处理;
3.你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。这就是I/O复用,此时的socket应该采用非阻塞模式。
这样做的优点:整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。
类比,I/O的复用能够避免第一种方法的缺陷,在回射服务器版本3客户端中,客户端采用I/O复用,能够及时察觉到服务器的提前终止(即及时察觉到FIN包的到来)。
为什么I/O复用通常要搭配非阻塞I/O?
为什么 IO 多路复用要搭配非阻塞 IO?
1.阻塞式I/O与非阻塞式I/O是如何处理数据的?
当epoll检测到某个I/O上有事件发生时:
- 如果该I/O是非阻塞的,通常情况下,我们的处理流程是:循环调用read 、 accept或write,直到读完接收缓冲区的数据、accept完所有连接或者写满发送缓冲区(抛出 EAGAIN 异常)。
- 如果该I/O是阻塞式的,通常情况下,我们的处理流程是:每次只调用一次read、accept或write;为什么不能相比非阻塞I/O那样循环调用呢?因为阻塞式I/O是不会返回EAGAIN异常的,而是会阻塞以read为例,如果接收缓冲区为空,那么它会阻塞在那里,一直等到数据的到来,当数据到来之后,read将其读取,接收缓冲区再次为空,于是read又会阻塞,再次等待数据的到来,这样循环下去,是永远无法终止的;因此,阻塞式I/O通常只调用一次read、accept或write。
2.I/O复用搭配非阻塞I/O效率更高
假设一个场景: socket 的读缓冲区中已有足够多的数据,需要调用三次 read(注意:read函数的第二个参数是最大读取字节数) 才能读取完以看到:
- 由于非阻塞I/O能够循环调用read,因此能够一次性将数据读取出来;
- 阻塞I/O每次调用一次read,需要触发三次才能读取完成,复杂性明显要高于非阻塞I/O;
- 因此I/O复用通常会搭配非阻塞式I/O;
3.EPOLL的ET模式“必须”采用非阻塞I/O
“必须”加引号表示:并不是真正的必须,你也可以不这样做,但是效率会很低很低
- 同其他I/O复用一样,EPOLL的LT模式和ET模式采用非阻塞I/O会获得更高的效率,但是ET模式下是“必须”采用非阻塞I/O,原因如下:
- 以EPOLLIN为例:在ET(后面再对ET和LT进行完整的描述)模式下:只有当对端有数据写入时才会触发,这意味着如果你没有一次性将数据读取完,就只能等到下一次对端写入数据时,才能接着读取上一次的数据,这会导致效率的低下,因此,在ET模式下,我们“必须”一次性将缓冲区中的数据读完;
- 为此,在ET模式下,我们必须采用非阻塞I/O,因为只有阻塞式I/O才能将缓冲区的数据一次性读完。
EPOLL
描述符就绪触发条件
这里讨论的是普通情况下的就绪条件,在细节方面(例如描述符是阻塞式还是非阻塞式)可能不那么深入;
1.满足下列四个条件之一时,一个套接字准备好读
- 数据到来:该套接字接收缓冲区中的数据字节数大于等于接收缓冲区的低水位标记。对这样的套接字执行读操作不会阻塞并且会返回一个大于0的值;
- 该连接的读半部关闭(也就是受到了FIN的TCP连接)。对这样的套接字的读操作将不受阻塞,返回0(也就是EOF);
- 该套接字是一个监听套接字,且已完成的连接数不为0;
- 该套接字上有一个套接字错误待处理,对这样的套接字的读操作不会阻塞,并且返回-1。
2.满足下列四个条件之一时,一个套接字准备好写
- 发送缓冲区有空间,该套接字的发送缓冲区中的可用空间字节数大于等于发送缓冲区的低水位标记,如果该套接字为非阻塞方式,那么对这样的套接字执行写操作将会返回一个大于0的值;
- 该套接字的写半部关闭,对这样的套接字执行写操作将会产生SIGPIPE信号;
- 若该套接字是非阻塞式connect的套接字,并且已建立连接或者连接失败;
- 该套接字上有一个套接字错误待处理,对这样的套接字的写操作不会阻塞,并且返回-1。
EPOLL的ET与LT模式的触发条件
1.ET模式下,EPOLLOUT触发条件是:缓冲区从不可写变为可写,具体而言是:
- 缓冲区由满变为不满时;
- 连接时触发一次,因为连接时缓冲区由无到有;
- 调用epoll_ctl重新设置时;
2.ET模式下,EPOLLIN触发条件是:当对端有数据到来时,具体而言是:
- 每当对端的数据到来时触发一次;
3.LT模式下,EPOLLOUT触发条件是:缓冲区未满时都会触发;
4.LT模式下,EPOLLIN触发条件是:缓冲区中有数据时都会触发;’
epoll的常用函数
PS:EPOLLERR和EPOLLHUP是默认注册的;
struct epoll_event
{
__uint32_t events; //epoll事件:EPOLLIN、EPOLLOUT等
epoll_data_t data; //用户数据,通常只是在处理被触发的事件时才用到
}
epoll_data_t
{
void* ptr;
int fd; //最常用,指定事件从属的文件描述符
...
}
int epoll_create(int size);
//成功时返回epoll描述符,失败返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
//成功时返回0,失败返回-1
--epfd:epollfd
--op:指定是增加、删除还是修改
--fd:被监视的对象
--event:监视事件
int epoll_wait(int epfd,struct epoll_event* events, int maxevents, int timeout);
//成功时发生事件的数量,失败返回-1
--epfd:epollfd
--events:保存被触发的事件
--maxevents:约束第二个参数的最大事件数
--timeout:-1表示无穷大,即不会超时
一个例子:
//epoll_create的参数在linux2.6之后就没什么用处了,只需要大于0
int epollfd = epoll_create(5);
int fd1 = ...;//某个文件描述符
epoll_event event1;//文件描述符1的监听事件
event1.events = EPOLLIN|EPOLLET;//ET模式,监听读事件
epoll_ctl(epollfd,EPOLL_CTL_ADD,fd1,&event1);//epollfd监听fd1,监听事件为event1
epoll_event events[1024];//用于返回被触发的事件(最多监听1024个文件描述符)
while(true)//循环监听
{
int nready = epoll_wait(epollfd,events,1024,-1);//开始监听,最后一个参数是超时时间,-1表示无穷大
for(int i =0;i < nready;i++)
{
int sockfd = events[i].data.fd;//获取fd
if(events[i].events&EPOLLIN)//判断是什么事件
{
...
}
else if(events[i].events&EPOLLOUT)
{
...
}
}
}
socket服务端:
//socket()
int listenfd = socket(AF_INET,SOCK_STREAM,0);
//构造服务端套接字的IP加端口
struct sockaddr_in servaddr;
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htonl(1234);
servaddr.sin_addr = htonl(INADDR_ANY);
//bind()
bind(listenfd,(struct sockaddr*)servaddr,sizeof(servaddr));
//listen(),第二个参数是backup
listen(listen,5);
//accept()
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int sockfd = accept(listenfd,(struct sockaddr*)&cliaddr,&clilen);
socket客户端:
//socket()
int sockfd = socket(AF_INET,SOCK_STREAM,0);
//构造好需要连接的服务端的IP+端口信息
struct sockaddr_in servaddr;
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htonl(atoi(argv[2]); //main函数的第二个参数是端口号
inet_pton(AF_INET , argv[1] , &servaddr.sin_addr) //main函数的第一个参数是地址
//connect()
connect(sockfd,(struct sockaddr*)&servaddr,sizeof(servaddr));