透过linux内核剖析NIO原理

  • Post author:
  • Post category:linux


一、IO模型

1.     什么是IO

你想开发高性能的服务框架吗?你能评估自己开发的IO程序的性能吗?你能对高并发环境下出现的故障快速定位问题吗?你清楚一次IO API调用后,在操作系统或设备中发生了什么事,这些事需要花费多长时间吗?这些是搭建高性能服务架构的基础,也是我们做架构决策的依据,要想游刃有余的完成一个高性能服务框架的设计,对IO的了解就要更深入,因为IO是影响整个架构性能的重要因素。

当你在调用一个API进行IO开发时,其实完成了多个层次的调用,见下图。每个层次都对IO功能做了封装,封装的细节和实现对我们透明。我们先看看我们的调用模型:

  • 用户程序:我们所写的JAVA项目,通过编程语言语法完成IO调用。
  • 编程语言:如JAVA,它通过java虚拟机来实现封装,并通过操作系统的系统调用完成具体的IO实现。
  • 操作系统:把和设备无关的功能抽象成模块和接口,对上提供服务。具体实现细节有设备驱动程序完成。
  • 驱动程序:实现操作系统定义的IO接口,通过命令行操控外部设备。
  • 外部设备:提供命令行入口,根据命令行执行设备操作。

每个层次的调用都提供了多种调用模式或调用参数,我们通过这些模式的选择和参数的设置,正确选择模式和设置参数可以开发更高效的IO应用程序。

2.     初探IO原理

操作系统,驱动程序,外部设备甚至JVM对IO的封装都极其复杂,我们要完全讲明白他们的原理也许需要一本书的内容。而且我们并不需要做驱动程序编程,只要了解其原理就足以我们完成高性能服务架构的编写,所以我在这里做一个IO的简化模型,模型分为两个块:

  • 第一块:应用程序通过系统调用和操作系统完成读写交互:

在操作系统中,所有的IO设备都被认为是文件,每个文件结构中都有读写缓冲区以及对应的读写队列,我们对IO的读写,其实就是对缓冲区的读写:

  • 读操作(read):应用程序通过read系统调用读取数据,操作系统检查文件对应的读缓冲区是否有数据,有就直接返回,否则把当前进程放入读等待队列,并阻塞当前进程,等有数据时再恢复当前进程的运行。
  • 写操作(write):应用程序通过write系统调用写入数据,操作系统检查文件对应的写缓冲区是否有空闲位置,有直接写数据到缓冲区,否则把当前进程放入读等待队列,并阻塞当前进程,等有空闲位置时再恢复当前进程的运行。
  • 事件检测(poll):应用程序通过poll系统调用,检查文件是否具备就绪的事件(如读写事件),并返回就绪的事件,如果没有就绪事件,则把当前进程放入事件等待队列,并阻塞当前进程。当事件就绪时再恢复当前进程的运行。

步骤二:操作系统和IO设备交互完成读写:

  • 读数据:当IO设备有数据需要传递给操作系统时,先把数据放入自己的读缓冲区,然后产生一个中断,中断通过CPU捕获,并交给设备驱动程序处理。设备驱动程序从设备缓冲区读取数据,经处理后放入操作系统的缓冲区。设置读事件就绪,同时唤醒在读等待队列和读事件等待队列中的进程。
  • 写数据:操作系统调用设备驱动程序的接口,写入数据。如果设备的写缓冲区有空闲位置,则写入成功,否则阻塞直到可以写入为止。在写入完成后会唤醒在写等待队列和写事件等待队列中的进程。

对于文件的系统调用有很多,如Socket文件还有accept操作,其实也可以套用上述原理去理解,获取Socket对象,也可以理解成一种特殊的read,只是系统调用的方法名不一样,它也有对应的Socket缓冲区(读缓冲区)也有事件等待列表,也可以被poll检查,在原理上无差异。

在操作系统层面,每个文件通过自己的私有结构可以定义很多事件,并且可以通过poll检查事件是否就绪,如果没有就绪可以放入事件等待对列,在就绪时唤醒等待队列中的进程。本文为了简化,只保留核心原理部分,细节我们在后面部门逐渐分析。

二、五种IO模型

根据上述的简化模型,我们就可以得到多种IO访问方式,如直接read(可能被阻塞),先poll检查是否就绪再read(read时确定已经有数据了,所以不会被阻塞)等等。IO模型的访问方式不同,会导致IO性能有很大的差距,操作系统根据常用的访问方式,实现了多种访问模型。下面章节我们会对常用的五种IO模型进行分析和讲解。

在介绍IO模型前,先做一个小科普,由于用户进程可以访问的虚存地址是0-3G(32位操作系统),而内核代码可以访问的虚存地址是3G-4G。在系统调用时从用户态进入内核态,只能访问内核内存,所以需要把用户空间的数据复制到内核空间,同样从内核态返回用户态时,也需要把内核态的数据复制到用户空间。

1.     阻塞IO模型


以套接字来举例,所有的文件操作都是阻塞。在用户进程进行系统调用,进程会阻塞一直到应用进程收到数据或出错才会返回。此模型在JDK1.4以前都是使用此模型,在多客户端并发访问时,由于每次调用都可能阻塞,所以需要开启一个线程进行读写来提高并发效率。当线程数较多时会导致系统性能急剧下降。有些系统(如早期的Tomcat)会通过线程池+缓冲区来减少线程的个数。但如果发送方速度过慢或网络问题也会导致线程用尽或缓冲区用尽导致系统崩溃。

2.     非阻塞IO模型


通过在进行读写操作前,先检查读写是否准备就绪来减少读写的阻塞。这种模式需要不停的轮询操作系统,看操作是否就绪。大家知道,每进行一次系统调用,都会进行用户态到内核态的切换,需要保存用户的寄存器和相关数据,返回时再恢复,频繁的轮询对效率上也会带来较大损耗。

3.     IO复用模型


由于系统调用是一个较重的操作,我们期望每次系统调用能帮我们检查更多的文件准备就绪的事件,这个模式就是把多个要检查的文件及事件通过系统调用一次性传递给操作系统进行检查,如果有任何一个文件的事件准备就绪就会返回,否则阻塞当前进程直到超时或有文件的事件达到就绪状态。这种模式把多个文件阻塞在一个进程上,大大减少了阻塞进程的个数,可以由一个进程管理成千上万的文件,现在NIO多是用这种方式实现的select选择器。下一节会对此模式进行更深度的分析。

4.     信号驱动IO模型


当进程从内核态返回用户态时会检查进程有没有信号,如果有就执行信号处理程序,Linux提供了64种信号,其中有一个信号就是IO事件准备就绪。进程可以通过系统调用指定当前进程处理这个信号的程序。

此模式的核心是预先通过系统调用设置信号的信号处理程序。而后在设备驱动程序中,如果某种事件准备就绪,就产生一个信号,并把信号放到进程的内存结构中,当进程返回用户态时会执行这个信号对应的信号处理程序。

此模式和IO复用模型比,没有任何阻塞,它通过设置回调函数(信号处理程序)的方式,在某事件发生时,由操作系统自动调用。回调函数再通过read操作读取数据,进行操作。

5.     异步IO模型


信号驱动IO模型,在被操作系统回调时,还需要通过read系统调用获取数据,而异步IO模型会在回调前就把数据从内核空间copy到了用户空间,回调函数可以直接处理数据,不需要再进行系统调用。

三、IO复用模型原理

通过第一章的IO模型我们知道,其实NIO中的非阻塞实现,其实是实现了IO模型中的第三种模型IO复用模型,本节我们对IO复用模型的实现做一个剖析,让大家了解,在操作系统层面是如何实现IO复用模型的。

操作系统实现IO复用模型,一般通过3个系统调用poll,select和epoll,其中poll和select实现相似,在这里统一讲解。epoll实现略有差别,我们单独讲解,在目前的JDK实现中,优先选择epoll实现。

1.     poll和select实现



  • Select/poll在系统调用时一般需要3个参数:

    • 待检查的文件描述符
    • 待检查的文件描述符对应的事件
    • 超时时间
  • 操作系统会逐个检查待检查的文件(Sock是Socket在内核中的数据结构),是否有事件就绪。每个文件也有一个poll方法,主要是检查自己的某个事件是否准备就绪。我们也用一个简化的模型来分析,每个文件有一个事件和状态的对应关系,当事件就绪就设置此状态。同时每个事件还有一个等待队列。
  • 当事件状态是就绪状态,返回就绪。
  • 否则创建一个内部对象放入等待队列,这个内部对象有一个回调方法,用来唤醒当前进程。
  • 如果有事件就绪返回就绪文件的个数。
  • 如果所有的文件都没有事件就绪,则阻塞进程,阻塞时间为timeout。
  • 在阻塞期间,如果设备通过中断促使某些事件变为就绪,则对事件对应的等待列表进行回调唤醒阻塞的进程。

2.     epoll的实现


由于poll和select系统调用会把所有的文件当作参数传递给操作系统,每次调用会产生大量用户空间到内核空间(内核空间到用户空间)的拷贝。而且由于每次对所有的文件进行遍历,如果存在大量不活跃的文件导致性能的急剧下降。所以提出了一个新的系统调用epoll。

它通过3个系统调用来完成:

  • poll_create:会创建一个文件,其实是创建了在内核的数据结构,在Linux中一切设备都是文件,epoll可以理解为一个虚拟设备。
  • poll_ctl:向epoll中加入/删除/修改待检测的文件描述符。当文件描述符加入待检测列表时,epoll会向此文件注册回调函数,在文件某事件就绪时会回调此函数。函数的默认实现是把此文件加入事件就绪列表中。
  • poll_wait:检查是否有就绪的文件描述符。直接检查就绪列表,如果有数据就返回就绪个数,否则返回空。

由于epoll是通过事件通知的方式获取就绪列表的,所以它只关注活跃的文件,这样对于大量不活跃文件的监控有更好的效率,但由于它实现的复杂性,当所检测的文件大多是活跃的情况,它的性能有可能低于select或poll。

3.     三种实现对比


四、NIO原理剖析

本节主要介绍NIO两个主要的组建:SocketChannel/ServerSocketChannel,Select。并通过java代码来模拟它在操作系统的实现。

1.     SocketChannel/ServerSocketChannel

以ServerSocketChannel为例,来分析它非阻塞的实现方案(非阻塞IO模型),想使用一个非阻塞的ServerSocketChannel,非常简单,只要执行下面3句话,就可以完成。

这几句话执行完成后,JVM和操作系统都做了什么呢?我们先来分析ServerSocketChannel在JVM中的实现,为了简化逻辑和让JAVA开发人员更方便读代码,我用java代码模拟实现调用过程,如果需要了解更详细的细节,请读操作系统的C代码。

FileDescription是底层操作系统的一个文件描述符。在上文提过,所有的设备都对应一个文件,访问文件获取的是一个文件描述符,我用java实现了一个文件描述符,只保留和本章内容有关的内容。SocketFileDescription是对文件描述符的一个子类,所有的读写,获取连接都通过文件描述符访问底层结构。

open方法,创建一个文件描述符。accept根据blocking状态来进行不同的处理,如果值为false,则直接调用文件的accept方法,在文件内部就会变成读取对应的缓冲区,如果缓冲区为空,系统被阻塞,否则返回Socket对象。如果值为true,先调用文件的poll方法,poll方法先检查缓冲区是否有数据,并立刻返回。如果poll返回值为true,则调用文件的accept方法,读取数据。如果返回false,直接返回空。

下面代码是文件描述符的代码,在操作系统的描述符对应的函数中,有很多函数,在这里做了精简和处理,大家只理解原理即可,不必深究。

本节主要对poll方法的实现进行解析(此poll和系统调用的poll/select不是一回事,请注意区分)。下面代码是SocketFileDescription的poll的实现。waitQueues是事件对应的等待队列,EventKey是一个枚举类,里面有常用的事件如读,写,连接。eventStatus是事件对应的状态,如果事件就绪就设置对应值为true。



  • 获取要检测的事件。
  • 判断事件状态是否就绪,如果就绪直接放回。
  • 如果没有就绪调用add2Queue方法,把相关数据加入waitQueues中。
  • add2Queue的代码如下:

  • EventKey的代码如下:

2.     select/poll

在上一个章节介绍了poll/select的概念,在本节中,我们也写了一个模拟的实现。epoll的实现比较复杂,我们暂不模拟实现。我们看poll的模拟实现,代码如下:

  • pollfds主要是待检测文件描述符的列表。具体代码如下:

  • timeout,超时时间,如果为0,不管是否有就绪的事件,立刻返回;如果为-1,一直阻塞知道事件就绪,否则如果在timeout事件内还未有事件就绪,就返回
  • 循环调用FileDescription的poll方法,并把就绪的事件放入revents中。
  • 如果循环完成有就绪事件或已经超时,则直接返回就绪的事件个数。
  • 如果循环完成后还没有就绪的事件,让进程阻塞timeout时间。
  • 进程被唤醒后再次按上述循环过程检测就绪事件。

如果进程被阻塞,除了超时唤醒外,在某被检测的文件的事件就绪时也会唤醒进程。下面代码是在SocketFileDescription中模拟中断的代码实现,我们假设中断会促使某个事件变为就绪:

  • 设置时间的就绪状态。
  • 对于事件对应的等待队列进行循环。
  • 执行队列元素的wakeup方法。此处wakeup方法的实现代码如下,直接唤醒进程。Task是进程对象。

  • 如果有注册回调函数,调用回调函数。

五、NIO架构模型

在本节分析几种常用的IO复用模型的架构,并分别分析其使用场景

1.     Hadoop架构


Hadoop采用的是一个典型的Reactor工作模式,通过一个Selector,接收连接请求,并从Reader线程池中选择一个Reader线程进行处理,而Reader线程可以有多个,每个内部有一个Selector,用于监听读事件,把读到的数据封装成call对象,放入队列中。一个工作线程池负责从队列中拉取数据进行处理,处理的结果一般会直接写回客户端,但如果网络慢或结果很大,则把结果传递给Responder对应的队列中,并由Responder线程负责写的就绪选择,最终完成写入。

2.     Thrift架构

Thrift自带2中IO复用模型的架构实现,一个是TThreadedSelectorServer,一个是THsHaServer。接下来我们分别对这两种架构进行分析:

  • THsHaServer

会启动一个Selector线程,负责做就绪选择,根据选择的类型交给不同好的handle处理,而read handle会启动一个线程池来进行完成读操作。

  • TThreadedSelectorServer

有两种就绪选择,第一组只有一个就绪选择线程,负责获取新连接,另一组有多个线程来做读写的就绪选择,线程个数一般等于cpu个数(也可以根据需要配置)。把获取的连接根据负载均衡策略,放入目标线程的队列中,读写selector线程,完成读写的就绪选择,并把读操作交给一个线程池来进行处理。

阿里云招聘,欢迎技术大牛加入:chris.yxd@alibaba-inc.com



版权声明:本文为anboyyd原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。