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 交互模式,其實就屬于同步非阻塞的模式。
那么接下來,我們就通過一個 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);
}
}
}
您能看到,業務代碼中會持續不斷地循環執行 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>() {
@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 交互設計時,實際上需要權衡眾多因素,這是一項頗為復雜的工作,我們絕對不能輕視它。