基础概念相关

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

文件描述符(File Descriptor)

linux系统中一切都可以看成文件,文件分为:普通文件、目录文件、链接文件、字符设备文件、块设备文件和套接口文件。分别通过字符-/d/l/c/b/s指代。

文件描述符是内核为了高效管理已经被打开的文件所创建的索引。其值通常为一个非负整数,用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。

  • 每个文件描述符会与一个打开的文件相对应
  • 不同文件描述符也可能指向同一个文件
  • 相同的文件可以被不同的进程打开,也可以在同一个进程中被打开多次

linux提供了三个表来维护文件描述符,分别是:进程级的文件描述符表,系统及的文件描述符表,文件系统的i-node表

linux系统文件描述符

  • 在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(#23),这可能是该进程多次对执行打开操作
  • 进程A中的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(#73),这种情况有几种可能
    • 进程A和进程B可能是父子进程关系
    • 进程A和进程B打开了同一个文件,且文件描述符相同(低概率事件)
    • A、B中某个进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程。
  • 进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(#1936),换言之,指向同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了打开请求。同一个进程两次打开同一个文件,也会发生类似情况。

缓存I/O

缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。

读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。

写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令

阻塞与同步

阻塞、非阻塞说的是调用者。同步、异步说的是被调用者。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

同步与异步

同步请求

A调用B,B的处理是同步的,在处理完之前他不会通知A,只有处理完之后才会明确的通知A。

异步请求

A调用B,B的处理是异步的,B在接到请求后先告诉A我已经接到请求了,然后异步去处理,处理完之后通过回调等方式再通知A。

同步和异步最大的区别就是被调用方的执行方式和返回时机。 同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方。

阻塞与非阻塞

阻塞请求

A调用B,A一直等着B的返回,别的事情什么也不干。

非阻塞请求

A调用B,A不用一直等着B的返回,先去忙别的事情了。

所以说,阻塞和非阻塞最大的区别就是在被调用方返回结果之前的这段时间内,调用方是否一直等待。 阻塞指的是调用方一直等待别的事情什么都不做。非阻塞指的是调用方先去忙别的事情。

Unix中的五种I/O模型

对于一次I/O访问read操作分为两个阶段

  1. 等待数据准备,数据被拷贝到操作系统内核的缓冲区
  2. 数据从内核拷贝到进程

对于socket流而言

  1. 通常涉及到等待网络上的数据分组到达,也就是被复制到内核的某个缓冲区
  2. 把数据从内核缓冲区复制到应用进程缓冲区

1. 同步阻塞I/O

分为两个阶段,这两个阶段都必须完成后才能继续下一步操作,blocking IO的特点就是IO执行的两个阶段都被block了。

  1. 等待数据就绪。网络I/O中就是等待远端数据陆续抵达。数据从网络中或者从磁盘上被复制到内核缓冲区中。
  2. 数据拷贝。出于系统安全考虑,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝一份到用户态内存中

blockingIO

send函数是应用程序用来向TCP连接的另一端发送数据

recvfrom或recv函数是应用程序用来从TCP连接的另一端接收数据

2. 同步非阻塞I/O

非阻塞是对于主调方来说的,用户进程可以在阶段1的时候选择去做其他事情,通过轮询的方式看看内核缓冲区是否就绪。如果数据就绪,再执行阶段2,第2阶段的拷贝数据的整个过程,进程仍然是属于阻塞状态的。nonblocking IO的特点就是用户进程需要不断的主动轮询kernel数据好了没有。

noneBlockingIO

3. I/O多路复用

I/O多路复用也称为时间驱动模型。I/O多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,一个进程可以见识多个描述符,一旦某个文件描述符fd就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。select/pselect/poll/epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。相比如同步非阻塞I/O,它的改进在于原本需要用户进程去轮询的事情交给了内核线程帮你完成,而且这个内核线程可以等待多个socket,能实现同时对多个I/O端口进行监听。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞IO的web server性能更好,可能延迟还更大。 也就是说,select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。高并发的程序一般使用同步非阻塞方式而非多线程 + 同步阻塞方式。在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如下图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

  • select

    select可以先对要操作的描述文件符进行查询,查看是否目标囚犯们描述符可以进行读、写或者错误操作,然后当文件描述符满足操作条件的时候才进行真正的I/O操作。函数select()返回值为0,-1或者一个大于1的整数值,当监视的文件集中有文件描述符符合要求,即读文件描述符集中的文件可读,写文件描述符集中的文件可写或者错误文件描述符中的文件发生错误时,返回值为大于0的正值;当超时的时候返回0;当发生错误的时候返回-1。

  • pselect

    与select函数一致,除了超时时间结构是纳秒级的结构。不过Linux平台下内核调度的精度为10毫秒级,所以根本达不到设置的精度。

  • poll

    poll解决了select中fds集合大小1024的限制。但是,它并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,poll不适合用于大并发场景。

  • epoll

    是select和poll的增强版。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

    io多路复用

4. 信号驱动I/O

信号驱动式I/O是指进程预先告知内核,使得当某个描述符上发生某事是,内核使用信号通知相关进程。信号驱动式I/O对于TCP套接字近乎无用,因为该信号产生得过于频繁,不能区分具体是哪种事件

  • 监听套接字上某个连接请求请求已经完成
  • 某个断连接请求已经发起
  • 某个连接之半已经关闭
  • 数据到达套接字
  • 数据已经从套接字发送走(即输出缓冲区有空闲空间)
  • 发生某个异步错误

5. 异步I/O

相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段, 进程都是非阻塞的。Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。

异步IO

参考文献

Linux IO模式及select、poll、epoll详解

文件描述符简介

Linux中的文件描述符与打开文件之间的关系

缓存IO与直接IO

详解 Java 中 4 种 I/O 模型

Linux网络编程—select()和pselect()函数