You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

05 | IO设计如何设计IO交互来提升系统性能

你好我是尉刚强。今天这节课我想从性能的角度来跟你聊聊IO交互设计。

对于一个软件系统来说影响其性能的因素有很多与IO之间的交互就是其中很关键的一个。不过可能有不少的程序员会觉得IO交互是操作系统底层干的事情好像跟上层的业务关系不太大所以很少会关注IO交互设计。其实这是一种不太科学的认识。

事实上在测试软件性能的时候如果你发现了这样一种很奇怪的现象虽然CPU使用率还没有到100%但是系统吞吐量却无法再提升了。那么这个时候就很有可能是因为IO交互设计没有做好导致软件的很多业务处理线程都被阻塞了所以性能提不上去。可见在软件设计当中良好的IO交互设计对于系统性能的提升非常重要。

因此在这节课中我想先帮你打开一下思路了解下在软件设计中可能会碰到的各种IO场景从而树立起对IO交互设计的正确认知。然后我会给你介绍下针对不同的IO场景应该怎样进行IO交互设计才能在软件实现复杂度与性能之间实现平衡从而帮助你提升在IO交互设计方面的能力。

那么下面我们就一起来了解下在软件设计中都有哪些IO场景吧。

突破对IO的片面认识

提到IO你首先想到的会是什么呢键盘、鼠标、打印机吗实际上现在的软件系统中很少会用到这些东西了。一般来说大部分程序员所理解的IO交互是文件读取操作、底层网络通信等等。

那么这里我想问你一个问题是不是当系统中没有这些操作的时候就不用进行IO交互设计了

其实并不是的。**对一个软件系统而言除了CPU和内存外其他资源或者服务的访问也可以认为是IO交互。**比如针对数据库的访问、REST请求还有消息队列的使用你都可以认为是IO交互问题因为这些软件服务都在不同的服务器之上直接信息交互也是通过底层的IO设备来实现的。

下面我就带你来看一段使用Java语言访问MongoDB的代码实现你会发现在软件开发中有很多与IO相关的代码实现其实是比较隐蔽的不太容易被发现。所以你应该对这些IO相关的问题时刻保持警觉不要让它们拖垮了软件的业务性能。

这段代码的业务逻辑是在数据库中查询一条数据并返回,具体代码如下:

// 从数据库查询一条数据。
MongoClient client = new MongoClient("*.*.*.*");
DBCollection collection = mClient.getDB("testDB").getCollection("firstCollection");
BasicDBObject queryObject = new BasicDBObject("name","999");
DBObject obj = collection.findOne(queryObject);  // 查询操作

其中我们可以发现代码中的最后一行是采用了同步阻塞的交互方式。也就是说这段代码在执行过程中是会把当前线程阻塞起来的这个过程与读取一个文件的代码原理是一样的。所以它也是一种很典型的IO业务问题。

可见我们一定要突破对传统IO的那种片面理解和认识用更加全局性、系统性的视角来认识系统中的各种IO场景这才是做好基于IO交互设计提升软件性能的先决条件。

那么说到这里我们具体要如何针对系统中不同的IO场景进行交互设计并提升系统性能呢接下来我就给你详细介绍下在软件设计中IO交互设计的不同实现模式进而帮助你理解不同IO交互对软件设计与实现以及在性能上的影响。

IO交互设计与软件设计

我们知道在Linux操作系统内核中内置了5种不同的IO交互模式分别是阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO。但是不同的编程语言和代码库都基于底层IO接口重新封装了一层接口而且这些接口在使用上也存在不少的差异。所以这就导致很多程序员对IO交互模型的理解和认识不能统一进而就对做好IO的交互设计与实现造成了比较大的障碍。

所以接下来,我就会站在业务使用的视角将IO交互设计分为三种方式分别是同步阻塞交互方式、同步非阻塞交互方式和异步回调交互方式给你一一介绍它们的设计原理。我认为只要你搞清楚这些IO交互设计的原理以及理解它们在不同的IO场景下如何在软件实现复杂度与性能之间做好权衡你就离设计出高性能的软件不远了。

另外这里你要知道的是这三种交互设计方式之间是层层递进的关系越是靠后的方式在IO交互过程中CPU介入开销的可能就会越少。当然CPU介入越少也就意味着在相同CPU硬件资源上潜在可以支撑更多的业务处理流程因而性能就有可能会更高。

同步阻塞交互方式

首先我们来看看第一种IO交互方式同步阻塞交互方式

什么是同步阻塞交互方式呢在Java语言中传统的基于流的读写操作方式其实就是采用的同步阻塞方式前面我介绍的那个MongoDB的查询请求也是同步阻塞的交互方式。也就是说虽然从开发人员的视角来看采用同步阻塞交互方式的程序是同步调用的但在实际的执行过程中程序会被操作系统挂起阻塞。我们来看看采用了同步阻塞交互方式的原理示意图

从图上你可以看到业务代码中发送了读写请求之后当前的线程或进程会被阻塞只有等IO处理结束之后才会被唤醒。

所以这里你可能会产生一个疑问:**是不是使用同步阻塞交互方式,性能就一定会非常差呢?**实际上并没有那么绝对因为并不是所有的IO访问场景都是性能关键的场景。

我给你举个例子,针对在程序启动过程中加载配置文件的场景,因为软件在运行过程中只会加载配置文件一次,所以这次的读取操作并不会对软件的业务性能产生影响,这样我们就应该选择最简单的实现方式,也就是同步阻塞交互方式。

既然如此,你可能又要问了:如果系统中有很多这样的IO请求操作时那么软件系统架构会是怎样的呢

实际上早期的Java服务器端经常使用Socket通信也是采用的同步阻塞交互方式它对应的架构图是这样的

可以看到每个Socket会单独使用一个线程当使用Socket接口写入或读取数据的时候这个对应的线程就会被阻塞。那么对于这样的架构来说如果系统中的连接数比较少即使某一个线程发生了阻塞也还有其他的业务线程可以正常处理请求所以它的系统性能实际上并不会非常差。

不过现在很多基于Java开发的后端服务在访问数据库的时候其实也是使用同步阻塞的方式所以就只能采用很多个线程来分别处理不同的数据库操作请求。而如果针对系统中线程数很多的场景,每次访问数据库时都会引起阻塞,那么就很容易导致系统的性能受限。

由此我们就需要考虑采用其他类型的IO交互方式避免因频繁地进行线程间切换而造成CPU资源浪费以此进一步提升软件的性能。所以同步非阻塞交互模式就被提出来目的就是为了解决这个问题下面我们具体来看看它的设计原理。

同步非阻塞交互方式

这里我们先来了解下同步非阻塞交互方式的设计特点在请求IO交互的过程中如果IO交互没有结束的话当前线程或者进程并不会被阻塞而是会去执行其他的业务代码然后等过段时间再来查询IO交互是否完成。Java语言在1.4版本之后引入的NIO交互模式其实就属于同步非阻塞的模式。

注意实际上Java NIO使用的并不是完全的同步非阻塞交互方式比如FileChannel就不支持非阻塞模式。另外Java NIO具备高性能的其中一个重要原因是因为它增加了缓冲机制通过引入Buffer来支持数据的批量处理。

那么接下来我们就通过一个SocketChannel在非阻塞模式中读取数据的代码片段来具体看看同步非阻塞交互方式的工作原理

while(selector.select()>0){   //不断循环选择可操作的通道。
      for(SelectionKey sk:selector.selectedKeys()){
	      selector.selectedKeys().remove(sk);
		    if(sk.isReadable()){ //是一个可读的通道
			      SocketChannel sc=(SocketChannel)sk.channel();
			      String content="";
			      ByteBuffer buff=ByteBuffer.allocate(1024);
			      while(sc.read(buff)>0){
				        sc.read(buff);
				        buff.flip();
				        content+=charset.decode(bff);
				        }
            System.out.println(content);
			      sk.interestOps(SelectionKey.OP_READ);					
		    }
	  }
}

你能看到业务代码中会不断地循环执行selector.select()操作选择出可读就绪的SocketChannel然后再调用channel.read把通道数据读取到Buffer中。

也就是说在这个代码执行过程中SocketChannel从网口设备接收数据期间并不会长时间地阻塞当前业务线程的执行所以就可以进一步提升性能。这个IO交互方式对应的原理图如下

从图中你能看到当前的业务线程虽然避免了长时间被阻塞挂起但是在业务线程中会频繁地调用selector.select接口来查询状态。这也就是说在单IO通道的场景下使用这种同步非阻塞交互方式性能提升其实是非常有限的。

不过,与同步阻塞交互方式刚好相反,当业务系统中同时存在很多的IO交互通道时使用同步非阻塞交互方式我们就可以复用一个线程来查询可读就绪的通道这样就可以大大减少IO交互引起的频繁切换线程的开销。

因此在软件设计的过程中如果你发现核心业务逻辑也是多IO交互的问题你就可以基于这种IO同步非阻塞交互方式来支撑产品的软件架构设计。在采用这种IO交互设计方式实现多个IO交互时它的软件架构如下图所示

如果你详细阅读了前面SocketChannel在非阻塞模式中读取数据的代码片段你就会发现在这个图中包含了三个很熟悉的概念分别是Buffer、Channel、Selector它们正是Java NIO的核心。这里我也给你简单介绍下Buffer是一个缓冲区用来缓存读取和写入的数据Channel是一个通道负责后台对接IO数据而Selector实现的主要功能就是主动查询哪些通道是处于就绪状态。

所以Java NIO正是基于这个IO交互模型来支撑业务代码实现针对IO进行同步非阻塞的设计从而降低了原来传统的同步阻塞IO交互过程中线程被频繁阻塞和切换的开销。

补充但Java的NIO接口设计得并不是非常友好代码中需要关注Channel的选择细节而且还需要不断关注Buffer的状态切换过程。因此基于这套接口的代码实现起来会比较复杂。
 
那么有没有什么办法可以帮助降低代码实现的复杂度呢我们可以基于Java NIO设计的Netty框架来帮助屏蔽这些细节问题。Netty框架是一个开源异步事件编程框架它的系统性能非常高同时在接口使用上也非常友好所以目前使用也很广泛。如果你在开发网络通信的高性能服务器产品那么你也可以考虑使用这种框架Elasticsearch底层实际上就是采用的这种机制

不过基于同步非阻塞方式的IO交互设计如果在并发设计中没有平衡好IO状态查询与业务处理CPU执行开销管理就很容易导致软件执行期间存在大量的IO状态的冗余查询从而造成对CPU资源的浪费。

因此我们还需要从业务角度的IO交互设计出发来进一步减少IO对CPU带来的额外开销而这就是我接下来要给你介绍的异步回调交互方式的重要优势。

异步回调交互方式

所谓异步回调的意思就是当业务代码触发IO接口调用之后当前的线程会接着执行后续处理流程然后等IO处理结束之后再通过回调函数来执行IO结束后的代码逻辑。

这里我们同样来看一段代码示例这是Java语言针对MongoDB的插入操作它采用的就是异步回调的实现方式

Document doc = new Document("name", "Geek")
               .append("info", new Document("age", 203).append("sex", "male"));

collection.insertOne(doc, new SingleResultCallback<Void>() { 
    @Override
    public void onResult(final Void result, final Throwable t) {
        System.out.println("Inserted success");
    }
});

我们可以发现在这段代码中调用collection.insertOne在插入数据时同时还传入了回调函数。

实际上这个MongoDB访问接口在底层使用是Netty框架只是重新封装了接口使用方法而已。

由此我们就可以最大化地减少IO交互过程中CPU参与的开销。这种IO交互方式的原理图如下所示

从这个图中可以看到,在使用异步回调这种处理方式时,回调函数经常会被挂载到另外一个线程中去执行。所以使用这种方式会有一个好处,就是业务逻辑不需要频繁地查询数据,但同时,它也会引入一个新问题,那就是回调处理函数与正常的业务代码被割裂开了,这会给代码实现增加不少的复杂度。

我给你举个例子如果代码中的回调函数在处理过程中还需要进一步执行其他IO请求时如果再使用回调机制那么就会出现万恶的回调嵌套问题也就是回调函数中再嵌套一个回调函数这样一直嵌套下去代码就会很难阅读和维护。

所以后来在Node.js中就引入了async和await机制在C++、Rust中也都引入了类似的机制比较好地解决了这个问题。我们使用这个机制可以将背后的回调函数机制封装到语言内部底层实现中这样我们就依旧可以使用串行思维模式来处理IO交互。

而且当有了这种机制之后IO交互方式对软件设计架构的影响就比较少了所以像Node.js这样的单进程模型也可以处理非常多的IO请求。

另外,使用异步回调交互方式还有一个好处因为现在的互联网场景中对数据库、消息队列、REST请求都是非常频繁的所以如果你采用异步回调方式比较有可能将IO阻塞引起的线程切换开销还有频繁查询IO状态的时间开销都降低到比较低的状态。

最后我还想告诉你的是实际上IO交互设计不仅与语言系统的并发设计相关性很大而且与缓冲区Buffer的设计和实现关系也很紧密我们在进行IO交互设计时其实需要权衡很多因素这是一个挺复杂的工作我们一定不能小看它。

小结

今天这节课我通过一些常见的业务代码逻辑带你突破了之前对IO的片面认识帮助你更加清楚地识别出系统中的各种IO交互场景。另外我也给你重点介绍了在应用软件设计过程中三种常用的IO同步交互设计帮助你去理解不同IO交互对软件设计与实现以及在性能上的影响。你可以基于这些理解和认识来指导软件架构设计避免因为IO交互问题而引起比较严重的性能问题。

其实还有一种在软件设计中使用的比较少的IO交互同步方式我并没有给你介绍这种IO交互方式叫做零协调交互方式它在高性能嵌入式系统设备或高性能服务器中使用会比较多。比如你可以使用DPDK技术来减少网络数据接收期间操作系统内核的参与或者使用链式DMA拷贝来减少内存拷贝中的CPU介入等。这里你简单了解下即可如果还想要深入学习的话你可以参考这个文档

思考题

今天课程中介绍的异步回调交互方式操作系统底层是不是采用的Linux内核的异步IO交互方式呢

欢迎在留言区分享你的答案和思考。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。