1. はじめに
あなたがオーケストラ(あなたのアプリケーション)の指揮者だと想像してください。ヴァイオリン奏者がチューニングに時間をかけている(長い I/O)たびに彼が終わるのを待ってから次に進むなら、オーケストラ全体が止まってしまいます。でも奏者が「自分でチューニングするから、君たちは先に進んでて。準備できたら合図するよ」と言ったら—それが非同期です!
C# と .NET 9 の世界には、この「止まらないマルチタスク」のための道具が用意されています。今日の主役は、Read と Write の非同期版である ReadAsync と WriteAsync です。
これらは読み取りや書き込みの操作を開始して、すぐに現在の実行スレッドを「解放」して他の仕事をさせることを可能にします。入出力操作が完了したら(例えばディスクからデータが読み込まれたり書き込まれたりしたとき)、あなたのコードは「目を覚まし」中断した箇所から処理を再開します。
これらのメソッドを使うには、2012 年の C# 5.0 から導入された二つの魔法の言葉が必要です(C# 14 ではすっかり馴染みのものになっています):
- async: メソッドに付ける修飾子で、コンパイラに「このメソッド内で非同期操作が行われ、await を使う可能性がある」ことを伝えます。
- await: ReadAsync や WriteAsync のような非同期操作の前に書く演算子で、「この操作を開始するが、ここで完了を待たずに呼び出し元に制御を返し、操作完了時に戻ってきて続ける」という意味です。
これらの概念がまだ少しぼんやりしていても心配いりません。async と await に専念した別のレクチャー(レベル 58)を用意しています。今は、これらがメインスレッドをブロックしないのに役立つ、という点を理解しておけば十分です。
2. ReadAsync: 焦らずに読む
ReadAsync はストリームから非同期にデータを読むためのメソッドです。ディスクからバイトが読み込まれるのを待つ代わりに、読み取りを開始してすぐに他のタスクに切り替えることができます。
バッファに読み込む場合の基本シグネチャは次の通りです:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
または、現代の C#(と .NET 9)でよく使われる Memory<byte> を使う形:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
パラメータの解説:
- buffer: 読み込まれたデータを格納する byte 配列(または Memory<byte>)。バッファはパフォーマンス最適化で重要です。
- offset: 読み込んだバイトを書き始める buffer 内のオフセット。
- count: 読み込む最大バイト数。
- CancellationToken cancellationToken: 操作が不要になったとき(例: ユーザーがアプリを閉じた、または「キャンセル」ボタンを押した)にキャンセルするための便利なパラメータ。
- ValueTask<int>: 操作完了時に int(読み取られたバイト数)を返す「約束」。ValueTask は結果が同期的に得られる場合と非同期の場合の両方に最適化された版の Task です。
例 1: ファイルを非同期に読む
大きなテキストファイルをメインスレッドをブロックせずに読みたいとします。基本的な例はこちら:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// ファイルを読み、行数を数える非同期関数
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// 非同期でファイルを開く
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"ファイル {filename} の行数: {count}");
}
}
コードへのコメント:
- useAsync: true を FileStream に渡している点に注目してください。これは本当の非同期動作のために重要です。
- await を ReadLineAsync に対して使い、行が読み込まれる間スレッドをブロックしないようにしています。
- メソッド Main は非同期になっています(C# 7+ で可能になりました)。
GUI アプリケーションであれば、ファイルを読んでいる間(ReadAsync がディスクのデータを待っている間)でもユーザーはボタンを押したりスクロールしたり他の操作ができます。コンソールアプリでは少し分かりにくいですが、原理は同じです。
3. WriteAsync: ためらわずに書く
ReadAsyncWriteAsync はストリームへ非同期にデータを書き込むことを可能にします。大量のデータを書き込む必要があるときに、アプリを待たせないためにとても役立ちます。
主なシグネチャ:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
書き込みではバッファを変更しないので ReadOnlyMemory<byte> を使う形もあります:
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
パラメータは ReadAsync に似ています:
- buffer: 書き込むデータを含む byte 配列(または ReadOnlyMemory<byte>)。
- offset: 書き込みを開始する buffer 内のオフセット。
- count: 書き込むバイト数。
- CancellationToken cancellationToken: 操作のキャンセル用。
- ValueTask: 戻り値はありません(書き込まれるバイト数は count で指定するため)。
例 2: ファイルへの非同期書き込み
今度は何かをファイルに非同期で書いてみます。
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"行番号 {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"{filename} の書き込みが完了しました。");
}
}
このループは 10000 行を書きますが、メインスレッドはブロックされません。GUI アプリケーションであれば、インターフェースが「固まる」ことはありません。
async と await のおかげで、コンソールアプリでもファイルのコピーなどの操作中にユーザー入力(例えば Enter を押してキャンセルする)に応答できます。これはモダンで高性能、かつ応答性のある C# アプリケーションを書くための基本原則です。
4. 便利な注意点
可視化: 非同期読み書きはどう動くか
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│あなたのコード(UI/ロジック)│ ─────────────────────→ │ OS/IO: 非同期操作 │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(別の作業をする) │(ファイルを読み、ディスクを待つ)
│<────────────────────────────────────────→│
└─ Task を待ち、結果を受け取る ←──────────┘
概念図: ディスクが遅く動作している間、コードは他の仕事ができます。データが必要になった時点で Task の結果を待ちます。
実務での適用: いつ必要か?
- デスクトップアプリ: ログファイル、データベース、ビデオなど大きな I/O を扱うなら非同期は必須級。マシンが速くても、ネットワーク越しのファイルは亀のように遅いことがあります。
- バックエンドやウェブアプリ: 多数のユーザーが同時に来るとき、各スレッドがファイル読み込みでブロックするとパフォーマンスが急落します。そうすると 502 Bad Gateway のような問題につながります。
- モバイルアプリ: ファイルを開いたり書いたりするのに時間がかかるとユーザーはラグを感じます。非同期を使いましょう。
- 大量ファイル処理: アーカイバ、パーサ、分析ツールなど、ファイル集合を扱うアプリは非同期 I/O で有利になります。
同期的 vs 非同期的 読み書き
| メソッド | スレッドをブロックするか? | 実装は簡単か? | 最高のパフォーマンスか? | UI/サーバにとっての利便性 |
|---|---|---|---|---|
| 同期 (Read/Write) | はい | はい | いいえ | いいえ |
| 非同期 (ReadAsync) | いいえ | ほぼ同じ | はい | はい |
5. 注意点とベストプラクティス
バッファリングは依然重要: ReadAsync や WriteAsync を使っていても、1 バイトずつ読む/書くのは極めて非効率です。非同期はブロックを取り除きますが、個々のバイト読み書きを魔法のように速くするわけではありません。良い開始バッファサイズは 4096–8192 バイトで、大きなファイルなら 65536 や 131072 を試す価値があります。
"非同期は全体に広げるべき" (Async All The Way Down): ある一箇所で async/await を使い始めたら、呼び出しチェーン全体にそのアプローチを伸ばすのが普通です: C が非同期操作を行うなら C は async Task、B も async Task、A も async Task。そうしないとブロックや UI のデッドロックが発生する可能性があります。
例外処理: 非同期コードでも通常の try-catch を使います。よく出るのは OperationCanceledException と IOException — 明示的に処理しましょう。
リソースの解放 (await using): ストリームやその他の IDisposable オブジェクトは正しく解放してください。型が IAsyncDisposable を実装していれば await using が DisposeAsync() を呼び、もし IDisposable のみであれば Dispose() が呼ばれます。
内部で何が起きているか(簡潔に): await によってコンパイラはメソッドをステートマシンに変換します: 操作を開始してメソッドは「一時停止」し、制御が呼び出し元に戻ります。結果が準備できると SynchronizationContext(UI)や ThreadPool(コンソール/サーバ)によって実行が再開され、中断した場所から処理が続きます。これにより一つのスレッドで多くの「一時停止した」タスクをブロックせずに扱えます。
まとめると、async と await を使った非同期プログラミングは、応答性が高くスケーラブルなアプリケーションを作るための強力な道具です。UI やサーバのスレッドをブロックせずにシステム資源を効率的に使えます。最初は少し慣れが必要かもしれませんが、学ぶ価値は大いにあります!次の講義では非同期性と並列処理の世界をさらに深掘りしていきます。ではまた!
GO TO FULL VERSION