操作系统与I/O多路复用
Everything is File
在Linux中所有的I/O设备都被抽象为了文件这个概念,一切皆文件(Everything is File),磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。
所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序员使用一套接口就能对所有外设I/O操作。
常用的I/O操作接口一般有以下几类:
- 1)打开文件,open;
- 2)改变读写位置,seek;
- 3)文件读写,read、write;
- 4)关闭文件,close。
文件描述符
要想进行I/O读操作,像磁盘数据,我们需要指定一个buff用来装入数据,即:
read(buff);
此处虽然我们指定了往哪里写数据(buff),但是我们该从哪里读数据呢?
通过上文我们可以知道,通过文件这个概念我们能实现几乎所有I/O操作,我们这里读取的源就是文件
那么我们一般都怎样使用文件呢?
那么我们一般都怎样使用文件呢?
举个例子:如果周末你去比较火的餐厅吃饭应该会有体会,一般周末人气高的餐厅都会排队,然后服务员会给你一个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是什么、来自哪里、喜好是什么、是不是保护环境爱护小动物等等,这里的关键点就是:服务员对你一无所知,但依然可以通过一个号码就能找到你。
同样的:在Linux世界要想使用文件,我们也需要借助一个号码,这个号码就被称为了文件描述符(file descriptors),其道理和上面那个排队号码一样。文件描述符对应着某个文件。
因此:文件描述符以一个数字表示,通过这个数字我们可以操作一个打开的文件。
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
因此读取的代码一般为:
int fd = open(file_name);
read(fd,buff);
如何处理大量的文件描述符
从前几节我们知道,所有I/O操作都可以通过文件样的概念来进行,这当然包括网络通信。
如果你有一个IM服务器,当三次握手建议长连接成功以后,我们会调用accept来获取一个链接,调用该函数我们同样会得到一个文件描述符,通过这个文件描述符就可以处理客户端发送的聊天消息并且把消息转发给接收者。
也就是说,通过这个描述符我们就可以和客户端进行通信了:
// 通过accept获取客户端的文件描述符
int conn_fd = accept(...);
Server端的处理逻辑通常是接收客户端消息数据,然后执行转发(给接收者)逻辑
if(read(conn_fd, msg_buff) > 0) {
do_transfer(msg_buff);
}
当然这是在文件描述符较少,简单场景下。如果在高并发场景中,Server端就不可能只和一个客户端通信,而是可能会同时和成千上万个客户端进行通信。这时你需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符。
对于这种场景,最直接简单的处理方式为:
if(read(socket_fd1, buff) > 0) { // 处理第一个
do_transfer();
}
if(read(socket_fd2, buff) > 0) { // 处理第二个
do_transfer();
这样的处理方式(阻塞式I/O),如果此时没有数据可读那么进程会被阻塞而暂停运行。这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着处理某一个客户端时由于进程被阻塞导致剩下的所有其它客户端必须等待,在同时处理几万客户端的server上。这显然是不能容忍的。
你可能会想到使用多线程:为每个客户端请求开启一个线程,这样一个客户端被阻塞就不会影响到处理其它客户端的线程了。注意:既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗,大量创建销毁线程会严重影响系统性能。
那么这个问题该怎么解决呢?
这里的关键点在于:我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的,在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。
此时我们要使用一种更加高效的I/O处理机制——I/O多路复用。我们把这些感兴趣的文件描述符交给内核,使内核对这些文件描述符进行监控,有可以读写的文件描述符就通知我们(工作线程)以进行处理。
即这样一个过程:
- 1)我们拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以);
- 2)通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件描述符中有可以进行I/O读写操作的时候你再返回”;
- 3)当调用的这个函数返回后我们就能知道哪些文件描述符可以进行I/O操作了。
也就是说通过I/O多路复用我们可以同时处理多路I/O。那么有哪些函数可以用来进行I/O多路复用呢?
由于高并发网络研发主要基于Linux,所以以Linux为例,有这样三种机制可以用来进行I/O多路复用:
- 1)select
- 2)poll
- 3)epoll
I/O多路复用的三剑客
本质上Linux上的select、poll、epoll都是阻塞式I/O,也就是我们常说的同步I/O。原因在于:调用这些I/O多路复用函数时如果任何一个需要监视的文件描述符都不可读或者可写那么进程会被阻塞暂停执行,直到有文件描述符可读或者可写才继续运行。
select
在select这种I/O多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中。
我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个。因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。
因此,总结下来select有这样几个特点:
- 1)能照看的文件描述符数量有限,不能超过1024个;
- 2)用户给的文件描述符需要拷贝的内核中;
- 3)只能告诉你有文件描述符满足要求了,但是不知道是哪个,需要自己遍历查找。
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结构体的定义如下:
#include<sys/select.h>
#define __FD_SETSIZE 1024
typedef long int __fd_mask;
#undef __NFDBITS
#define __NFDBITS (8 * (int) sizeof (__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
结构体仅包含一个整形数组,该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set
能容纳的文件描述符数量由FD_SETSIZE
指定,这就限制了select
能同时处理的文件描述符的总量。
由于位操作过于烦琐,我们应该使用下面的一系列宏来访问 fd_set
结构中的位:
#include<sys/select.h>
FD_ZERO(fd_set *fdset); /*清除 fd_set 的所有位*/
FD_SET(int fd, fd_set *fdset); /*设置 fd_set 的位fd*/
FD_CLR(int fd, fd_set *fdset); /*清除 fd_set 的位fd*/
int FD_ISSET(int fd, fd_set *fdset) /*测试 fd_set 的位fd是否被设置*/
timeout
参数用来设置 select
函数的超时时间,它是一个 timeval
结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select
等待了多久。不过我们不能完全信任 select
调用返回后的timeout
值,比如调用失败时timeout
值时不确定的。timeout
结构体的定义如下:
struct timeval
{
long tv_sec; /*秒数*/
long tv_usec; /*微秒数*/
};
由以上定义可知,select
给我们提供了一个微秒级的定时方式, 如果给timeout
变量的tv_sec
成员和 tv_usec
成员都传0,则 select
将立即返回; 若给 timeout
传 NULL 则 select
将一直阻塞,直到某个文件描述符就绪。
select
成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪, select
将返回0。
select
失败时返回-1并设置errno。如果在select
等待期间,程序接收到信号,则 select
立即返回-1,并设置 errno为EINTR.
poll
poll
系统调用和 select
类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
poll相对于select的优化仅仅在于解决了文件描述符不能超过1024个的限制,select
和 poll
都会随着监控的文件描述数量增加而性能下降。
poll API
#include<poll.h>
int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
1)fds
参数是一个 pollfd
结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。pollfd
结构体的定义如下:
/* Data structure describing a polling request. */
struct pollfd
{
int fd; /* poll文件描述符 */
short int events; /* 注册的感兴趣事件. */
short int revents; /* 实际发生的事件,由内核填充 */
};
其中,fd
成员指定文件描述符:events
成员告诉poll
监听fd
上的哪些事件,它是一系列事件的按位或:revents
成员则由内核修改,以通知应用程序fd上实际发生了哪些事件。poll
支持的事件类型如
2)nfds
参数指定被监听事件集合fds
的大小。其类型nfds_t
的定义如下:
typedef unsigned long int nfds_t;
3)timeout
参数指定poll
的超时值,单位为毫秒。当 timeout
为-1时,poll将永远阻塞,直到某个事件发生:当 timeout
为0时,poll
调用将立即返回。
4)poll
系统调用的返回值含义与 select
相同,成功时返回就绪(可读、可写和异常)文件描述符的总数,如果在超时时间内没有任何文件描述符就绪, poll
将返回0;失败时返回-1并设置 errno
。
epoll
epoll 是Linux特有的I/O复用函数,它与select 和 poll 有很大差异。对于select 面对的几个问题,首先针对拷贝问题,epoll使用的策略是各个击破与共享内存。由于文件描述符变化频率较低,select 和 poll 频繁的拷贝整个集合,高并发场景下给内核造成了较大的负担,epoll通过引入epoll_ctl使得只操作那些有变化的文件描述符。同时epoll 和内存共享了一部分内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。
针对需要遍历文件描述符才能知道哪个可读可写这一问题,epoll的策略是 进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就通知epoll,epoll记录下来然后唤醒进程,这样进程被唤醒后就无需自己再遍历各个文件描述符,这即是事件驱动的方法。
epoll API
epoll_create
更具体来说,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select 和 poll 那样每次调用都要重复传入文件描述符集或事件集,但epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用 epoll_create 函数来创建:
#include<sys/epoll.h>
int epoll_create(int size)
size
参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
epoll_ctl
使用函数 epoll_ctl
用来操作epoll的内核事件表:
extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;
__epfd
为epoll维护的内核时间表的文件描述符,
fd
参数是要操作的文件描述符,
op
参数则指定操作类型。操作类型有如下3种:
- EPOLL_CTL_ADD 往事件表中注册fd上的事件
- EPOLL_CTL_MOD 修改fd上的注册事件
- EPOLL_CTL_MOD 删除fd上的注册事件
epoll_event
结构指针类型的 event
参数指定事件,epoll_event
的定义如下:
struct epoll_event
{
uint32_t events; /* Epoll 事件 */
epoll_data_t data; /* 用户数据 */
};
其中 events
成员描述事件类型, epoll
支持的事件类型和 poll
基本相同。 表示 epoll
事件类型的宏是在 poll
对应的宏前加上"E",即 EPOLLIN
表示epoll
的数据可读事件。 但除了基本的事件外,epoll
有两个额外的事件类型—— EPOLLET
和 EPOLLONESHOT
。
data
成员用于存储用户数据, 其类型 epoll_data_t
的定义如下:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
由上面的定义,epoll_data_t
为一个联合体,成员中使用最多的为fd, fd指定事件所从属的目标文件描述符。 ptr 成员可用来指定与fd相关的用户数据。
epoll_ctl
成功时返回0, 失败时返回-1并设置errno。
epoll_wait
epoll
系列系统调用的主要接口是 epoll_wait
函数,它在一段超时时间内等待一组文件描述符上的事件,其原型为:
extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
该函数成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno。
该函数的前两个参数与 epoll_ctl
的参数相同,
maxevents
参数指定最多监听多少个事件,它必须大于0。
timeout
参数与 poll 接口的timeout 参数相同,单位为毫秒,当 timeout
为-1时,poll将永远阻塞,直到某个事件发生:当 timeout
为0时,poll
调用将立即返回。
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读 | 是 | 是 |
POLLPRI | 高优先级数据可读 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLRDNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起,比如管道的写端被关闭后,该端描述符上将收到POLLHUP事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表中复制到它的第二个events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件,而不像select 和 poll 数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。
简单的示例比较 poll 和 epoll 索引就绪文件描述符的示例如下:
/*索引 poll 返回的就绪文件的描述符*/
int ret = poll(fds, MAX_EVENT_NUMBER, -1);
//必须遍历所有的已注册的文件描述符并找到找到其中的就绪者
for(int i=0; i<ret; ++i)
{
if(fds[i].revents & POLLIN)
{
int sockfd = fds[i].fd;
//处理sockfd...
}
}
/*索引 epoll 返回的就绪文件的描述符*/
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
//仅遍历就绪的ret个文件描述符
for(int i=0; i<ret; ++i)
{
int sockfd = events[i].data.fd;
//sockfd 必定是就绪的,可以直接处理...
}