select多路IO转接
多路IO转接,又称IO多路复用。多路复用,意思就是本来一条链路上一次只能传输一个数据流,如果要实现两个源之间多条数据流同时传输,那就得需要多条链路了,但是复用技术可以通过将一条链路划分频率,或者划分传输的时间,使得一条链路上可以同时传输多条数据流。
多路IO转接的字面意思:原本使用socket套接字编程时,是服务器(应用程序)一直在阻塞等待客户端的连接,这样服务器端(应用程序)的压力太大。于是服务器请来了助手,即select、poll、epoll等,这几个函数借助内核来替服务器监视有无客户端的连接请求,当有客户端的连接请求时,再经select、poll、epoll等助手转接给服务器端处理,这样可以有效减轻服务器的压力。
select函数
头文件
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout)
作用:监听多个文件描述符的状态变化
参数:
nfds:
监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readfds:
监控有读数据到达文件描述符集合,传入传出参数 fd_set是一个结构体,可以理解为文件描述符的集合,理解为位图
writefds:
监控写数据到达文件描述符集合,传入传出参数
exceptfds:
监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout:
定时阻塞监控时间,3种情况
1.NULL,永远等下去,即阻塞监听
2.设置timeval,等待固定时间
3.设置timeval里时间均为0,检查描述字后立即返回,轮询,即非阻塞监听
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
返回值:
>0:所有监听集合中(即读、写、异常3个集合),满足对应事件的总数
0:没有满足监听条件的文件描述符
-1:失败,并设置errno
相关集合操作函数
void FD_CLR(int fd, fd_set *set); //把文件描述符集合里指定的fd清0
int FD_ISSET(int fd, fd_set *set); //测试文件描述符集合里指定的fd是否置1
void FD_SET(int fd, fd_set *set); //把指定的文件描述符fd添加到文件描述符集合中
void FD_ZERO(fd_set *set); //把文件描述符集合里所有位清0
select优缺点
优点:
- 跨平台。win、linux、macOS、Unix、类Unix、mips。
缺点:
- select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数
- 解决1024以下客户端时使用select是很合适的,但如果连接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力
poll多路IO转接
poll函数
头文件
#include <poll.h>
函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout)
作用:监听多个文件描述符的状态变化
参数:
fds:
监听的文件描述符数组
nfds:
监听文件描述符数组的实际有效的监听个数
timeout:
>0:超时时长。单位是毫秒
-1:阻塞等待
0:不阻塞
返回值:
成功返回满足对应监听事件的文件描述符总个数,失败返回-1,并设置errno
struct pollfd结构体
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 监控事件中满足条件返回的事件 */
};
events或revents对应的事件的宏:
POLLIN 普通或带外优先数据可读,即POLLRDNORM | POLLRDBAND
POLLRDNORM 数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级可读数据
POLLOUT 普通或带外数据可写
POLLWRNORM 数据可写
POLLWRBAND 优先级带数据可写
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
events常取POLLIN、POLLOUT或POLLERR。
poll优缺点
优点:
- 传入传出事件分离,无需每次调用时,重新设定监听事件
- 可以拓展监听的文件描述符1024上限
缺点:
- 不能跨平台,只能用在Linux系统
- 无法直接定位满足监听事件的文件描述符,编码难度大
补充:
这里再次补充在socket通信中,read函数返回值
>0:实际读到的字节数
0:socket中,表示对端关闭。因此本端也需close.
-1:如果errno==EINTR 被异常中断 需要重启
如果errno==EAGAIN 或 EWOULDBLOCK 以非阻塞方式读数据,但是没有数据 需要再次读
如果errno==ECONNRESET 说明连接被重置 需要close,移除监听队列
如果errno==其他,认为发生错误。
epoll多路IO转接
epoll相关操作函数
头文件
#include <sys/epoll.h>
- epoll_create
int epoll_create(int size)
作用:创建一棵监听红黑树
参数:
size:
创建的红黑树的监听节点数量。(仅供内核参考)
返回值:
成功返回指向新创建的红黑树的根节点的fd,失败返回-1,并设置errno
- epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
作用:操作监听红黑树,控制某个epoll监控的文件描述符上的事件:注册、修改、删除
参数:
epfd:
epoll_create的返回值,即红黑树根节点对应的fd
op:
对该监听红黑树所做的操作,用3个宏来表示
EPOLL_CTL_ADD 注册新的fd到监听红黑树
EPOLL_CTL_MOD 修改已经注册的fd的监听事件
EPOLL_CTL_DEL 将fd从监听红黑树上摘下,取消监听
fd:
要监听的fd
event:
本质是一个结构体,主要描述监听的事件等信息
返回值:
成功返回0,失败返回-1,并设置errno
struct epoll_event结构体
typedef union epoll_data {
void *ptr;
int fd;//对应监听事件的fd
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:表示监听的事件 通常取值如下
EPOLLIN 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT 表示对应的文件描述符可以写
EPOLLERR 表示对应的文件描述符发生错误
- epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
作用:监听多个文件描述符的状态变化
参数:
epfd:
epoll_create的返回值,即红黑树根节点对应的fd
events:
传出参数,是一个数组,表示满足监听条件的fd对应的epoll_event结构体
maxevents:
上述events数组的元素的总个数
timeout:
-1:阻塞监听
0:不阻塞监听
>0:超时时间(毫秒)
返回值:
>0:满足监听条件的事件的总个数,可用作循环上限
0:没有fd满足监听条件
-1:失败,并设置errno
epoll事件模型
EPOLL事件有两种模型:
- ET模式 (Edge Triggered)边缘触发只有数据到来才触发,不管缓存区中是否还有数据。设置epoll事件为ET模式,只需要在调用epoll_ctl函数时在最后一个参数event中指定EPOLLET宏。如
struct epoll_event event;
event.events=EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&events);
- LT模式 (Level Triggered) 水平触发只要有数据都会触发。LT模式时默认的方式。
举个具体的例子,来阐述ET模式与LT模式的区别。假设客户端每隔一段时间发10个字符,第一次发"aaaaabbbbb",第二次发"cccccddddd",以此类推。但是服务器端每次只读取5个字符,那么假设采用ET模式,那么服务器第一次接收到"aaaaa"之后就不会在触发,虽然缓冲区中还有数据。只有当客户端第二次发送"cccccddddd",其才会再次触发,并且把之前存放在缓存中的"bbbbb"读走。而假设采用LT模式,那么服务器第一次接收到"aaaaa"之后,由于缓冲区中还有数据,所以其会紧接着再被触发,读取"bbbbb"。在ET模式下,上述我们举的例子中,客户端第二次发送"cccccddddd"时,服务器端收到的是"bbbbb",如果按照我们的想法,假设当客户端第二次发送"cccccddddd"时,服务器想读的是"ccccc",那么服务器可以在第一次读完之后清理一下缓冲区即可。ET模式就可以应用在一些我们只需到对客户端数据的一部分进行处理的情况。
注意:
epoll的ET模式,是一种高效模式,但是只支持非阻塞模式(注意这个地方的非阻塞是指对套接字非阻塞读写,而不是指非阻塞监听,跟epoll_wait里面的阻塞监听无关)。所以使用epoll的ET模式,应该与fcntl函数搭配使用进行设置:
struct epoll_event event;
event.events=EPOLLIN | EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&events);
int flag=fcntl(cfd,F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd,F_SETFL,flag);
由于epoll的ET模式只能工作在非阻塞模式下,所以其必然需要忙轮询。即假设读一个套接字,每次要读500字节,但是对面只发过来400字节,因为其工作在非阻塞模式下,所以其读完400字节不会阻塞,但需要轮询,等后面的100字节到了及时读取。
另外,如果假设使用epoll的ET模式,我们对一个缓冲区进行读,为了保证触发一次就将缓冲区读空,我们每次至少要调用两次read函数才行,即使第一次read就可能已经读完了。举个例子,假设一个缓冲区有500字节数据到达,epoll的ET模式触发去调用read来读缓冲区,假设read一次性就将500字节数据全部读走,但是我们怎么就知道它一次性就把数据全部读走了呢,所以我们必须要再次调用read,如果read返回-1,并且errno为EAGAIN时,说明缓冲区读空了,这时就可以不用再读了( 如上文所写,在非阻塞情况下,读取对应的描述符,如果缓冲区为空,返回值为-1,errno为 EAGAIN)。而LT模式由于只要我们没有读空,缓冲区内有数据,它就会提醒我们去读,而ET模式它只提醒一次。所以这也是ET模式的一大缺点,有的时候明明一次就可以读空却要调用两次read(增加了一次系统调用)。
epoll优缺点
优点:
- 高效
- 能突破文件描述符1024的限制
缺点:
- 不能跨平台,只能在Linux上使用
epoll反应堆模型
epoll ET模式+非阻塞+void *ptr
原来普通的epoll模型:
epoll_wait监听 --- cfd ----可读 ---- epoll返回 ---- read --- 小写转大写 --- write ---- epoll_wait继续监听
epoll反应堆模型:
epoll_wait监听 --- cfd ---- 可读 ---- epoll返回 ---- read -- cfd从树上摘下 --- 小写转大写 --- 设置监听cfd写事件-- 等待epoll_wait 返回 --- 回写客户端 -- cfd从树上摘下 ----- 设置监听cfd读事件 --- epoll_wait继续监听
也就是说,epoll反应堆模型在读到数据后先把cfd从树上摘下,然后再处理客户端的数据再处理完之后,其并没有立即把数据发送给客户端,而是设置写事件,等epoll_wait监听到可写时再把数据发送给客户端,发送给客户端以后,其再把cfd从树上摘下,并设置cfd读事件。而我们以前的做法是一处理完就立刻回写给客户端。但其实以前的这种做法是有问题的,在以前我们遇到的情况都比较简单,一般服务器处理完数据以后,其写缓冲区一般都没有满,因此其可以直接发过去,但是在真正的网络中,遇到的情况可能会复杂很多,也就是说我们有可能不能立刻就发过去,立刻发过去很可能遇到错误。举两个例子:
例1:服务器接收到客户端数据,刚好此时客户端的接收滑动窗口满,我们假设不进行可写事件设置,并且客户端是有意让自己的接收滑动窗口满的情况(黑客)。那么,当前服务器将随客户端的状态一直阻塞在可写事件,除非你自己在写数据时设置非阻塞+错误处理
例2:客户端在发送完数据后突然由于异常原因停止,这将导致一个FIN发送至服务器,如果服务器不设置可写事件监听,那么在接收数据后写入数据会引发异常SIGPIPE,最终服务器进程终止。
另外,为什么epoll反应堆模型要搭配非阻塞IO,因为在服务器开发中,一般不会直接用采用类似read()函数这一类系统调用(只有内核缓冲区),会使用封装好的一些库函数(有内核缓冲区+用户缓冲区)或者自己封装的函数。
例如:使用封装好的readn()函数,设置读取200B返回,假设数据到来100B,可读事件触发,而程序要使用readn()读200B,那么此时如果是阻塞式的,将在此处形成死锁
流程是:100B ⇒ 触发可读事件 ⇒ readn()调用 ⇒ readn()都不够200B,阻塞 ⇒ cfd又到来200B ⇒ 此时程序在readn()处暂停,没有机会调用epoll_wait() ⇒ 完成死锁
也就是说,readn在没读到足够数据就会阻塞当前进程,而readn要想再次读到数据,就必须要epoll_wait监测到文件描述符状态变化并返回,但是由于readn被阻塞了,因而epoll_wait根本没机会调用,epoll_wait也在等待readn读结束,这就形成了相互等待的死锁局面,所以epoll一般最好搭配非阻塞IO。