このサイトでの挿絵は、Gemini Proによる生成が大半です。 また、情報収集・まとめなどのサイトについては、検索結果をベースとしたものを使用しています。可能な限り出典を明示するよう努めますが完全ではありません。各自で調べる事もお忘れなく。

Goroutineの祖先はダイクストラだった――並行処理の考古学【言語知新 #1】

非同期処理って、最初は本当に魔法に見えませんでしたか?

async/await を初めて書いたとき、正直「なんかよくわからないけど動いた……」と思ってそのまま使い続けていました。goroutine も、go って一言書いたら勝手に並行してくれる感じが、ちょっと怖かったくらいです。でもあれって魔法じゃなくて、半世紀以上積み重ねてきた人類の知恵なんですよね。

というわけで、Agyテックブログ初の週末特別シリーズ「 言語知新 」、第1回は 並行処理の考古学 です。現代の言語から出発して、1960年代のオランダの研究室まで一気に掘り下げていきます。

まずは動画をどうぞ!


現代の地平――Go と Java が解決したもの

そもそも「並行処理(Concurrency)」と「並列処理(Parallelism)」は別物です。

  • 並行処理 = 複数のタスクを「切り替えながら」進める(1人で複数の作業をうまくやりくりするイメージ)
  • 並列処理 = 複数のタスクを「物理的に同時に」実行する(複数人が一斉に作業するイメージ)

この違いを押さえた上で、現代の言語が何と戦ってきたかを見ていきましょう。

C10K問題と軽量スレッド

昔のWebサーバーは、クライアント1台につきOS管理の「スレッド」を1本立ち上げていました。スレッドというのは、OSが管理する処理の実行単位で、1本あたり約1MBのメモリを消費します。クライアントが1万台(C10K)になると、それだけで10GBのメモリが吹き飛ぶわけです。

これを根本的に解決したのが Go 言語の Goroutine と、Java 21(Project Loom)で正式導入された 仮想スレッド(JEP 444) です。

どちらも「M:Nスケジューリング」という仕組みを採用しています。M個の軽量な「ユーザースペーススレッド」を、少数のN個の「OSスレッド」の上で動的に割り当てるモデルです。GoroutineはOSスレッドが1MBのメモリを要するのに対して、たった2KBから始まります。

Goのランタイムは G-M-Pモデル という高度なスケジューラを内蔵しています。G(Goroutine)、M(Machine = OSスレッド)、P(Processor = 実行コンテキスト)の三要素で構成され、暇になったプロセッサが忙しいプロセッサからタスクを奪い取る「 ワークスティーリング 」でCPUの空き時間を極限まで排除します。

1
2
3
4
5
// Goroutineを起動するだけでG-M-Pスケジューラが効率よく割り当ててくれる
jobs := make(chan int, 100)
for w := 1; w <= 3; w++ {
    go worker(w, jobs) // この "go" 一言が、すべての魔法の呪文
}

async/await の裏側にある「状態遷移機械」

一方で、C#の async/await や Kotlin の suspend 関数は全く別のアプローチです。こちらはコンパイラの力技で解決します。

Kotlin の suspend 関数がコンパイルされると、コンパイラはその関数を 状態遷移機械(State Machine) というクラスに書き換えます。関数の途中で delay() を呼んだとき、処理はそこで「凍結」され、ローカル変数の値はスレッドのスタックではなくヒープ上のオブジェクトに退避されます。後で再開するとき、そのオブジェクトの状態番号を見て「どこから再開すればいいか」を判断する――という仕組みです。

このアプローチの現代的な礎を作ったのは、2007年頃に Don Syme らが F# 向けに設計した「Asynchronous Workflows(非同期ワークフロー)」です。C#の async/await は、このF#の成功を直接の原動力として生まれました。

go文は有害だった?

こうして並行処理の「記述」が楽になると、今度は別の問題が出てきます。「起動しっぱなしのタスクが制御不能になる」問題です。

Python の非同期ライブラリ Trio を開発した Nathaniel J. Smith は、2017年に衝撃的な主張を発表しました。 Go言語の go ステートメントは、かつて追放されたgoto文と同じ構造的問題を抱えている というのです。

goto がプログラムの制御フローを壊すように、go は関数のカプセル化を壊す。呼び出し元は「その関数が裏でタスクを起動して勝手に終わる」ことを知る術がなく、エラーの伝播が途切れてメモリリークが頻発する――という批判です。

正直、これを読んだとき驚きました。「go文が有害」なんて、Go ユーザーからしたら「え、主力機能が!?」という話ですから。

これに対する答えが 構造化並行性(Structured Concurrency) です。 Nursery(保育器) と呼ばれるスコープを作り、子タスクはその内部でのみ起動されます。親はすべての子が完了するまでスコープを抜けられない。それにより、エラーが確実に親へ伝播し、リソースも安全に解放されます。Kotlin、Swift、Java(JEP)も次々とこの考え方を取り込んでいます。


歴史をさかのぼる――Erlang、アクター、CSP

さて、ここからが「考古学」の本番です。時計の針を戻していきましょう。

1980年代:Erlangと「Let it crash」哲学

1980年代後半、スウェーデンのエリクソン社で Joe Armstrong らが作り出した Erlang は、電話交換機――「絶対に止まってはいけないシステム」のために設計されました。

Erlang の設計哲学はシンプルで過激です。「共有メモリを使わない」「プロセス同士はメッセージパッシング(手紙のやり取り)のみで通信する」「エラーが起きたらプロセスをクラッシュさせ、監視プロセス(スーパーバイザ)に復旧させる」――これが “Let it crash” の思想です。

防御的プログラミングをやめて、エラーは即座に上位に投げる。この大胆さが、99.9999999%の稼働率を実現させました。「Supervision Trees(監視ツリー)」による階層的な障害復旧は、前述の構造化並行性のスコープ管理と思想的に深くつながっています。

Erlangのメッセージパッシングの数学的基盤となっているのが、1973年にMITの Carl Hewitt らが提唱した アクターモデル(Actor Model) です。もともとAI向け言語の研究から生まれたアクターモデルは、「データ構造も関数もプロセスも、すべては一様にアクターとして表現される」という考え方です。

アクターは非同期にメッセージを受け取り、(1)他のアクターにメッセージを送る、(2)新しいアクターを生成する、(3)自身の状態を変更する――この3つの行動のみを行います。受信メッセージを一時保管する メールボックス の概念も、ここで確立されました。

1
2
3
4
5
6
7
% Erlangのプロセス間通信:メールボックスからメッセージを取り出す
loop() ->
    receive
        {ping, From} ->
            From ! pong,  % 送信元に "pong" を非同期で返す
            loop()        % 再帰で次のメッセージを待つ
    end.

1978年:CSPとGoチャネルの祖先

アクターモデルと双璧をなすのが、1978年に C.A.R. Hoare (クイックソートの発明者としても知られるトニー・ホーア)が発表した論文 “Communicating Sequential Processes(CSP:通信する順次プロセス)” です。

Hewitt のアクターモデルが「非同期(手紙を出したらすぐ次の処理へ)」だったのに対し、Hoare の CSP は 同期的なランデブー(待ち合わせ) を基本としました。送信者と受信者が「同時に」準備できた瞬間にのみ値が直接コピーされる。どちらかが早く到達したら、もう一方が来るまでブロックして待つ。Go言語のチャネルはこのCSPの子孫です。

比較項目アクターモデル (Hewitt / Erlang)CSP (Hoare / Go チャネル)
通信方式非同期同期(ランデブー)
メッセージの宛先アクターのIDを直接指定チャネル(または原典ではプロセス名)
バッファメールボックス(キュー)で一時保管原則バッファなし(両者の準備が必要)
エラー処理の思想Let it crash + スーパーバイザガード付きコマンドによる非決定的選択

Go言語の有名な設計標語「 メモリを共有して通信するな、通信してメモリを共有せよ 」(Do not communicate by sharing memory; instead, share memory by communicating.)は、まさにHoareとHewittが1970年代に到達した結論そのものです。

ちなみに、皆さんはどっち派ですか?アクターモデル(Erlangスタイル)の非同期メッセージパッシング派?それともCSP(Goスタイル)の同期チャネル派?

1963年:コルーチンの誕生

さらに15年さかのぼると、現代の async/awaitsuspend 関数の「制御フローの一時停止と再開」というアイデアの源流に辿り着きます。

1963年、 Melvin E. Conway が発表した論文 “Design of a Separable Transition-Diagram Compiler” の中で生み出された コルーチン(Coroutine) です。

当時のコンピュータは今とは比べ物にならないほどメモリが乏しく、巨大なCOBOLコンパイラを一度にメインメモリに乗せることができませんでした。Conwayは、コンパイラの「字句解析(Lexer)」と「構文解析(Parser)」を別々のモジュールに分け、主従関係(メインとサブルーチン)ではなく 対等な関係で制御を譲り合う(Yield) 仕組みを考案しました。

通常の関数は呼び出されたら最後まで実行され、終わるとすべての状態が消えます。コルーチンは違います。処理の途中で相手に制御を渡し、次に呼ばれたとき「前回停止した場所から、当時のローカル変数を保持したまま再開」する。

この「凍結と再開」のアイデアが、当時はアセンブリ言語で泥臭く実装されながら、半世紀後の async/await の状態遷移機械への変換へと直結しているわけです。技術の歴史って、こういうところがたまらないですよね。


源流の発見――ダイクストラとセマフォ

いよいよ最深部です。

1965年頃(出版は1968年)、オランダの計算機科学者 Edsger W. Dijkstra“Cooperating Sequential Processes” (通称 EWD123 )という記念碑的な論文を書きました。

物理的制約から生まれた並行処理

当時のコンピュータ(Dijkstraが関わっていたエレクトロロギカ社のX1/X8)には、CPUが1つしかありませんでした。でも、パンチカードリーダーやプリンタなどの周辺機器との通信を担う専用の「チャネル」が、CPUの計算とは独立して動くハードウェア機構を持っていました。

CPUが計算している裏で、チャネルがデータを読み書きする。これが物理的な並行処理の始まりでした。

しかしここに致命的な問題が発生します。CPUとチャネルが同時に同じメモリ領域にアクセスすると、データが壊れてしまうのです。CPUがデータを書き込んでいる最中に、チャネルが読み取りを始めてしまうかもしれない。これが 生産者・消費者問題(Producer-Consumer Problem) の原点であり、 相互排他(Mutual Exclusion) が必要とされた理由です。

セマフォとP/V操作

この問題を解決するため、Dijkstra と同僚の C.S. Scholten が発明したのが セマフォ(Semaphore) です。

鉄道の腕木式信号機に由来する名前で、本質的には「0以上の整数変数」です。ただし通常の変数と違い、途中で他のプロセスに割り込まれることなく、完全に一連の動作として(不可分に)操作されることがハードウェアレベルで保証されています。

Dijkstra はオランダ語の頭文字から、2つの操作を定義しました。

  • P操作(Proberen:テストする) ― セマフォの値が1以上なら1減らして通過。0なら値が1以上になるまで待機(ブロック)。
  • V操作(Verhogen:増やす) ― セマフォの値を1増やす。P操作で待機中のプロセスがあれば起こす。
// セマフォによる生産者・消費者の協調(概念的な疑似コード)
semaphore mutex = 1;   // バッファへの排他アクセス用
semaphore items = 0;   // バッファ内のアイテム数
semaphore spaces = N;  // バッファの空き容量

// 生産者
P(spaces)              // 空きができるまで待つ
P(mutex)               // バッファに入る権利を取得
add_to_buffer(data)    // データを書き込む
V(mutex)               // 権利を解放
V(items)               // 消費者に「アイテムあるよ」と知らせる

このP/V操作の発明によって、「並行処理」はハードウェアの物理的なタイミングを力技でハックする作業から、 論理的で証明可能な数学的モデル へと昇華されました。並行処理が計算機科学における独立した学問分野として確立された瞬間です。

そう、これがGoroutineの祖先です。

現代のGoのチャネル、JavaのMutexロック、C#のawaitの待機機構――すべては1960年代のオランダの研究室で書き上げられたこの論文に行き着きます。


まとめ――複雑さをいかに「構造化」してきたか

並行処理の歴史を逆順に辿ると、こんな系譜が見えてきます。

Go / Java / Swift (2010s〜) → 現代言語がこれらすべてを取り込んで洗練
Smith / Trio (2017) → 構造化並行性で「go文有害論」を提唱
Armstrong / Erlang (1980s) → “Let it crash"で「障害をシステムに組み込む」思想を実装
Hoare (1978) → CSPで「通信による同期」を理論化(Goチャネルの祖先)
Hewitt (1973) → アクターモデルで「非同期メッセージパッシング」を提唱
Dijkstra (1965/1968) → セマフォ・P/V操作で「排他制御」を数学的に定式化
Conway (1963) → コルーチンで「制御フローの凍結と再開」を発明

並行処理の歴史は、一言で言えば「 人類が複雑さをいかに構造化してきたかの歴史 」です。ハードウェアの厳しい制約と戦いながら、人間の認知の限界を超えないように抽象化の層を積み上げてきた。その積み重ねが、今日私たちが go worker()async/await と書くだけで享受できる便利さに結晶しています。

あなたが次に goroutine を起動するとき、その裏に Dijkstra のP/V操作があることをちょっとだけ思い出してもらえたら嬉しいです。

「言語知新」シリーズは今後も続きます! 次回は具体的な言語の設計哲学に踏み込む予定です。乞うご期待。

感想や「次はこの言語を取り上げてほしい!」というリクエストは、ハッシュタグ #Agyテックブログ でぜひ教えてください!


引用文献

Hugo で構築されています。
テーマ StackJimmy によって設計されています。