9 I/O复用

时间:2023-03-09 15:09:52
9 I/O复用

I/O复用使得程序能够同时监听多个文件描述符,适用于以下情况:

  • 客户端同时处理多个socket,比如非阻塞connect
  • 客户端同时处理用户输入和网络连接,比如聊天室程序
  • TCP服务器同时处理监听socket和连接socket,这是IO复用最多的用法
  • 服务器要同时处理TCP请求和UDP请求,比如回射服务器
  • 服务器要同时监听多个端口,或者处理多种事物,比如xinetd服务器

linux下实现IO复用的系统调用主要有select poll epoll

9.1 select系统调用

 #include <sys/socket.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

nfds参数指定被监听的文件描述符总数,痛陈设置为selcet监听的所有文件描述符的最大值加1

readfds writefds exceptfds分别指向可读,可写和异常等事件对应的文件描述符集合,调用select后,通过这三个参数传入自己感兴趣的文件描述符,select返回,内核将修改它们来通知应用程序哪些文件描述符就绪

fd_set通过一些列宏来访问

 #include <sys/socket.h>
FD_ZERO(fd_set *fdset);         //清除fdset所有位
FD_SET(int fd, fd_set *fdset);     //设置fdset的位fd *
FD_CLR(int fd, fd_set *fdset);     //清除fdset的位fd *
int FD_ISSET(int fd, fd_set *fdset); //测试fdset的位fd是否被设置

timeout用来设置select函数的超时时间

 struct timeval {
long tv_sec;
long tv_usec;
};

文件描述符就绪条件:
socket可读:

  • socket内核接收缓冲区中的字节数目大于或等于其低水平标记SO_RCVLOWAT。
  • socket通信的对方关闭连接。此时对该socket的读操作将返回0
  • 监听socket上有新的连接请求。
  • socket上有未处理的错误。此时我们可以使用getsockopt来读取和清除该错误。

socket可写:

  • socket内核发送缓冲区中的可用字节数大于或等于其低水平标记SO_SNDLOWAT。
  • socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。
  • socket使用非阻塞connect连接成功或者失败(超时)之后。
  • socket上有未处理的错误。此时我们可以使用getsockopt来读取和清除该错误。

网络程序中,select能处理的异常情况只有一种:socket上接收到带外数据。

9.2 poll系统调用

poll和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者

 #include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
struct pollfd{
int fd;
short events;//注册的事件
short revents;//实际发生的事件,由内核填充
};

ndfs参数指定被监听事件集合fds的大小

poll事件类型:

主要记住读 POLLIN 写 POLLOUT 关闭连接 PULLREHUP

通常,应用程序需要根据recv调用的返回值来区分socket上接收到的是有效数据还是对方关闭连接的请求,并做相应的处理。不过,自linux内核2.6.17开始,GNU为poll系统调用增加了一个POLLRDHUP事件,它在socket上接收到对方关闭连接的请求之后触发。这为我们区分上述两种情况提供了一种更简单的方式。但是用POLLRDHUP事件时,我们需要在代码最开始处定义_GNU_SOURCE。

9 I/O复用

9 I/O复用

9.3 epoll系列系统调用

epoll把用户关心的文件描述符上的事件放在内核的一个事件表中,无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。

epoll三个函数epoll_create,epoll_ctl,epoll_wait

文件描述符使用epoll_create函数来创建:

 #include <sys/epoll.h>
int epoll_create(int size);

size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大,返回值是文件描述符

epoll的时间注册函数epoll_ctl

 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

op参数指定操作类型:

 EPOLL_CTL_ADD    //向事件表中注册fd上的事件
EPOLL_CTL_MOD //修改fd上注册事件
EPOLL_CTL_DEL //删除fd上注册事件

event参数指定事件

 struct epoll_event{
__uint32_t events;//epoll事件
epoll_data_t data;//用户数据
};

events成员描述事件类型:

EPOLLIN  和  EPOLLOUT表示读写,EPOLLET和EPOLLONESHOT对epoll的高效运作非常关键

 EPOLLIN     //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT //表示对应的文件描述符可以写;
EPOLLPRI //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR //表示对应的文件描述符发生错误;
EPOLLHUP //表示对应的文件描述符被挂断;
EPOLLET //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(LevelTriggered)来说的。
EPOLLONESHOT //只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

data成员用于存储用户数据,其类型epoll_data_t的定义如下:

 typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;

成员中使用最多的是fd, 它指定事件所从属的目标文件描述符

epoll_wait收集在epoll监控的事件中已经发生的事件

 int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

返回值:成功返回就绪的文件描述符个数,失败返回-1

timeout含义与poll相同,单位毫秒。

maxevents参数指定最多监听多少个事件,必须大于0

总结:epoll遍历就绪事件,selelct poll遍历所有事件

epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大的提高了应用程序索引就绪文件描述符的效率

 /*索引poll返回就绪文件描述符*/
int ret = poll(fds,MAX_EVENT_NUMBER,-);
for(int i = ; i < MAX_EVENT_NUMBER; i++)
{
if( fds[i].revents & POLLIN) //判断文件描述符是否就绪(可读)
{
int sockfd = fds[i].fd;
}
} /*索引epoll返回就绪文件描述符*/
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -);
for(int i = ; i<ret; i++)
{
int sockfd = event[i].data.fd; //肯定就绪,直接处理
}

LT和ET模式 

epoll的两种工作模式LT和ET,默认情况是LT模式,ET是高效模式

LT水平触发:相当于一个高效的poll,LT模式下,当epoll_wait检测到有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样应用程序下次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理

ET边缘触发:ET模式下,epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。可见,ET模式降低了同一个epoll事件被重复触发的次数,因此效率比LT高。

使用ET模式每个文件描述符都应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态

nginx默认用ET模式

EPOLLONESHOT事件  (简单来说就是事件设置了这个属性只能被触发一次)

即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另一个线程被唤醒来读取这些新的数据。于是出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用epoll的EPOLLONESHOT事件实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket。

9.4 三组I/O复用函数的比较

9 I/O复用

自己总结:书中这一块写的很好

事件集合方面

这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数获取内核处理结果

  • select参数fd_set没有将文件描述符和事件绑定,select提供了三种事件:可读、可写、异常。这样select不能处理更多类型的事件,而且由于内核对fd_set集合在线修改,每次使用select前需要重置(用FE_SET函数)fd_set集合
  • poll把文件描述符和事件都定义于pollfd结构体中,内核修改的是pollfd结构体中的revents成员,而events成员保持不变,下次调用poll无须想select一样对文件描述符重置
  • epoll 它在内核中维护一个事件表,采用独立的系统调用epoll_ctl来控制添加删除修改事件,这样epoll_wait系统调用events参数仅用来返回就绪的事件

监听的数目

select有最大限制,一般是1024,poll和epoll是系统允许打开的最大文件描述符的数目:65535

实现原理

select和poll是轮询方式,扫描所有注册的文件描述符集合,时间复杂度O(n);epoll_wait采用回调的方式,内核检测到就绪事件就出发回调函数,时间复杂度O(1)

9.5 i/o复用的高级应用一:非阻塞connect / 162
9.6 i/o复用的高级应用二:聊天室程序 / 165
9.7 i/o复用的高级应用三:同时处理tcp和udp服务 / 171
9.8 超级服务xinetd / 175