Carpe Diem

備忘録

Non-Blocking I/O, I/O Multiplexing, Asynchronous I/Oの区別

概要

各言語がC10K問題をどう解決してきたかを調べてみたところ、Non-Blocking I/O, I/O Multiplexing, Asynchronous I/Oの区別がよく分からなかったので調べてみました。
正直なところ人によってちょこちょこ定義が異なるのではっきりとした答えはなさそうですが、自分で調べてしっくりした形をまとめます。

前提

同期と非同期の違い

用語 説明
同期 OSにタスクを投げて、入出力の準備ができたら
アプリケーションに処理が返ってくる
非同期 OSにタスクを投げて、入出力が完了したら通知をもらう

ブロッキングとノンブロッキングの違い

用語 説明
ブロッキング OSへ依頼したタスクが完了するまで待つ
ノンブロッキング OSへ依頼したタスクの完了を待たない

Blocking I/O

ref: java Selector is asynchronous or non-blocking architecture - Stack Overflow

システムコール
↓
カーネルモードにコンテキストスイッチ
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放

という流れ。典型的な同期処理の動きです。

Non-Blocking I/O

ref: java Selector is asynchronous or non-blocking architecture - Stack Overflow

ファイルディスクリプタにO_NONBLOCKをセットしてノンブロッキングモードに
↓
そのファイルディスクリプタを使ってシステムコール
↓
カーネルモードにコンテキストスイッチ
↓
すぐにEWOULDBLOCK(EAGAIN)を返す(ユーザモードにコンテキストスイッチ)
↓
ファイルディスクリプタの準備が完了するまで何度もpolling(そのたびにコンテキストスイッチが走る)
↓
ファイルディスクリプタの準備完了
↓
準備ができたデータグラムに対するシステムコール
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放

このようにすぐにユーザモードに戻るため、ブロックが起きません。
一方で何度もpollingして状態をチェックしなくてはいけません。

またブロックはしませんが準備が完了していない状態の場合はエラーが返ります

注意としてファイルディスクリプタの状態のチェックがノンブロッキングなだけであって、I/Oで発生するカーネル<->ユーザプロセス間のread(2)やwrite(2)のデータ転送はブロッキングです。

I/O Multiplexing

ref: java Selector is asynchronous or non-blocking architecture - Stack Overflow

システムコール
↓
カーネルモードにコンテキストスイッチ
↓
ファイルディスクリプタの準備ができたらユーザモードにコンテキストスイッチ
↓
準備ができたデータグラムに対するシステムコール
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放

Blocking処理を2回に分けたような形です。
しかし多重化という文字通り、前述のBlocking I/OやNon-Blocking I/Oと違うのは複数のファイルディスクリプタの状態を同時に監視できる点です。
この図ではファイルディスクリプタの準備が完了するまでブロックされているように見えるためメリットがなさそうですが、実際はこれ(select()の部分)を複数同時に行えるため、複数のシステムコールがあった場合はこちらの方が高速になります。

イメージがつきにくい人はGo言語のchannelのselectを浮かべると分かりやすいです。

    select {
    case req := <-requests:
        fmt.Println("received request", req)
    case msg := <-messages:
        fmt.Println("received message", msg)
    case sig := <-signals:
        fmt.Println("received signal", sig)
    }

これらはchannelのキューに値が詰まった瞬間に実行されます。
リクエストが来た瞬間に処理を実行できますし、それ以外の処理も複数のチャンネルをselectが常に監視してくれるので多重でi/oをハンドリングできます。

また大抵はこの時監視するファイルディスクリプタはノンブロッキングモードにするのが普通なので、Non-Blocking I/OとI/O Multiplexingはセットになっていることが多いです。

追記(2019/11/18)

こちらの記事で改めて調べました

christina04.hatenablog.com

Asynchronous I/O

ref: java Selector is asynchronous or non-blocking architecture - Stack Overflow

aio_readでシステムコール
↓
リクエストがキューされた時点ですぐ返る(ユーザモードにコンテキストスイッチ)
↓
他の処理を実行可能
↓
I/O処理が完了
↓
カーネルがI/O処理の完了を通知してくれる(callbackやsignal)

aio_readaio_writeというシステムコールを使うと、処理をブロックせず、かつI/O処理の完了を通知してくれるようになります。
なぜ非同期処理ができるのかというと、これは内部的に別スレッドを用意するからのようです。

The POSIX AIO is a user-level implementation that performs normal blocking I/O in multiple threads

ref: asynchronous - Difference between POSIX AIO and libaio on Linux? - Stack Overflow

その他調べる過程で疑問に思ったことまとめ

Asynchronous I/Oが一番良さそうだけど全部それを使わないのはなぜ?

  • 同期処理の方がロジックがシンプルになる
  • 別スレッドを使うのでメモリを消費する

といった理由だと思われます。


ならNon-Blocking I/O × I/O Multiplexingの方が良さそう?

  • マルチコアCPUを活用できない
  • ノンブロッキングと言ってもユーザ-カーネル間のデータ転送はブロック処理(recvfrom()の部分)

といった点からこれがベストとも言えません。


非同期で有名なNode.jsは何を使ってる?

Nodeのコアライブラリであるlibuvにその設計が載っています。

ref: Design overview - libuv documentation

C10K問題をクリアするためにネットワーク周りはノンブロッキングI/O×I/O多重化になっています。こちらはシングルスレッドです。
しかしfsといったファイル周りは別スレッドを使ったAsynchronous I/Oで実現しています。こちらはシングルスレッドではないです。


Go言語はどうやってC10K問題をクリアしている?

goroutineというスレッドより遥かに軽量で動かせる仕組みを用意しており、それを使うことでブロッキングI/Oだけど処理できます。
※スレッドが最低2MBのスタックを必要するのに対し、goroutineは数KBで済む。

イベントループなしでのハイパフォーマンス – C10K問題へのGoの回答 | POSTD


goroutineはスレッドでありコルーチンって聞いた

goroutineのは複数のスレッドを持ち、各スレッドの中で複数のコルーチンが動いてます。
あるコルーチンがブロック処理で止まれば、残りのコルーチンを別スレッドへ移動させることで、全体として処理を並行で動かせます。
この動きを抽象化してユーザが意識しなくても使えるようにしています。


スレッドとコルーチンのスケジューリングの違い

スレッドはOSによってスケジュールされているため、スレッド自身がスケジューリングを気にする必要はないです。
一方、コルーチンはユーザースペースでのただのルーチンのため、自分自身でスケジューリングをしないといけないです。

ソース