(转)LinuxIO模式及select、poll、epoll详解

原创
小哥 2年前 (2023-05-23) 阅读数 29 #大杂烩

转自: https://segmentfault.com/a/1190000003063859

注:本文是对众多博客的研究和总结,可能存在误解。请保持怀疑的眼光,如果有任何错误,请指出来。

同步IO和异步IO,阻塞IO和非阻塞IO有什么区别,有什么区别?不同的人在不同的语境中给出不同的答案。因此,让我们首先限制本文的上下文。

本文的背景是Linux环境下的network IO。

一 概念说明

在给出解释之前,首先要澄清几个概念:

  • 用户空间和内核空间
  • 进程切换
  • 进程阻塞
  • 文件描述符
  • 缓存 I/O

用户空间和内核空间

如今,操作系统使用虚拟内存,因此32对于位操作系统,其寻址空间(虚拟存储空间)为4G(2的32到的力量。操作系统的核心是内核,它独立于普通应用程序,可以访问受保护的内存空间,以及访问底层硬件设备的所有权限。要确保用户进程不能直接操作内核(kernel)为了保证内核的安全性,担心系统将虚拟空间分成两部分,一部分是内核空间,另一部分是用户空间。鉴于linux对于操作系统,最高1G字节(来自虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为内核空间,及以下3G字节(来自虚拟地址0x00000000到0xBFFFFFFF)对于每个进程,称为用户空间。

进程切换

为了控制进程的执行,内核必须能够挂起它CPU在先前挂起的进程上运行并恢复执行的进程。此行为称为进程切换。因此,可以说任何进程都是在操作系统内核的支持下运行的,并且与内核密切相关。

从一个进程转换到在另一个进程上运行的过程涉及以下更改:

  1. 保存处理器上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移动到相应的队列,例如就绪、事件期间阻止等。
  4. 选择要执行的另一个进程并更新它PCB。
  5. 更新内存管理的数据结构。
  6. 还原处理器上下文。

注: 总之,这是非常资源密集型的 具体详情请参考本文: 进程切换

进程阻塞

正在执行的进程,由于某些预期事件未发生,例如对系统资源的请求失败,等待某些操作的完成,新数据尚未到达或没有新工作正在完成,将由系统作为阻塞原语自动执行(Block),使自己由运行状态变为阻塞状态。可见,进程阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU)只有这样,它才能变成阻塞状态。 当进程进入阻塞状态时,它不会占用CPU资源的

文件描述符fd

文件描述符(File descriptor)它是计算机科学中的一个术语,也是用于表达对文件的引用的抽象概念。

文件描述符的形式是非负整数。实际上,它是一个索引值,指向内核维护的每个进程打开的文件的记录表。当程序打开现有文件或创建新文件时,内核会向进程返回文件描述符。在程序设计中,一些低级程序通常是围绕文件描述符编写的。但是文件描述符的概念通常仅适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 也称为标准 I/O大多数文件系统的默认值 I/O 缓存所有操作 I/O。在 Linux 的缓存 I/O 在机制中,操作系统将 I/O 在文件系统的页面缓存中缓存数据( page cache 换句话说,数据首先复制到操作系统内核的缓冲区,然后从操作系统内核的缓冲区复制到应用程序的地址空间。

缓存 I/O 的缺点:
在传输过程中,需要在应用地址空间和内核中多次复制数据,这带来了 CPU 而且内存开销非常高。

二 IO模式

正如我刚才提到的,有一段时间IO访问(以read例如,数据将首先复制到操作系统内核的缓冲区,然后从操作系统内核的缓冲区复制到应用程序的地址空间。所以,当read当操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核复制到进程 (Copying the data from the kernel to the process)

因为这两个阶段,linux系统生成了以下五种网络模式解决方案。

  • 阻塞 I/O(blocking IO)
  • 非阻塞 I/O(nonblocking IO)
  • I/O 多路复用( IO multiplexing)
  • 信号驱动 I/O( signal driven IO)
  • 异步 I/O(asynchronous IO)

注:由于signal driven IO实践中不常用,所以我只提剩下的四个IO Model。

阻塞 I/O(blocking IO)

在linux默认情况下,所有socket都是blocking典型的读取操作过程大致如下:

当用户进程调用时recvfrom此系统调用,kernel就开始了IO第一阶段:准备数据(用于网络)IO例如,很多时候数据还没有到达开头。例如,我还没有收到完整的UDP包。此时kernel我们需要等待足够的数据到达。此过程需要等待,这意味着将数据复制到操作系统内核的缓冲区中需要一个过程。在用户进程端,整个进程都会被封堵(当然是进程自己选择的封堵)。什么时候kernel在数据准备就绪之前,它将从kernel复制到用户内存,然后kernel用户进程仅在返回结果时终止block状态,重新启动。

所以,blocking IO的特点是IO执行的两个阶段都是block了。

非阻塞 I/O(nonblocking IO)

linux接下来,您可以设置socket使其变为non-blocking。当面对non-blocking socket执行读取操作时,该过程如下所示:

当用户进程出现问题时read在操作期间,如果kernel中的数据尚未准备就绪,因此不会block用户进程,但立即返回error。从用户流程的角度 它启动一个read手术后,无需等待,但会立即获得结果。用户进程判断结果为一个error当它这样做时,它知道数据尚未准备就绪,因此它可以再次发送read操作。一次kernel中的数据已准备就绪,并且已从用户进程中再次接收system call因此,它会立即将数据复制到用户内存中并返回。

所以,nonblocking IO的特点是用户进程需要 不断主动询问 kernel数据准备好了吗?

I/O 多路复用( IO multiplexing)

IO multiplexing这就是我们正在谈论的select,poll,epoll有些地方也这样称呼它IO方式为event driven IO。select/epoll好处在于个人process可以同时处理多个网络连接IO。其基本原理是select,poll,epoll这个function将持续调查所有责任人socket,当某个socket在数据到达时通知用户进程。

当用户进程调用时select所以整个过程将是block 与此同时,kernel将“监控”所有select负责的socket当任何socket中的数据已准备就绪,select会回来的。此时,用户进程再次调用read从中传输数据的操作kernel复制到用户进程。

所以,I/O 多路复用的特征在于一种机制,在该机制中,进程可以同时等待多个文件描述符,并且这些文件描述符(套接字描述符)中的任何一个都进入读取就绪状态,select()该函数可以返回。

这个图和blocking IO情况并没有太大的不同,事实上,情况更糟。因为这里我们需要使用两个system call (select 和 recvfrom),而blocking IO只打了一个电话system call (recvfrom)。但是,使用select优点是可以同时处理多个connection。

因此,如果处理的连接数不是很高,请使用select/epoll的web server不一定比使用更好multi-threading + blocking IO的web server更好的性能,可能具有更大的延迟。select/epoll优点不在于它可以更快地处理单个连接,而是它可以处理更多的连接。

在IO multiplexing Model在实践中,对于每个socket,通常设置为non-blocking但是,如上图所示,整个用户的process实际上,它一直在block的 只是process是被select这个函数block而不是成为socket IO给block。

异步 I/O(asynchronous IO)

inux下的asynchronous IO实际上,它很少使用。我们先来看看它的流程:

用户进程启动read手术后,您可以立即开始做其他事情。另一方面,从kernel它受到asynchronous read之后,首先它会立即返回,因此不会对用户进程产生任何影响block。然后,kernel我们将等待数据准备完成,然后将数据复制到用户的内存中。在所有这些完成后,kernel将发送一个signal,告诉它read操作完成。

总结

blocking和non-blocking的区别

调用blocking IO会一直block停留在相应的进程中,直到操作完成,并且non-blocking IO在kernel如果数据仍然准备就绪,它将立即返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO在区分之前,有必要提供两者的定义。POSIX“的定义如下:

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
  • An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别在于synchronous IO做”IO operation“当时process堵塞。根据这个定义,如前所述blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有一个非常“狡猾”的地方,定义指的是”IO operation“它指的是真实的IO操作,如示例中所示recvfrom这个system call。non-blocking IO在执行recvfrom这个system call何时,如果kernel数据尚未准备就绪,因此此时不会准备就绪block过程。但是当kernel当数据准备就绪时,recvfrom将传输数据从kernel复制到用户内存,此时进程是block在此期间,该过程是block的。

而asynchronous IO流程开始时情况不同IO 手术后直接返回,不再注意,直到kernel向进程发送信号,说明IO完成。在整个过程中,整个过程完全不受影响block。

各个IO Model对比如图所示:

从上图可以看出,non-blocking IO和asynchronous IO区别还是很明显的。留non-blocking IO虽然大部分时间过程不会受到影响block但它仍然需要这个过程是主动的check并且数据准备完成后,流程还需要再次主动调用recvfrom将数据复制到用户内存。和asynchronous IO这是完全不同的。这就像一个用户进程将整个IO手术已移交给其他人(kernel)完成,然后在其他人完成时发送通知他们的信号。在此期间,用户进程不需要检查IO操作状态不需要主动复制数据。

三 I/O 多路复用select、poll、epoll详解

select,poll,epoll都是IO多路复用的机制。I/O多路复用是一种机制,通过该机制,进程可以监视多个描述符,一旦描述符准备就绪(通常是读取或写入就绪),它就可以通知程序执行相应的读写操作。但select,poll,epoll从本质上讲,这一切都是同步的I/O因为它们都需要在读写事件准备好后自行负责读写,这意味着读写过程被阻塞和异步I/O你不需要负责自己读写,异步I/O的实现将负责将数据从内核复制到用户空间。(这里要啰嗦)

select

int select (int n, fd_set readfds, fd_set writefds, fd_set exceptfds, struct timeval timeout);

select 功能监控的文件描述符得分3类,分别writefds、readfds、和exceptfds。调用后select该函数将阻塞,直到准备好描述(带有数据) 可读、可写或可用except),或超时 (timeout指定等待时间,以及如果立即返回设置为null函数返回。什么时候select函数返回后,您可以 通过遍历fdset查找现成的描述符。

select目前,它几乎在所有平台上都得到支持,其出色的跨平台支持也是一个优势。select的一 缺点是单个进程可以监视的文件描述符数量存在最大限制Linux上一般为1024可以通过修改宏定义甚至重新编译内核来提高此限制,但是 这也可能导致效率下降。

poll

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

不同与select使用三个位图表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd { int fd; / file descriptor / short events; / requested events to watch / short revents; / returned events witnessed / };

pollfd该结构包含event和发生的event,不再使用select“参数-传递值的方法。同时pollfd没有最大数量限制(但如果数量太大,性能也会降低)。 和select功能是一样的,poll返回后,需要轮询pollfd获取现成的描述符。

从上面看,select和poll回来后, 通过遍历文件描述符做好准备socket 。事实上,同时连接的大量客户端一次可能只有很少处于就绪状态,因此随着监控描述符数量的增加,其效率也会线性下降。

epoll

epoll是在2.6在内核中提出,这是以前的select和poll的增强版本。相对于select和poll来说,epoll更灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,并将用户关系的文件描述符的事件存储在内核的事件表中,以便copy就一次。

一 epoll操作过程

epoll操作过程需要三个接口,如下所示:

int epoll_create(int size);//创建一个epoll的句柄,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);

1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共这有多大参数不同于select()中的第一个参数提供最大侦听fd+1的值, 参数size这不是限制epoll可以侦听的最大描述符数量只是内核对内部数据结构初始分配的建议
当创建好epoll手柄后,它将占据一个fd值,在linux如果查看/proc/进程id/fd/能够看到这个fd所以使用后epoll之后,有必要打电话close()关闭,否则可能会导致fd被耗尽。

*2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event event);**
该函数适用于指定的描述符fd执行op操作。

  • epfd:是epoll_create()的返回值。
  • op:表示op操作,由三个宏表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的侦听事件。
  • fd:需要监控fd(文件描述符)
  • epoll_event它告诉内核要侦听什么,struct epoll_event结构如下:

struct epoll_event { __uint32_t events; / Epoll events / epoll_data_t data; / User data variable / };

//events它可以是以下宏的集合: EPOLLIN :表示可以读取相应的文件描述符(包括对等方)SOCKET正常关机); EPOLLOUT:表示可以写入相应的文件描述符; EPOLLPRI:表示对应的文件描述符有紧急数据要读取(这里应该表示带外数据已经到达); EPOLLERR:表示对应的文件描述符有错误; EPOLLHUP:表示对应的文件描述符挂断; EPOLLET: 将EPOLL设置为边缘触发(Edge Triggered)模式,相对于水平触发(Level Triggered)来说的。 EPOLLONESHOT:只收听一个事件。听完这个事件,如果还需要继续听socket如果是这样,我们需要再次添加它socket加入到EPOLL队列里

*3. int epoll_wait(int epfd, struct epoll_event events, int maxevents, int timeout);**
等待epfd上的io事件,最大回报maxevents个事件。
参数events用于从内核获取事件集合,maxevents告诉内核这个events这有多大maxevents的值不能大于创建的值epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1会有不确定性,有人说这是一个永久性的阻塞。此函数返回需要处理的事件数,例如返回0指示已发生超时。

二 工作模式

epoll文件描述符有两种操作模式: LT(level trigger)ET(edge trigger) 。LT该模式是默认模式,LT模式与ET模式差异如下:
LT模式 :当epoll_wait检测描述符事件的发生并通知应用程序此事件, 应用程序可能不会立即处理此事件 。下一个调用epoll_wait何时,应用程序将再次响应并收到此事件的通知。
ET模式 :当epoll_wait检测描述符事件的发生并通知应用程序此事件, 应用程序必须立即处理此事件 。如果未处理,请下次致电epoll_wait此时,应用程序将不会再次响应并收到此事件的通知。

  1. LT模式

LT(level triggered)这是默认的工作模式,支持两者block和no-block socket.这样,内核会告诉你一个文件描述符是否准备好了,然后你可以检查准备好的fd进行IO操作。如果您不采取任何操作,内核将继续通知您。

  1. ET模式

ET(edge-triggered)它是一种高速工作模式,仅支持no-block socket。在此模式下,当描述符从“从未就绪”更改为“就绪”时,内核将传递epoll告诉你。然后,它将假定您知道文件描述符已准备就绪,并且不会为该文件描述符发送更多就绪通知,直到您执行导致该文件描述符不再准备就绪的操作(例如,当您发送、接收或接收请求时,或者当发送或接收的数据量小于特定数量时,它会导致EWOULDBLOCK 错误)。但请注意,如果这仍然不正确fd作IO操作(导致它再次变得毫无准备)内核不会发送更多通知(only once)

ET模式已大大减少epoll事件被重复触发的次数,因此效率高于LT模式高。epoll工作在ET在模式下,必须使用非阻塞套接字以避免由于文件句柄而阻塞读取/阻塞写入操作会使处理多个文件描述符的任务变得匮乏。

  1. 总结

如果有示例:

  1. 我们创建了一个文件句柄,用于从管道读取数据(RFD)添加到epoll描述符
  2. 此时,它是从管道的另一端写入的2KB的数据
  3. 调用epoll_wait(2)它会回来RFD,表示已准备好执行读取操作
  4. 然后我们读1KB的数据
  5. 调用epoll_wait(2)......

LT模式:
如果是LT模式,然后在部分中5步调用epoll_wait(2)之后,他们仍然可以收到通知。

ET模式:
如果我们在1步将RFD添加到epoll使用描述符时EPOLLET签名,然后在5步调用epoll_wait(2)之后,可能会出现挂起,因为剩余数据仍然存在于文件的输入缓冲区中,并且数据发送方仍在等待对已发送数据的反馈。仅当受监视的文件句柄上发生事件时 ET 只有在工作模式下才会报告事件。因此,在部分5在步骤中,调用方可能会放弃等待文件输入缓冲区中仍然存在的剩余数据。

当使用epoll的ET当模型开始工作时,当EPOLLIN事件后,
读取数据时,需要考虑何时recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次阅读:

while(rs){ buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0); if(buflen < 0){ // 由于其非阻塞模式,所以当errno为EAGAIN时,指示当前缓冲区没有要读取的数据 // 这是处理事件的地方. if(errno == EAGAIN){ break; } else{ return; } } else if(buflen == 0){ // 这代表相反的一端socket常闭. }

if(buflen == sizeof(buf){ rs = 1; // 需要再次阅读 } else{ rs = 0; } }

Linux中的EAGAIN含义

Linux在环境中开发经常会遇到许多错误(设置errno),其中EAGAIN是他们之间的常见错误(例如,在非阻塞操作中)。
从字面上看,这是一个重试的提示。当应用程序执行某些非阻塞时,通常会发生此错误(non-blocking)操作(对文件或socket)的时候。

例如,以 O_NONBLOCK标记要打开的文件/socket/FIFO如果你继续做read没有数据可读性的操作。此时,程序不会阻塞并等待数据准备好返回,read该函数将返回错误EAGAIN如果您的应用程序当前没有要读取的数据,请稍后重试。
例如,当系统调用时(比如fork)因为没有足够的资源(例如,虚拟内存)并且执行失败,返回EAGAIN提示它再次调用(也许我们下次能成功)。

三 代码演示

以下是格式不正确的不完整代码,旨在表达上述过程并删除一些模板代码。

define IPADDRESS "127.0.0.1"

define PORT 8787

define MAXSIZE 1024

define LISTENQ 5

define FDSIZE 1000

define EPOLLEVENTS 100

listenfd = socket_bind(IPADDRESS,PORT);

struct epoll_event events[EPOLLEVENTS];

//创建描述符 epollfd = epoll_create(FDSIZE);

//添加侦听描述符事件 add_event(epollfd,listenfd,EPOLLIN);

//循环等待 for ( ; ; ){ //此函数返回准备好的描述符事件数 ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1); //处理收到的连接 handle_events(epollfd,events,ret,listenfd,buf); }

//事件处理函数 static void handle_events(int epollfd,struct epoll_event events,int num,int listenfd,char buf) { int i; int fd; //进行遍历;您需要做的就是遍历准备好的io事件。num不像原来那样epoll_create时的FDSIZE。 for (i = 0;i < num;i++) { fd = events[i].data.fd; //基于描述符类型和事件类型的流程 if ((fd == listenfd) &&(events[i].events & EPOLLIN)) handle_accpet(epollfd,listenfd); else if (events[i].events & EPOLLIN) do_read(epollfd,fd,buf); else if (events[i].events & EPOLLOUT) do_write(epollfd,fd,buf); } }

//添加事件 static void add_event(int epollfd,int fd,int state){ struct epoll_event ev; ev.events = state; ev.data.fd = fd; epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev); }

//处理收到的连接 static void handle_accpet(int epollfd,int listenfd){ int clifd;
struct sockaddr_in cliaddr;
socklen_t cliaddrlen;
clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);
if (clifd == -1)
perror("accpet error:");
else {
printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port); //添加客户描述符和事件
add_event(epollfd,clifd,EPOLLIN);
} }

//读处理 static void do_read(int epollfd,int fd,char *buf){ int nread; nread = read(fd,buf,MAXSIZE); if (nread == -1) {
perror("read error:");
close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLIN); //删除监听 } else if (nread == 0) {
fprintf(stderr,"client close.\n"); close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLIN); //删除监听 }
else {
printf("read message is : %s",buf);
//将描述符对应的事件从读修改为写
modify_event(epollfd,fd,EPOLLOUT);
} }

//写处理 static void do_write(int epollfd,int fd,char *buf) {
int nwrite;
nwrite = write(fd,buf,strlen(buf));
if (nwrite == -1){
perror("write error:");
close(fd); //记住close fd
delete_event(epollfd,fd,EPOLLOUT); //删除监听
}else{ modify_event(epollfd,fd,EPOLLIN); }
memset(buf,0,MAXSIZE); }

//删除事件 static void delete_event(int epollfd,int fd,int state) { struct epoll_event ev; ev.events = state; ev.data.fd = fd; epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev); }

//修改事件 static void modify_event(int epollfd,int fd,int state){
struct epoll_event ev; ev.events = state; ev.data.fd = fd; epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev); }

//注意:在另一端,我保存了它

四 epoll总结

在 select/poll在进程中,内核仅在进程调用某些方法后扫描所有受监视的文件描述符 epoll事先通过epoll_ctl()来注册一 文件描述符。一旦文件描述符准备就绪,内核将使用callback在进程调用时快速激活文件描述符epoll_wait() 立即通知 。( 此处删除了遍历文件描述符的机制,但监视了回调机制 。这正是epoll魅力在于。)

epoll其主要优点如下:

  1. 受监控的描述符数量不受限制,它支持FD上限是可以打开的最大文件数,通常远大于2048,举个例子,在1GB在内存机器上,它大约10万左 对,具体数字可以是cat /proc/sys/fs/file-max察看,一般来说,这个数字与系统内存密切相关。select最大的缺点是过程是开放的fd有数量限制。这对 对于连接数相对较大的服务器,这根本不够。虽然也可以选择多种工艺解决方案( Apache这就是它的工作原理)但虽然linux上面创建流程的成本相对较小,但不容忽视。此外,进程之间的数据同步远不如线程同步效率低,因此它不是一个完美的解决方案。

  2. IO效率不会随监控而变化fd数量的增加导致减少。epoll不同于select和poll轮询的方法,但通过每个fd定义了要实现的回调函数。仅准备就绪fd只有这样,才会执行回调函数。

如果不是大量idle -connection或者dead-connection,epoll效率无法与select/poll要高得多,但是当遇到大量idle- connection你会发现epoll效率远高于select/poll。

版权声明

所有资源都来源于爬虫采集,如有侵权请联系我们,我们将立即删除

热门