淺談I/O Model

前言

Carl
Carl
Jan 30, 2018 · 15 min read

我們常常會在開發Java I/O相關程式的時候看到以下幾種名詞:

  • blocking
  • non-blocking
  • synchronous
  • asynchronous

這麼多名詞其實還挺讓人頭痛的, 所以想在這邊整理一下我個人對於這部分的理解. 由於這種東西你在網路上可能會看到很多種說法, 因為每個人的知識背景以及職業都存在一定程度的差異, 所以討論時的context也不同. 這邊我想以Richard Stevens所著的[Unix Network Programming, Volume 1: The Sockets Networking API (3rd Edition)]的6.2節: I/O Models 為主要出發點來做個簡單的記錄, 且基本上只討論user space的I/O, 以下大部分內容都是從原文翻譯過來的.

對於一個network I/O (以下以read為範例), 基本上會涉及到兩個系統層面的對象: 其一是呼叫此I/O的process/thread, 再來就是系統kernel, 而一個read operation基本上又會經歷以下兩個階段(phase):

  1. 等待資料準備(Waiting for the data to be ready)
  2. 將資料從kernel copy至process/thread中(Copying the data from the kernel to the process)

這兩個階段很重要的原因是因為: 在這兩個階段的各部分中所存在的些微差異, 產生了以下將會談到的各種I/O model.

Blocking I/O

基本上, Linux中大部分的socket都是blocking的. 以下是以UDP為範例的blocking I/O 流程圖(書上寫因為TCP比較複雜, 所以用UDP做範例).

Blocking I/O model for UDP

當user process呼叫了recvfrom這個system call時, kernel就會進入前面提到的I/O之第一階段: 等待資料準備. 就network I/O來說, 大多數情況下, 這個時間點都還沒有資料(datagram)到達, 甚至是可能發生錯誤了(通常是system call被interrupt signal中斷). 這時候user process就會一直處於blocking的狀態, 直到recvfrom回傳準備好的資料, 並將資料從kernel複製至user process的memory, 最後待kernel回傳結果(OK)給user process後, user process才解除blocking的狀態並且繼續運作.

從上圖來看, 可以知道所謂的blocking就是在I/O執行的兩個階段都被block住了. 在system歸還process控制權之前, process都不能再做任何的事情.

在Java中, FileInputStream, FileOutputStream以及對Socket的讀寫基本上就是屬於這種model.

Nonblocking I/O

在Linux中如果把socket設置成non-blocking的話, 就相當於告訴kernel: “在不讓process進行sleep的情況下, 若request無法得到回覆, 就直接回傳error.” 其簡單的流程圖如下:

Nonblocking I/O model

當user process呼叫recvfrom之後, 若kernel這邊的資料還沒有準備好, 就不要block user process了, 反之, 立刻回傳一個error(EWOULDBOLCK). 所以站在user process的視角來看, 呼叫recvfrom後並不用卡在那邊等待, 而是可以立刻得到一個結果. 當user process發現回傳的是error時, 就可以知道資料還沒準備好, 這時就可以再次發送recvfrom操作. 當kernel這邊準備好資料後, 且又再次收到來自user process的system call時, kernel就可以把資料copy到user process中(maybe application buffer), 然後回傳結果.

所以, nonblocking其實就是user process要不斷地去問kernel說資料好了沒. 這在application中的做法, 基本上就是用一個loop去一直call recvfrom, 這其實就是我們常說的polling. 儘管這種方式看來很浪費CPU時間, 但似乎還是滿常見的.

對應到Java中, Socket Channel就是基於這種model來運作的, 常見的一些abstract class如: ServerSocketChannel, SocketChannel以及DatagramChannel等.

I/O multiplexing

所謂的I/O multiplexing, 其實就是select/epoll. 相似的概念如Java NIO裡的selector, event driven I/O…等. 這種model的好處在於使用單個process/thread即可同時處理多個網路連接的I/O. 其原理就是select/epoll這類的function會一直輪詢其所負責監視的socket, 若當中有某個socket已經有資料了, 就通知user process, 流程圖如下:

I/O multiplexing model

當user space呼叫了select或是epoll(上圖以select為例), 整個process就會被block住, 同時, kernel會去監視所有由select負責的socket, 當其中任一socket有資料準備好了, select就會立刻return. 此時user process再呼叫recvfrom將資料copy至application buffer中.

這樣看來, I/O multiplexing其實跟blocking I/O沒有什麼差別, 但事實上還是有的:

  • 缺點: I/O multiplexing要用兩次system call, 以上圖為例, 就是select/recvfrom, 而blocking I/O只需要一次system call
  • 優點: 可同時處理多個connection

綜合來看, 如果I/O multiplexing要處理的connection數量沒有很多的話, 其效能不見得會比使用blocking I/O的multi-thread程式要來得好, 甚至可能還會有較高的latency. 要注意的是, I/O multiplexing的優勢不是對單個連接處理會更快, 而是可以在只使用單個process/thread的情況下, 監視/處理更多的connection.

在Java中, 這種model被用在Selector上, 如此一來我們就可以只用一個或是少量的執行緒來達到控制多個channel的目的. 且這些channel基本上應該要是nonblocking的.

Signal driven I/O

在這種model裡, 我們可以跟kernel說: 當資料準備好的時候, 給我們發個SIGIO信號. 這種就叫Signal-Driven I/O, 示意圖如下:

Signal driven I/O model

當在socket上啟用signal-driven I/O後, 我們可以透過sigaction這個system call去安裝一個signal handler. 這個system call會馬上回傳, 然後user process就可以繼續執行, 並不會被block住。當資料準備好了之後, kernel會為user process產生一個SIGIO信號, 這時有兩種處理方式:

  • 透過recvfrom從signal handler讀取資料, 然後通知main loop說資料已經準備好可以處理了
  • 直接通知main loop說資料已經可以讀取了, 讓main loop自己去讀取跟處理

不管用哪種方式處理信號, 我們在等待資料到來的過程中都不會被block. 對main loop來說, 其可以繼續執行要做的工作, 並且只需要等待signal handler的通知即可, 不管是資料已經讀取好並準備接受處理了或者是資料已經準備好可以被讀取了.

Asynchronous I/O

所謂的asynchronous I/O, 就是告訴kernel去進行一個操作(operation), 並且在整個操作完成(包含從kernel複製資料至application buffer裡)的時候通知我們. 此model跟signal-driven的主要差異在於: signal-driven中, kernel會在I/O操作可以被初始化(initiated)的時候通知我們; 但在asynchronous中, kernel是在I/O操作完成後(completed)才通知我們.

這種model的示意圖如下:

Asynchronous I/O model

當user process啟動讀取的操作(透過aio_read)將descriptor, buffer pointer, buffer size, file offset以及當整個操作結束後如何通知user process等參數傳給kernel後, 就會立刻回傳, 這樣就不會讓user process產生block. 再來, 當kernel這邊等到資料準備完成, 並且將資料複製到application buffer後, 其會向user process發送一個signal, 說這個讀取操作已經完成了, 可以把資料拿去做事了. 要補充的是, 這邊送給user process的signal是由aio_read指定的, 所以基本上也不會送錯人.

在Java中, asynchronous主要被利用在AsynchronousSocketChannel, AsynchronousServerSocketChannel以及AsynchronousFileChannel等class之中.

各種I/O Model之比較

介紹完常見的這幾種I/O model後, 現在就可以來看開頭提到的那幾個名詞了:

Blocking與nonblocking
從前面的解說中可以知道, blocking I/O基本上會讓user process進入block的狀態, 直到操作完成才會繼續作業, 而nonblocking則是在kernel還在準備資料的情況下會立刻回傳.

Synchronous與Asynchronous
關於這兩個model, POSIX的定義是這樣寫的

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes
  • An asynchronous I/O operation does not cause the requesting process to be blocked

所以我們可以這樣想: synchronous I/O在執行I/O operation的時候會將process/thread給block住, 而asynchronous則不同, 其在user process觸發I/O操作後, 就直接回傳去做別的事了, 等到kernel處理完I/O後, 會發送一個信號給user process, 說I/O已經結束了, 在這段過程中, user process就沒有被block住(就是叫別人去幫你等東西或是做事喇).

再換個講法, 區別這兩者的關鍵就是到底是誰在進行真正的I/O, 如果是主執行緒, 那就是synchronous, 若是衍生出來的子執行緒, 待子執行緒完成I/O operation後回報給主執行緒, 這就是asynchronous.

講到這邊可能有些地方還是會讓人搞混, 譬如說non-blocking可能會被認為沒有block產生因而被歸類在asynchronous之下, 但事實上是有的. 前一段講到, synchronous I/O在執行I/O operation的時候會將process給block住, 這邊對I/O operation的定義指的是真實的I/O操作(物理意義上的). 什麼是真實的I/O操作呢? 像是recvfrom這種system call就是. 確實, 在nonblocking中, 我們會一直問kernel東西好了沒, 沒好就不管, 但是若好了的話呢? 這時我們就必須透過recvfrom來將資料copy至application buffer中, 在copy的這個過程, user process就是被block住的. 所以也有人會說nonblocking是指當前這次的I/O operation可以保證constant time回傳, 但回傳的可能只有狀態(因為資料還沒好), 所以你才要做polling一直去check看資料到底好了沒. 但在某些context之下, 譬如說討論一個API的return速度時, asynchronous與nonblocking其實都是立刻return, 所以在這種context之下, 就沒有必要那麼嚴格的區分了(當然, 如果你用asynchronous去call一個blocking API, 那你還是要等的, 因為blocking本身就不保證constant time return). 這也是為什麼在開頭就要先限制context的範圍再開始介紹這些model的原因之一.

以下這張圖是書中的I/O比較圖:

I/O comparison

從這張圖來看, 我們也可以看到nonblocking I/O跟asynchronous I/O的差異其實很明顯. 對nonblocking來說, 確實大部分時間都沒有被block, 但是user process還是要主動地去做check的動作, 然後在資料準備完後, 也要自己主動呼叫recvfrom去把資料copy至application buffer中; 而對asynchronous來說, 則是user process把I/O operation整個委託給kernel去完成, 然後kernel完成後會再發信號通知user process, 這樣一來, user process就不用自己去check還有copy資料了, 因為這些都會由kernel來代勞.

結論

以上, 就是在Java中看到的各種I/O其背後的原理, 比較要注意的是對這些概念的分類要清晰, 才不會搞混.

  • Synchronous I/O: 包含了blocking I/O, nonblocking, I/O multiplexing(selector), 以及signal-driven I/O
  • Asynchronous I/O: 就是asynchronous I/O, 但它跟nonblocking還是差很多的

其實對於synchronous這一詞, 我想還可以這樣看: 所謂的同步(synchronous), 就是user space跟kernel space要一起合作, 由user space trigger一個I/O operation, 然後由kernel space來回應這個request. 至於在非同步(asynchronous)的概念裡, user space就可以不用跟kernel space合作了, 我們可以從前面的例子中看到, 在非同步的場景下, user space就像是買家在家裡網購一般, 一個訂單送出後, 等商家(kernel)把東西送到府上後再直接拿就好了. 講白了就是工具人喇.

最後, 在Java NIO一書裡, 有以下這段話, 我想在這邊紀錄一下以加深印象:

True readiness selection must be done by the operating system. One of the most important functions performed by an operating system is to handle I/O requests and notify processes when their data is ready. So it only makes sense to delegate this function down to the operating system. The Selector class provides the abstraction by which Java code can request readiness selection service from the underlying operating system in a portable way.

其實Java在I/O這塊還是相當依賴OS的, 所以要想真的了解Java中的I/O, 就要先了解作業系統層面上的I/O原理才行.

References

Carl

Written by

Carl

Stand for something or you will fall for anything.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade