IO阻塞、同步与多路复用详解
Categories: System / Network
作者:腾讯技术工程 链接:https://zhuanlan.zhihu.com/p/565012118 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
2.1 内核态与用户态
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。
对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。
如何从用户空间进入内核空间?
其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。
其实就是通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以开开心心的往下执行了。简单说就是应用程序把高科技的事情(从磁盘读取文件)外包给了系统内核,系统内核做这些事情既专业又高效。
三、 IO
在进行 IO 操作时,通常需要经过如下两个阶段:
- 数据准备阶段:数据从硬件到内核空间
- 数据拷贝阶段:数据从内核空间到用户空间
通常我们所说的 IO 的阻塞/非阻塞以及同步/异步,和这两个阶段关系密切:
- 阻塞 IO 和非阻塞 IO 判定标准:数据准备阶段,应用程序是否阻塞等待操作系统将数据从硬件加载到内核空间;
- 同步 IO 和异步 IO 判定标准:数据拷贝阶段,数据是否备好后直接通知应用程序使用,无需等待拷贝;
3.1 (同步)阻塞 IO
阻塞 IO :当用户发生了系统调用后,如果数据未从网卡到达内核态,内核态数据未准备好,此时会一直阻塞。直到数据就绪,然后从内核态拷贝到用户态再返回。
阻塞 IO 每个连接一个单独的线程进行处理,通常搭配多线程来应对大流量,但是,开辟线程的开销比较大,一个程序可以开辟的线程是有限的,面对百万连接的情况,是无法处理。非阻塞 IO 可以一定程度上解决上述问题。
3.2 (同步)非阻塞 IO
非阻塞 IO :在第一阶段(网卡-内核态)数据未到达时不等待,然后直接返回。 数据就绪后,从内核态拷贝到用户态再返回。
非阻塞 IO 解决了阻塞 IO每个连接一个线程处理的问题,所以其最大的优点就是 一个线程可以处理多个连接。然而,非阻塞 IO 需要用户多次发起系统调用。频繁的系统调用是比较消耗系统资源的。
3.3 IO 多路复用
为了解决非阻塞 IO 存在的频繁的系统调用这个问题,随着内核的发展,出现了 IO 多路复用模型。
IO 多路复用:通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,就可以返回。
IO 多路复用本质上复用了系统调用,使多个文件的状态可以复用一个系统调用获取,有效减少了系统调用。select、poll、epoll均是基于 IO 多路复用思想实现的。
| select | poll | epoll | |
|---|---|---|---|
| 函数 | int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict timeout); | int poll(struct pollfd *fds, nfds_t nfds, int timeout); | |
| 事件集合 | 通过 3 个参数分别传入感兴趣的可读、可写、异常事件,内核通过对这些参数的在线修改返回就绪事件 | 统一处理所有事件类型,用户通过 pollfd 中的 events 传入感兴趣的事件,内核通过修改 pollfd 中的 revents 反馈就绪事件 | 内核通过事件表管理所有感兴趣的事件,通过调用 epoll_wait 获取就绪事件,epoll_wait 的 events 仅返回就绪事件 |
| 内核实现&工作效率 | 采用轮询方式检测就绪事件,时间复杂度 o(n) | 采用轮询方式检测就绪事件,时间复杂度 o(n) | 回调返回就绪事件,时间复杂度 o(1) |
| 最大连接数 | 1024 | 无上限 | 无上限 |
| 工作模式 | LT 水平触发 | LT 水平触发 | LT&ET |
| fd 拷贝 | 每次调用,每次拷贝 | 每次调用,每次拷贝 | 通过 mmap 技术,降低 fd 拷贝的开销 |
select 和 poll 的工作原理比较相似,通过 select()或者 poll()将多个 socket fds 批量通过系统调用传递给内核,由内核进行循环遍历判断哪些 fd 上数据就绪了,然后将就绪的 readyfds 返回给用户。再由用户进行挨个遍历就绪好的 fd,读取或者写入数据。所以通过 IO 多路复用+非阻塞 IO,一方面降低了系统调用次数,另一方面可以用极少的线程来处理多个网络连接。select 和 poll 的最大区别是:select 默认能处理的最大连接是 1024 个,可以通过修改配置来改变,但终究是有限个;而 poll 理论上可以支持无限个。而 select 和 poll 则面临相似的问题在管理海量的连接时,会频繁的从用户态拷贝到内核态,比较消耗资源。
epoll 有效规避了将 fd 频繁的从用户态拷贝到内核态,通过使用红黑树(RB-tree)搜索被监视的文件描述符(file descriptor)。在 epoll 实例上注册事件时,epoll 会将该事件添加到 epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。
3.3.1 epoll 数据结构 + 算法
epoll 的核心数据结构是:1 个 红黑树 和 1 个 双向链表,还有 3个核心API 。
3.3.2 监视 socket 索引-红黑树
为什么采用红黑树呢?因为和 epoll 的工作机制有关。epoll 在添加一个 socket 或者删除一个 socket 或者修改一个 socket 的时候,它需要查询速度更快,操作效率最高,因此需要一个更加优秀的数据结构能够管理这些 socket。 我们想到的比如链表,数组,二叉搜索树,B+树等都无法满足要求,
- 因为链表在查询,删除的时候毫无疑问时间复杂度是 O(n);
- 数组查询很快,但是删除和新增时间复杂度是 O(n);
- 二叉搜索树虽然查询效率是 lgn,但是如果不是平衡的,那么就会退化为线性查找,复杂度直接来到 O(n);
- B+树是平衡多路查找树,主要是通过降低树的高度来存储上亿级别的数据,但是它的应用场景是内存放不下的时候能够用最少的 IO 访问次数从磁盘获取数据。比如数据库聚簇索引,成百上千万的数据内存无法满足查找就需要到内存查找,而因为 B+树层高很低,只需要几次磁盘 IO 就能获取数据到内存,所以在这种磁盘到内存访问上 B+树更适合。
因为我们处理上万级的 fd,它们本身的存储空间并不会很大,所以倾向于在内存中去实现管理,而红黑树是一种非常优秀的平衡树,它完全是在内存中操作,而且查找,删除和新增时间复杂度都是 lgn,效率非常高,因此选择用红黑树实现 epoll 是最佳的选择。 当然不选择用 AVL 树是因为红黑树是不符合 AVL 树的平衡条件的,红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决;而 AVL 树是严格平衡树,在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。所以红黑树的插入效率更高。
3.3.2 就绪 socket 列表-双向链表
就绪列表存储的是就绪的 socket,所以它应能够快速的插入数据。 程序可能随时调用 epoll_ctl 添加监视 socket,也可能随时删除。当删除时,若该 socket 已经存放在就绪列表中,它也应该被移除。(事实上,每个 epoll_item 既是红黑树节点,也是链表节点,删除红黑树节点,自然删除了链表节点) 所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll 使用 双向链表来实现就绪队列 (rdllist)
3.3.3 三个 API
3.3.3.1 int epoll_create(int size)
功能:内核会产生一个 epoll 实例数据结构并返回一个文件描述符 epfd,这个特殊的描述符就是 epoll 实例的句柄,后面的两个接口都以它为中心。同时也会创建红黑树和就绪列表,红黑树来管理注册 fd,就绪列表来收集所有就绪 fd。size 参数表示所要监视文件描述符的最大值,不过在后来的 Linux 版本中已经被弃用(同时,size 不要传 0,会报 invalid argument 错误)
3.3.3.2 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
功能:将被监听的 socket 文件描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改;同时向内核中断处理程序注册一个回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
3.3.3.3 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能: 阻塞等待注册的事件发生,返回事件的数目,并将触发的事件写入 events 数组中。 events: 用来记录被触发的 events,其大小应该和 maxevents 一致 maxevents: 返回的 events 的最大个数处于 ready 状态的那些文件描述符会被复制进 ready list 中,epoll_wait 用于向用户进程返回 ready list(就绪列表)。 events 和 maxevents 两个参数描述一个由用户分配的 struct epoll event 数组,调用返回时,内核将就绪列表(双向链表)复制到这个数组中,并将实际复制的个数作为返回值。 注意,如果就绪列表比 maxevents 长,则只能复制前 maxevents 个成员;反之,则能够完全复制就绪列表。 另外,struct epoll event 结构中的 events 域在这里的解释是: 在被监测的文件描述符上实际发生的事件 。
3.3.4 工作模式
epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger)。
-
LT 模式 LT(level triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你。
-
ET 模式 ET(edge-triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误)。注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once) ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
3.4 网络 IO 模型
实际的网络模型常结合 I/O 复用和线程池实现,如 Reactor 模式。
3.4.1 单 reactor 单线程模型
此种模型通常只有一个 epoll 对象,所有的接收客户端连接、客户端读取、客户端写入操作都包含在一个线程内。
优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成 缺点:单线程无法完全发挥多核 CPU 的性能; I/O 操作和非 I/O 的业务操作在一个 Reactor 线程完成,这可能会大大延迟 I/O 请求的响应;线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障; 使用场景:客户端的数量有限,业务处理非常快速,比如 Redis 在业务处理的时间复杂度 O(1) 的情况
3.4.2 单 reactor 多线程模型
该模型将读写的业务逻辑交给具体的线程池来处理
优点:充分利用多核 cpu 的处理能力,提升 I/O 响应速度; 缺点:在该模式中,虽然非 I/O 操作交给了线程池来处理,但是所有的 I/O 操作依然由 Reactor 单线程执行,在高负载、高并发或大数据量的应用场景,依然容易成为瓶颈。
3.4.3 multi-reactor 多线程模型
在这种模型中,主要分为两个部分:mainReactor、subReactors。mainReactor 主要负责接收客户端的连接,然后将建立的客户端连接通过负载均衡的方式分发给 subReactors,subReactors 来负责具体的每个连接的读写 对于非 IO 的操作,依然交给工作线程池去做。
优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。 缺点:编程复杂度较高。
3.4.4 主流的中间件所采用的网络模型
| 框架 | epll 触发方式 | reactor 模式 |
|---|---|---|
| redis | 水平触发 | 单 reactor |
| memcached | 水平触发 | 多线程多 reactor |
| kafka | 水平触发 | 多线程多 reactor |
| nginx | 边缘触发 | 多线程多 reactor |
3.5 异步 IO
前面介绍的所有网络 IO 都是同步 IO,因为当数据在内核态就绪时,在内核态拷贝用用户态的过程中,仍然会有短暂时间的阻塞等待。而异步 IO 指:内核态拷贝数据到用户态这种方式也是交给系统线程来实现,不由用户线程完成,如 windows 的 IOCP ,Linux 的 AIO。
四、 零拷贝
4.1 传统 IO 流程
传统 IO 流程会经过如下两个过程:
- 数据准备阶段:数据从硬件到内核空间
- 数据拷贝阶段:数据从内核空间到用户空间
零拷贝:指数据无需从硬件到内核空间或从内核空间到用户空间。下面介绍常见的零拷贝实现
4.2 mmap + write
mmap 将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
4.3 sendfile
通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝,sendfile 调用中 I/O 数据对用户空间是完全不可见的,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
4.4 Sendfile + DMA gather copy
Linux2.4 引入 ,将内核空间的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区(socketbuffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝;
4.5 splice
Linux2.6.17 版本引入,在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作,2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。
4.6 写时复制
通过尽量延迟产生私有对象中的副本,写时复制最充分地利用了稀有的物理资源。