性能优化:用IO设计提升系统性能转载
大家好,我是TT。
今天这篇内容,我想从性能的角度,来聊聊 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 资源浪费,以此进一步提升软件的性能。所以,同步非阻塞交互模式就被提出来,目的就是为了解决这个问题,下面我们具体来看看它的设计原理。
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);
}
}
}
同步非阻塞交互方式
这里,我们先来了解下同步非阻塞交互方式的设计特点:在请求 IO 交互的过程中,如果 IO 交互没有结束的话,当前线程或者进程并不会被阻塞,而是会去执行其他的业务代码,然后等过段时间再来查询 IO 交互是否完成。Java 语言在 1.4 版本之后引入的 NIO 交互模式,其实就属于同步非阻塞的模式。
注意:实际上,Java NIO 使用的并不是完全的同步非阻塞交互方式,比如 FileChannel 就不支持非阻塞模式。另外,Java NIO 具备高性能的其中一个重要原因,是因为它增加了缓冲机制,通过引入 Buffer 来支持数据的批量处理。
那么接下来,我们就通过一个 SocketChannel 在非阻塞模式中读取数据的代码片段,来具体看看同步非阻塞交互方式的工作原理:
你能看到,业务代码中会不断地循环执行 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("***", "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 交互设计时,其实需要权衡很多因素,这是一个挺复杂的工作,我们一定不能小看它。