一、IO读写原理
1、内核态与用户态
为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分:
①内核空间(kernel-Space):内核模块运行在内核空间,对应的进程处于内核态;
②用户空间(User-Space):用户程序运行在用户空间,对应的进程处于用户态。
内核态和用户态是操作系统的两种运行状态。
1.1、内核态
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内核空间,也有访问底层硬件设备的权限。内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在内核空间域进行读写,也是不允许直接调用内核代码定义的函数。
1.2、用户态
每个应用程序进程都有一个单独的用户空间,对应的进程处于用户态,用户进程不能访问内核空间中的数据,也不能直接调用内核函数,必须通过[系统调用]陷入内核中,才能访问特权资源。
1.3、为什么要区分用户态和内核态
在CPU的所有指令中,有一些指令是非常危险的,如果错用,会导致整个系统崩溃。比如:清空内存、修改时钟等。如果所有程序代码都能够直接使用这些指令,那么可能系统短时间内就会死n次。
所以,CPU将指令分为特权指令和非特权指令,对于较为危险的指令,只允许操作系统本身及其相关模块进行调用;普通的、用户自行编写的应用程序只能使用那些不会造成危险的指令。
基于安全的考虑,CPU提供了特权分级机制,将区域分成了四个Ring,越往内侧权限越高,越往外侧权限越低。
内核空间对应特权等级的Ring0,用户空间对应特权等级的Ring3。
1.4、IO底层
内核态进程可以执行任意命令,调用系统的一切资源,而用户态进程只能调用简单的运算,不能直接调用系统资源,所以用户态进程必须通过系统接口(System Call),才能向内核发出指令,完成调用系统资源之类的操作;
用户程序进行IO的读写,依赖于底层IO读写,基本都会用到底层的两大系统调用:sys_read和sys_write。sys_read和sys_write两大系统调用都会涉及到缓冲区。sys_read系统调用会把数据从内核缓冲区复制到应用程序的进程缓冲区;sys_write会把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区。
应用程序的IO操作实际是内核缓冲区和应用程序进程缓冲区的缓冲复制sys_read和sys_write两大系统的调用,都不负责数据在内核缓冲区和物理设备(如磁盘、网卡等)之间的交换。这项底层的读写交换操作,是由操作系统内核(kernel)来完成的。
所以,应用程序中的IO操作,无论是对socket的IO操作,还是对文件的IO操作,都属于上层应用的开发,它们的在输入(input)和输出(output)维度上的执行流程,都是在内核缓冲区和进程缓冲区之间进行的数据交换。
2、内核缓冲区和进程缓冲区
设置缓冲区的目的为:减少频繁地与设备之间的物理交换。
计算机的外部物理设备与内存及CPU相比,有很大的差别,外部设备的直接读写,涉及到操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗、性能损耗,于是出现了内核缓冲区。
有了内核缓冲区,操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量时,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系统的性能。至于具体在什么时候执行系统中断(包括读中断、写中断),则由系统的内核来决定,应用程序不需要关心这个问题。
上层应用程序使用sys_read系统调用时,仅仅是吧数据从内核缓冲区复制到了进程缓冲区;
而在使用sys_write系统调用时,仅仅是把数据从进程缓冲区复制到了内核缓冲区。
内核缓冲区与进程缓冲区在数量上也不相同。在Linux系统中,操作系统内核只有一个内核缓冲区,而每个用户程序都有自己独立的缓冲区(即进程缓冲区)。Linux系统中的用户程序的IO读写程序,在大多数情况下,并没有进行实际的IO操作,而是在内核缓冲区和进程缓冲区之间进行数据的交换。
举例说明,比如客户端和服务端之间完成一次socket请求和响应的数据交换,其流程如下:
- 客户端发送请求:客户端通过sys_write系统调用,将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过客户端机器的网卡发送出去;
- 服务端系统接收数据:在服务端,这份请求数据会被服务端操作系统通过DMA硬件,从接收网卡中读取大服务端机器的内核缓冲区;
- 服务端程序获取数据:服务端程序通过sys_read系统调用,从Linux内核缓冲区复制数据到用户缓冲区;
- 服务端业务处理:服务器在自己的用户空间中,完成客户端请求所对应的业务处理;
- 服务端返回数据:服务端程序完成处理后,构建好的响应数据,通过sys_write将这些数据从用户缓冲区写入内核缓冲区;
- 服务端系统发送数据:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议,会将数据发送给目标客户端。
二、五种IO模型
IO的基本概念
1、阻塞IO和非阻塞IO
网卡同步数据到内核缓冲区,如果内核缓冲区中的数据未准备好,用户进程发起read操作,阻塞则会一直等待内存缓冲区数据完整后再解除阻塞,而非阻塞则会立即返回不会等待,注意,内核缓冲区与用户缓冲区之间的读写操作肯定是阻塞的。2、同步和异步
同步:调用者主动发送请求,调用者主动等待这个结果返回,一旦调用就必须有返回值;
异步:调用发出后直接返回,所以没有返回结果。被调用者处理完成后通过回调、通知等机制来通知调用者。
1、阻塞IO模型 – BIO
吃饭排队 - 下单后原地等待直到收到食物离开。
2、非阻塞IO模型 – NIO
吃饭点单 – 下完单后收到小票,待食物准备好后通知取餐
3、多路复用
我觉得它不属于一种单独的IO模型,只是IO中一种提高用户态与内核态交换数据的效率,原本是每次交换只交换一个,通过多路复用后,加入了一个文件描述符fd,一次交换可以交换多个IO的状态数据。
I/O多路复用的好处在于单个进程就可以同时处理多个网络连接的I/O。它的基本原理是不再由应用程序自己监视连接,而由内核替应用程序监视文件描述符。通过 select、poll、epoll 等机制,允许一个进程同时监视多个文件描述符,当某个文件描述符就绪时再进行 IO 操作。这种模型下,程序可以同时处理多个连接,提高了并发处理能力。 以select函数为例,当用户进程调用了select,那么整个进程会被阻塞,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好,select就会返回。 这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程,如下图所示。
3.1、 select
select 使用了一个 fd_set 集合来保存需要监控的文件描述符,并提供了 select() 函数来检查这些文件描述符的状态。当调用 select() 函数时,内核会遍历这个 fd_set 集合,检查每个文件描述符的状态是否就绪。如果某个文件描述符就绪,select() 函数就会返回,否则会阻塞程序直到有文件描述符就绪或超时。
客户端操作服务器时就会产生这三种文件描述符(简称fd):writefds(写)、readfds(读)、和 exceptfds(异常)。select 会阻塞住监视 3 类文件描述符,等有数据、可读、可写、出异常或超时就会返回;返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。
监视的文件描述符数量有最大限制,通常为1024,增加数量会降低性能。
3.2、poll
基本原理与 select 一致,也是轮询 + 遍历。唯一的区别就是 poll 没有最大文件描述符限制(使用链表的方式存储 fd)。
3.3、epoll
epoll可以理解为event poll,它是一种事件驱动的I/O模型,可以用来替代传统的select和poll模型。epoll的优势在于它可以同时处理大量的文件描述符,而且不会随着文件描述符数量的增加而降低效率。(也即不需要遍历)
epoll的实现机制是通过内核与用户空间共享一个事件表,这个事件表中存放着所有需要监控的文件描述符以及它们的状态,当文件描述符的状态发生变化时,内核会将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
epoll 使用了一个事件表(event table)来保存需要监控的文件描述符和相应的事件类型,并提供了 epoll_ctl() 函数来向事件表中添加、修改或删除文件描述符。与 select 和 poll 不同的是,epoll 的设计更加高效,它使用了内核中的事件通知机制,可以避免遍历文件描述符集合,当文件描述符的状态发生变化时,内核会立即通知应用程序。这样可以避免遍历文件描述符集合,减少了不必要的 CPU 消耗,从而提高了效率。当调用 epoll_wait() 函数时,它会返回一个包含已就绪文件描述符的列表,并且它只返回那些真的有事件发生的文件描述符。这意味着它避免了遍历整个事件表,直接返回你感兴趣的文件描述符。
没有 fd 个数限制,用户态拷贝到内核态只需要一次,使用时间通知机制来触发。通过 epoll_ctl 注册 fd,一旦 fd 就绪就会通过 callback 回调机制来激活对应 fd,进行相关的 io 操作。epoll 之所以高性能是得益于它的三个函数:
- epoll_create() 系统启动时,在 Linux 内核里面申请一个B+树结构文件系统,返回 epoll 对象,也是一个 fd。
- epoll_ctl() 每新建一个连接,都通过该函数操作 epoll 对象,在这个对象里面修改添加删除对应的链接 fd,绑定一个
callback 函数 - epoll_wait() 轮询所有的 callback 集合,并完成对应的 IO 操作
4、信号驱动式I/O模型
外卖点单 – 下完单出餐后由快递送过来,你在这期间可以去干别的事情
该模型允许socket进行信号驱动I/O,并注册一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据,如图所示
注意:虽然信号驱动IO在注册完信号处理函数以后,就可以做其他事情了。但是第二阶段拷贝数据的过程当中进程依然是被阻塞的,而后要介绍的异步IO是完全不会阻塞进程的,所以信号驱动虽然具有异步的特点,但依然属于同步IO 。
5、 异步I/O模型 – AIO
数字订单 - 我饿了下单后食物还没端上来饭还没吃就饱了,然后就去干别的事,等到晚上一个规定进食时间把真正的饭吃了。(比较抽象,欢迎补充)
相对于同步I/O,异步I/O不是按顺序执行。用户进程进行aio_read系统调用之后,就可以去处理其他逻辑了,无论内核数据是否准备好,都会直接返回给用户进程,不会对进程造成阻塞。这是因为aio_read只向内核递交申请,并不关心有没有数据。 等到数据准备好了,内核直接复制数据到进程空间,然后内核向进程发送通知,此时数据已经在用户空间了,可以对数据进行处理。