在Linux系统中,实际上所有的I/O设备都被抽象为文件这个概念,一切皆文件(Everything is File)。无论是磁盘、网络数据、终端,还是进程间通信工具(如:管道pipe)等都被抽象为文件的概念。 这种设计使得 I/O 操作可以通过统一的文件描述符(File Descriptor, FD)来管理。 在了解多路复用select、poll、epoll实现之前,我们先简单回忆复习以下两个概念:
[1]blockingIO - 阻塞IO
[2]nonblockingIO - 非阻塞IO
[3]signaldrivenIO - 信号驱动IO
[4]asynchronousIO - 异步IO
[5]IOmultiplexing - IO多路复用
在阻塞式 I/O 模型中,在I/O操作的两个阶段均会阻塞线程:
recvfrom
系统调用)时,它会一直阻塞,直到内核确认数据已准备好(例:网卡接收数据、网络数据到达内核缓冲区)。在非阻塞式 I/O 模型中,当进程发起 I/O 系统调用(如 recvfrom
)时:
EWOULDBLOCK
或 EAGAIN
),而不会阻塞进程。在信号驱动 I/O 模型中,进程发起一个 I/O 操作时,会向内核注册一个信号处理函数(如 SIGIO
),然后立即返回,不会被阻塞。当内核数据就绪时,会向进程发送一个信号,进程在信号处理函数中调用 I/O 操作(如 recvfrom
)读取数据。
在异步 I/O 模型中,当进程发起一个 I/O 操作时,会立即返回,不会被阻塞,也不会立即返回结果。内核会负责完成整个 I/O 操作(包括数据准备和复制到用户空间),并在操作完成后通知进程。如果 I/O 操作成功,进程可以直接获取到数据。
大多数文件系统的默认IO操作都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。 至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。 总的来说,IO分两阶段: 1)数据准备阶段 2)内核空间复制回用户进程缓冲区阶段。如下图:
目前支持I/O多路复用的系统调用有select,pselect,poll,epoll。与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。 I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
int select (int n, fd_set
*readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
函数返回值:
从上述的select函数声明可以看出,fd_set本质是一个数组,为了方便我们操作该数组,操作系统提供了以下函数:
// 将文件描述符fd从set集合中删除
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件描述符对应的标志位设置为0
void FD_ZERO(fd_set *set);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds,当用户process调用select的时候,select会将需要监控的readfds集合拷贝到内核空间(假设监控的仅仅是socket可读),然后遍历自己监控的skb(SocketBuffer),挨个调用skb的poll逻辑以便检查该socket是否有可读事件,遍历完所有的skb后,如果没有任何一个socket可读,那么select会调用schedule_timeout进入schedule循环,使得process进入睡眠。如果在timeout时间内某个socket上有数据可读了,或者等待timeout了,则调用select的process会被唤醒,接下来select就是遍历监控的集合,挨个收集可读事件并返回给用户了,相应的伪码如下:
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
// 1.NULL,永远等下去
// 2.设置timeval,等待固定时间
// 3.设置timeval里时间均为0,检查描述字后立即返回,轮询
/*
* select服务端伪码
* 首先一个线程不断接受客户端连接,并把socket文件描述符放到一个list里。
*/
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
/*
* select函数还是返回刚刚提交的list,应用程序依然list所有的fd,只不过操作系统会将准备就绪的文件描述符做上标识,
* 用户层将不会再有无意义的系统调用开销。
*/
struct timeval timeout;
int max = 0; // 用于记录最大的fd,在轮询中时刻更新即可
// 初始化比特位
FD_ZERO(&read_fd);
while (1) {
// 阻塞获取 每次需要把fd从用户态拷贝到内核态
nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 0; i <= max && nfds; ++i) {
// 只读已就绪的文件描述符,不用过多遍历
if (i == listenfd) {
// 这里处理accept事件
FD_SET(i, &read_fd);//将客户端socket加入到集合中
}
if (FD_ISSET(i, &read_fd)) {
// 这里处理read事件
}
}
}
下面的动图能更直观的让我们了解select:
通过上面的select逻辑过程分析,相信大家都意识到,select存在三个问题:
[1] 每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间,高并发场景下这样的拷贝会使得消耗的资源是很大的。 [2] 能监听端口的数量有限,单个进程所能打开的最大连接数由FD_SETSIZE宏定义,监听上限就等于fds_bits位数组中所有元素的二进制位总数,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上为3264),当然我们可以对宏FD_SETSIZE进行修改,然后重新编译内核,但是性能可能会受到影响,一般该数和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认1024个,64位默认2048。
[3] 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次调用sk的poll函数收集可读事件:由于当初的需求是朴素,仅仅关心是否有数据可读这样一个事件,当事件通知来的时候,由于数据的到来是异步的,我们不知道事件来的时候,有多少个被监控的socket有数据可读了,于是,只能挨个遍历每个socket来收集可读事件了。
poll
的实现与 select
非常相似,都是通过监视多个文件描述符(fd)来实现 I/O 多路复用。两者的主要区别在于描述 fd 集合的方式:select
使用 fd_set
结构,而 poll
使用 pollfd
select
的 fd_set
结构限制了 fd 集合的大小(通常为 1024),而 poll
使用 pollfd
结构,理论上可以支持更多的 fd,解决了 select
的问题 (2)。select
类似,poll
也存在性能瓶颈。当监视的 fd 数量较多时,poll
需要将整个 pollfd
数组在用户态和内核态之间复制,无论这些 fd 是否就绪。这种复制的开销会随着 fd 数量的增加而线性增长,导致性能下降。poll
适合需要监视较多 fd 的场景,但在高并发或 fd 数量非常大的情况下,性能仍然不如 epoll
。struct pollfd {
int fd; /*文件描述符*/
short events; /*监控的事件*/
short revents; /*监控事件中满足条件返回的事件*/
};
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
函数参数:
函数返回值:
下面是poll的函数原型,poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。poll虽然解决了fds集合大小1024的限制问题,从实现来看。很明显它并没优化大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,使得poll也并不适合用于大并发场景。
poll服务端实现伪码:
struct pollfd fds[POLL_LEN];
unsigned int nfds=0;
fds[0].fd=server_sockfd;
fds[0].events=POLLIN|POLLPRI;
nfds++;
while {
res=poll(fds,nfds,-1);
if(fds[0].revents&(POLLIN|POLLPRI)) {
//执行accept并加入fds中,nfds++
if(--res<=0) continue
}
//循环之后的fds
if(fds[i].revents&(POLLIN|POLLERR )) {
//读操作或处理异常等
if(--res<=0) continue
}
}
在 Linux 网络编程中,select
曾长期被用作事件触发的机制。然而,随着高并发场景的需求增加,select
的性能瓶颈逐渐显现。为了解决这些问题,Linux 内核引入了 epoll
机制。相比于 select
,epoll
的最大优势在于其性能不会随着监听的文件描述符(fd)数量的增加而显著下降。如前面我们所说,在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的声明:
#define __FD_SETSIZE 1024 (select
最多只能同时监听 1024 个 fd(由 __FD_SETSIZE
定义)。虽然可以通过修改内核头文件并重新编译内核来扩大这一限制,但这并不能从根本上解决问题。) 而epoll
使用基于事件回调的机制,而不是轮询。它只会关注活跃的 fd,因此性能不会随着 fd 数量的增加而显著下降。
epoll_create
创建一个 epoll 句柄。参数 size
用于告诉内核监听 fd 的数量(在较新的内核中,size
参数已被忽略,但仍需大于
0),这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。epoll_ctl
向 epoll 对象中添加、修改或删除要管理的 fd。epoll_wait
等待其管理的 fd 上的 I/O 事件。int epoll_create(int size);
epoll_create
的源码实现:
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
struct eventpoll
*ep = NULL;
//创建一个 eventpoll 对象
error = ep_alloc(&ep);
}
//struct eventpoll 的定义
// file:fs/eventpoll.c
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
......
}
static int ep_alloc(struct eventpoll **pep)
{
struct eventpoll *ep;
//申请 epollevent 内存
ep = kzalloc(sizeof(*ep), GFP_KERNEL);
//初始化等待队列头
init_waitqueue_head(&ep->wq);
//初始化就绪列表
INIT_LIST_HEAD(&ep->rdllist);
//初始化红黑树指针
ep->rbr = RB_ROOT;
......
}
其中eventpoll 这个结构体中的几个成员的含义如下:
epoll_ctl
函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
1. EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
2. EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
3. EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
- 参数fd: 需要监听的文件描述符
- 参数event: 告诉内核要监听什么事件,struct epoll_event 结构如:
- events 可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
- 返回值:0表示成功,-1表示失败。
epoll_wait
函数
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。