IO小记

记录下关于网络IO的一些容易混乱的知识点。

网络IO

在Linux系统中,有这样一句话,叫做『万物皆文件』,也就是说,所有东西都可以抽象成『open –> write/read –> close』模式来操作。 故Linux的网络操作中也可以将从网络中读取/发送数据当成文件处理,只不过这个文件比较特殊数据来源于网卡。因此对于网络的操作也是可以通过IO去描述的。

为了方便不同的终端进行通信,网络协议栈抽象出了socket,通过对socket文件描述符的操作来实现网络传输。

IO的基本原理

IO读写分为read和write,read调用过程如下(write同理):

  1. 进程发起读文件请求。
  2. 内核通过查找进程文件符表,定位到内核已经打开的文件集上的文件信息,从而找到文件的inode
  3. 再通过inode查找需要请求的页面是否已经存在于页缓存(内核中)中。若存在则直接返回这片文件页的内容。若不存在(缺页中断),则通过inode定位到文件磁盘地址,将数据从磁盘复制到页缓存中,之后再次发起读页面的过程,进而将页缓存中的数据发给用户进程

如上:我们可以看到read/write都不是简单的将数据从用户进程写入到磁盘(read是从磁盘读到用户进程的内存),在它们中间还有一个缓冲区,这个缓冲区包括内核缓冲区用户进程缓冲区。同理,对于网络传输来说也存在着这个中间缓冲区,具体如下图。

因为所有socket的函数,都是系统调用,最终都是由内核去做事情,这里就涉及到用户态与内核态的问题。一般来讲,在Linux中每一个进程都有两个栈,一个内核栈一个用户栈,用户栈就是常规意义上的栈,而内核栈就是内核代码运行使用的栈。在网络IO的过程中,需要注意的地方就在于内核使用的内存跟用户使用的内存是相互独立的,两者之间没有共享内存的存在,所以,在我们的read方法的时候,内核其实是做了两步操作:

  1. 内核缓冲区从网卡读取数据,为用户进程准备好read的数据
  2. 把数据从内核中拷贝到用户可以访问的内存中。

阻塞IO与非阻塞IO

阻塞式IO

我们说的”阻塞”是指进程在发起一个系统调用后,由于该系统调用的操作不能立即完成,需要等待一段时间,于是内核将进程挂起为等待状态,以确保它不会被调度执行,从而节约CPU的资源。

上图中,当用户进程调用了recvfrom这个系统调用以后,kernel就开始IO的第一个阶段:准备数据(等待足够的数据到达),这个过程是需要等待的,也就是说数据从网卡拷贝到内核缓冲区是需要一个过程的。而在用户进程这边,整个过程都会被阻塞住。当kernel一直等到数据准备完成后,开始第二个阶段:它就会从kernel拷贝到用户进程的缓存中(这个过程在阻塞IO中也是阻塞的),然后用户进程解除阻塞状态,重新运行。(上图红线部分为阻塞状态)

我们需要注意到,在阻塞当前进程时,CPU转而去执行其他进程,因此阻塞并不意味着整个操作系统都被阻塞。由于在当前进程被阻塞时,CPU转而去执行其他进程了,所以CPU的利用率较高。

这种模型适合并发量较小的网络应用开发。不适合并发量大的应用。因为每个请求IO都需要一个进程(线程)去处理,所以得为每个请求分配一个处理进程(线程)以及时响应,系统的开销比较大。

非阻塞IO

我们说的非阻塞是指进程在发起一个系统调用后立刻返回返回,应用进程可以继续执行,等系统调用结束后由系统通知调用者,调用已完成。 所以,非阻塞IO可以空闲出很多用户线程的时间来处理别的事情。

如上图所示,当用户进程发出recvfrom操作时,如果kernel中的数据还没有准备好,那么它并不会阻塞用户进程,而是返回一个error。从用户进程的角度讲,它发起一个recvfrom操作以后,并不需要等待。而是马上得到一个结果,用户知道数据并没有准备好,于是它可以再次发送recvfrom操作。一旦kernel中的数据准备好了,并且再次收到用户进程的系统调用,那么它将数据拷贝至用户内存,然后返回。(特点是需要用户进程不断的主动进行轮询)

阻塞式IO与非阻塞式IO的区别

两者区别就在当阻塞IO调用recvfrom后,在第一步和第二步用户进程都会被阻塞而非阻塞IO,非阻塞指的是第一步,也就是从内核准备recvfrom需要的数据时不会阻塞用户进程,会直接返回,而第二步拷贝数据跟阻塞式IO是一样的,都会阻塞。通常来说,第二步的阻塞并非性能的瓶颈,因为从内核拷贝数据到用户进程是非常快的,网络通信最大的瓶颈在于网络之间传输数据的过程,因为这个过程需要网络协议来通过互联网传输数据。

虽然,非阻塞IO在第一步不阻塞用户进程,但是为了得知内核的数据是否已经准备好,用户进程(线程)需要不断的执行轮询内核来获取数据准备的情况。由于轮询的存在使得这种模型CPU的利用率比较低。为了优化非阻塞式IO轮询的机制,IO多路复用的概念就被提出来了。

IO多路复用

IO多路复用是一种同步IO模型,实现了一个线程可以见识多个文件句柄。一旦某个文件出现就绪,就能够通知程序进行相应的读写操作。没有文件句柄就绪时就阻塞应用程序,交出CPU。

多路是指的网络连接,复用是指的同一线程。

一般地,IO多路复用都依赖于一个时间多路分离器(如epoll_wait 返回的就绪事件,通过if else分开不同的事件)。分离器的对象来自事件源IO事件分离出来,并分发到对应的read/write事件处理器。开发者预先注册需要处理的时间以及时间处理器(回调函数),事件分离器负责将请求传递给事件处理器。

两种高效的事件处理模式:

  1. Reactor: 主要应用epoll ==> 同步IO
  2. Proactor:主要应用iocp ==> 异步IO

Reactor

Reactor要求主线程只负责监听文件描述符上是否有事件发生,有的话就通知给工作线程。除此之外主线程不做任何实质性的工作。读写数据,接受新的链接,以及处理客户请求均在工作线程中完成。

epoll的工作方式就是典型的Reactor模式。

  1. 主线程往epoll内核事件表中注册socket上的读就绪(其他也可以)事件。
  2. 主线程调用epoll_wait等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll通知主线程。主线程则将socket可读事件放入到请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket中读取数据,并且处理客户请求。

Reactor模式,本质上就是当IO事件触发时,通知我们主动去读取,也就是需要我们主动将socket中的数据读取到应用进程,再处理。

Proactor

与Reactor不同的是,Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

使用异步IO模型(以aio_readaio_write为例)Proactor的工作模式是如下。

  1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里使用的是信号)。
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入到用户缓冲区后,内核将向应用程序发送一个信号,以通知应用数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求(不用工作线程读取数据)。

在Proatcor模式中,我们需要指定一个应用进程内的buffer,交给系统,当有数据包到达时,则写入到这个buffer中并通知我们收了多少字节。

参考链接:

非阻塞IO学习分享

Linux高性能服务器编程