5 ポイント 投稿者 GN⁺ 2025-07-20 | 1件のコメント | WhatsAppで共有
  • 非同期並行性はしばしば混同される概念だが、意味は異なる
  • 非同期は、処理が順序に関係なく実行されてもよい可能性を指す
  • 並行性は、システムが複数の処理を同時に進行できる能力を意味する
  • 言語およびライブラリのエコシステムでこの2つが明確に区別されていないため、非効率性と複雑性が生じる
  • Zig言語では、非同期と並行性を分離することで、コードの重複なしに同期コードと非同期コードを共存させられる

序論: 非同期と並行性を区別する必要性

Rob Pikeの有名な発表によって「並行性は並列性ではない」という文はよく知られているが、それ以上に実質的に重要な論点がある。まさに「非同期」という概念の必要性である。Wikipediaの定義によれば、

  • 並行性: システムが複数の処理を時間分割または並列に同時処理できる能力
  • 並列コンピューティング: 実際の物理レベルで複数の処理を同時実行すること
    このほかに、私たちが見落としている重要な概念がまさに「非同期」である。

例1: 2つのファイルを保存する

2つのファイル(A, B)を保存するとき、順序が関係ないなら、

io.async(saveFileA, .{io})
io.async(saveFileB, .{io})
  • Aを先に保存してもBを先に保存してもよく、途中で交互に保存しても問題ない
  • さらには、Aファイルをすべて保存し終えてからBファイルを開始しても、コード上は正しい

例2: 2つのソケット (サーバー、クライアント)

同一プログラム内でTCPサーバーを作成し、クライアントを接続しなければならないとき、

io.async(Server.accept, .{server, io})
io.async(Client.connect, .{client, io})
  • この場合、2つの処理は必ず重なって進行する必要がある
  • つまり、サーバーが接続を受け入れている間に、クライアントも接続を試みなければならない
  • 最初のファイルの例のように直列で処理すると、意図した動作にならない

概念の整理

非同期、並行性、並列性の概念を次のように定義する

  • 非同期(asynchrony) : 処理が順序から外れて実行されても正しい結果になる性質
  • 並行性(concurrency) : 並列であれ分割実行であれ、複数の処理を同時に進められる能力
  • 並列性(parallelism) : 物理的に複数の処理がリアルタイムで同時に実行される能力

ファイル保存とソケット接続の2つの例はどちらも非同期的だが、2つ目(サーバー-クライアント)では並行性が必須である

非同期と並行性を区別する実益

この区別をしないと、次のような問題が生じる

  • ライブラリ作者は非同期版と同期版のコードを2回書かなければならない(例: redis-py vs asyncio-redis)
  • ユーザーは非同期コードの「伝染性」により、たった1つの非同期ライブラリ依存があるだけで、プロジェクト全体を非同期化しなければならないという不便さを被る
  • これを避けようとして場当たり的な回避策が生まれ、それがしばしば *デッドロック(deadlock)* や非効率を招く

したがって、2つの概念を明確に分離することは、ライブラリとユーザーの双方に大きな利点をもたらす

Zig: 非同期と並行性の分離

Zig言語は io.async を通じて非同期を利用するが、これは並行性を保証しない

  • つまり、io.async を使っても内部的にはシングルスレッドのブロッキングモードで実行できる
  • たとえば
    io.async(saveFileA, .{io})
    io.async(saveFileB, .{io})
    
    このコードはブロッキング環境では
    saveFileA(io)
    saveFileB(io)
    
    と同じように動作しうる
  • つまり、ライブラリ作者が io.async を使っても、ユーザーは望めば逐次的なブロッキングI/Oとして実行できる柔軟性を確保できる

並行性の導入とタスク切り替え(スケジューリング)メカニズム

並行性が必要な場合、実際に効果的に動作させるには

  1. ブロッキングではないイベントベースI/O (epoll, io_uring など) を使う
  2. タスク切り替え(スイッチング)プリミティブ(例: yield) が必要
  • 例として、Zigはグリーンスレッド環境でスタックスワップ技法を使ってタスク切り替えを行う
  • OSレベルのスレッドスケジューリングと同様に、CPUレジスタやスタックなどの状態を保存・復元して複数タスク間を切り替える
  • このような切り替えメカニズムがあってこそ、非同期コードを実際に並行にスケジュールできる
  • スタックレスコルーチン実装(例: suspend, resume)も同じ原理である

同期コードと非同期コードの共存

以下のように2回の saveDataio.async で実行すると、

io.async(saveData, .{io, "a", "b"})
io.async(saveData, .{io, "c", "d"})
  • 2つの処理は互いに非同期的であるため、内部的には同期的に書かれた関数であっても、自然に処理を並行性コンテキスト内でスケジュールできる
  • ユーザーやライブラリ作者がコードを重複させることなく同期/非同期関数を併用しても問題ない

並行性が「必須」である状況を明示する

特定の関数(例: TCPサーバーの accept)は、実行時に明示的に並行性が必要であることをコードで表現する必要がある

  • これをZigでは io.asyncConcurrent などの明示的な関数で区別する
  • この方式では、その処理が実行環境で並行性をサポートしていなければエラーを発生させられる
  • 非同期目的の io.async とは異なり、並行性の保証が必須であるため、failableな関数として実装される

結論

  • 非同期と並行性はまったく異なる概念であり、明確に区別すべきである
  • 同期コードと非同期コードを共存させることは可能である
  • Zigの非同期/並行性モデルは、コードの重複なしに両方の世界を活用できるようにしてくれる
  • このような構造はGoなど他の言語にも適用されており、async/awaitの伝染性を克服する道を示している
  • Zigの新しいasync I/O設計により、今後さらに直感的な並行性/非同期プログラミング環境が期待できる

1件のコメント

 
GN⁺ 2025-07-20
Hacker Newsの意見
  • async の定義は本当に難しいと感じる。私自身も JavaScript で async を設計した人間の一人だが、この記事で示されている定義には同意しない。単に async だからといって正しく動作するわけではないし、async コードでも依然としてさまざまな種類のユーザーレベルのレースコンディションが起こりうる。言語が async/await をサポートしているかどうかは関係ない。最近私がたどり着いた定義は、async とは「並行性のために明示的に構造化されたコード」だというもの。この見方もまだ磨く必要がある。関連して自分でまとめた記事もあるので、Quite a few words about async を参照してほしい

    • 非同期(Asynchronism)という抽象的な概念と、実際の実装を区別することが重要だと思う。後者には言語レベルの抽象も機械的な調停手段も含まれる。最も高いレベルの抽象概念では、同期(Synchronism)の反対がそのまま非同期だ。通常、複数の主体が一緒に動かなければならないとき(たとえば、ある作業が終わってから別の作業が進む場合)、それがいつ起こるのかわからない、あるいは定義されていないことが非同期の核心だ。この定義自体は難しくない。問題は、言語レベルでこの抽象を設計するときに生じる認知的負荷だ

    • この話題に深いわけではないが、私の考えでは async コードとは、本来ブロッキングする処理をノンブロッキングに変えることで、他の処理が同時に進められるようにするものだ。特に私の場合、組み込みのループで長時間ブロックするコードは I/O を壊し、目に見えたり耳に聞こえたりする不具合を引き起こすので、この見方は非常にしっくりくる

    • そもそも async を定義する必要があるのかすら疑わしい。実際、定義が難しいのは、ある一つの概念に完全に当てはまるものがないからだと思う。async や event loop を厳密に定義しなければならない必要性にも疑問がある。実際に並列処理が可能な物理チップの領域には、私の知らない多くの概念があるはずだ。私は「user finger」(タッチ操作のようなもの)と「quickies」(実行時間が非常に短い処理)、job queue、ブロッキング/ノンブロッキング API だけ知っていれば十分だと思っている。自分の目的を達成するにはノンブロッキング API が都合がいい。時間のかかる処理は下位システムに任せ、自分は必要なデータ保存のような「quicky」だけを書き、成功時・失敗時の別々の quicky を定義しておけばいいからだ。sync と async の区別そのものはあまり助けにならない。もちろん他人がその言葉を使う以上、概念は理解しておく必要がある。本質的に async とはノンブロッキング API のことだと思う。async プログラミングモデルとは、実際には(実行時間の観点で)小さく原子的なブロッキング処理を、「混沌として非決定的な」イベントに合わせて書く形だ。システム内部で何が起きていようと、ブラウザや OS、デバイス自体が複数の実行ユニットと良いスケジューラを提供してくれると信じている。async は私にとって曖昧に定義された概念で、たとえ定義できてもそれが本当に有用かは疑問だ。むしろイベント、自分が書く処理のブロッキング特性、関数クロージャ、API を使うときに何が別の job に分割されるのかといった概念のほうがはるかに実用的だ。「callback」という用語自体も最初はとても混乱した。コードがその場で止まるのかと思っていたが、実際にはその部分は最後まで実行され、その後で「callback」が呼ばれたときにどのコードが動き、どんな情報が見えるのかを精密に理解しなければならなかった。正直、これは混沌であると同時に天才的な発想でもある。「async」そのものより、根本のモデル、つまりイベント、ブロッキング処理、タスクキュー、ノンブロッキング API のほうがずっと単純だ。そして自分が何をしていて、ブラウザや OS などが何をしているのかを理解することもかなり重要だ。たとえば C++ は concurrent モデルを宣言するが、実際の実行は OS が担う。JS ではノンブロッキング API によって、ブラウザや Node に「おそらく」 concurrency があることを宣言し、内部で実際に concurrent に処理させる。最も重要なのは、各タスクを短く(<50ms)保ち、ノンブロッキング API で意図だけを伝えられることだ。C++ や Rust は実際のタスクを concurrent に実行してくれと OS に伝えるので、物理的に 1 スレッドしかなくても UI の応答性は保たれる。結局、async プログラマがやるべきことは「優れた UX モデル」を作り、イベントを quickies にうまくマッピングすることだ

  • 筆者は「yield(実行を譲る)という概念」を並行性の定義から取り出して、新たに「非同期(asynchrony)」という用語に入れたように見える。そして、この概念がなければ並行性(concurrency)全体が成り立たないと主張している。私の考えでは、並行性にはもともと yield の機能が必須であり、それ自体に内在する概念だ。重要な概念ではあるが、新しい用語として切り出すのは混乱を増やすだけだ

    • 私は 1:1 並列性が、yield のない並行性の一形態だと思う。それ以外の非並列型の並行性(concurrency)は、どんな周期であれ実行を譲らなければならない。命令レベルであっても同じだ。たとえば CUDA では、同じ warp 内で分岐したスレッドが互いに命令を交互実行するため、一方の分岐が他方をブロックしうる

    • 引用された記事でも、むしろ「yield は並行性の概念である」と明示されていることを強調したい

    • 並行性が必ずしも yield を意味するわけではない。同期ロジックには明確な同期が必要で、yield は同期手段の一つにすぎない。私の言う非同期ロジックとは、同期や yield なしで動作する並行性を意味する。現実的な観点では、並行性や非同期ロジックはフォン・ノイマン機械上では完全な形では存在しない

  • この文脈での非同期とは、要求の準備・送信と結果の回収を分離する抽象化だ。複数の要求を送ってから、その結果を後で確認できるようになる。並行的な実装を許すが必須ではない。それでも、この抽象の目的は並行性を確保することにある。並行性がなければ、得ようとする利点も消える。一部の非同期抽象化は、最低限の並行性がなければ実装自体が不可能だ。たとえばコールバック方式はシングルスレッドでも真似できるが、非再帰 mutex を保持しているときにデッドロックが発生するなど限界がある。つまり、並行性のない非同期抽象化は必ず失敗する。要求側が mutex を握ったまま要求を出し、unlock 前にコールバックが実行されると、unlock が永遠に実行されない可能性がある。少なくとも別スレッドを使って、要求側が unlock まで到達できるようにしなければならない

    • 同意する。記事のサーバー・クライアントの例は一例にすぎず、今挙げたケースはまったく別種の解決策を必要とする例だ。今後も同様のケースはもっと見つかるだろう。結局、async を使うなら常に並行性を保証しなければならない
  • 「協調的マルチタスクはプリエンプティブではない」。「非同期」という用語は通常、「単一スレッド・協調的マルチタスク(明示的な yield)・イベント駆動」であり、外部演算が並行して実行され、その結果がイベントとして報告されることを意味する。マルチスレッドや並行実行モデルでは、非同期にはあまり意味がない。そのスレッドがブロックされてもプログラム全体は進み続けるからで、yield ポイントを明示的にする必要も薄れる

    • Rust、C#、F#、OCaml(5+)などは OS スレッドと async の両方をサポートしている。OS スレッドは CPU バウンドな作業、async は IO バウンドな作業に向いている。async または Go 風の M:N スケジューリングの最大の利点は、メモリさえ十分ならタスク/ゴルーチンを自由に増やせることだ。OS スレッド方式ではコンテキストスイッチの負担やスレッド/メモリ不足などの問題があり、IO バウンドが増えるだけでもデッドロック問題に突き当たることがある
  • Zig の新しい IO のアイデアは、一般的なアプリ開発には斬新に見える。スタックレスコルーチンが不要な人には最適だろう。ただ、ライブラリを書く側にはバグが増えそうだ。ライブラリ作者は、与えられた IO が単一/マルチスレッドなのか、イベントベースの IO なのかを把握しにくい。並行性/非同期/並列に関するコードは、IO スタックを完全に理解していても書くのが難しいのに、IO が外から与えられる構造では難しさが倍増する。IO インターフェースが「小さな OS」のように巨大化すれば、テストすべきシナリオも爆発的に増える。インターフェースが提供する async プリミティブだけで実際のエッジケースをすべて扱えるのか確信が持てない。多様な IO 実装をサポートするには、コードは非常に「防御的」でなければならず、常に最も並列的な IO を想定する必要がありそうだ。特にスタックレスコルーチンとこの方式を混ぜるのは簡単ではなさそうだ。不要なコルーチン spawn を減らすにはコルーチンの明示的な polling が必要だが、たいていの開発者はそういうコードを自分で書かないだろう。結局、通常の async/await コードと似た構造に落ち着く気がする。動的ディスパッチと Zig のボトムアップ設計志向まで考えると、最終的にはかなり高水準の言語になりそうだ。まだ実運用例がないのに「妥協なき」アプローチと呼ぶのは、やや大げさだと思う。数年使われてからでないと、本当の評価はできない

    • スタックレスコルーチンはどうせサポート予定だ。WASM ターゲット対応に必要なので必ず入る。動的ディスパッチは IO 実装が 2 つ以上あるときにしか使われず、1 つだけならダイレクトコールに置き換えられる。まだ現場で検証されていないので、「妥協なき」という表現は時期尚早だと思う。Jai 言語で似たモデルがうまく使われているとは聞くが(明示的なコンテキスト受け渡しではなく暗黙の IO コンテキストという違いはある)、これも現場で実際に使われたと言えるほどではない

    • 同期実行と非同期実行の両方をサポートするなら、コードが常に最も並列的な IO を想定しなければならないという点には同意する。しかし、低レベルの IO イベントハンドラで非同期が正しく実装されているなら、あとはどこでも同じ原則を適用すればいい。最悪でもコードが単に逐次的に(遅く)動くだけで、レースやデッドロックには陥らない

  • 2 つのライブラリを別々に使わずに済むという点で、Zig のアイデアはとても良いと思う。ただし、非同期コードのテストは常に不安だ。今日通ったテストが、実際の途中で起こりうるすべてのシナリオや順序を再現していると、どうやって確信すればいいのかわからない。スレッドプログラムも同じ問題を抱えているが、マルチスレッドコードは書くのもデバッグするのも常に難しい。私はなるべくスレッドを使わないようにしている。実際の問題は「開発者に非同期/スレッド環境を正確に理解させること」だ。最近、Python システムで JS 半分・Python 半分のチームと仕事をしたが、大規模なコードを async 化・スレッド化しておきながら、Global Interpreter Lock(GIL)が何かすら知らなかった。私の話は小言にしか聞こえなかったようだ。しかも彼らのテストは、実際にコードを壊しても常に通ってしまう。mangum が HTTP request の終了時に background と async の作業を強制的に finishing してしまうことも知らなかった。こういうことを伝えても、みんなあまり気にしない。知っていること自体より、周囲がそれを見てくれるかどうかのほうが重要だ

    • Zig では Io のテスト実装を導入する予定で、これを使って並列実行モデル上でファズテストなどのストレステストも可能にする計画だ。ただし重要なのは、多くのライブラリコードが io.async や io.asyncConcurrent を直接呼ぶ必要はないだろうという点だ。たとえば大半のデータベースライブラリは純粋な同期コードだけで十分で、そのコードをアプリケーション開発者が io.async(writeToDb)、io.async(doOtherThing) のように簡単に非同期化できる。こうすれば、コード全体に async/await を振りまくよりエラーを起こしにくく、はるかに理解しやすい

    • その通りだと思う。非同期・マルチスレッドコードであらゆるインターリーブをテストするのは悪名高いほど難しい。ファザーや並行性テストフレームワークを使っても、実運用で得る教訓がなければ確信は持ちにくい。分散システムではさらに悪化する。たとえば webhook インフラを設計するときには、自分のコード内の async だけでなく、ネットワークの再試行、タイムアウト、部分的失敗など多様な外部要因まで重なる。高並行環境ではリトライ、deduplication、idempotency の保証など、それ自体がエンジニアリング上の課題になる。だから Vartiq.com のような専門サービスを使う必要が出てくる(そこで働いている)。こうしたサービスは運用上の並行性の複雑さをある程度抽象化し、blast radius を減らしてくれるが、自分のコード内 async のテスト問題は残る。結論として、async、threading、分散並行性は互いにリスクを増幅するので、コミュニケーションとシステム設計のほうが、どんな構文やライブラリより重要だ

  • 筆者は並行性の定義で混乱しているように思う。Lamport の論文 を参照するとよい

    • 論文リンクだけでなく説明もお願いしたい。私の考えでは定義自体は悪くなかった。たとえば、非同期: 作業が順番どおりに実行されなくても正しければそれは非同期。並行性: 並列であれタスクスイッチングであれ、複数の作業を同時進行させられるシステムの性質。並列性: 実際の物理レベルで 2 つ以上の作業が同時に動いていること

    • こういう理由で、私はこれらの用語を完全に使うのをやめた。誰と話しても理解が違うので、用語そのものにコミュニケーション上の意味がなくなってしまう

    • 筆者もブログ記事の中で、その用語に既存の定義があることは知っている。自分で新しい定義を提案しているのであって、その定義の中で一貫していれば十分だ。読者がそれを受け入れるかどうかが違うだけだ

    • Lamport 論文の半分は、たいていの言語では概念的に表現できない。スレッドを作ったからといって全順序や半順序を論じることはほとんどない。TLA+ でプロトコル設計をするときのような場面で初めて必要になる話だ。Zig async API で「非同期実行環境でのみ動く」関数がコンパイルエラーを出すことを、新しい理論とまで呼ぶ必要はない

  • 「非同期(asynchrony)」という用語が本当に必要かを測る良い方法は、1 つの言語やモデルだけでなく、さまざまな並行性モデルでも有用かを考えることだ。たとえば Haskell、Erlang、OCaml、Scheme、Rust、Go など、いろいろな環境で共通して必要な用語なら価値が高い。一般に協調スケジューリングが入ると、システム全体が 1 つのコードの問題でロックアップしたり、遅延の問題が出たりと、より多くの注意が必要になる。プリエンプティブスケジューリングなら、こうした問題の多くは消える。システム全体のロックアップが不可能になるので、問題の種類が大幅に減る

  • この場合「非同期(asynchrony)」は不適切な語で、すでによく定義された数学用語「可換性(commutativity)」がある。ある演算は順序が重要でなく(加算、乗算など)、ある演算は順序が重要だ(減算、除算など)。通常、コードの演算順序は行番号(上から下)で表現されるが、async コードではこの順序が崩れる。そういう意味で書かれた asyncConcurrent(...) はかなりわかりにくい。ブログ記事の内容を完全に把握していなければ、何を意味しているのか理解しにくい。Zig(そして私の好きな Rust も)では、こういう「ヒップスター的」アプローチがよく出てくる気がする。手続き的な(async ベースの)可換性/順序の体系を Rust の lifetime のように実装するか、あるいは人々が慣れているものをそのまま使うほうがよい

    • 「asyncConcurrent(...) が混乱する」という意見には同意しない。ブログ記事の要点を内面化すれば、まったく混乱しない。そのアイデアを学ぶ価値があるかどうかは別問題だ。実際にこれを内面化した人たちが多く練習し、時間がたてば現場でこのアイデアが良いか悪いかわかるだろう。そして「commutativity」という語に置き換えると、むしろ Zig では演算子自体に可換なものがあるため、さらに混乱する。f() + g() なら加算は可換なのだから、Zig が並列に実行してよいのか、という誤解が生じやすい。実行順序と可換性はまったく別の話なので、区別すべきだ

    • 厳密に言えば、可換性(commutativity)は(二項)演算について成り立つ性質だ。connect/accept のような 2 つの async 文が可換だと言うとき、「どの演算に関して?」という問いが出てくる。現時点では bind(>>=)演算子(あるいは .then(...) など)がそれに近い役割を果たすが、まだ直感の領域を出ない

    • 非同期は部分順序も許容する。2 つの演算が同じ順序で退役しなければならないとしても、実際の実行順序とは別だ。たとえば減算は可換ではないが、残高計算と差し引き額計算を 2 つのクエリで並列に実行し、その後で結果を適切な順序で適用することは可能だ

    • 別の用語がこの概念を含むからといって、それが asynchrony より良い単語だとは限らない。「commutativity」という語は読むにも聞くにも書くにも煩雑だ。asynchrony のほうがずっと親しみやすい

    • 可換性だという主張には限界がある。A と B がそれぞれ C と可換なら ABC=CAB だが、だからといって必ずしも ACB と等しいとは限らない。非同期では ABC=ACB=CAB のすべてが等しくなければならない(既存の数学用語があるのかもしれないが、私は知らない)

  • ネットワークプログラマとして並行性、並列性、非同期コードを大量に書いてきたが、この記事は少し混乱しているように感じる。抜け穴だらけの抽象の上で答えを見つけようとしているように見える。ツールや実装そのものが間違っているなら、こんなに簡単に「壊れる」こと自体が問題だ。実のところ、マルチスレッドコードのデバッグはかなり楽しい。他の人たちがマルチスレッドの怪物をひどく恐れているのを見ると、むしろ面白く感じる