I/O多路复用
I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。串起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。I/O复用使得程序能同时监听多个文件描述符。
I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行的,如果要实现并发,只能使用多进程或多线程等编程手段。
linux下实现I/O复用的系统调用主要有select、poll、epoll。
一 select 系统调用
select 系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写、和异常事件。
select API select 系统调用的原型如下: #include <sys/select.h> int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout ); 1) nfds 参数指定被监听的文件描述符的总数,它通常被设置为select监听的所有文件描 述符中的最大值加1,因为文件描述符是从0开始计数的。 2) readfds、writefds和exceptfds参数分别指向可读,可写和异常等事件对应的文件 描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。 select 调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这3个参 数是fd_set 结构指针类型。fd_set 结构体仅包含一个整型数组,该数组的每个元素的每 一个位(bit)标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定, 这就限制了select能同时处理的文件描述符的总量。 问题:这个值不能改么 改了会怎么样????、 #define __FD_SETSIZE 1024 3) timeout 参数用来设置select函数的超时时间,它是一个timeval的结构类型的指针, 采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。这个timeout在调用 失败的时候timeout值不确定。 struct timeval { long tv_sec; /*秒数*/ long tv_usec; /*微秒数*/ } 如果给timeout俩成员都传递0,则select将立即返回。如果给timeout传递NULL,则select 将一直阻塞,直到某个文件描述符就绪。 返回值: select 成功时候返回就绪(可读、可写、异常) 文件描述符的总数。如果在超时时间内没 有任何文件描述符就绪,select将返回0。 select 失败时返回-1 并设置errno。 如果在select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。
2 文件描述符就绪条件
socket可读的情况
socket内核接受缓存区中字节数大于或等于其低水位标记SO_RCVLOWAT。此时可以无阻塞的读该socket,并且读操作返回的字节数大于0。
socket 通信的对方关闭连接。此时对该socket的读操作将返回0。
监听socket上有新的连接请求。
socket上有未处理的错误。此时我们可以使用getsocket来读取和清除该错误。
socket 可写的情况
socket内核发送缓冲区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT。此时可以无阻塞的写该socket,并且写操作返回的字节数大于0。
socket的写操作被关闭。对写操作被关闭的socket执行写操作,将触发一个SIGPIPE信号。
socket使用非阻塞connect连接成功或者失败(超时)之后。
socket上有未处理的错误。此时我们可以使用getsocket来读取和清除该错误。
select 能处理的异常情况:socket上接收到的带外数据。
socket 上接到普通数据和带外数据的时候都能使select返回,但socket处于不欧他那个的就绪状态“
前者处于可读状态,后者处于异常状态。对于可读事件,采用普通的recv函数读取数据。对于异常事件,采用带MSG_OOB标志的recv函数读取带外数据。
二 poll系统调用
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
#include <poll.h> int poll( struct pollfd* fds, nfds_t nfds, int timeout ); 1) fds 参数是一个pollfd结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的 可读、可写和异常等事件。 struct pollfd { int fd ; /*文件描述符*/ short events; /*注册的事件*/ short revents; /*实际发生的事件,由内核填充*/ } 其中,fd成员指定文件描述符:events成员告诉poll监听fd上的哪些事件,它是一系列事件 按位或;revents成员则由内核修改,以通知应用程序fd上实际发生了哪些事件。poll事件 支持的类型:
常量
|
说明
|
POLLIN
|
普通或优先级带数据可读
|
POLLRDNORM
|
普通数据可读
|
POLLRDBAND
|
优先级带数据可读(linux不支持)
|
POLLPRI
|
高优先级数据可读
|
POLLOUT
|
普通数据、优先数据可写
|
POLLWRNORM
|
普通数据可写
|
POLLWRBAND
|
优先级带数据可写
|
POLLRDHUP
|
tcp连接被对方关闭,或者对方关闭了写操作,它由GNU引入
|
POLLERR
|
发生错误
|
POLLHUP
|
发生挂起。比如管道的写端被关闭后,该读端描述符上将收到POLLHUP事件
|
POLLNVAL
|
文件描述符没有打开
|
POLLRDHUP 事件是自Linux内核2.6.17 开始,GNU为poll系统调用增加了一个POLLRDHUP事 件,它在socket上接收到对方关闭连接请求之后触发。这为我们区分上述情况提供了一种更简 单的方式。 2) nfds 参数指定被监听事件集合的fds的大小。 typedef unsigned long int nfds_t; 3) timeout 参数指定poll的超时值,单位是毫秒。当timeout为-1时,poll调用将永远阻塞 直到某个事件发生;当timeout为0时,poll调用将立即返回。 poll 系统调用的返回值的含义与select相同。
三 epoll 系列系统调用
A 内核事件表
epoll 是linux特有的I/O复用函数。它在实现和使用上与select、poll有很大差异。
首先epoll是一组函数来完成任务,不是单个函数。
其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次都调用都要重复传入文件描述符集或事件集。但epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用如下epoll_create函数来创建:
#include <sys/epoll.h>
int epoll_create( int size )
size 参数并不起作用,只是给内核一个提示,告诉内核事件表需要多大。该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
下面的函数用来操作epoll的内核事件表:
#include <sys/epoll.h> int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event ) fd 参数是要操作的文件描述符,op参数则指定操作类型。操作类型有如下3种: EPOLL_CTL_ADD 往事件表中注册fd上的事件。 EPOLL_CTL_MOD 修改fd上的注册事件。 EPOLL_CTL_DEL 删除fd上的注册事件。 event 参数指定事件,它是epoll_event 结构指针类型。 struct epoll_event { __unit32_t events; /*epoll事件*/ epoll_data_t data; /*用户数据*/ }; 其中events成员描述事件类型。epoll支持的事件类型和poll基本相同。表示epoll事件类型 的宏是在poll对应的宏上加“E”,比如epoll的数据可读事件是EPOLLIN。但epoll有两个额外 的事件类型--EPOLLET和EPOLLONESHOT。它们对于epoll的高效运作非常关键。data成员用 于存储用户数据,其类型epoll_data_t的定义如下: typedef union epoll_data { void* ptr; int fd; unit32_t u32; unit64_t u64; } epoll_data_t; epoll_data_t 是一个联合体,其4个成员中使用最多的是fd,它指定事件所属的目标文件描 述符。 ptr 成员可用来指定与fd相关的用户数据。但由于epoll_data_t 是一个联合体,我们不能 同时使用其ptr成员和fd成员,如果要将文件描述符和用户数据关联起来,以实现快速的数据 访只能使用其他手段,比如放弃使用epoll_data_t的成员,而在ptr指向的用户数据中包含 fd epoll_ctl成功时返回0,失败则返回-1并设置errno。
epoll_wait 函数
epoll系列系统调用的主要接口是epoll_wait 函数。它在一段超时时间内等待一组文件描述符上的事件,
# include <sys/epoll.h> int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout) 该函数成功时返回就绪的文件描述符的个数,失败时返回-1,并设置errno。 参数: timeout:参数指定epoll_wait的超时值,单位是毫秒。 当timeout为-1时,epoll_wait调用将永远阻塞,直到某个事件发生; 当timeout为0时,epoll_wait调用将立即返回。 maxevents: 参数指定最多监听多少个事件,它必须大于0。 epoll_wait 函数如果检测到事件,就将所有的就绪事件从内核事件表(由epfd参数指定) 复制到它的第二个参数events 指向的数组中。这个数组只用于输出epoll_wait检测到的就 绪事件,这就极大的提高了应用程序索引就绪文件描述符的效率。
LT 和 ET模式
epoll 对文件描述符的操作有2种模式:LT(Level Trigger 水平触发)模式和ET(Edge Trigger 边缘模式)。
LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll,当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET 模式是epoll的高效工作模式。
对于采用LT模式的文件描述符,当epoll_wait 检测到其上有事件发生,并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样当应用程序下一次调用epoll_wait时,epoll_wait 还会再次向应用程序通告此事件,知道该事件被处理。而对于采用ET工作模式的文件描述符,当epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait 调用将不再向应用程序通知这一事件。
ET模式在很大程度上降低了同一个epoll_事件被重复触发的次数,因此效率要比LT模式高。每个使用ET模式的文件描述符都应该是非阻塞的,如果文件描述符是阻塞的,那么读或者写操作都会因为没有后学事件而一直处于阻塞状态。
EPOLLONESHOT 事件
即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该sokcet上又有新的数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理,这一点可以使用epoll的EPOLLONESHOT事件实现。
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多在触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket的。所以注册了EPOLLONESHOT事件的socket 一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket。一个socket在不同的时间可能被不同线程处理,但同一时刻只能有一个线程在为他服务,这就保证了连接的完整性,从而避免了很多可能的静态条件。
三组I/O复用函数的比较
相同点:都能同时监听多个文件描述符,他们将等待由timeout参数指定的超时时间,直到一个或者多个文件描述符上有事情发生时返回,返回值是就绪的文件描述符的数量。返回0 表示没有事件发生。
事件集:
3组函数都通过结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。
select的参数类型fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此select需要提供3个这种类型的参数来分别传入和输出可读、可写和异常等事件。这使得select不能处理更多类型的事件,另外一方面由于内核对fd_set集合的在线修改,应该用程序下次调用select 前不得不重置这3个fd_set集合。
poll的参数类型pollfd把文件描述符和事件都定义其中,任何事件都统一被处理,从而使得变成接口简洁很多。并且内核每次修改的是pollfd结构体的revents成员,而events成员保持不变,因此下次调用poll时应用程序无须重置pollfd类型的事件集参数。由于select和poll调用的都返回整个用户注册的事件集合(其中包括就绪和未就绪),所以应用程序索引就绪的文件描述符的时间复杂度为O(n)。
epoll则采用与select和poll完全不同的方式来管理用户注册的事件,而无须反复从用户空间读入这些事件。epoll_wait 系统调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到O(1)
最大支持的文件描述符数:
select 的最大描述符个数是1024 通过__FD_SETSIZE 来指定,虽然可以修改这个限制,但可能导致不可预期的后果。
poll和epoll 用nfds和maxevents 参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符数目,即65535(cat /proc/sys/fs/file-max)
工作模式:
select 和poll 都只能工作在相对低效的LT模式,而epoll 则可以工作在ET高效模式。并且epoll还支持EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常事件被触发的次数。
具体实现:
select和poll采用的都是轮询方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法复杂度为O(n)。
epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。
因此epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法复杂度为O(1)。但是,当活动链接比较多的时候,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发得过于频繁。所以epoll_wait适用于连接数量多,但活动连接少的情况。
