非阻塞I / O ( Nonblocking I/O ) 与 Linux 内核 Epoll 原理详解
Kotlin 开发者社区
什么是文件描述符?
Unix中所有I / O的基本构建块是一个字节序列。大多数程序使用更简单的抽象 - 字节流或I / O流。
一种方法引用I / O流的帮助下描述,也被称为文件描述。管道,文件,FIFO,POSIX IPC(消息队列,信号量,共享内存),事件队列都是描述符引用的I / O流的示例。
创建和发布描述符:描述符要么由open,pipe,socket等系统调用显式创建 ,要么从父进程继承。
每个文件条目包含:
- 类型
- 函数指针数组。此函数指针数组将文件描述符上的泛型操作转换为特定于文件类型的实现。
进一步消除歧义,所有文件描述符都暴露了一个通用的通用API,它指示可以在描述符上执行的操作(例如读取,写入,更改描述符模式,截断描述符,ioctl操作,轮询等等)。
这些操作的实际实现因文件类型而异,并且不同的文件类型具有自己的自定义实现。对套接字的读取与管道上的读取不完全相同,即使暴露的更高级别的API是相同的。在开放的通话不在此列表中的一部分,因为执行差别很大的不同的文件类型。但是,一旦使用open调用创建了文件条目,就可以使用通用API调用其余操作。
在描述符上有多种多路复用I / O的方法:
- 非阻塞I / O(描述符本身被标记为非阻塞,操作可能部分完成)
- 信号驱动的I / O(当描述符的I / O状态改变时,通知拥有描述符的进程)
- 轮询I / O(使用select或poll系统调用,两者都提供有关描述符准备情况的级别触发通知)
select 选择
select的返回值 如下:
- 如果发生错误(EBADF或EINTR),则返回码为-1
- 如果在任何描述符准备就绪之前调用超时,则返回码为0
- 如果一个或多个文件描述符准备就绪,则返回码是一个正整数,表示所有准备好的三个集合中的文件描述符总数。然后单独检查每个集合以找出发生了哪个I / O事件。
poll 轮询
poll 与 select 仅在我们指定跟踪哪些描述符的方式上不同。
使用select,我们传入三组描述符,我们要监视读取,写入和异常情况。
使用 poll 轮询,我们传递一组描述符,每个描述符都标记有特定需要跟踪的事件。
poll的第一个参数 fds 是我们想要监视的所有描述符的数组。
第一个参数:struct pollfd * fds 数据结构包含三条信息:
- 要轮询的描述符的ID(让我们调用此描述符A)
- 指示要监视给定描述符A(事件)的事件的位掩码
- 由内核设置的位掩码,指示在描述符A上实际发生的事件(revents)
第二个参数nfds_t nfds :是我们正在监视的描述符总数(换句话说,用作第一个参数的描述符数组的长度)。
第三个参数 int timeout 超时时间:指定轮询 每次调用时阻止多长时间。
select 和 poll 是无状态的。每次进行select或poll系统调用时,内核都会检查作为事件发生的第一个参数传递的输入数组中的每个描述符, 并将结果返回给进程。这意味着poll / select的成本是O(N),其中N是被监视的描述符的数量。
此外,select和poll的实现包括两个层:
一个特定的顶层,它解码传入的请求;
以及底层,几个设备或插槽特定的底层。底层包含内核轮询功能,并由select和poll使用。
参考资料:https://medium.com/@copyconstruct/nonblocking-i-o-99948ad7c957
epoll 原理详解
与poll不同,epoll本身不是系统调用。它是一种内核数据结构。
epoll 允许进程在多个文件描述符上复用I / O.可以通过三次系统调用来创建,修改和删除此数据结构。
1)epoll_create
该epoll的实例 是由的方式创建epoll_create系统调用,它返回一个文件描述符到epoll的实例。epoll_create的签名如下:
该 size 参数是一个指示内核有关文件的数量描述符的过程要监视,这有助于内核来决定的大小epoll的实例。从Linux 2.6.8开始,此参数被忽略,因为epoll数据结构会随着文件描述符的添加或删除而动态调整大小。
该epoll_create 系统调用返回一个文件描述符到新创建的epoll的内核数据结构。然后,调用进程可以使用此文件描述符来添加,删除或修改它要监视的其他文件描述符,以便对epoll实例进行I / O操作。
还有另一个系统调用epoll_create1 ,定义如下:
标志参数flags可以是0或EPOLL_CLOEXEC。
如果设置为0,epoll_create1的行为方式相同epoll_create。
当EPOLL_CLOEXEC标志 的 设置,由当前进程创建任何子进程将关闭epoll的描述符前高管,所以孩子过程中不会有访问epoll的实例了。
需要注意的是,与epoll实例关联的文件描述符需要通过close()系统调用来释放,这一点很重要。多个进程可能会将描述符保存到同一个epoll实例,因为,例如,没有EPOLL_CLOEXEC标志的fork会将描述符复制到子进程中的epoll实例。当所有这些进程都将其描述符放弃到epoll实例时(通过调用close()或退出),内核会破坏epoll实例。
2)epoll_ctl
进程可以通过调用将要监视的文件描述符添加到epoll实例。在epoll实例中注册的所有文件描述符统称为epoll集或兴趣列表。
在上图中,过程483已经用epoll实例注册了文件描述符fd1,fd2,fd3,fd4和fd5。这是该特定epoll实例的兴趣列表或epoll集。随后,当注册的任何文件描述符为I / O做好准备时,则认为它们在就绪列表中。该就绪列表是的一个子集的兴趣列表。
epoll_ctl系统调用的签名如下:
- epfd :返回的文件描述符,epoll_create用于标识内核中的 epoll实例。
- op :指对文件描述符 fd执行的操作。通常,支持三种操作:
- 向epoll实例(EPOLL_CTL_ADD)注册 fd并获得有关fd上发生的事件的通知
- 从epoll实例中删除 /注销fd。这意味着该进程将不再获得有关该文件描述符(EPOLL_CTL_DEL)上的事件的任何通知。如果已将文件描述符添加到多个epoll实例,则关闭它将将其从添加它的所有epoll兴趣列表中删除。
- 修改fd正在监视的事件(EPOLL_CTL_MOD)
- fd :是我们要添加到 epoll 列表 /兴趣 列表的文件描述符。
- epoll_event * event :是一个指向 epoll_event结构的指针,该结构存储我们实际想要监视 fd的事件。
3)epoll_wait
通过调用系统调用,向线程通知epoll实例兴趣集上发生的事件。
该系统调用将阻塞,直到被监视的fd描述符为I / O 做好准备。
epoll_wait 签名如下:
- epfd - 返回的文件描述符,epoll_create用于标识内核中的epoll实例。
- evlist - 是一个epoll_event结构数组。evlist由调用进程分配,当epoll_wait返回时,修改此数组以指示有关兴趣列表中处于就绪状态的文件描述符子集的信息(这称为就绪列表)。
- maxevents - 是evlist数组的长度。
- timeout - 此参数的行为与 poll或select的行为相同。此值指定 epoll_wait系统 调用将阻塞的时间:
epoll_wait的返回值 如下:
epoll的陷阱
要完全理解epoll背后的细微差别,了解文件描述符的确如何工作非常重要。
进程在描述符的帮助下引用I / O流。每个进程都维护一个可以访问的文件描述符表。此表中的每个条目都有两个字段:
- 控制文件描述符操作的标志(唯一的标志是exec标志上的close)
- 指向底层内核数据结构的指针
描述符实际上只是一个指向底层内核数据结构的每个进程指针变得非常重要,该结构称为(令人困惑的)文件描述。
内核维护一个称为打开文件表的所有打开文件描述的表。
我们有三个描述符 - 进程A中的fd0和fd3以及进程B中的fd0 - 都指向相同的底层内核打开文件描述。
inode是文件系统数据结构,包含有关文件系统对象(如文件或目录)的信息。这些信息包括:
- 存储文件或目录数据的磁盘上块的位置 。
- 文件或目录的属性。
- 有关文件或目录的其他元数据,例如访问时间,所有者,权限等。
文件系统中的每个文件(和目录)都有一个inode条目,该条目是一个引用该文件的数字。该数字也称为inode编号。在许多文件系统上,最大inode数量限制为某个值,这意味着可以存储在系统上的文件总数也是上限。
磁盘上有一个inode表条目,它将inode编号的映射维护到磁盘上的实际inode数据结构。大多数文件系统都是通过内核的文件系统驱动程序访问的。此驱动程序使用inode 编号来访问存储在inode中的信息。因此,为了知道文件的位置或与文件有关的任何元数据,内核的文件系统驱动程序需要访问inode表。
假设在进程A forks进程B之后,进程A创建了另外两个文件描述符fd4和fd5。这些在过程B中不重复。
假设fd5是由进程A调用open文件abc.txt进行读取而创建的。让我们假设进程B还呼吁open有关abc.txt,但对于写作和文件描述符的open调用返回处理B是FD10。
然后处理A的fd5并将进程B的fd10指向打开文件表中的不同打开文件描述,但它们指向相同的inode表条目(或者换句话说,相同的文件)。
这有两个非常重要的含义:
- 由于进程A和进程B中的fd0引用相同的打开文件描述,因此它们共享文件偏移量。这意味着如果进程A提前文件偏移(通过调用read()
或write()
或lseek()
),那么进程B的偏移也会改变。这也适用于FD3属于处理A,由于FD3是指相同的开放文件描述为FD0。
- 这也适用于文件描述符在一个进程中对打开文件状态标志(O_ASYNC,O_NONBLOCK,O_APPEND)所做的修改。因此,如果进程B 通过系统调用设置标志,则将fd0设置为非阻塞模式,然后是描述符O_NONBLOCK
fcntl
属于进程A的 fd0和 fd3也将开始观察非阻塞行为。
epoll 实例实际上是监视底层文件描述,而不是每个进程文件描述符:
为什么epoll 比 select 选择和 poll 轮询性能好
select / poll的成本是O(N),这意味着当N非常大时(想想一个Web服务器处理数万个大多数困倦的客户端),每次调用select/ poll时,即使实际上只发生了少量事件,内核仍然需要扫描列表中的每个描述符。
由于epoll 监视底层文件描述,因此每次打开的文件描述为 I / O 做好准备时,内核都会将其添加到就绪列表中,而无需等待进程调用epoll_wait
来执行此操作。当 一个进程调用时epoll_wait
,那时内核不需要做任何额外的工作来响应该调用,而是返回它一直维护的就绪列表的所有信息。
此外,每次调用select / poll都需要向内核传递有关我们要监视的描述符的信息。从签名到两个调用都很明显。内核返回有关传递的所有文件描述符的信息,进程再次需要检查(通过扫描所有描述符)以找出哪些已准备好进行I / O.
int poll(struct pollfd * fds , nfds_t nfds , int timeout ); int select(int nfds , fd_set * readfds , fd_set * writefds ,fd_set * exceptfds , struct timeval * timeout );
使用epoll,一旦我们使用调用将文件描述符添加到epoll实例的兴趣列表中epoll_ctl
,那么当我们epoll_wait
将来调用时,我们不需要随后传递我们希望找到其准备情况信息的文件描述符。内核再次只返回有关那些准备好进行I / O的描述符的信息,而不是选择 / 轮询模型,其中内核返回有关传入的每个描述符的信息。
结果,epoll的成本是
而不是select / poll 的
参考资料:https://medium.com/@copyconstruct/the-method-to-epolls-madness-d9d2d6378642
番外篇:
Linux 文件描述符 fd
Linux内核的VFS子系统图如下:
fd只是一个整数,在open时产生。起到一个索引的作用,进程通过PCB中的文件描述符表找到该fd所指向的文件指针filp。
open:文件描述符的操作(如: open)返回的是一个文件描述符(int fd),内核会在每个进程空间中维护一个文件描述符表, 所有打开的文件都将通过此表中的文件描述符来引用(fd1,fd2,fd3...);
fopen:而流(如: fopen)返回的是一个FILE结构指针, FILE结构是包含有文件描述符的,FILE结构函数可以看作是对fd直接操作的系统调用的封装, 它的优点是带有I/O缓存.
Linux支持各种各样的文件系统格式,如ext2、ext3、reiserfs、FAT、NTFS、iso9660等等,不同的磁盘分区、光盘或其它存储设备都有不同的文件系统格式,然而这些文件系统都可以mount到某个目录下,使我们看到一个统一的目录树,各种文件系统上的目录和文件我们用ls命令看起来是一样的,读写操作用起来也都是一样的,这是怎么做到的呢?Linux内核在各种不同的文件系统格式之上做了一个抽象层,使得文件、目录、读写访问等概念成为抽象层的概念,因此各种文件系统看起来用起来都一样(VFS作用),这个抽象层称为虚拟文件系统(VFS,Virtual Filesystem)。
每个进程在PCB(Process Control Block)即进程控制块中都保存着一份文件描述符表,文件描述符就是这个表的索引,文件描述表中每个表项都有一个指向已打开文件的指针,现在我们明确一下:已打开的文件在内核中用file结构体表示,文件描述符表中的指针指向file结构体(理解:fd为打开文件的文件描述符,而每个进程都有一张文件描述表,fd文件描述符就是这张表的索引,同样这张表中有一表项,该表项又是指向前面提到打开文件的file结构体,file结构体才是内核中用于描述文件属性的结构体)。
PCB(Process Control Block)
PCB,进程控制块。是为了管理进程设置的一个数据结构。是系统感知进程存在的唯一标志。
PCB中包含以下内容:
(1)进程标识符(内部,外部)
(2)处理机的信息(通用寄存器,指令计数器,PSW,用户的栈指针)。
(3)进程调度信息(进程状态,进程的优先级,进程调度所需的其它信息,事件)
(4)进程控制信息(程序的数据的地址,资源清单,进程同步和通信机制,链接指针)
数据结构中定义的内容是为后面的管理提供支持的,所以不同的操作系统根据自己的特点又对PCB的内容做了一些调整。
Linux系统的PCB包括很多参数,每个PCB约占1KB多的内存空间。
用于表示PCB的结构task_struct简要描述如下:
下面对部分数据成员进行说明:
(1)unsigned short pid 为用户标识
(2)int pid 为进程标识
(3)int processor标识用户正在使用的CPU,以支持对称多处理机方式;
(4)volatile long state 标识进程的状态,可为下列六种状态之一:
(5)long prority表示进程的优先级
(6)unsigned long rt_prority 表示实时进程的优先级,对于普通进程无效
(7)long counter 为进程动态优先级计数器,用于进程轮转调度算法
(8)unsigned long policy 表示进程调度策略,其值为下列三种情况之一:
SCHED_OTHER(值为0)对应普通进程优先级轮转法(round robin)
SCHED_FIFO(值为1)对应实时进程先来先服务算法;
SCHED_RR(值为2)对应实时进程优先级轮转法
(9)struct task_struct *next_task,*prev_task为进程PCB双向链表的前后项指针
(10)struct task_struct *next_run,*prev_run为就绪队列双向链表的前后项指针
(11)struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_ptr指明进程家族间的关系,分别为指向祖父进程、父进程、子进程以及新老进程的指针。