1

IO 模型详解

 2 years ago
source link: https://lemon-cs.github.io/2020/07/11/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/IO%E6%A8%A1%E5%9E%8B%E8%AF%A6%E8%A7%A3/#8-%E9%98%BB%E5%A1%9E%E3%80%81%E9%9D%9E%E9%98%BB%E5%A1%9E%E3%80%81%E5%90%8C%E6%AD%A5%E3%80%81%E5%BC%82%E6%AD%A5IO%E5%88%92%E5%88%86
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Lemon-CS

IO 模型详解

发表于 2020-07-11|更新于 2021-12-29|操作系统
字数总计:7.5k|阅读时长:24 分钟 | 阅读量:9

什么是 IO 呢?什么是阻塞非阻塞 IO?什么是同步异步 IO?什么是 IO 多路复用?select/epoll 跟 IO 模型有什么关系?有几种经典 IO 模型呢?BIO、NIO、AIO 到底有什么区别的?

1. 什么是 IO 呢?

IO,英文全称是 Input/Output,翻译过来就是输入 / 输出。平时我们听得挺多,就是什么磁盘 IO,网络 IO。那 IO 到底是什么呢?是不是有种懵懵懂懂的感觉呀,好像大概知道它是什么,又好像说不清楚。

IO,即输入 / 输出,到底谁是输入?谁是输出呢?IO 如果脱离了主体,就会让人疑惑。

1.1 计算机角度的 IO

我们常说的输入输出,比较直观的意思就是计算机的输入输出计算机就是主体。大家是否还记得,大学学计算机组成原理的时候,有个冯。诺依曼结构,它将计算机分成分为 5 个部分:运算器、控制器、存储器、输入设备、输出设备。

输入设备是向计算机输入数据和信息的设备,键盘,鼠标都属于输入设备;输出设备是计算机硬件系统的终端设备,用于接收计算机数据的输出显示,一般显示器、打印机属于输出设备。

例如你在鼠标键盘敲几下,它就会把你的指令数据,传给主机,主机通过运算后,把返回的数据信息,输出到显示器。

鼠标、显示器这只是直观表面的输入输出,回到计算机架构来说,涉及计算机核心与其他设备间数据迁移的过程,就是 IO。如磁盘 IO,就是从磁盘读取数据到内存,这算一次输入,对应的,将内存中的数据写入磁盘,就算输出。这就是 IO 的本质。

1.2 操作系统的 IO

我们要将内存中的数据写入到磁盘的话,主体会是什么呢?主体可能是一个应用程序,比如一个 Java 进程(假设网络传来二进制流,一个 Java 进程可以把它写入到磁盘)。

操作系统负责计算机的资源管理和进程的调度。我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。也就是说,你的应用程序要把数据写入磁盘,只能通过调用操作系统开放出来的 API 来操作。

  • 什么是用户空间?什么是内核空间?
  • 以 32 位操作系统为例,它为每一个进程都分配了 4G (2 的 32 次方) 的内存空间。这 4G 可访问的内存空间分为二部分,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。

我们应用程序是跑在用户空间的,它不存在实质的 IO 过程,真正的 IO 是在操作系统执行的。即应用程序的 IO 操作分为两种动作: IO 调用和 IO 执行。IO 调用是由进程(应用程序的运行态)发起,而 IO 执行是操作系统内核的工作。此时所说的 IO 是应用程序对操作系统 IO 功能的一次触发,即 IO 调用。

2. 操作系统的一次 IO 过程

应用程序发起的一次 IO 操作包含两个阶段:

  • IO 调用:应用程序进程向操作系统内核发起调用。
  • IO 执行:操作系统内核完成 IO 操作。

操作系统内核完成 IO 操作还包括两个过程:

  • 准备数据阶段:内核等待 I/O 设备准备好数据
  • 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区

其实 IO 就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket 通讯的网卡。一个完整的 IO 过程包括以下几个步骤:

  • 应用程序进程向操作系统发起 IO 调用请求
  • 操作系统准备数据,把 IO 外部设备的数据,加载到内核缓冲区
  • 操作系统拷贝数据,即将内核缓冲区的数据,拷贝到用户进程缓冲区

在 Linux 系统中,传统的访问方式是通过 write () 和 read () 两个系统调用实现的,通过 read () 函数读取文件到到缓存区中,然后通过 write () 方法把缓存中的数据输出到网络端口。

下图分别对应传统 I/O 操作的数据读写流程,整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝,总共 4 次拷贝,以及 4 次上下文切换。

  • CPU 拷贝: 由 CPU 直接处理数据的传送,数据拷贝时会一直占用 CPU 的资源。
  • DMA 拷贝: 由 CPU 向 DMA 磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。
  • 上下文切换: 当用户程序向内核发起系统调用时,CPU 将用户进程从用户态切换到内核态; 当系统调用返回时,CPU 将用户进程从内核态切换回用户态。

文件描述符 fd

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

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

2.1 读操作

当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。

如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(Read Buffer)中,再从读缓存拷贝到用户进程的页内存中。

read(file_fd, tmp_buf, len);

基于传统的 I/O 读取方式,read 系统调用会触发 2 次上下文切换,1 次 DMA 拷贝和 1 次 CPU 拷贝。

发起数据读取的流程如下:

  1. 用户进程通过 read () 函数向 Kernel 发起 System Call,上下文从 user space 切换为 kernel space。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到 kernel space 的读缓冲区(Read Buffer)。
  3. CPU 将读缓冲区(Read Buffer)中的数据拷贝到 user space 的用户缓冲区(User Buffer)。
  4. 上下文从 kernel space 切换回用户态(User Space),read 调用执行返回。

2.2 写操作

当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(Socket Buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。

基于传统的 I/O 写入方式,write () 系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。

用户程序发送网络数据的流程如下:

  1. 用户进程通过 write () 函数向 kernel 发起 System Call,上下文从 user space 切换为 kernel space。
  2. CPU 将用户缓冲区(User Buffer)中的数据拷贝到 kernel space 的网络缓冲区(Socket Buffer)。
  3. CPU 利用 DMA 控制器将数据从网络缓冲区(Socket Buffer)拷贝到 NIC 进行数据传输。
  4. 上下文从 kernel space 切换回 user space,write 系统调用执行返回。

2.3 网络 I/O

2.4 磁盘 I/O

3. 阻塞 IO 模型

我们已经知道 IO 是什么啦,那什么是阻塞 IO 呢?

假设应用程序的进程发起 IO 调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次 IO 操作,称之为阻塞 IO

  • 阻塞 IO 比较经典的应用就是阻塞 socket、Java BIO
  • 阻塞 IO 的缺点就是:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能,可以使用非阻塞 IO 优化。

4. 非阻塞 IO 模型

如果内核数据还没准备好,可以先返回错误信息给用户进程,让它不需要等待,而是通过轮询的方式再来请求。这就是非阻塞 IO,流程图如下:

非阻塞 IO 的流程如下:

  • 应用进程向操作系统内核,发起 recvfrom 读取数据。
  • 操作系统内核数据没有准备好,立即返回 EWOULDBLOCK 错误码。
  • 应用程序进程轮询调用,继续向操作系统内核发起 recvfrom 读取数据。
  • 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。
  • 完成调用,返回成功提示。

非阻塞 IO 模型,简称 NIONon-Blocking IO。它相对于阻塞 IO,虽然大幅提升了性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的 CPU 资源。可以考虑 IO 复用模型,去解决这个问题。

5. IO 多路复用模型

既然 NIO 无效的轮询会导致 CPU 资源消耗,我们等到内核数据准备好了,主动通知应用进程再去进行系统调用,那不就好了嘛?

在这之前,我们先来复习下,什么是文件描述符 fd(File Descriptor), 它是计算机科学中的一个术语,形式上是一个非负整数。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

IO 复用模型核心思路:系统给我们提供一类函数(如我们耳濡目染的 select、poll、epoll 函数),它们可以同时监控多个 fd 的操作,任何一个返回内核数据就绪,应用进程再发起 recvfrom 系统调用。

5.1 什么是 IO 多路复用

「定义」

  • IO 多路复用是一种同步 IO 模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出 cpu。多路是指网络连接,复用指的是同一个线程。

5.2 为什么有 IO 多路复用机制?

没有 IO 多路复用机制时,有 BIO、NIO 两种实现方式,但有一些问题

同步阻塞(BIO)

  • 服务端采用单线程,当 accept 一个请求后,在 recv 或 send 调用阻塞时,将无法 accept 其他请求(必须等上一个请求处 recv 或 send 完),无法处理并发
// 伪代码描述  
while(1) {
  // accept阻塞
  client_fd = accept(listen_fd)
  fds.append(client_fd)
  for (fd in fds) {
    // recv阻塞(会影响上面的accept)
    if (recv(fd)) {
      // logic
    }
  }  
}
  • 服务器端采用多线程,当 accept 一个请求后,开启线程进行 recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept都开一个线程也是一种资源浪费
// 伪代码描述  
while(1) {
  // accept阻塞
  client_fd = accept(listen_fd)
  // 开启线程read数据(fd增多导致线程数增多)
  new Thread func() {
    // recv阻塞(多线程不影响上面的accept)
    if (recv(fd)) {
      // logic
    }
  }  
}

同步非阻塞(NIO)

  • 服务器端当 accept 一个请求后,加入 fds 集合,每次轮询一遍 fds 集合 recv (非阻塞) 数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu
setNonblocking(listen_fd)  
// 伪代码描述
while(1) {
  // accept非阻塞(cpu一直忙轮询)
  client_fd = accept(listen_fd)
  if (client_fd != null) {
    // 有人连接
    fds.append(client_fd)
  } else {
    // 无人连接
  }  
  for (fd in fds) {
    // recv非阻塞
    setNonblocking(client_fd)
    // recv 为非阻塞命令
    if (len = recv(fd) && len > 0) {
      // 有读写数据
      // logic
    } else {
       无读写数据
    }
  }  
}

IO 多路复用(现在的做法)

  • 服务器端采用单线程通过 select/epoll 等系统调用获取 fd 列表,遍历有事件的 fd 进行 accept/recv/send,使其能支持更多的并发连接请求
fds = [listen_fd]  
// 伪代码描述
while(1) {
  // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
  // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
  for (fd in select(fds)) {
    if (fd == listen_fd) {
        client_fd = accept(listen_fd)
        fds.append(client_fd)
    } elseif (len = recv(fd) && len != -1) { 
      // logic
    }
  }  
}

5.3 IO 多路复用之 select

应用进程通过调用 select 函数,可以同时监控多个 fd,在 select 函数监控的 fd 中,只要有任何一个数据状态准备就绪了,select 函数就会返回可读状态,这时应用进程再发起 recvfrom 请求去读取数据。

非阻塞 IO 模型(NIO)中,需要 N(N>=1)次轮询系统调用,然而借助 select 的 IO 多路复用模型,只需要发起一次询问就够了,大大优化了性能。

但是呢,select 有几个缺点:

  • 监听的 IO 最大连接数有限,在 Linux 系统上一般为 1024。可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
  • select 函数返回后,是通过遍历fdset,找到就绪的描述符 fd。(仅知道有 I/O 事件发生,却不知是哪几个流,所以遍历所有流

因为存在连接数限制,所以后来又提出了 poll。与 select 相比,poll 解决了 连接数限制问题。但是呢,select 和 poll 一样,还是需要通过遍历文件描述符来获取已经就绪的 socket。如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降

select 函数接口

#include <sys/select.h>  
#include <sys/time.h>

#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)

// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;

// API
int select(
int max_fd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout
)                              // 返回值就绪描述符的数目

FD_ZERO(int fd, fd_set* fds)   // 清空集合
FD_SET(int fd, fd_set* fds)    // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds)  // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds)    // 将给定的描述符从文件中删除

select 使用示例

int main() {  
  /*
   * 这里进行一些初始化的设置,
   * 包括socket建立,地址的设置等,
   */

fd_set read_fs, write_fs;
  struct timeval timeout;
  int max = 0;  // 用于记录最大的fd,在轮询中时刻更新即可

// 初始化比特位
  FD_ZERO(&read_fs);
  FD_ZERO(&write_fs);

int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
  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) {
         --nfds;
         // 这里处理accept事件
         FD_SET(i, &read_fd);//将客户端socket加入到集合中
      }
      if (FD_ISSET(i, &read_fd)) {
        --nfds;
        // 这里处理read事件
      }
      if (FD_ISSET(i, &write_fd)) {
         --nfds;
        // 这里处理write事件
      }
    }
  }

select 缺点

    1. select 最大的缺陷就是单个进程所打开的 FD 是有一定限制的,它由 FD_SETSIZE 设置,默认值是 1024。

      一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32 位机默认是 1024 个。64 位机默认是 2048.

  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
  • 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)

    当套接字比较多的时候,每次 select () 都要通过遍历 FD_SETSIZE 个 Socket 来完成调度,不管哪个 Socket 是活跃的,都遍历一遍。这会浪费很多 CPU 时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是 epoll 与 kqueue 做的。

5.4 IO 多路复用之 poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
  2. poll还有一个特点是“水平触发”,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd。

注意:
从上面看,select 和 poll 都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

poll 函数接口

poll 与 select 相比,只是没有 fd 的限制,其它基本一样

#include <poll.h>  
// 数据结构
struct pollfd {
    int fd;                         // 需要监视的文件描述符
    short events;                   // 需要内核监视的事件
    short revents;                  // 实际发生的事件
};

// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);

poll 使用示例

// 先宏定义长度  
#define MAX_POLLFD_LEN 4096

int main() {
  /*
   * 在这里进行一些初始化的操作,
   * 比如初始化数据和socket等。
   */

int nfds = 0;
  pollfd fds[MAX_POLLFD_LEN];
  memset(fds, 0, sizeof(fds));
  fds[0].fd = listenfd;
  fds[0].events = POLLRDNORM;
  int max  = 0;  // 队列的实际长度,是一个随时更新的,也可以自定义其他的
  int timeout = 0;

int current_size = max;
  while (1) {
    // 阻塞获取
    // 每次需要把fd从用户态拷贝到内核态
    nfds = poll(fds, max+1, timeout);
    if (fds[0].revents & POLLRDNORM) {
        // 这里处理accept事件
        connfd = accept(listenfd);
        //将新的描述符添加到读描述符集合中
    }
    // 每次需要遍历所有fd,判断有无读写事件发生
    for (int i = 1; i < max; ++i) {     
      if (fds[i].revents & POLLRDNORM) { 
         sockfd = fds[i].fd
         if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
            // 这里处理read事件
            if (n == 0) {
                close(sockfd);
                fds[i].fd = -1;
            }
         } else {
             // 这里处理write事件     
         }
         if (--nfds <= 0) {
            break;       
         }   
      }
    }
  }

poll 缺点

  • 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
  • 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)

5.5 IO 多路复用之 epoll

为了解决 select/poll 存在的问题,多路复用模型 epoll 诞生,它采用事件驱动来实现,流程图如下:

epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次

epoll 先通过 epoll_ctl() 来注册一个 fd(文件描述符),一旦基于某个 fd 就绪时,内核会采用回调机制,迅速激活这个 fd,当进程调用 epoll_wait() 时便得到通知。这里去掉了遍历文件描述符的坑爹操作,而是采用监听事件回调的机制。这就是 epoll 的亮点。

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait 便可以收到通知。

epoll 优点

  • 没有最大并发连接的限制,能打开的 FD 的上限远大于 1024(1G 的内存上能监听约 10 万个端口)。
  • 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的 FD 才会调用 callback 函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll 的效率就会远远高于 select 和 poll。
  • 内存拷贝,利用 mmap () 文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销

epoll 操作模式

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 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读 / 阻塞写操作把处理多个文件描述符的任务饿死。

在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而 epoll 事先通过 epoll_ctl () 来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait () 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll 函数接口

#include <sys/epoll.h>  

// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
};

// API

int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程

epoll 使用示例

int main(int argc, char* argv[])  
{
   /*
   * 在这里进行一些初始化的操作,
   * 比如初始化数据和socket等。
   */

// 内核中创建ep对象
    epfd=epoll_create(256);
    // 需要监听的socket放到ep中
    epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);

while(1) {
      // 阻塞获取
      nfds = epoll_wait(epfd,events,20,0);
      for(i=0;i<nfds;++i) {
          if(events[i].data.fd==listenfd) {
              // 这里处理accept事件
              connfd = accept(listenfd);
              // 接收新连接写到内核对象中
              epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
          } else if (events[i].events&EPOLLIN) {
              // 这里处理read事件
              read(sockfd, BUF, MAXLINE);
              //读完后准备写
              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
          } else if(events[i].events&EPOLLOUT) {
              // 这里处理write事件
              write(sockfd, BUF, n);
              //写完后准备读
              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
          }
      }
    }
    return 0;
}

5.6 select、poll、epoll 区别

综上,在选择 select,poll,epoll 时要根据具体的使用场合以及这三种方式的自身特点:

  1. 表面上看 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟 epoll 的通知机制需要很多函数回调。
  2. select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。

6. IO 模型之信号驱动模型

信号驱动 IO 不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用 sigaction 的时候建立一个 SIGIO 的信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过 SIGIO 信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号之后,立即调用 recvfrom,去读取数据。

信号驱动 IO 模型,在应用进程发出信号后,是立即返回的,不会阻塞进程。它已经有异步操作的感觉了。但是你细看上面的流程图,发现数据复制到应用缓冲的时候,应用进程还是阻塞的。回过头来看下,不管是 BIO,还是 NIO,还是信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的。还有没有优化方案呢? AIO(真正的异步 IO)!

7. IO 模型之异步 IO (AIO)

前面讲的 BIO,NIO和信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO 实现了 IO 全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程 IO 操作执行完毕。

流程如下:

异步 IO 的优化思路很简单,只需要向内核发送一次请求,就可以完成数据状态询问和数据拷贝的所有操作,并且不用阻塞等待结果。日常开发中,有类似思想的业务场景:

比如发起一笔批量转账,但是批量转账处理比较耗时,这时候后端可以先告知前端转账提交成功,等到结果处理完,再通知前端结果即可。

8. 阻塞、非阻塞、同步、异步 IO 划分

参考感谢
看一遍就理解:IO 模型详解 (qq.com)
https://mp.weixin.qq.com/s/qLGFLVYieO5NI6ay_8d7QA
【面试】彻底理解 IO 多路复用? (qq.com)
聊聊 IO 多路复用之 select、poll、epoll 详解 - 简书 (jianshu.com)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK