在计算机操作系统中,所谓的I/O就是 输入(Input)和输出(Output),也可以理解为读(Read)和写(Write),针对不同的对象,I/O模式可以划分为磁盘IO模型和网络IO模型。
IO操作会涉及到用户空间和内核空间的转换,先来理解以下规则:
- 内存空间分为用户空间和内核空间,也称为用户缓冲区和内核缓冲区;
- 用户的应用程序不能直接操作内核空间,需要将数据从内核空间拷贝到用户空间才能使用;
- 无论是read操作,还是write操作,都只能在内核空间里执行;
- 磁盘IO和网络IO请求加载到内存的数据都是先放在内核空间的;
再来看看所谓的读(Read)和写(Write)操作:
- 读操作:操作系统检查内核缓冲区有没有需要的数据,如果内核缓冲区已经有需要的数据了,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,对于磁盘IO,直接从磁盘中读取到内核缓冲区(这个过程可以不需要cpu参与)。而对于网络IO,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间,然后把内核空间的数据copy到用户空间,供应用程序使用。
- 写操作:用户的应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘或通过网络发送出去,由操作系统决定。除非应用程序显示地调用了sync 命令,立即把数据写入磁盘,或执行flush()方法,通过网络把数据发送出去。
- 绝大多数磁盘IO和网络IO的读写操作都是上述过程,除了后面要讲到的零拷贝IO。
野生程序员对于这个概念可能比较陌生,这其实是 Linux 操作系统中的概念。虚拟内存(操作系统中的概念,和物理内存是对应的)被操作系统划分成两块:User Space(用户空间 和 Kernel Space(内核空间),本质上电脑的物理内存是不划分这些的,只是操作系统开机启动后在逻辑上虚拟划分了地址和空间范围。
操作系统会给每个进程分配一个独立的、连续的虚拟内存地址空间(物理上可能不连续),以32位操作系统为例,该大小一般是4G,即232 。其中将高地址值的内存空间分配给系统内核占用(网上查资料得知:Linux下占1G,Windows下占2G),其余的内存地址空间分配给用户进程使用。
因为我们不是要深入学习操作系统,所以这里以32位系统举例旨在帮助你理解原理。32 位的 LInux 操作系统下,03G为用户空间,34G为内核空间:
那为什么要这样划分出空间范围呢?
也很好理解,毕竟操作系统身份高贵,太重要了,不能和用户应用程序在一起玩耍,各自的数据都要分开存储并且严格控制权限不能越界。这样才能保证操作系统的稳定运行,用户应用程序太不可控了,不同公司或者个人都可以开发,碰到坑爹的误操作或者恶意破坏系统数据直接宕机玩完了。隔离后应用程序要挂你就挂,操作系统可以正常运行。
简单说,内核空间 是操作系统 内核代码运行的地方,用户空间 是 用户程序代码运行的地方。当应用进程执行系统调用陷入内核代码中执行时就处于内核态,当应用进程在运行用户代码时就处于用户态。
同时内核空间可以执行任意的命令,而用户空间只能执行简单的运算,不能直接调用系统资源和数据。必须通过操作系统提供接口,向系统内核发送指令。
一旦调用系统接口,应用进程就从用户空间切换到内核空间了,因为开始运行内核代码了。
简单看几行代码,分析下是应用程序在用户空间和内核空间之间的切换过程:
str = "i am qige" // 用户空间
x = x + 2
file.write(str) // 切换到内核空间
y = x + 4 // 切换回用户空间
上面代码中,第一行和第二行都是简单的赋值运算,在用户空间执行。第三行需要写入文件,就要切换到内核空间,因为用户不能直接写文件,必须通过内核安排。第四行又是赋值运算,就切换回用户空间。
用户态切换到内核态的3种方式:
- 系统调用。也称为 System Call,是说用户态进程主动要求切换到内核态的一种方式,用户态进程使用操作系统提供的服务程序完成工作,比如上面示例代码中的写文件调用,还有像 fork() 函数实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。
- 异常。当CPU在用户空间执行程序代码时发生了不可预期的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,切换到内核态,比如缺页异常。
- 外围设备的中断。 当外围设备完成用户请求的某些操作后,会向CPU发送相应的中断信号,这时CPU会暂停执行下一条即将执行的指令转而去执行与中断信号对应的处理程序,如果当前正在运行用户态下的程序指令,自然就发了由用户态到内核态的切换。比如硬盘数据读写完成,系统会切换到中断处理程序中执行后续操作等。
以上3种方式,除了系统调用是进程主动发起切换,异常和外围设备中断是被动切换的。
查看 CPU 时间在 User Space 与 Kernel Space 之间的分配情况,可以使用top命令。它的第三行输出就是 CPU 时间分配统计。
我们来看看图中圈出来的 CPU 使用率的三个指标:
其中,第一项 7.57% user 就是 CPU 消耗在 User Space 的时间百分比,第二项 7.0% sys是消耗在 Kernel Space 的时间百分比。第三项 85.4% idle 是 CPU 消耗在闲置进程的时间百分比,这个值越低,表示 CPU 越忙。
大家都知道一般我们的数据是存储在磁盘上的,应用程序想要读写这些数据肯定就需要加载到内存中。接下来给大家介绍下 PIO 和 DMA 这两种 IO 设备和内存之间的数据传输方式。
- 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态。
- 操作系统收到用户进程的请求后,进一步将IO请求发送给磁盘。
- 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,此时不占用CPU。当磁盘的缓冲区被读满之后,向内核发起中断信号告知自己缓冲区已满。
- 内核收到磁盘发来的中断信号,使用CPU将磁盘缓冲区中的数据copy到内核缓冲区中。
- 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤2、3、4,直到内核缓冲区的数据符合用户的要求为止。
- 内核缓冲区的数据已经符合用户的要求,CPU停止向磁盘IO请求。
- CPU将数据从内核缓冲区拷贝到用户缓冲区,同时从系统调用中返回。
- 用户进程读取到数据后继续执行原来的任务。
PIO缺点:每次IO请求都需要CPU多次参与,效率很低。
DMA(直接内存访问,Direct Memory Access)。
- 用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态。
- 操作系统收到用户进程的请求后,进一步将IO请求发送给DMA,然后CPU就可以去干别的事了。
- DMA将IO请求转发给磁盘。
- 磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向DMA发起中断信号告知自己缓冲区已满。
- DMA收到磁盘驱动器的信号,将磁盘缓冲区中的数据copy到内核缓冲区中,此时不占用CPU( PIO 这里是占用CPU的)。
- 如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止。
- 内核缓冲区的数据已经符合用户的要求,DMA停止向磁盘发IO请求。
- DMA发送中断信号给CPU。
- CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核空间copy到用户空间,系统调用返回。
- 用户进程读取到数据后继续执行原来的任务。
跟PIO模式相比,DMA就是CPU的一个代理,它负责了一部分的拷贝工作,从而减轻了CPU的负担。
需要注意的是,DMA承担的工作是从磁盘的缓冲区到内核缓冲区或网卡设备到内核的 soket buffer的拷贝工作,以及内核缓冲区到磁盘缓冲区或内核的 soket buffer 到网卡设备的拷贝工作,而内核缓冲区到用户缓冲区之间的拷贝工作仍然由CPU负责。
可以肯定的是,PIO模式的计算机我们现在已经很少见到了。
学习用户空间和内核空间的时候我们也说了,用户空间是不能直接访问内核空间的数据的,如果需要访问怎么办?很简单,就需要将数据从内核空间拷贝的用户空间。
- 缓冲 IO:其实就是磁盘中的数据通过 DMA 先拷贝到内核空间,然后再从内核空间拷贝到用户空间。
- 直接 IO:磁盘中的数据直接通过 DMA 拷贝到用户空间。
缓冲 IO 也被成为标准 IO,大多数的文件系统系统默认都是以缓冲 IO 的方式来工作的。在Linux的缓冲I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
接下来我们看看缓冲 IO 下读写操作是如何进行?
- 读操作:
操作系统检查内核的缓冲区有没有需要的数据,如果已经缓冲了,那么就直接从缓冲中返回;否则从磁盘中读取到内核缓冲中,然后再复制到用户空间缓冲中。
- 写操作:
将数据从用户空间复制到内核空间的缓冲中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。
缓冲I/O的优点:
- 在一定程度上分离了内核空间和用户空间,保护系统本身的运行安全;
- 因为内核中有缓冲,可以减少读盘的次数,从而提高性能。
缓冲I/O的缺点:
在缓冲 I/O 机制中,DMA 方式可以将数据直接从磁盘读到内核空间页缓冲中,或者将数据从内核空间页缓冲直接写回到磁盘上,而不能直接在用户地址空间和磁盘之间进行数据传输,这样数据在传输过程中需要在应用程序地址空间(用户空间)和内核缓冲(内核空间)之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。
顾名思义,直接IO就是应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理I/O缓冲区,这样做的目的是减少一次从内核缓冲区到用户程序缓冲的数据复制。
引入内核缓冲区这个主要是为了提升从磁盘读写数据文件的性能,这也是很多系统优化中常见的手段,多一层缓存可以有效减少很多磁盘 IO 操作;而当用户程序需要向磁盘文件中写入数据时,实际上只需要写入到内核缓冲区便可以返回了,而真正的落盘是有一定的延迟策略的,但这无疑提升了应用程序写入文件的响应速度。
在数据库管理系统这类应用中,它们更倾向于选择自己实现的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。
直接I/O的优点:
应用程序直接访问磁盘数据,不经过操作系统内核数据缓冲区,这样做的最直观目的是减少一次从内核缓冲区到用户程序缓冲的数据复制。这种方式通常用在数据库、消息中间件中,由应用程序来实现数据的缓存管理。
直接I/O的缺点:
如果访问的数据不在应用程序缓冲中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。通常 直接I/O 跟 异步I/O 结合使用会得到较好的性能。(异步IO:当访问数据的线程发出请求之后,线程会接着去处理其他事,而不是阻塞等待)
我们常说的 IO 操作,不仅仅是磁盘 IO,还有常见的网络数据传输即网络 IO。
读操作:
当应用程序调用read()方法时,操作系统检查内核高速缓冲区中是否存在需要的数据,如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,通过通过DMA方式从磁盘中读取数据到内核缓冲区,然后由CPU控制,把内核空间的数据copy到用户空间。
这个过程会涉及到两次缓冲区copy,第一次是从磁盘到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第一次是DMA的copy,第二次是CPU的copy。
写操作:
当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区),这时对用户程序来说写操作就已经完成,至于什么时候把数据再写到磁盘(从内核缓冲区到磁盘的写操作也由DMA控制,不需要cpu参与),由操作系统决定。除非应用程序显示地调用了sync命令,立即把数据写入磁盘。
如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作,这时会涉及到四次缓冲区的copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第三次是从用户缓冲区到内核缓冲区,第四次是从内核缓冲区写回到磁盘。前两次是为了读,后两次是为了写。这其中有两次 CPU 拷贝,两次DMA拷贝。
磁盘IO的延时:
为了读或写,磁头必须能移动到所指定的磁道上,并等待所指定的扇区的开始位置旋转到磁头下,然后再开始读或写数据。磁盘IO的延时分成以下三部分:
- 寻道时间:把磁头移动到指定磁道上所经历的时间;
- 旋转延迟时间 :指定扇区移动到磁头下面所经历的时间;
- 传输时间 :数据的传输时间(数据读出或写入的时间);
读操作:
网络 IO 既可以从物理磁盘中读数据,也可以从Socket中读数据(从网卡中获取)。当从物理磁盘中读数据的时候,其流程和磁盘IO的读操作一样。当从Socket中读数据,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议栈(网卡)中读取客户端发送的数据到内核空间(这个过程也由DMA控制),然后把内核空间的数据 copy 到用户空间,供应用程序使用。
写操作:
为了简化描述,我们假设网络IO的数据从磁盘中获取,读写操作的流程如下:
- 当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区;
- 由cpu控制,将内核缓冲区的数据拷贝到用户空间的缓冲区中,供应用程序使用;
- 当应用程序调用 write() 方法时,CPU 会把用户缓冲区中的数据 copy 到内核缓冲区的 Socket Buffer 中;
- 最后通过DMA方式将内核空间中的Socket Buffer拷贝到Socket协议栈(即网卡设备)中传输;
网络IO 的写操作也有四次缓冲区的copy,第一次是从磁盘缓冲区到内核缓冲区(由DMA控制),第二次是内核缓冲区到用户缓冲区(CPU控制),第三次是用户缓冲区到内核缓冲区的 Socket Buffer(由CPU控制),第四次是从内核缓冲区的 Socket Buffer 到网卡设备(由DMA控制)。四次缓冲区的copy工作两次由CPU控制,两次由DMA控制。
网络IO的延时:
网络IO主要延时是由:服务器响应延时+带宽限制+网络延时+跳转路由延时+本地接收延时 决定。一般为几十到几千毫秒,受环境影响较大。所以,一般来说,网络IO延时要大于磁盘IO延时(不过同数据中心的交互除外,会比磁盘 IO 更快)。
在上述IO中,一次读写操作要经过四次缓冲区的拷贝,并经历了四次内核态和用户态的切换。 零拷贝(zero copy)IO 技术减少不必要的内核缓冲区跟用户缓冲区之间的拷贝,从而减少CPU的开销和状态切换带来的开销,达到性能的提升。
我们还是对比上面不使用零拷贝时的网络 IO 传输过程来对比分析下:
和上图普通的网络 IO 传输过程对比,零拷贝的传输过程:硬盘 -> kernel buffer (快速拷贝到kernel socket buffer) -> Socket协议栈(网卡设备中)。
- 当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区;
- 由CPU控制,将内核缓冲区的数据直接拷贝到另外一个与 Socket 相关的内核缓冲区,即kernel socket buffer;
- 然后由 DMA 把数据从 kernel socket buffer 直接拷贝给 Socket 协议栈(网卡设备中);
这里,只经历了三次缓冲区的拷贝,第一次是从磁盘缓冲区到内核缓冲区,第二次是从内核缓冲区到 kernel socket buffer,第三次是从 kernel socket buffer 到Socket 协议栈(网卡设备中)。只发生两次内核态和用户态的切换,第一次是当应用程序调用read方法时,用户态切换到内核态执行read系统调用,第二次是将数据从网络中发送出去后系统调用返回,从内核态切换到用户态。
零拷贝(zero copy)的应用:
- Linux 下提供了zero copy的接口:sendfile和splice,用户可通过这两个接口实现零拷贝传输;
- Nginx 可以通过sendfile配置开启零拷贝;
- 在 Linux 系统中,Java NIO中 FileChannel.transferTo 的实现依赖于 sendfile()调用;
- Apache 使用了 sendfile64() 来传送文件,sendfile64() 是 sendfile() 的扩展实现;
- Kafka也用到了零拷贝的功能;
注意:零拷贝要求输入的fd必须是文件句柄,不能是socket,输出的fd必须是socket,也就是说,数据的来源必须是从本地的磁盘,而不能是从网络中,如果数据来源于socket,就不能使用零拷贝功能了。我们看一下sendfile接口就知道了:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
- out_fd:待写入文件描述符;
- in_fd:待读出文件描述符;
- offset:从读入文件流的哪个位置开始读,如果为空,则默认从起始位置开始;
- count:指定在文件描述符in_fd 和out_fd之间传输的字节数;
- 返回值:成功时,返回出传输的字节数,失败返回-1;
**in_fd 必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。由此可见,**sendfile 几乎是专门为在网络上传输文件而设计的。
在Linxu系统中,一切皆文件,因此socket也是一个文件,也有文件句柄(或文件描述符)。
这两组概念,我接触编程以来,经过听到别人说服务端是 同步非阻塞模型 或者 异步阻塞的 IO 模型,也前后了解过几次,但是理解都不够透彻,特别是这个非阻塞和异步、同步和阻塞的概念很容易懵逼,每个人的说法都不一样,最近我耐心看了几篇文章,这次我感觉我是顿悟了,这里分享下我的理解:
同步和异步是针对应用程序向内核发起任务后的状态而言的:如果发起调用后,在没有得到结果之前,当前调用就不返回,不能接着做后面的事情,一直等待就是同步。异步就是发出调用后,虽然不能立即得到结果,但是可以继续执行后面的事情,等调用结果出来时,会通过状态、通知和回调来通知调用者。
举个例子加深下理解:
- 在互联网普及之前,我们去医院看病都是排队的模式,想看病就得排队等,直到轮到你,在此之前你必须一直排队等待,这个就是同步;
- 现在互联网普及了,都是直接大屏叫号,我们预约登记后,就可以去休息厅坐着,打游戏啥的都可以干,到自己看病的时候会有通知的,这就是异步;
阻塞blocking、非阻塞non-blocking,则聚焦的是CPU在等待结果的过程中的状态。
阻塞调用是指调用结果返回之前,当前线程会被挂起,只有在得到结果之后才会返回。你可能会把阻塞调用和同步调用等同起来,实际上它们是不同的,同步只是说必须等到出结果才可以返回,但是等的过程中线程可以是激活的,阻塞是说线程被挂起了。
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
比如前面的例子,排队的过程中什么也不能做就是阻塞,CPU 执行权是交出去的;一边排队,一边看手机就是非阻塞,CPU 执行权还在自己手里,但是没看完病之前依旧是在排队死等,所以还是同步的。
通过今天的学习,我们掌握了什么是 IO、常见的 IO 操作类型以及对应操作的原理,还有非常重要但是却很容易搞混的同步&异步、阻塞&非阻塞之间的区别,讲解的应该还是比较清楚的。
本文内容还是比较简单的,是一些基础知识,但是如果想深入学习网络编程这些基础是绕不开的,了解了操作系统对于 IO 操作的优化,才能搞明白各种高性能网络服务器的原理。
版权声明:本文为知乎博主「玩转Linux内核」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。