linux高性能服务器编程学习笔记八:I/O复用

1、I/O复用使得程序能够监听多个文件描述符,网络程序一般在下列情况使用I/O复用技术

   1)客户端要同时处理多个socket。(非阻塞connect技术)

   2)客户端程序既要处理用户输入又要进行网络连接。(聊天室技术)

   3)TCP服务器要同时处理监听socket和连接socket。(I/O复用使用最多的场合)

   4)服务器同时处理TCP请求和UDP请求。(回射服务器)

   5)服务器要同时监听多个端口,或者处理多种服务。(xinetd服务器)

2、虽然I/O复用使得程序能监听多个文件描述符,但是假如有多个文件描述符同时就绪,假如不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符(I/O复用本身是阻塞的,串行工作)。如果要实现并发,需要使用多进程或多线程手段。

3、selectAPI

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)timeval结构类型指针timeout,内核将修改它以告诉应用程序select等待了多久。假如timeout变量的tv_sec和tv_usec都设置为0,则表示select立即返回,假如timeout设置为NULL,则select一直阻塞直到某个文件描述符就绪。如果在超时时间还没有文件描述符就绪,select返回0,selcet成功调用则返回就绪文件描述符的总数。失败或接收到信号则返回-1,并设置errno。

4、由于位操作过于烦琐,一般使用下面的一系列宏来访问fd_set结构体中的位

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是否被设置

5、文件描述符就绪条件

(1)在网络编程中,下列情况下socket可读:

   1)socket内核接收缓冲区的字节数大小大于或等于其低水位SO_RCVLOWAT。此时可以无阻塞的读该socket。

   2)socket通信的对方关闭连接,此时读该socket立即返回0。

   3)监听socket上有新的连接请求

   4)socket上有未处理的错误。此时用setsockopt来读取和清除该错误

(2)在下列情况中,socket可写

   1)socket内核发送缓冲区的可用字节数大于或等于其低水位SO_SNDLOWAT。此时可以无阻塞的写该socket

   2)socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE。

   3)socket使用一个非阻塞的connect连接成功或失败(超时)之后

   4)socket上有未处理的错误,此时使用setsockopt接收到带外数据。

6、epoll系统调用

(1)epoll是Linux特有的I/O复用函数。它在实现和使用上与select、poll有很大的差异。epoll使用一组函数来完成任务,而不是单个函数。其次,epoll将用户关心的文件描述符上的事件放在内核里的一个事件表中,而无需像select和poll那样每次调用都要重复传入文件描述符集和事件集。但epoll需要一个额外的文件描述符来标识内核中的这个事件表。这个文件描述符通过epoll_create函数来创建。

int epoll_create(int size)

size函数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其它所有epoll系统调用的第一个函数,以指定要访问的内核事件表。

(2)epoll_ctl用来控制epoll的内核事件表:

int epoll_ctl(intepfd,int op, int fd, int epoll_event* event)

fd是要操作的文件描述符,op参数则指定操作类型。操作类型有以下三种:

   1)EPOLL_CTL_ADD,往事件表中注册fd上的事件

   2)EPOLL_CTL_MOD,修改fd上的注册事件

   3)EPOLL_CTL_DEL,删除fd上的注册事件

event参数指定事件,它是epoll_event结构指针类型,定义如下

struct epoll_event

{

   _unit32_t events;  //epoll事件

   Epoll_data_t data; //用户数据

}

其中events成员描述事件类型,支持的事件类型和poll事件类型基本相同。但epoll有两个额外的事件类型—EPOLLET和EPOLLONESHOT,这两个事件对于epoll的高效运作至关重要。Data成员用于存储用户数据,其类型与epoll_data_t的定义如下:

Typedef unionepoll_data

{

   void* ptr;

   int fd;

   uint32_t u32;

   uint64_t u64;

}epoll_data_t;

epoll_data_t是一个联合体,其4个成员中使用的最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据。但由于epoll_wait_t是一个联合体,因此不能同时使用ptr和fd成员,因此如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能放弃使用fd成员,而在ptr指向的用户数据中包含fd。epoll_ctl成功时返回0,失败返回-1并设置errno。

(3)epoll_wait函数

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

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

linux高性能服务器编程学习笔记八:I/O复用

(4)LT和ET模式:LT(水平触发)模式是默认的工作模式,相当于效率较高的poll。当往epoll内核事件注册一个文件描述符上的EPOLLET事件时,epoll将以ET(边缘触发)模式来操作该文件描述符。ET是epoll的高效工作模式。两者的区别是:处于LT模式下的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件,在下一次调用epoll_wait的时候还会向应用程序通告此事件。处于ET模式下的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立刻处理此事件,因为后续的epoll_wait不会再通知应用程序此事件。因此,ET模式很大程度上降低了同一个epoll事件被重复触发的次数,效率比LT模式高。

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

(6)EPOLLONESHOT事件:即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题,比如一个线程(或进程)在读取完某个socket上的数据(读取完后,下次可读会触发EPOLLIN)后开始处理这些数据,而在数据处理的过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时由于本线程在处理数据,因此会使用另一个线程被唤醒读取这些新的数据,这会出现两个线程同时操作一个socket的局面。这不是所期望的。我们期望在任意时刻一个socket只能被一个线程处理。这可以通过使用epoll的EPOLLONESHOT事件实现。对于注册了epoll的EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,也就是某线程在读取完毕数据并在处理数据的时候,即使有新的数据可读,其它线程也不会操作该socket。那么反过来思考,EPOLLONESHOT的正确用法应当是在某个线程读取并处理完毕数据之后,该线程就应当立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下次可读时,其EPOLLIN事件能被触发,进而让其它工作线程继续处理这个socket。

7、三组I/O复用函数的比较。

我们从事件集、最大支持文件描述符数、工作模式和具体实现四个方面进行比较

(1)select函数并没有将文件描述符和事件绑定,因此需要3个类型的参数来分别传入可读、可写和异常等事件。这一方面使得select不能处理更多类型的事件,另一方面由于内核对其参数的修改,不得不在下一次调用select之前重置这三个fd_set集合。poll函数把文件描述符和事件都定义在pollfd其中,任何事都被统一处理,简洁了编程接口。由于每次select和poll调用都返回整个用户注册的事件集合(就绪和未就绪的),因此遍历就绪文件描述符的时间复杂度是O(n)。epoll则采用select和poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了epoll_ctl函数控制往其添加、删除、修改事件。每次epoll_wait调用都直接从内核表中取得用户注册的事件,而无需反复从用户空间读入这些事件。epoll_wait系统调用的events参数仅用来返回就绪的事件,这使得应用程序遍历就绪文件描述符的时间复杂度为O(1)。

(2)poll和epoll_wait最多监听的文件描述符和系统允许打开的最大文件描述符数目有关,一般最大值为65535,默认为1024。而select则有限制,一般为1024。

(3)select和poll都只能工作在相对低效的LT模式,而epoll可以工作在ET高效模式,并且还支持EPOLLONESHOT事件,进一步较少可读、可写和异常等事件被触发的次数。

(4)从实现原理来看,select和poll都采用轮询的方式,每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给应用程序,因此检测就绪事件的时间复杂度为O(1)。epoll_wait则不同,它采用回调的方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上的对应的事件插入内个就绪事件队列中。因此,epoll_wait无需轮询整个文件描述符类检测哪些事件已经就绪,其事件复杂度为O(1)。但是假如活动连接比较多,因为会频繁的调用回调函数,epoll_wait的效率未必比poll和select高。所以epoll_wait适用于连接数量多但是活动连接少的情况。

linux高性能服务器编程学习笔记八:I/O复用