IO设计:如何设计IO交互来提升系统性能?

科技事要畅享 2024-08-14 15:59:10

对于一个软件系统而言,其性能受诸多因素影响,其中与 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); // 查询操作1.2.3.4.5.

其中我们能够发现,代码中的最后一行采用了同步阻塞的交互方式。这意味着,在这段代码的执行过程中,会将当前线程阻塞住,此过程和读取一个文件的代码原理相同。所以,这也是一类十分典型的 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 交互模式,其实就属于同步非阻塞的模式。

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

复制

while(selector.select()>0){ //不断循环选择可操作的通道。for(SelectionKey sk:selector.selectedKeys()){ selector.selectedKeys().remove(sk);if(sk.isReadable()){ //是一个可读的通道 SocketChannel sc=(SocketChannel)sk.channel(); String cnotallow=""; 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); } }}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

您能看到,业务代码中会持续不断地循环执行 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 交互过程中,线程频繁被阻塞和切换的开销。

不过,基于同步非阻塞方式的 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>() { @Overridepublic void onResult(final Void result, final Throwable t) { System.out.println("Inserted success"); }});1.2.3.4.5.6.7.8.9.10.

我们能够发现,在这段代码里,调用 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 交互设计时,实际上需要权衡众多因素,这是一项颇为复杂的工作,我们绝对不能轻视它。

更多资讯,点击

0 阅读:1

科技事要畅享

简介:感谢大家的关注