网络IO模型之BIO、NIO、SELECT、EPOLL简析

1,241 阅读6分钟

铺垫

用户态和内核态

首先来看一Linux架构图:

从上图可以看到,系统调用把Linux系统最底下的内核和上面部分做了分隔,而分开的这两部分,上层“应用程序+库函数+Shell”就是用户空间,而底下的内核就是内核空间了。

在用户空间中,应用程序为了访问途中最底层的硬件资源,必须通过系统调用来让内核去操作所有的硬件资源,然后内核从硬件资源获取反馈后,将反馈再拿到内核空间,再从内核空间返回给用户空间的应用程序。

为什么要区分用户空间和内核空间呢?

在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。
所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。
其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。
在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。
对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。

Linux 系统调用和库函数的区别

cpu中断

软中断
硬中断

有了上述的基本铺垫后,下面来解析下各网络IO模型的结构,这里我们以redis服务为例。

BIO(Blocking IO)同步阻塞IO

    1. redis-server启动,调用系统调用函数socket来在内核空间创建一个文件描述符6 - fd6(仅有一个fd6但还没有实际内存地址),接着使用系同调用bind函数为该文件描述符绑定一个内存地址,然后再使用系统调用函数listen来监听fd6,最后使用accept来接收连接至fd6的连接。
    1. 一个redis-cli启动准备连接至redis-server,也就是要连接fd6,也会调用一系列系统函数,当步骤1阻塞中的accept收到该客户端发来的连接请求时会生成一个文件描述符fd7,然后会调用read函数来读取fd7文件描述符发送过来的消息,如果此时没有消息发送过来,read函数将一直阻塞直到有消息发送过来。
    1. 如果读取fd7的进程一直在阻塞当中的时候,有一个客户端来连接redis-server,那么该客户端将无法连接,因为原先的进程还在阻塞当中,需要等待处理完fd7这个客户端的请求(内核将接收到的fd7的数据从内核空间拷贝到用户空间)或者超时后,另一个客户端才能连接进来 以上就是整个BIO的阻塞过程。

BIO的缺点显而易见了,一个进程只能去处理一个fd,如果有多个客户端连接,就会出现堵塞住,当然,可以启动多个redis-server进程或者线程来处理客户端连接,一旦请求量上来后,这样会浪费很多的系统资源。创建线程也需要系统调用

NIO(NoneBlocking IO)同步非阻塞IO

可以看出,NIO和BIO由图中得出的最大不同就是在read读取上,BIO是redis-server调用read,等待读取消息,但是没有消息到达时,他会一直阻塞在那,直到有消息进来,他才读取后从内核态拷贝至用户态才算完事儿;而NIO是不断循环调用read来读取连接至redis-server的fd,如果有消息那就处理,需要将数据从内核空间拷贝至用户空间,所以这里会阻塞进程,如果没消息则不会阻塞,而是立即返回无数据的结果。

循环不断地调用read去检测数据是否准备好,是十分浪费系统资源的,如果1000个客户端连接中只有1个哭护短发送了消息,也就是白白浪费了999个系统调用read,而且每一次调用read,都要让系统内核去判断一次,加大了系统内核的压力。

Select 多路复用

select模式不像NIO模式,循环系统调用read来检测每一个fd文件描述符,而是将所有连接进来的文件描述符都丢给内核,也就是调用select系统调用函数,将所有连接过来的文件描述符一块发送给内核,此时select进入阻塞状态,而内核来监视所有的文件描述符是否有新消息到达,如果有文件描述符活跃,则select返回,然后redis-server再调用read函数将消息从内核态拷贝到用户态。

select模式解决了NIO模式的循环调用问题,但是他把监视的循环丢给了内核来处理,这也是个问题,CPU就会被占用去主动遍历所有fd

Epoll 多路复用

Epoll模型是调用epoll_create来在内核中创建一个空间,然后再使用epoll_ctl来将连接过来的客户端文件描述符加到这个空间中,来一个加一个,如果有客户端发送消息,那么通过事件驱动,正在处理其他操作的cpu会收到一个软中断,来将有消息的fd移到内核中的另一块空间中,而redis-server则不断调用epoll_wait来循环遍历有消息的这块内存,如果该内存中有fd,则调用read来读取数据。

Epoll的好处就是充分利用了cpu,不需要cpu主动去遍历所有客户端连接