非同期I/Oは、プログラムがI/O操作を開始し、その操作が完了する前に他のタスクの処理を継続できる入出力処理の一種です。制御を即座に返すものの、繰り返しポーリングが必要になる場合があるノンブロッキングI/Oとは異なり、非同期I/Oでは、システムまたはAPIが操作の完了をプログラムに通知できます。Windows APIでは、非同期I/Oを「オーバーラップI/O」と呼んでいます。
コンピュータの入出力処理は、データ処理に比べて非常に遅い場合があります。入出力デバイスには、ハードドライブが読み書きするトラックを探すなど、物理的に移動する必要のある機械的な装置が組み込まれていることがあります。これは、電流の切り替えよりも桁違いに遅い場合が多いのです。例えば、10ミリ秒かかるディスク操作中に、1ギガヘルツで動作するプロセッサは、1000万回の命令処理サイクルを実行している可能性があります。
入出力処理の単純なアプローチとしては、アクセスを開始してから完了を待つという方法があります。しかし、同期入出力またはブロッキング入出力と呼ばれるこのアプローチでは、通信処理中はプログラムの実行が停止し、システムリソースがアイドル状態になります。プログラムが多くの入出力操作を行う場合(例えば、ユーザー入力に大きく依存するプログラムなど)、プロセッサは入出力操作の完了を待つために、ほぼすべての時間をアイドル状態で費やすことになります。
あるいは、通信を開始してから、I/Oの完了を必要としない処理を実行することも可能です。この方法は非同期入出力と呼ばれます。I/Oの完了に依存するタスク(読み取り値の使用と、書き込み操作の完了を保証するクリティカル操作の両方を含む)は、I/O操作の完了を待つ必要があるため、依然としてブロックされますが、I/O操作に依存しないその他の処理は続行できます。
オペレーティングシステムには、さまざまなレベルで非同期I/Oを実装するための多くの機能があります。実際、最も基本的なオペレーティングシステムを除くすべてのオペレーティングシステムの主要機能の1つは、少なくとも何らかの基本的な非同期I/Oを実行することですが、これはユーザーやプログラマーには特に明らかではないかもしれません。最も単純なソフトウェアソリューションでは、ハードウェアデバイスの状態を一定間隔でポーリングして、デバイスが次の操作の準備ができているかどうかを検出します。(たとえば、CP/Mオペレーティングシステムはこのように構築されました。そのシステムコールセマンティクスは、これ以上複雑なI/O構造を必要としませんでしたが、ほとんどの実装はより複雑で、したがってより効率的でした。)ダイレクトメモリアクセス(DMA)は、ポーリングベースのシステムの効率を大幅に向上させることができ、ハードウェア割り込みはポーリングの必要性を完全に排除することができます。マルチタスクオペレーティングシステムは、ハードウェア割り込みによって提供される機能を活用しながら、割り込み処理の複雑さをユーザーから隠蔽することができます。スプーリングは、非同期I/Oを活用するために設計された最初のマルチタスクの形式の1つです。最後に、マルチスレッド処理とユーザープロセス内の明示的な非同期I/O APIは、ソフトウェアの複雑さが増すという代償を伴うものの、非同期I/Oをさらに活用できる。
非同期I/Oは、エネルギー効率の向上、場合によってはスループットの向上に用いられます。しかし、場合によってはレイテンシやスループットに悪影響を与える可能性もあります。
入出力の形式とPOSIX関数の例:
非同期I/Oは、あらゆる形態において、アプリケーションを潜在的なリソース競合とそれに伴う障害にさらす可能性があります。これを防ぐには、慎重なプログラミング(多くの場合、相互排他、セマフォなどを使用)が必要です。
アプリケーションに非同期I/Oを公開する場合、実装には大きく分けていくつかの種類があります。アプリケーションに提供されるAPIの形式は、オペレーティングシステムが実際に提供するメカニズムと必ずしも一致するとは限りません。エミュレーションも可能です。さらに、アプリケーションのニーズやプログラマの要望に応じて、1つのアプリケーションで複数のメソッドが使用される場合もあります。多くのオペレーティングシステムはこれらのメカニズムを複数提供しており、すべてを提供しているシステムもあります。
初期の Unix で利用可能。マルチタスクオペレーティングシステムでは、処理は複数のプロセスに分散できます。各プロセスは独立して実行され、独自のメモリを持ち、独自の I/O フローを処理します。これらのフローは通常、パイプラインで接続されます。プロセスの作成と維持にはかなりのコストがかかるため[ 1 ]、このソリューションはプロセスのセットが小さく、比較的安定している場合にのみうまく機能します。また、個々のプロセスが互いの I/O を処理することとは別に、独立して動作できることを前提としています。他の方法で通信する必要がある場合、それらを調整することは困難になる可能性があります。
このアプローチの拡張版がデータフロープログラミングであり、パイプがサポートするチェーンよりも複雑なネットワークを可能にする。
バリエーション:
ポーリングは、非同期APIの実装に使用できる非ブロッキング同期APIを提供します。従来のUnixおよびWindowsで利用可能です。主な問題点は、発行プロセスが他に処理すべきことがない場合でも繰り返しポーリングを行うことでCPU時間を浪費し、他のプロセスに利用できる時間を減少させてしまうことです。また、ポーリングアプリケーションは基本的にシングルスレッドであるため、ハードウェアが持つI/O並列処理能力を十分に活用できない場合があります。
BSD Unixおよび、 BSD 実装を利用するか、BSD 実装をモデルにしたTCP/IPプロトコル スタックを備えたほぼすべてのシステムで利用可能です。ポーリングのバリエーションである選択ループは、selectシステム コールを使用して、ファイル ディスクリプタで条件が発生するまで(たとえば、読み取り可能なデータが利用可能になったとき)、タイムアウトが発生するまで、またはシグナルが受信されるまで (たとえば、子プロセスが終了したとき) スリープします。呼び出しの戻りパラメータを調べることで、ループはどのファイル ディスクリプタが変更されたかを検出し、適切なコードを実行します。使いやすさのために、選択ループは、コールバック関数などを使用してイベント ループとして実装されることがよくあります。この状況は、イベント駆動型プログラミングに特に適しています。select
この方法は信頼性が高く比較的効率的ですが、「すべてがファイルである」というUnixのパラダイムに大きく依存しています。ファイルディスクリプタに関係しないブロッキングI/Oは、プロセスをブロックします。selectループは、すべてのI/Oを中央の呼び出しに含めることができることも前提としています。独自のI/Oを実行するライブラリは、この点で特に問題があります。もう1つの潜在的な問題は、selectとI/O操作が十分に分離されているため、selectの結果が事実上嘘になる可能性があることです。2つのプロセスが単一のファイルディスクリプタから読み取っている場合(設計上問題があると言えるでしょう)、selectは読み取りデータが利用可能であることを示しますが、読み取りが発行される頃にはデータは消えており、結果としてブロッキングが発生します。2つのプロセスが単一のファイルディスクリプタに書き込んでいる場合(それほど珍しいことではありません)、selectはすぐに書き込み可能であることを示しますが、バッファがその間に他のプロセスによって満たされたり、書き込みが利用可能なバッファに対して大きすぎたり、その他の理由で受信者に適さなかったりするため、書き込みがブロックされる可能性があります。select
選択ループは、たとえば完了キュー方式で可能な究極のシステム効率には達しません。これは、呼び出しのセマンティクスがselect、呼び出しごとに許容されるイベント セットを調整できるため、選択配列を走査する呼び出しごとに一定の時間を消費するためです。これは、ウィンドウ システム用に 1 つのファイル ディスクリプタを、開いているファイル用に数個のファイル ディスクリプタを開くユーザー アプリケーションにはほとんどオーバーヘッドを生じさせませんが、潜在的なイベント ソースの数が増えるにつれて問題が大きくなり、C10k 問題のように、多数のクライアント サーバー アプリケーションの開発を妨げる可能性があります。このような場合、他の非同期方式の方が明らかに効率的である可能性があります。一部の Unix では、スケーリングが優れたシステム固有の呼び出しが提供されています。たとえば、Linuxepollでは(イベントが発生したイベント ソースのみで戻り選択配列を埋めます)、FreeBSDでは、Solarisではイベント ポート(および) が提供されています。kqueue/dev/poll
SVR3 Unixはシステムコールを提供していましたpoll。おそらく よりも適切な名前ですがselect、この議論の目的においては、実質的に同じものです。SVR4 Unix (したがってPOSIX ) は両方のコールを提供しています。
BSDおよびPOSIX Unixで利用可能です。I/Oは非同期で発行され、完了するとシグナル(割り込み)が生成されます。低レベルのカーネルプログラミングと同様に、シグナルハンドラ内で安全に使用できる機能は限られており、プロセスのメインフローはほぼ任意の時点で中断される可能性があり、その結果、シグナルハンドラから見たデータ構造に矛盾が生じる可能性があります。シグナルハンドラは通常、それ自体でさらに非同期I/Oを発行することはできません。
シグナル方式はOS内での実装は比較的容易であるものの、アプリケーションプログラムにはOSカーネルの割り込みシステムを記述する際に伴う厄介な問題が持ち込まれる。その最大の欠点は、すべてのブロッキング(同期)システムコールが潜在的に割り込み可能であることであり、プログラマは通常、各呼び出しに再試行コードを組み込む必要がある。
従来の Mac OS、VMS、Windowsで利用可能です。シグナル方式と本質的に同じものであるため、シグナル方式と多くの特徴を共有していますが、そのように認識されることは稀です。違いは、各 I/O リクエストには通常独自の完了関数があるのに対し、シグナルシステムには単一のコールバックしかない点です。
一方、コールバックを使用する際の潜在的な問題点として、スタックの深さが制御不能に増大する可能性があることが挙げられます。これは、1つのI/O処理が完了した後に次のI/O処理をスケジュールすることが非常に一般的であるためです。この処理を即座に完了させる必要がある場合、次のコールバックが呼び出される前に最初のコールバックがスタックから「アンワインド」されないことになります。これを防ぐためのシステム(例えば、新規処理の「中間段階」スケジューリングなど)は、複雑さを増し、パフォーマンスを低下させます。しかし実際には、新しいI/O処理が開始されるとすぐに次の処理が返されるため、スタックが「アンワインド」されることが多く、これは通常問題になりません。また、最初のコールバックが返されるまでキューを使用してそれ以上のコールバックを回避すれば、この問題を回避することもできます。
軽量プロセス(LWP)またはスレッドは、ほとんどの最新のオペレーティングシステムで利用可能です。プロセス方式と同様ですが、オーバーヘッドが少なく、フローの調整を妨げるデータ分離もありません。各LWPまたはスレッド自体は、従来のブロッキング同期I/Oを使用するため、プログラミングロジックが簡素化されます。これは、JavaやRustを含む多くのプログラミング言語で使用されている一般的なパラダイムです。マルチスレッドでは、カーネルが提供する同期メカニズムとスレッドセーフライブラリを使用する必要があります。この方式は、必要なスレッド数が多いため、Webサーバーなどの非常に大規模なアプリケーションに最適です。
このアプローチは、Erlangプログラミング言語のランタイム システムでも使用されています。Erlang仮想マシンは、数個のスレッド、場合によっては 1 つのプロセスのみからなる小さなプールを使用して非同期 I/O を使用し、最大で数百万個の Erlang プロセスからの I/O を処理します。各プロセスの I/O 処理は、主にブロッキング同期 I/O を使用して記述されています。このようにして、非同期 I/O の高いパフォーマンスと通常の I/O のシンプルさが融合されます (アクター モデルを参照)。Erlang の多くの I/O 問題はメッセージ パッシングにマッピングされ、組み込みの選択的受信を使用して簡単に処理できます。
ファイバー/コルーチンは、Erlangランタイムシステムの外部で非同期I/Oを実行するための同様に軽量なアプローチと見なすことができますが、Erlangプロセスとまったく同じ保証を提供するわけではありません。
Microsoft Windows、Solaris、AmigaOS、DNIX、Linux ( io_uringを使用、5.1 以降で利用可能)で利用可能です。 [ 2 ] I/O 要求は非同期で発行されますが、完了通知は同期キュー機構を介して完了順に提供されます。通常、メインプロセスの状態マシン構造 (イベント駆動プログラミング) に関連付けられており、非同期 I/O を使用しないプロセスや他の形式のいずれかを使用するプロセスとはほとんど似ていないため、コードの再利用が妨げられます。追加の特別な同期メカニズムやスレッドセーフライブラリは必要なく、テキスト (コード) と時間 (イベント) の流れも分離されていません。
VMSおよびAmigaOSで利用可能(多くの場合、完了ポートと組み合わせて使用)。本質的には深さ1の完了キューであるため、完了キュー方式の多くの特徴を備えている。キューの「深さ」の効果をシミュレートするには、処理されていない(ただし完了した)可能性のある各イベントに追加のイベントフラグが必要であり、そうしないとイベント情報が失われる可能性がある。このようなイベントの塊で次に利用可能なイベントを待つには、同期メカニズムが必要となるが、並列処理される可能性のあるイベントの数が増えると、スケーリングがうまくいかない可能性がある。
IBM、Groupe Bull、Unisysのメインフレームで利用可能なチャネルI/Oは、ほとんどのI/O処理をコプロセッサにオフロードすることで、CPUの利用率とスループットを最大化するように設計されています。コプロセッサはオンボードDMAを備え、デバイス割り込みを処理し、メインCPUによって制御され、本当に必要な場合にのみメインCPUに割り込みをかけます。このアーキテクチャは、チャネルプロセッサ上で実行され、I/O処理やプロトコルの重い処理を実行する、いわゆるチャネルプログラムもサポートしています。
Windows Server 2012およびWindows 8で利用可能です。多数の小さなメッセージを処理するアプリケーション向けに最適化されており、ジッターとレイテンシを低減しながら、 1 秒あたりの I/O 操作数を向上させます。[ 3 ]
汎用コンピューティングハードウェアの大部分は、非同期I/Oの実装方法としてポーリングと割り込みという2つの方法に完全に依存しています。通常、両方の方法が併用されますが、そのバランスはハードウェアの設計と要求される性能特性に大きく左右されます。(DMA自体は独立した方法ではなく、ポーリングまたは割り込みごとに処理できる作業量を増やすための手段にすぎません。)
純粋なポーリングシステムは十分に実現可能であり、小型マイクロコントローラ( PICマイクロコントローラを使用するシステムなど)はしばしばこの方式で構築されます。CP /MシステムもDMAの有無にかかわらず、この方式で構築できます(ただし、実際に構築されることは稀でした)。また、他の潜在的なタスクを犠牲にしてでも、ごく少数のタスクに最高のパフォーマンスが必要な場合、割り込み処理のオーバーヘッドが望ましくない可能性があるため、ポーリングが適切な場合もあります。(割り込み処理には、プロセッサの状態の少なくとも一部を保存するための時間とメモリ、および中断されたタスクを再開するための時間が必要です。)
ほとんどの汎用コンピューティングシステムは、割り込みに大きく依存しています。純粋な割り込みシステムも可能かもしれませんが、通常はポーリングの要素も必要となります。これは、複数の潜在的な割り込み発生源が共通の割り込み信号線を共有することが非常に一般的であり、その場合、デバイスドライバ内でポーリングを使用して実際の発生源を特定する必要があるためです。(この特定時間も、割り込みシステムのパフォーマンス低下の一因となります。長年にわたり、割り込み処理に伴うオーバーヘッドを最小限に抑えるための多くの取り組みが行われてきました。現在の割り込みシステムは、高度に最適化された以前のシステムと比較するとやや鈍感ですが、ハードウェア性能の全般的な向上により、この問題は大幅に緩和されています。)
ハイブリッド方式も可能で、割り込みによって非同期I/Oのバーストが開始され、そのバースト内でポーリングが使用される。この手法は、ネットワークやディスクなどの高速デバイスドライバでよく用いられる。これらのドライバでは、割り込み前のタスクに戻るまでの時間が、次の処理が必要になるまでの時間よりも長くなるためである。(現在一般的に使用されているI/Oハードウェアは、比較的性能の低い割り込みシステムを補うために、DMAと大容量データバッファに大きく依存している。これらのハードウェアは、ドライバループ内でポーリングを特徴的に使用し、非常に高いスループットを実現できる。理想的には、データごとのポーリングは常に成功するか、せいぜい少数の回数しか繰り返されない。)
かつて、このようなハイブリッド方式は、DMAや十分なバッファリングが利用できないディスクドライバやネットワークドライバで一般的でした。必要な転送速度は、データごとに最小4つの操作(ビットテスト、条件分岐、フェッチ、ストア)からなるループでも許容できる速度よりも速かったため、ハードウェアはI/Oデバイス上で自動的に待機状態を生成するように構築されることが多く、データ準備完了ポーリングをソフトウェアからプロセッサのフェッチまたはストアハードウェアに移行し、プログラムされたループを2つの操作に削減していました。(実質的にプロセッサ自体をDMAエンジンとして使用していたのです。)6502プロセッサは、アサートされるとプロセッサのオーバーフロービットを直接セットするハードウェアピンを備えていたため、データごとに3要素のループを実現する珍しい手段を提供していました。(もちろん、デバイスドライバ以外でオーバーフロービットをオーバーライドしないように、ハードウェア設計には細心の注意を払う必要がありました。)
ポーリングと割り込みという2つのツールだけを使用することで、上記で説明した他のすべての非同期I/O形式を合成することが可能であり、実際に合成されている。
Java仮想マシン(JVM)のような環境では、JVMが動作している環境自体が非同期I/Oを提供していなくても、非同期I/Oを合成することができます。これは、JVMがインタプリタ型であるためです。JVMは定期的にポーリング(または割り込み)を行い、内部的な制御フローの変更を確立することで、複数の同時実行プロセスが存在するように見せかけます。これらのプロセスのうち少なくとも一部は、非同期I/Oを実行するために存在していると考えられます。(もちろん、ミクロレベルでは並列処理はかなり粗く、理想的とは言えない特性を示す場合もありますが、表面上は望ましい状態に見えるでしょう。)
実際、非同期I/Oを別の形で実現するためにポーリングをどのような形であれ使用する場合、これが問題となります。ポーリングに使われるCPUサイクルはすべて無駄になり、本来のタスクを実行するのではなく、オーバーヘッドに費やされてしまいます。一方、ポーリングに使われないCPUサイクルはすべて、保留中のI/Oに対する反応の遅延の増加を意味します。これら相反する2つの要素の間で適切なバランスを取ることは困難です。(これが、そもそもハードウェア割り込みシステムが発明された理由です。)
効率を最大化する秘訣は、割り込みを受信した際に適切なアプリケーションを起動するために必要な処理量を最小限に抑えることです。次に重要なのは(おそらく同じくらい重要ですが)、アプリケーション自体が何をすべきかを判断する方法です。
アプリケーションの効率性にとって特に問題となるのは、select/poll メカニズムを含む、公開されているポーリング方式です。これらの方式が関心を持つ基盤となる I/O イベントは、おそらく割り込み駆動型ですが、このメカニズムとのやり取りはポーリングによって行われ、ポーリングに多くの時間を費やす可能性があります。これは、select (および poll) を介して実行可能な大規模なポーリングにおいて特に顕著です。割り込みはシグナル、コールバック関数、完了キュー、イベントフラグに非常によく対応しており、このようなシステムは非常に効率的です。
以下の例は、入出力を読み取るための3つのアプローチを示しています。オブジェクトと関数は抽象的なものです。
1. ブロッキング、同期:
device : Device = IO . open () data : Data = device . read () # デバイスにデータがあるまでスレッドはブロックされますprint ( data )2. ブロッキングとノンブロッキング、同期: (ここではIO.poll()最大 5 秒間ブロックしますが、そうdevice.read()ではありません)
device : Device = IO . open () ready : bool = False while not ready : print ( "読み取るデータがありません!) ready = IO . poll ( device , IO . INPUT , 5 ) # 5 秒経過するか、読み取るデータ (INPUT) がある場合は制御を返しますdata : Data = device . read () print ( data )3. ノンブロッキング、非同期:
ios : IOService = IO.IOService ( ) device : Device = IO.open ( ios )def input_handler ( data : Data , err : Error ) -> None : """入力データハンドラ""" if not err : print ( data )device.read_some ( input_handler ) ios.loop ( ) # すべての操作が完了するまで待機し、適切なハンドラをすべて呼び出します以下は、 async/awaitを使用した同じ例です。
ios : IOService = IO.IOService ( ) device : Device = IO.open ( ios )async def task () -> None : try : data : Data = await device . read_some () print ( data ) except Exception : passios.addTask ( task ) ios.loop ( ) # すべての操作が完了するまで待機し、適切なハンドラをすべて呼び出しますリアクターパターンを使った例を以下に示します。
デバイス: Device = IO.open ( )リアクター: Reactor = IO.Reactor ( )def input_handler ( data : Data ) -> None : """入力データハンドラ""" print ( data ) reactor . stop ()reactor.add_handler ( input_handler , device , IO.INPUT ) reactor.run () #イベントを処理し、適切なハンドラを呼び出すリアクターを実行します