Linux高性能服务器-第九章-IO复用

50 阅读9分钟

IO复用能使得程序能同时监听多个文件描述符,但它本身的阻塞的。当多个文件描述符同时就绪时,若不采取额外措施,程序就只能按顺序依次处理这些描述符。如果要实现并发,只能使用多进程或多线程等编程手段。

9.1 select系统调用

9.1.1 select函数

select系统调用的用途是在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

#include <sys/select.h>

/*
	nfds: 指定被监听的文件描述符的总数,通常被设置为上select监听的所有文件描述符的最大值加1;
	readfds: 监听的可读事件文件描述符集合;
	writefds: 监听的可写事件文件描述符集合;
	exceptfds: 监听的异常事件文件描述符集合;
	select调用返回时,内核将修改上述三个参数来通知应用程序哪些文件描述符已就绪。
	timeout: 设置select函数的超时时间,结构体定义见下
	
	成功时返回就绪文件描述符的总数,失败返回-1并设置errno,若在等待期间,程序收到信号则select立即返回-1并设置errno为EINTR
*/
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );


/* fd_set结构体定义 */
/* 
	fd_set结构体仅包含一个整型数组,该数组每个元素的每一位标记一个文件描述符
	fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符总量
*/
#include <typesizes.h>
#define __FD_SETSIZE 1024

#include <sys/select.h>
#include FD_SETSIZE __FD_SETSIZE
typedef long int __fd_mask;
#undef __NFDBITS
#define __NFDBITS ( 8 * (int) sizeo(__fd_mask) )
typedef struct
{
    #ifdef __USE_XOPEN
    	__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    #define __FDS_BITS(set) ((set)->fds_bits)
    #else
    	__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
    #define _FDS_BITS(set) ((set)->fds_bits)
    #endif
} fd_set;

/* 使用下列宏访问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是否被设置

/* timeval结构体 */
/* 
	tv_sec,tv_usec均为0, select将立即返回
	若为NULL,则select将一直阻塞
*/
struct timeval
{
    long tv_sec;	// 秒数
    long tv_usec;	// 微秒数
}
9.1.2 文件描述符就绪条件

socket可读条件

  • socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT,可无阻塞读该socket,读返回的字节数大于0。

  • socket通信对方关闭连接,对该socket的读返回0。

  • 监听socket上有新的连接请求。

  • socket上有未处理的错误,此时可用getsockopt来读取和清除该错误。

socket可写条件

  • socket内核发送缓冲区中可用字节数大于或等于其低水位标记SO_SEDLOWAT,可无阻塞写该socket,写返回字节数大于0。

  • socket的写操作被关闭,对该socket的写操作将触发SIGPIPE信号。

  • socket使用非阻塞connect连接成功或失败后。

  • socket上有未处理的错误,此时可用getsockopt来读取和清除该错误。

异常条件

  • 只有一种,socket接收到带外数据。

9.2 poll系统调用

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

#include <poll.h>

/* 
	fds: 指定要监听的可读、可写、异常事件文件描述符集合, pollfd结构体定义见下
	nfds: 指定被监听事件集合fds大小
	timeout: 指定poll的超时值,单位ms。值为-1时,poll将永远阻塞
*/
int poll( struct pollfd* fds, nfds_t nfds, int timeout );

struct pollfd
{
    int fd;				// 文件描述符
    short events;		// 注册的事件
    short revents;		// 实际发生的事件,由内核填充
}

poll支持的事件类型:

pollev1.png

pollev2.png

9.3 epoll系列

9.3.1 内核事件表

epoll是Linux特有的IO复用函数,但在实现和使用上与select、poll有很大差异。

首先epoll一组函数协调来完成任务;

其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,而不需像select那样每次调用都要重复传入,但epoll需要一个额外的文件描述符来标识内核中的这个事件表。

#include <sys/epoll>

/*创建指向内核事件表的文件描述符*/
/* 
	size并不实际起作用,只是提示内核这个事件表需要多大。
 	返回的描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表
*/
int epoll_create( int size );

/* 操作epoll内核事件表 */
/*
	fd: 要操作的文件描述符
	op: 指定操作类型,取值如下
		EPOLL_CTL_ADD 	往事件表中注册fd上的事件
		EPOLL_CTL_MOD 	修改fd上的注册事件
		EPOLL_CTL_DEL	删除fd上的注册事件
	event: 指定事件,定义见下
	成功返回0,失败返回-1并设置errno
*/
int epoll_ctl( int epfd, int op, int fd, struct epoll_event* event );

struct epoll_event
{
    __uint32_t events;	// epoll事件类型,支持事件表与poll基本一致,只是宏名前加“E”
    epoll_data_t data;	// 用户数据
}

// 由于epoll_data_t是一个联合体,不能同时使用其ptr,fd成员。
typedef union epoll_data
{
    void* ptr;			// 指定与fd相关的用户数据
    int fd;				// 指定事件所从属的目的文件描述符
    uint32_t u32;
    uint32_t u64;
} epoll_data_t;
9.3.2 epoll_wait函数

epoll_wait是epoll系列的主要接口,它在一段超时时间内等待一组文件描述符上的事件。

#include <sys/epoll.h>

/*
	events:epoll_wait如果检测到事件,就将所有就绪事件从内核事件表中复制到events指向的数组中
			该数组只用于输出就出就绪事件,而不像select数组参数那样记录所有事件,因此提高了效率
	maxevents: 指定最多监听多少个事件,必须大于0
	成功时返回就绪文件描述符个数,失败返回-1并设置errno
*/
int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout );

下面代码可看出poll和epoll使用上的差别:

// poll
int ret = poll( fds, MAX_EVENT_NUMBER, -1);
for( int i = 0; i < MAX_EVENT_NUMBER; ++i) {	// 需遍历所有已注册的文件描述符
    if(fds[i].revents & POLLIN) {				// 判断是否就绪
        int socket = fds[i].fd;
        ...	// 处理
    }
}

// epoll
int ret = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1);
for( int i = 0; i < ret; ++i) {		// 仅遍历就绪的文件描述符
    int socket = events[i].data.fd;
    ...		// 处理
}


9.3.3 lt和et模式

epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。

LT模式是默认的工作模式。对采用LT模式的文件描述符,当epoll_wait检测到其上有事件发生并通知到应用程序后,应用程序可不立即处理该事件。当应用程序下次调用epoll_wait时,epoll_wait还会再次向应用程序通知此事件,直到该事件被处理。

ET模式是epoll的高效模式。当往epoll内核事件表中注册一个文件描述符上的EPOLLLET事件后,epoll将以ET模式操作该文件描述符。此模式下,epoll_wait检测到有事件发生并通知到应用程序后,应用程序必须立即处理该事件,后续epoll_wait不会再通知此事件。可见,ET模式降低了同一个epoll事件被重复触发的次数,因此效率更高。

9.3.4 epolloneshot事件

即使使用ET模式,在并发状态下一个socket上的某个事件还是可能被触发多次。比如一个线程在读取完某个socket上的数据后进行处理,而在处理过程中该socket上又有新数据可读,EPOLLIN再次被触发,另一个线程被唤醒来读取这些新数据,这就出现多个线程同时操作一个socket的局面。

我们期望一个socket连接只被一个线程处理,这可通过在其文件描述符上注册EPOLLONESHOT事件来实现。注册EPOLLONESHOT事件后,该文件描述符上注册的可读、可写、异常事件至多只被触发一个,且只触发一次,除非使用epoll_ctl重置该文件描述符上的EPOLLONESHOT。

在处理完之后,线程应立即重置socket上的EPOLLONESHOT事件,以确保该socket的下次事件到来后能被其他线程处理。

9.4 三组对比

/* select  */
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );

/* poll  */
int poll( struct pollfd* fds, nfds_t nfds, int timeout );

/* epoll  */
int epoll_create( int size );
int epoll_ctl( int epfd, int op, int fd, struct epoll_event* event );
int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout );

从事件集、最大支持文件描述符数、工作模式、具体实现四个方面对比。

事件集

select - select的参数类型fd_set仅仅是一个文件描述符集合,没有与事件绑定,因此需要三个fd_set来分别传入可读、可写、异常事件。一方面限制了select可处理的事件类型,一方面调用后内核直接修改fd_set,使得每次调用select都要重置这3个fd_set。

poll - poll参数类型pollfd将文件描述符与事件绑定,任何事件将被统一处理,因此poll接口更为简洁。并且内核修改的是pollfd结构体中的revents成员,而注册的events成员不变,因此不需每次调用重置pollfd的事件集参数。

epoll - 在内核中维护一个事件表,epoll_wait直接从内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件。

最大支持文件描述符数

select - 由fd_set的__FD_SETSIZE决定,默认1024个,用户可修改,但可能导致不可预期的后果;

poll - 可用nfds参数指定,可达系统允许打开的最大文件描述符数目65536;

epoll - 可用maxevents参数指定,可达系统允许打开的最大文件描述符数目65536;

工作模式:

select、poll - LT

epoll - LT/ET,支持EPOLLONESHOT事件,进一步减少事件触发次数。

实现方式:

select、poll - 采用轮询方式检测就绪事件,时间复杂度为O(n);

epoll - 采用回调方式检测就绪事件,事件复杂度为O(1);内核检测到就绪事件,触发回调函数,回调函数将该文件描述符上的对应事件插入内核就绪事件队列,然后内核在合适的时机将该就绪事件队列中的内容拷贝到用户空间。