彻底弄懂IO这件事情 - 从5种io模型开始

新人菜鸟,尝试讲清楚io这件事情

主要包括 bio,nio,select,epoll

可能也会包含一些 mmap,零拷贝 等一些系统调用

本文中的一些例子主要以 linux + 网络io 为主

闲扯淡

  • 为啥要彻底弄懂IO这件事情?
  • 面试的时候问:redis网络模型是什么啊?nginx网络模型是什么啊?
  • epoll 啊,这就需要牵扯到IO的事情了
  • 其实IO这就是就是一个成本的问题,因为用户进程不能直接操作硬件,无法直接从网卡读取数据,需要走 kernel 这一层,必须通过 systemcall(软中断),作用在 cpu 上
  • 让kernel帮我们取到硬件上的数据,而这个操作就是成本所在,如何减少这一步的消耗,成为了网络吞吐的一个关键点,有关IO技术的发展,也都是基于这一个出发点的

操作系统的预备知识

  • 有关这个部分的知识请看我的另外一篇文章用户态 内核态
  • 我在这儿把重点列一下
    • 操作系统启动之后的第一个进程是 kernel (操作系统内核)
      • kernel 会注册 GDT
    • kernel 运行在 Range0 级别,用户进程运行在 Range3 级别
    • 内核 控制着计算机硬件资源(eg:磁盘,网卡)
    • 用户进程需要进行 磁盘读写、网络通信等io事件的时候需要通过系统调用来实现,这个时候进程从 Ring3 切换到 Ring0 级别,完成相应的操作之后再切回 Ring3
    • 除去上面说说的 io 事件需要进行 用户态 & 内核态 的切换,例如申请内存之类的也是需要的

服务器架构图(无论什么语言)

  1. 系统调用 socket 得到一个文件描述符fd5
  2. 系统调用 bind 一个端口
  3. 系统调用 listen 文件描述符fd5
  4. 系统调用 accept(阻塞) fd5 接受连接,返回一个新的文件描述符 fd6,代表与客户端的连接
  5. 系统调用 read/rerecvfrom fd6 读取数据

五种 IO 模型

  • 在神作《UNIX 网络编程》里,总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:

    • 阻塞 I/O (Blocking I/O)
    • 非阻塞 I/O (Nonblocking I/O)
    • I/O 多路复用 (I/O multiplexing)
    • 信号驱动 I/O (Signal driven I/O)
    • 异步 I/O (Asynchronous I/O)
  • 操作系统上的 I/O 是用户空间和内核空间的数据交互,因此 I/O 操作通常包含以下两个步骤:

    1. 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
    2. 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)
  • 而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。

  • 这 5 种 I/O 模型的对比如下:
    五个I/O模型比较

Bio

  • block io

  • 同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。
    Bio

  • 采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。我们一般通过在while(true) 循环中服务端会调用 accept() 方法等待接收客户端的连接的方式监听请求,请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成, 不过可以通过多线程来支持多个客户端的连接,如上图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 新建socket
socket = new Socket();
for {
// socket 监听连接
conn = socket.accept();

// 单独的线程去处理这个连接
go func() {
for {
data = conn.read();
// logic
conn.write(ret_data);
}
}
}

Bio 中的问题?

  1. 线程数量太多,创建线程也需要走系统调用clone出来,走软中断,系统调用多
  2. 线程消耗计算机资源:线程栈是独立的,堆是共享的
  3. CPU在某个时间点只可以有一个线程执行,线程多的话会造成频繁切换

Bio 如何优化?

  • 总的来说,bio模型中就是因为 socket 是阻塞的模式,导致需要多线程去解决
  • 系统调用 socket 也可以设置成 NONBLOCK(每次系统调用不阻塞,有数据返回数据,没数据直接返回)
  • 这就有了后面的nio,一个线程就可以解决n多客户端的连接问题

Nio

  • 有人称它为 new io, 也有人称为 non-block io
  • 一种同步非阻塞的I/O模型
  • 不用开设很多线程,是对 bio 的优化

nio 的问题是什么?

  1. C10K/C100K 问题 :很多客户端连接时候的问题,如果有1w个客户端,每次循环会依次去查询这1w个socket是否有数据
  2. 每次循环会有 O(n) 级别的系统调用(SC),但是可能只有 10 个连接中有数据,大量的系统调用,而且大部分都是无数据的

nio 如何优化?

  • 比如 n 个连接中有 m 个 socket 有数据,将原本 O(n) 级别的系统调用降低到 O(m) 级别?
  • 系统内核增加了一个系统调用 select

Select

  • 多路复用器(多条路/多个连接 复用了同一个系统调用)

  • 同步模型 : 多路复用器只是返回一个fd状态,具体的读/写还是得进程自己去操作,所以说是同步的

  • 该系统调用允许程序监控一个/多个文件描述符,直到一个/多个文件描述符变成可用状态

    1
    select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);
  • 在这种模型之下,服务器的模式就变成这样:

    1. 有1w个连接(1w个文件描述符),通过 select 系统调用返回可用的文件描述符 -> 时间复杂度为O(1)
    2. 对于可用的m个文件描述符,进行读操作 -> 时间复杂度为O(m)

select 的问题是什么?

  1. 假如有1w个连接(1w个文件描述符),每次循环一次调用一次selelct,每次select我需要将这一万个文件描述符传进去,内核对这1w个fd进行遍历,时间复杂度为 O(n),但这与nio中的 O(n) 不同,后者是 O(n) 次系统调用。
  2. 每次 select 都需要大量传值(1w个fd)
  3. 每次 select 内核都需要 O(n) 的遍历

select 如何优化?

  • 如果内核中可以开辟一个空间,服务器每次 accept 到一个连接(产生一个文件描述符fd),服务器将fd传入内核,让内核保存,这样就可以减少传递的过程
  • 内核事件驱动模式,网卡收到数据,内核将这些 fd 进行标记,后续服务器通过系统调用得到这些有数据的fd即可
    • 上面说的这种事件驱动的实现是通过中断来实现
    • kernel 会为硬件驱动在内存中开辟 DMA 空间
    • 网卡收到数据,会将数据放到 DMA 空间中
    • 网卡通过硬中断,中断CPU,让CPU去 DMA 空间中读取数据,得到哪个文件描述符(哪个socket)有数据
    • 这样内核就可以直到哪些 fd 中有数据
  • 总的来说,上述的两点分别解决了select的 问题2 & 问题3,为此,系统内核增加了系统调用 epoll

epoll

  • event poll
  • 同步模型 : 多路复用器只是返回一个fd状态,具体的读/写还是得进程自己去操作,所以说是同步的
  • 多路复用器(多条路/多个连接 复用了同一个系统调用)
    1
    2
    3
    4
    #include <sys/epoll.h>  
    int epoll_create(int size); // int epoll_create1(int flags);
    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);

epoll 使用的流程

  1. 通过 epoll_create 系统调用在内核创建一块区域,返回一个 fd
  2. 当有 连接建立/连接断开,通过 epoll_ctl 系统调用来对内核的这块空间进行修改
  3. 每次循环通过 epoll_wait 来获取网络数据/事件

epoll 的边缘触发(ET)和水平触发(LT)

  • epoll的默认模式是水平触发。

  • 先大概了解一下这两种触发模式有什么不同:

    1. 水平触发(Level Trigger,也称条件触发):只要满足条件,就触发一个事件(只要有数据还未读完,就会一直触发)
    2. 边缘触发(Edge Trigger):每当状态发生变化时就触发一个事件。
  • ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。

  • 可能概念不容易理解,这里举一个例子大概就能明白两者的区别了:比如某个人让你去买几袋酱油,你只买了一袋回去,水平触发的做法就是他让你继续去把剩下的几袋酱油买回来,如果没有完成任务,就一直通知你;边缘触发的做法就是不管完没完成任务,反正他让你买了,买没买完就是你自己的事了,下次买酱油这件事他就不管了,会让你去做其它的事。

  • 当我们使用边缘触发时,将对应的文件描述符设置为非阻塞即可。

    • 因为 read 的时候可能一次性读不完,需要多次读数据,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,如果是阻塞的话,会导致整个线程阻塞

设置方法

  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

  • 第一个参数是epoll_create()的返回值,

  • 第二个参数表示动作,用三个宏来表示:

    1. EPOLL_CTL_ADD:注册新的fd到epfd中;
    2. EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
    3. EPOLL_CTL_DEL:从epfd中删除一个fd;
  • 第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

    1
    2
    3
    4
    struct epoll_event {
    __uint32_t events; /* Epoll events /
    epoll_data_t data; / User data variable */
    };
  • events可以是以下几个宏的集合:

    1. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    2. EPOLLOUT:表示对应的文件描述符可以写;
    3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    4. EPOLLERR:表示对应的文件描述符发生错误;
    5. EPOLLHUP:表示对应的文件描述符被挂断;
    6. EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
    7. EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

小结

  • 综合一下上面所说的几种网络模型,有这样的一个问题:不同网络模型中,CPU在执行什么?
    • select : 部分时间是在调用select之后遍历fds,这部分时间就无法执行业务代码
    • epoll :cpu无需遍历fds,只有在中断的时候(必定有数据到达)才去读数据 -> 尽量不浪费cpu

几个IO相关的应用

redis

  • redis是nonblick的(轮询的),而 nginx 是阻塞的
    • redis 只有一个线程,这个线程除去要干io的事情需要干很多其他的事情(eg:客户端请求、LRU,LFU 的淘汰过滤、RDB/AOF)
    • nginx 就是等待客户端请求,在进行后续操作,没有其他的工作
  • redis 是单线程,串行化
  • redis 使用了 epoll 解决io消息事件的问题,即无论多少连接,epoll会告诉我去读/写哪些fds
  • 在redis 6.0 之前是纯单线程版本,一个线程不仅仅完成io数据的读写,还需要完成计算,并且返回客户端
  • redis 6.x 之后引入了多线程(IO threads)
    • 通过epoll拿到多个可读写的fds
    • redis允许多线程去读io(可以充分发挥CPU多核性能)
    • 但是读取到数据之后的计算操作依旧是有同一个线程去完成的(保证了操作的原子性+串行化)
    • 完成计算之后,需要返回客户端,这部操作可以由其他的线程完成

kafka

  • 零拷贝(前提:数据不需要加工,数据就是我磁盘中的某个文件)
  • 消息队列,支持持久化(存磁盘)
  • 由于需要存磁盘,绕不开 kernel
  • kafka 是分 segment 的,每一个 segment 的大小可以配置,每一个 segment 就是对于一个磁盘文件

生产者发送数据

  • 通过 epoll + recvfrom/read系统调用 获取数据,给数据加头部
  • 写数据的时候,如果直接通过 kernel,需要走系统调用,消耗很大
    • 通过 mmap 系统调用完成
    • 其实就是使用 kernel 的缓存页

消费者取数据

  • 读取数据,kafka发现数据在磁盘中,一般流程是这样的:
    • kafka进程通过系统调用去读磁盘,获得数据
    • kafka进程再通过 write系统调用 将数据写到socket中
  • 有两次系统调用
  • kafka其实是通过另外一个系统调用 sendfile
    1
    ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  • 通过上述系统调用,直接将两个 fd 传入(磁盘文件,socket),直接将数据发出
  • nginx 也使用了 sendfile