
2026/04/22 14:28
非同期処理が約束していたことと、実際に届けたこととは何か。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
現代のプログラミングは、高価な OS スレッドから非同期パターンへと進化し、C10K(多数の接続を効率的にスケールさせる)問題を解決しています。しかし、この移行に伴い「関数カラーリング」と呼ばれる重大な断片化が生じており、I/O 操作を追加することで関数の動作が変化するため、開発者は互換性のない同期および非同期コードベースを維持せざるを得なくなっています。この複雑さは、標準的なツールが「futurelock」といった新しいデッドロッククラスに対処することが難しいことに起因しており、これは未来値がロックを持っていてポーリングされなくなった場合に発生します(Rust で特に顕著な問題です)。エコシステムは既に分断されており、代表的な例として Rust の競合するランタイム(Tokio、async-std、smol)があり、これらは TCP ストリームのような基本型を実装していますが、互換性のない動作を持っています。また、「シーケンシャルトラップ」と呼ばれる微妙なコストが存在しており、これはシーケンシャルな
await チェーンが並列実行の機会を隠蔽し、開発者が Promise.all などのパターンを手動で使用する必要があることを意味します。その結果、企業は類似の機能のために異なるライブラリバージョンをサポートする必要があるため、保守負荷が倍増しており、ユーザーは実行時の互換性の欠如に直面し、相互運用性が妨げられています。業界全体での標準化が行われない限り、これらの異なる実行モデルのウイルス様の拡散により、デバッグが困難になり、シーケンシャルに見える構文の中で並列性を達成することが難しくなり、断片化されたツールの間で安定性を確保しようとするソフトウェア作者にとって見えない負担を生み出すことになります。本文
OS スレッドはコストがかかります。通常の OS スレッドは、スタック空間として約 1 メガバイトを確保する必要があり、作成にはおよそ 1 ミリ秒かかります。コンテキストスイッチはカーネル空間で行われるため、CPU サイクルを消費します。数千の同時接続を処理するサーバーが、各接続に 1 つのスレッドを割り当てると、その結果として数百万のスレッドがメモリを消費し、スケジューリングを争うことになります。システムは、実際には有用な作業を行うためにより良く使われるべき時間をスレッドの管理に費やしてしまいます。
これを「C10K problem」と呼び、Dan Kegel が 1999 年に名付けました。Web サーバーやチャットシステムなど、同時に多数の接続を扱うアプリケーションを構築する際、各接続に対して独立したスレッドを持つことなく並行性を処理する方法が必要でした。
その解決策は、前の世代が悪化させた問題を解消しつつ新たな課題を生み出す波状攻撃で提示されました。以前は Go のチャンネルや Erlang のアクターについてご紹介しました。次に登場するのは、現代において不可欠な「asynchronous(非同期)」アプローチです。
コールバック (Callbacks)
最初の波は単純明快でした。「スレッドをブロックしない」ことです。I/O 操作が完了するまで待つのではなく、完了時に呼び出される関数を登録し、すぐに次の作業に進みます。イベントループ(select、poll、epoll、kqueue)は、複数のスレッドに数百数千の接続をマッピングし、コールバックが開発者对这些机制的接口でした。
Node.js はこのモデル全体のエコシステムを構築し、単一のスレッドで数千の同時接続を処理しました。Nginx のイベント駆動型アーキテクチャが、高負荷なワークロードにおいて Apache をしのぐ主要原因となっています。
このアプローチはパフォーマンスの問題を解決しましたが、代償がありました:それは制御フローの逆転です。「A を実行し、その後 B を実行し、さらに C を実行する」という 3 つの順次文ではなく、「A を実行し、完了したらその関数を呼び出して B を実行し、さらに完了したら別の関数を呼び出して C を実行する」と書くことになります。プログラマの意図は、ネストされたクロージャに分散してしまいます。JavaScript の開発者はこれを「コールバック地獄 (callback hell)」と呼び、共感するためのウェブサイトさえ構築しました。
コールバックには美的問題だけでなく、より深い本質的な問題があります。例えばエラーハンドリングの破片化です。各コールバックは独自のエラー経路を必要とし、エラーは自然なように呼び出しスタックの上へ伝播できません(コールバックは登録された場所とは異なるコンテキストで実行されるため)。チェーン状に配置されたコールバックにおける部分的な失敗を処理するには、エラー状態をチェーン内のすべての関数を通じて渡す必要があります。
また、コールバックには「キャンセル」の概念がありません。非同期操作を開始した後、その結果が不要になったとしても、それを停止する一般的な手段がありません。コールバックは最終的に発火するため、コードはその結果への興味がない場合を処理する必要があります。
コールバックはリソースの問題(スレッドが多すぎる)を解決しましたが、代わりに人間工学上の問題(記述・読解・正しく記述することが困難なコード)を生み出しました。
Promise と Future
次の波は素晴らしい着想から始まりました。「後続の呼び出しのためのコールバックを受け渡す代わりに、非同期操作が最終結果を代表するオブジェクトを即時に返したらどうか」というものです。
これが JavaScript の「Promise」や Java・Rust などの「Future」です。この概念は Baker と Hewitt が 1977 年に提案していますが、主流なプログラミングへの導入には 2010 年代の C10K プロブレムの圧力が必要でした。コミュニティ主導の Promises/A+ 仕様に従い、JavaScript は ES2015 でネイティブの Promise を標準化し、Java 8 は CompletableFuture を導入しました。
Promise はコールバックよりも人間工学に優れています。第一に、Promise は合成可能です:
promise.then(f).then(g) はネストされたピラミッドのように見えず、パイプラインのように読めます。エラーハンドリングも統合されます:チェーンの最後に.catch()を配置するだけで、ステップからの失敗すべてを処理できます。さらに、Promise は関数から返したり、保持したり、渡したりできる値です。将来的な結果への一次元(first-class)ハンドルは、議論の焦点を生なスレッドからデータ依存関係へ移行させます。「この値はまだ完了していない計算に依存している」という概念を表せることは有用です。
以下は JavaScript でユーザープロフィールを読み取り、その後最近の注文を取得する例です。最初はコールバックで、次に Promise を使った場合を示しています:
// コールバック: ネストしており、各レベルでエラーハンドリングが必要 getUser(userId, (err, user) => { if (err) return handleError(err); getOrders(user.id, (err, orders) => { if (err) return handleError(err); render(user, orders); }); }); // Promise: チェインされており、エラーハンドリングは統合されたもの getUser(userId) .then(user => getOrders(user.id).then(orders => [user, orders])) .then(([user, orders]) => render(user, orders)) .catch(handleError);
この小さな例では Promise ベースのバージョンが大きな改善に見えませんが、複雑さが増すと差は広がります。コールバックで 5 ステップネストするとほぼ読み不能ですが、5 つの
.then() を連結するだけなら少なくとも線形に読めます。
しかし、Promise も独自の課題を生みました:
- Promise は「一発限り」です。 Promise は正確に一度だけ解決します。これはストリーム、イベント、反復メッセージ、または継続的な通信をモデル化するのに不適切です。メッセージのストリームを受信する WebSocket は「将来存在する値」とマッピングできません。これにより、リクエストレスポンスパターンには Promise を使い、それ以外のものは別の何か(イベントエミッター、オブザーバブル、あるいは再度コールバック)に分けることを余儀なくされます。
- 合成は不器用です。 上記の例もそのヒントを示しています:最終的な
でユーザーと両方の注文を取得するにはネストするか、.then()
を使ったくびれのある体操が必要です。2 つの独立した非同期操作は簡単ですが (Promise.all
)、それ以上の複雑さ(条件分岐、非同期操作に対するループ、早期終了など)には、次第に洗練された組み合わせパターンが必要になります。これらのパターンは機能しますが、imperative な言語に構造化プログラミングのイディオムを接合したものであり、自然には感じられません。Promise.all([a, b]) - エラーは無音で消えます。
ハンドラなしで reject する JavaScript の Promise はもともとエラーをそのまま飲み込んでいました。値が失われるため、失敗が見えなくなりました。これは Node.js が未処理の拒絶 (unhandled rejection) を警告からプロセスクラッシュへと変更させるほど悪質で、ブラウザも.catch()
イベントを追加しました。エラーハンドリングを改善することを意図した機能が、コールバックでは存在しなかった新しい種類の無音の失敗を完全に生み出すことに成功しました。unhandledrejection - 型による分離。 今やあらゆる関数は「値」か「値の Promise」かのどちらかを返します。そのため、呼び出し元は受け取るものがどっちかわねておく必要がありますし、ライブラリも提供すべきものを決定する必要があります。データベースコールを足すだけで同期関数が非同期になり、現在ではすべての呼び出し元が値ではなく Promise を処理する必要があります。これは次の波ですでにさらに悪化させる coloring problem(色分け問題)の軽微な形態です。
Async/Await
Promise チェインはまだ、他のすべてのものを記述するために開発者が書いている順次コードとは何も似ていませんでした。C# で 2012 年に先駆的に採用され、JavaScript (ES2017)、Python (3.5)、Rust (1.39)、Kotlin、Swift、そして Dart によって採用された Async/Await はまさにそれを実現しました:
// Promise チェイン function loadDashboard(userId) { return getUser(userId) .then(user => getOrders(user.id) .then(orders => [user, orders])) .then(([user, orders]) => render(user, orders)); } // Async/Await async function loadDashboard(userId) { const user = await getUser(userId); const orders = await getOrders(user.id); return render(user, orders); }
Async/Await のバージョンは順次コードのように読めます。変数は自然に結合されます。
.catch()の代わりにtry/catchを使うことができます。ループ内でawaitを使用することも可能です。非同期操作の線形シーケンスにおける人間工学上の勝利です。
業界はこれを急速に採用しました。JavaScript フレームワークは全面的に乗り出し、Python の
asyncio が並行 I/O の標準アプローチとなり、Rust においては Async/Await が高パフォーマンスなネットワーキングへの道となりました。数年のうちに、Async/Await はほとんどの主流な言語で並行 I/O コードを書くためのデフォルト方法になりました。
関数の色分け税を払う
2015 年、Async/Await が注目を集める直前に、Bob Nystrom が"What Color is Your Function?"という思考実験を投稿しました。すべての関数が「赤」か「青」かのどちらかである言語についてのものです。赤い関数は青い関数を呼び出せますが、青い関数が赤い関数を呼び出すには特別な儀礼が必要です。各関数は色を選ぶ必要がありますし、青い関数から赤い関数を呼び出す場合、青い関数自体が赤くなるという形でコードベース全体にウイルスのように広がります。
これは Async/Await に喩えたものであり:非同期関数は「赤」、同期関数は「青」です。非同期関数は同期関数を問題なく呼び出せますが、同期関数が非同期関数を呼び出すにはスレッドをブロックするか、コードを再構成する必要があります。プログラムの各関数は色を選ぶ必要があり、その選択はすべての呼び出し元へと伝播します。
Nystrom の投稿は、開発者が語彙を持たずに経験していた何かに名前を与えられたため、定着しました。関数の色分けは、コードベースやエコシステム全体を再形成します。
Rust の非同期エコシステムは、TCP ストリームやタイマーといった基本的な型の互換性のない実を提供する競合するランタイム(Tokio、async-std、smol)の周りで破片化しました。Tokio 向けに書かれたライブラリを async-std で簡単に使うことはできません。人気のある HTTP クライアント reqwest は単に Tokio を要求しており、あなたのプロジェクトが異なるランタイムを使用している場合、それはあなたの問題となります。今ではライブラリ著者は Tokio を選択して他の代替案を排除するか、あるいはランタイムに関与しない抽象化を試みる(複雑性を増し、時にはパフォーマンスのオーバーヘッドも生む)かの二択です。Tokio の優位性はエコシステム規模での関数の色分けそのものです。税は他の規模でも現れます:
- 関数のレベルでは: 以前同期的だった関数に単一の I/Oコールを追加すると、そのシグネチャ、戻り型、呼び出し規約が変化します。すべての呼び出し元を更新する必要があり、それらの呼び出し元もまた更新が必要です。この変更は呼び出しグラフ全体を通じて広がり、フレームワークのエントリポイントやメイン関数に達するまで続きます。単一行のデータベース検索でも、数十ファイルの変更を要求する可能性があります。
- ライブラリのレベルでは: 著者は同期ライブラリを書いて非同期ユーザーを排除するか、あるいは非同期ライブラリを書いて同期的なユーザーにランタイム依存関係を強制(または双方を維持する)かの選択に直面します。多くは「両方」を選択し、API の表面、テストマトリックス、保守負荷を倍増させます。Python では
(同期) とrequests
(非同期) は同じことを行うのに別の著者による別々のプロジェクトです。aiohttp
が最終的にパッケージから両インターフェースを提供するようになりましたが、これは分裂があった必要上だけの上昇です。httpx - エコシステムのレベルでは: 前述の Rust の例は例外ではなく標準です。I/O に触れる各ライブラリは色を選ぶ必要があり、その選択が他のどのライブラリと連携できるかを制限します。Rust の非同期本自体も、「同期および非同期コードはまた異なる設計パターンを促進する傾向があり、異なる環境に意図されたコードを合成することが困難になる」と述べています。
そしてコストは単にロジスティカルなものだけではありません:Async/Await はスレッドにはない新たなバグのカテゴリーを導入しました。O'Connor による文書によると、Rust の Deadlock の一種「futurelock」が存在します:Future がロックを取得し、他の Future が同じロックを取得しようとする間、それが停止してポーリングされなくなります。スレッドの場合、ロックを持っているスレッドは常に解放に向けて進展します(
SuspendThreadのような誰もが危険だと知っていることをする限り)。Rust 非同期では、標準ツールであるselect!、バッファードストリーム、およびFuturesUnordered は頻繁にリソースを保持している Future のポーリングを停止します。Oxide で発生した最初の futurelock は、コアダンプとディスアセッブラが必要でした才能診断できました。
順次による罠
より微妙で注目度が低いコストは、Async/Await が asynchronous コードを順次的に見せるという最大の強みがかえって認知の罠である点です。
async function loadDashboard(userId) { const user = await getUser(userId); const orders = await getOrders(user.id); const recommendations = await getRecommendations(user.id); return render(user, orders, recommendations); }
これは注文と推薦を順次取得します:
getRecommendations は getOrders が完了するまで開始されません。しかし、これら 2 つの操作は独立しています(推薦が注文に依存しないため)。つまり並列で実行できるかもしれませんが、実際にはそうではありません。コードは綺麗で正しく見えますが、パフォーマンスを犠牲にしています。
並列バージョンでは、プログラマーが順次的なスタイルを明示的に破る必要があります:
async function loadDashboard(userId) { const user = await getUser(userId); const [orders, recommendations] = await Promise.all([ getOrders(user.id), getRecommendations(user.id) ]); return render(user, orders, recommendations); }
このパターンは小さな例を超えるとスケールしません。実際のアプリケーションで数十の非同期呼び出しがある場合、どの操作が独立していて並列化できるかを決定するには、プログラマーが手動で依存関係を分析し、コードを再構築する必要があるためです。順次的な構文は、並列実行可能なことを示すはずの情報構造(依存構造)を能動的に隠蔽しています。
Async/Await は非同期コードを書きやすくするために導入されました。しかし、「何.concurrent に実行できるか」はプログラマーが手動で決定し、順次の流れそのものだったものを壊す組み合わせパターンで表現する必要があります。
Async が正しくしたこと
公正を期すなら、非同期抽象化は確かに物事を改善しました。
- Async/Await の線形シーケンスにおける人間工学は、コールバックや Promise チェインよりも優れています。本質的に順次的だが I/O を含むコードにおいて、Async/Await は実際の構文ノイズを除去します。コールベースのコードよりも読みやすくデバッグしやすいです。
- そして一部の言語は色分け問題から正しい教訓を学びました。例えば、Go は Async/Await よりも Goroutine を明確に選択し、関数の色分けを全く持たない代わりにより重いランタイムを受け入れました。(注:Go は実際には
による一種の色分けを導入しました、これがキャンセルのための呼び出しを通じて伝播します)。Java の Project Loom (Java 21 の仮想スレッド) も同じ賭けを行いました:通常のスレッドのように見える振る舞いと外観を持ち、これによりコードに色を変える必要がありません。Loom チームは明示的に関数の色分けを回避したいと考えている問題として引用しました。context.Context - Zig はさらに進みました:コンパイラレベルの Async/Await を完全に除去し、I/O 操作が受ける
インターフェイスパラメータを中心に再構築しました。ランタイム(スレッド化された、イベントループ、またはユーザーが提供するものであれ)はインターフェイスを実装します。関数のシグネチャはスケジューリング方法に基づいて変更されず、Async/Await は言語キーワードではなくライブラリ関数になります。ただし、一部の論者はIo
パラメータ自体が一種の色分けであると主張しています。Io
他のエコシステムでの Async/Await の経験を検討した言語設計者は、関数の色分けのコストがメリットを上回ると結論付け、異なる道を選びました。
蓄積するコスト
| 波 | 解決 | 導入 |
|---|---|---|
| コールバック | 接続ごとのスレッドによるリソース枯渇 | 制御フローの逆転、エラーハンドリングの破片化、コールバック地獄 |
| Promise | ネスト、エラー統合、コールバックからの値へ | 一発限り制限、無音のエラー飲み込み、軽微な型分離 |
| Async/Await | 線形非同期シーケンスの人間工学 | 関数の色分け、エコシステムの破片化、新しいデッドロックカテゴリー、順次による罠 |
各波は非同期コードを書いている局地的な体験をより快適にしつつ、全球的な体験をより複雑にしました。単一の非同期関数を書く開発者はかつてないほど幸運ですが、混合した同期/非同期コードで大きなコードベースを維持するチームや、ランタイム間での依存関係の互換性を管理し、順次に見える Await チェインの背後にある並列性の機会を見つけようとするチームは、これらの抽象化を導入される以前には存在しなかった負担を負っています。
これは不良エンジニアリングの場合ではありません。コールバック、Promise、そして Async/Await を設計した人々は真の問題を解決しており、各ステップは前段階の失敗に対する合理的な対応でした。しかし、15 年と数回の反復を経て、蓄積された税は大きくなり、パターンが見えます:各修正は症状を治療しますが、構造そのものを保ちます。
コールバックから Promise、そして Async/Await へのアーチは、このシリーズに流れるテーマの最も明確な例かもしれません:「どのように並行実行を管理するか」という問いで始まるアプローチが、抽象化のあらゆるレベルで新たな問題を生成し続けます。これは単一のエコシステム内で、単一のアプローチの中で、リアルタイムで観察できるでしょう。
参考文献
- Baker, Henry と Carl Hewitt. "The Incremental Garbage Collection of Processes." ACM SIGART Bulletin 64 (1977): 55–59.
- Kegel, Dan. "The C10K Problem." 1999.
- Nystrom, Bob. "What Color is Your Function?" 2015 年 2 月 1 日.
- Elizarov, Roman. "How Do You Color Your Functions?" Medium, 2019 年 11 月 18 日.
- Cro, Loris. "Zig's New Async I/O." ブログ記事,2025.
- "Virtual Threads in Java." Oracle Java Magazine.
- Corrode Rust Consulting. "The State of Async Rust: Runtimes." ブログ記事.
- O'Connor, Jack. "Never Snooze a Future." ブログ記事,2026.