C#非同期プログラミングのベストプラクティス!効率的なコード設計
生徒
「非同期プログラミングの基本はわかりましたが、実際に使うときに気をつけるべきことはありますか?」
先生
「はい、非同期処理には『こう書くのが一番良い』という設計のルールやベストプラクティスがあるんですよ。」
生徒
「ルールを守らないとどうなるんですか?」
先生
「アプリが突然止まってしまったり、動作がとても遅くなったりすることがあります。安全で快適なアプリを作るための秘訣を一緒に見ていきましょう!」
1. 非同期プログラミングの設計とは?
C#でアプリを開発する際、非同期プログラミング(async/await)は欠かせない技術です。しかし、ただキーワードを記述すれば良いというわけではありません。設計とは、家を建てる時の「青写真」のようなものです。どのようにコードを配置し、どのように命令を繋いでいくかという「設計の良し悪し」が、プログラムの安定性に直結します。
プログラミングを始めたばかりの方にとって、「設計」という言葉は難しく感じるかもしれません。簡単に言えば、「みんなが困らないように、綺麗で分かりやすい書き方のルールを守ること」だと考えてください。このルール(ベストプラクティス)を知っているだけで、プロ級の動かしやすくて壊れにくいプログラムが書けるようになります。
2. 原則その1:async void は避ける
非同期のメソッドを作るとき、戻り値(処理の結果として返す値)には Task 型を使うのが基本です。しかし、実は void(何も返さないという意味)を指定することもできてしまいます。これを async void と呼びますが、これは極力避けるべき書き方です。
なぜ async void がいけないのでしょうか。それは、「エラーが起きたときに捕まえられないから」です。例えば、料理の注文をして、店員さんが「わかりました」と言ったまま戻ってこず、キッチンでボヤが起きたとしても、注文したあなたはそれを知る術がありません。Task を返していれば、キッチンで何かが起きたときに「問題が発生しました」という通知を受け取ることができますが、void だと通知が届かず、アプリがいきなり終了してしまう原因になります。
唯一の例外は「ボタンをクリックしたとき」などのイベントハンドラーです。それ以外では必ず Task を返すようにしましょう。
// 悪い例:async void はエラーを追跡できません
public async void BadMethod()
{
await Task.Delay(1000);
throw new Exception("エラー発生!"); // これが捕まえられない
}
// 良い例:Task を返せば呼び出し元でエラーを処理できます
public async Task GoodMethod()
{
await Task.Delay(1000);
throw new Exception("エラー発生!"); // 呼び出し元で catch できる
}
3. 原則その2:最後まで非同期でつなげる
非同期処理を書くときは、「最初から最後までずっと非同期(Async all the way)」というルールがあります。これは、一度非同期処理を始めたら、その呼び出し元の関数も、さらにその上の関数も、すべて async と await でつなげていくという考え方です。
初心者がよくやってしまう失敗に、非同期メソッドを呼び出しているのに、途中で .Result や .Wait() を使って「無理やり結果を待とうとする」ことがあります。これをやってしまうと、デッドロックという現象が起きることがあります。デッドロックとは、お互いがお互いの返事を待ち続けて、プログラムが完全にフリーズ(停止)してしまう状態のことです。
交通渋滞で、四方の車が交差点の真ん中でお互いに譲り合って一歩も動けなくなってしまう状況をイメージしてください。これを防ぐには、途中で無理やり止めず、最後まで await で流れるように処理を繋ぐことが大切です。
// 悪い例:.Result を使って無理やり待つ(フリーズの原因!)
public void SyncMethod()
{
var result = DownloadDataAsync().Result;
}
// 良い例:await を使って最後まで非同期でつなぐ
public async Task AsyncMethod()
{
var result = await DownloadDataAsync();
}
4. 原則その3:命名規則を守る(Asyncをつける)
プログラミングにおいて、名前の付け方は非常に重要です。非同期処理を行うメソッドを作る場合、その名前の末尾には必ず Async という言葉を付けるのが C# の世界での共通ルールです。
例えば、データを保存するメソッドなら SaveData ではなく SaveDataAsync と名付けます。これにより、そのコードを読む他の人は「あ、このメソッドは時間がかかる処理なんだな」「呼び出すときは await を使わなきゃいけないんだな」とひと目で理解できるようになります。
パソコンを触ったことがない方にとって、英語の名前を付けるのは少し大変かもしれませんが、この少しの配慮が、大きなプログラムを作るときのミスを減らすことに繋がります。親切な名前を付けることは、未来の自分や仲間のプログラマーへの思いやりなのです。
5. 原則その4:CancellationToken を活用する
インターネットから大きなファイルをダウンロードしている最中に、「やっぱりやめたい!」と思ってキャンセルボタンを押したことはありませんか? プログラムでも、時間がかかる処理は途中で中止できるように作っておくのが親切です。
C# では、このキャンセルの仕組みに CancellationToken(キャンセル・トークン)という道具を使います。これは「中止命令が出ていないかチェックするための引換券」のようなものです。非同期メソッドの引数にこのトークンを渡しておき、処理の途中で「キャンセルされたかな?」と確認するように設計します。
ユーザーがキャンセルしたのに、裏側でプログラムがずっと動き続けてメモリや電気を無駄遣いするのは良くありません。いつでも「やめられる」柔軟な設計を心がけましょう。
// キャンセル可能なメソッドの例
public async Task DownloadFileAsync(string url, CancellationToken ct)
{
for (int i = 0; i < 100; i++)
{
// キャンセルされたかチェック。キャンセルされていたら処理を中断する
ct.ThrowIfCancellationRequested();
await Task.Delay(100); // 擬似的なダウンロード処理
}
}
6. まとめとしての重要ポイント:並列実行と順次実行
非同期プログラミングの醍醐味は、複数の処理を「同時に」進められることです。しかし、設計を誤ると、せっかくの非同期が活かせず、一つずつ順番にしか進まない効率の悪いコードになってしまいます。
例えば、朝ごはんに「トーストを焼く」のと「コーヒーを淹れる」のを順番にやっていたら時間がかかりますよね。トーストを焼き始めてから、焼けるのを待つ間にコーヒーを淹れれば、全体の時間は短縮されます。C# では Task.WhenAll という機能を使うことで、複数の非同期処理をまとめて同時にスタートさせることができます。
一つ一つの処理を await で待つのか、それともまとめて一気にやるのか。この使い分けができるようになると、あなたのアプリの速度は劇的に向上します。
// まとめて同時に実行する例
public async Task PrepareBreakfastAsync()
{
Task toastTask = ToastBreadAsync(); // トースト開始
Task coffeeTask = BrewCoffeeAsync(); // コーヒー開始
// 両方が終わるのを同時に待つ!
await Task.WhenAll(toastTask, coffeeTask);
Console.WriteLine("朝ごはんの準備完了!");
}
7. 難しい用語の解説コーナー
記事の中で出てきた難しい言葉をおさらいしましょう。最初はわからなくても大丈夫です!
- デッドロック:二つの処理がお互いの終了を待ち合って、石のように固まってしまう現象。
- イベントハンドラー:ボタンが押された、マウスが動いた、など「何かが起きた」ときに動くプログラムのこと。
- スレッド:CPU(パソコンの脳みそ)が一度に処理する作業の単位。非同期はこのスレッドを賢く使います。
- 戻り値(もどりち):魔法をかけた後に手元に残る宝箱のようなもの。処理が終わった後に返ってくるデータのこと。
- 例外(れいがい):プログラムの実行中に起きる予期せぬトラブルやエラーのこと。