Read OSS

チャネル、ネットワークポーラー、そしてランタイムを貫く横断的な仕組み

上級

前提知識

  • 第1〜5回:メモリ・GCまでのシリーズ全記事
  • 並行プログラミングの基礎(アトミック操作、ロックフリー構造)
  • I/O 多重化の概念(epoll、kqueue)

チャネル、ネットワークポーラー、そしてランタイムを貫く横断的な仕組み

これまでの連載では、リポジトリ構造からコンパイラパイプライン、ランタイムのブートストラップ、スケジューラ、メモリ管理へと Go の内部を順番に追ってきました。最終回となるこの記事では、それらの基盤の上に構築された高レベルの並行処理プリミティブと I/O インフラを取り上げます。チャネル、ネットワークポーラー、同期プリミティブ、そしてランタイム全体を結びつけるコンパイラディレクティブです。これらの仕組みこそが、「メモリを共有して通信するのではなく、通信によってメモリを共有せよ」という Go の設計哲学を実装レベルで支えています。

チャネルの実装:hchan と sudog

チャネルは Go を代表する並行処理プリミティブです。内部的には、待機キューを備えたミューテックス保護の循環バッファとして実装されています。

src/runtime/chan.go#L34-L55

type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint           // size of the circular queue
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    elemsize uint16
    closed   uint32
    timer    *timer          // timer feeding this chan
    elemtype *_type          // element type
    sendx    uint            // send index
    recvx    uint            // receive index
    recvq    waitq           // list of recv waiters
    sendq    waitq           // list of send waiters
    bubble   *synctestBubble
    lock     mutex
}

waitq 型は sudog 構造体の連結リストです。sudog は、同期操作を待機しているゴルーチンをランタイムが表現するためのデータ構造です。

src/runtime/runtime2.go#L404-L446

sequenceDiagram
    participant G1 as Goroutine 1
    participant CH as hchan (buffered, cap=2)
    participant G2 as Goroutine 2

    Note over CH: buf: [_, _], sendx=0, recvx=0

    G1->>CH: ch <- "a" (chansend)
    Note over CH: buf: ["a", _], sendx=1

    G1->>CH: ch <- "b" (chansend)
    Note over CH: buf: ["a", "b"], sendx=0

    G1->>CH: ch <- "c" (buffer full!)
    Note over CH: G1 parked in sendq as sudog

    G2->>CH: <-ch (chanrecv)
    Note over CH: Returns "a", copies "c" to buf
    CH->>G1: goready(G1) — wake from sendq
    Note over CH: buf: ["c", "b"], recvx=1

チャネル操作には、注目すべき3つの高速パスがあります。

  1. 直接送信: 受信待ちのゴルーチンがすでに recvq にいる場合、送信側はバッファを経由せず、値を直接受信側のスタックにコピーし、goready で受信側を起こします。バッファへの2回のコピーを省けるため、効率的です。

  2. バッファ送受信: バッファに空き(または未読データ)がある場合は、ロックを取得した上で循環バッファへのコピーだけで操作が完結し、ブロックは発生しません。

  3. ブロッキング: どちらの高速パスも使えない場合、ゴルーチンは sudog を生成して対応する待機キューに自身を登録し、gopark を呼び出してスケジューラから外れます。第4回で見たように、gopark はスケジューラと連携することで、OS スレッドを占有せずにゴルーチンを待機状態にします。

chan.go の先頭に記載された不変条件は、一読の価値があります。

src/runtime/chan.go#L9-L18

バッファ付きチャネルにおいて、バッファにデータがある(qcount > 0)なら受信キューは空でなければなりません。逆にバッファに空きがある(qcount < dataqsiz)なら送信キューは空でなければなりません。この不変条件により、待機者とバッファの空きが同時に存在する状況が起こりえなくなり、実装がシンプルになっています。

ヒント: 31行目の debugChan 定数を true にすると、開発中にチャネル操作の詳細なログを出力できます。ランタイムの各サブシステムには、同様のデバッグ用定数が用意されています。

select 文の実装

select 文はコンパイル時に runtime.selectgo の呼び出しへと変換されます。

src/runtime/select.go#L17-L43

各ケースは、チャネルとデータ要素へのポインタを持つ scase 構造体で表現されます。実装上、重要なのはロックの取得順序です。複数のチャネルを含む select では、デッドロックを避けるためにすべてのチャネルを同時にロックする必要があります。

func sellock(scases []scase, lockorder []uint16) {
    var c *hchan
    for _, o := range lockorder {
        c0 := scases[o].c
        if c0 != c {
            c = c0
            lock(&c.lock)
        }
    }
}

lockorder スライスはチャネルをアドレス順にソートすることで、一貫したグローバルロック順序を保証します。一方、ケースの評価は pollorder スライスを使ってランダム化されます。これはスタベーション(飢餓状態)を防ぐための工夫で、ランダム化がなければ常に先頭のケースが優先されてしまいます。

flowchart TD
    A["select statement<br/>(N cases)"] --> B["Shuffle pollorder<br/>(random evaluation)"]
    B --> C["Sort lockorder<br/>(by channel address)"]
    C --> D["Lock all channels"]
    D --> E{"Any case ready?"}
    E -->|Yes| F["Execute that case,<br/>unlock all"]
    E -->|No| G["Create sudog for each case"]
    G --> H["Enqueue in all channel wait queues"]
    H --> I["gopark (sleep)"]
    I --> J["Woken by some channel"]
    J --> K["Dequeue from all other channels"]
    K --> F

ネットワークポーラー

Go のネットワーク I/O はゴルーチンからはブロッキングに見えますが、実際は内部でノンブロッキング I/O に多重化されています。ネットワークポーラーは、この2つの世界をつなぐ橋渡し役です。

プラットフォーム非依存のインターフェースは netpoll.go で定義されています。

src/runtime/netpoll.go#L15-L41

各プラットフォームは netpollinit()netpollopen(fd, pd)netpollclose(fd)netpoll(delta)netpollBreak() を実装しなければなりません。pollDesc 構造体は各ファイルディスクリプタの状態を追跡します。

src/runtime/netpoll.go#L51-L80

各 pollDesc は読み書きそれぞれに対応するセマフォ(rgwg)を持ちます。これらのセマフォはゴルーチンポインタを状態として使用します。pdNil(アイドル)、pdWait(park 準備中)、pdReady(I/O 準備完了)、または *g ポインタ(ゴルーチンが park して待機中)のいずれかです。

Linux では、実装に epoll が使われます。

src/runtime/netpoll_epoll.go#L21-L40

graph TD
    subgraph "User Code"
        A["conn.Read()"]
    end
    subgraph "net package"
        B["pollDesc.waitRead()"]
    end
    subgraph "Runtime"
        C["runtime_pollWait"]
        D["gopark on pollDesc.rg"]
    end
    subgraph "Scheduler"
        E["findRunnable calls netpoll"]
        F["epoll_wait returns ready fds"]
        G["goready parked goroutines"]
    end

    A --> B --> C --> D
    E --> F --> G
    G -.->|"wake"| D

スケジューラとの統合(第4回参照)は非常に洗練されています。findRunnable は実行するゴルーチンを探す際、netpoll(0)(ノンブロッキング)を呼び出します。スレッドが仕事なしで park しようとしているときは、netpoll(delta) にタイムアウトを渡して I/O を待ちます。また、sysmon スレッドが定期的にポーリングを行うことで、I/O イベントの取りこぼしを防いでいます。

ランタイムの同期プリミティブ

ランタイムは独自の同期階層を持っており、その仕様は HACKING.md に記述されています。

src/runtime/HACKING.md#L139-L179

プリミティブ G をブロック M をブロック P をブロック 用途
mutex Yes Yes Yes ランタイム共有状態の保護
note Yes Yes Yes/No 単発通知
gopark/goready Yes No No チャネル操作、netpoll、タイマー

ランタイムミューテックスは最低レベルのロックです。Linux では futex を使って実装されています。

src/runtime/lock_futex.go#L1-L53

これは sync.Mutex とは別物です。OS スレッド自体をブロックするランタイム内部専用のロックで、ゴルーチンとスレッドの両方をブロックします。そのため、ランタイムの最下層における短いクリティカルセクションにのみ使用が限られています。

note プリミティブは、futex を使った単発通知を提供します。

func notewakeup(n *note) {
    old := atomic.Xchg(key32(&n.key), 1)
    if old != 0 {
        throw("notewakeup - double wakeup")
    }
    futexwakeup(key32(&n.key), 1)
}

sema.go に実装されたセマフォは、sync.Mutex が実際に利用している仕組みです。

src/runtime/sema.go#L1-L49

チャネルと同じ sudog 構造体を使用した平衡木を、251エントリ固定のハッシュテーブルに格納する設計です。ミューテックスごとにカーネルリソースを割り当てる必要がなく、異なるアドレスの待機者を O(log n) で検索できます。

linkname とコンパイラディレクティブ

ランタイムは特権的な立場に置かれており、関数をパブリック API にすることなく他のパッケージに公開しなければならない場面があります。これを実現するのが //go:linkname ディレクティブです。

src/runtime/HACKING.md#L277-L356

使い方は3種類あります。

  • Push linkname: ローカルの定義に別パッケージのシンボル名を付ける
  • Pull linkname: 別パッケージで定義されたシンボルを参照する
  • Export linkname: 他パッケージから linkname で参照できるようシンボルを公開する

たとえば、runtime.main はユーザーの main.main に次の方法でアクセスします。

//go:linkname main_main main.main
func main_main()

ランタイムはまた、通常の Go コードでは使えないコンパイラディレクティブも活用しています。

src/runtime/HACKING.md#L424-L488

  • //go:systemstack — システムスタック(g0)上で実行しなければならない関数に付与
  • //go:nowritebarrier — この関数内に書き込みバリアがないことをアサート
  • //go:nowritebarrierrec — この関数およびその呼び出し先(再帰的に)に書き込みバリアがないことをアサート
  • //go:nosplit — スタック拡張チェックを挿入しない(現在のスタックに収まる必要がある)

これらのディレクティブはランタイムの正確性に不可欠です。たとえば、P なしで実行されるコード(スケジューラの遷移中など)は書き込みバリアを発生させてはいけません。書き込みバリアには P が必要だからです。nowritebarrierrec ディレクティブは、コンパイル時にコールグラフ全体にわたってこの制約を強制します。

ヒント: ランタイムのコードを読むときは //go:nosplit アノテーションに注目しましょう。この注釈が付いた関数はスタックを拡張できないため、使用サイズに厳しい制限があります。//go:systemstack//go:nosplit が組み合わさっている場合、その関数は固定サイズのシステムスタック上で動作するため、スタック使用量には細心の注意が求められます。

全体像

この6本の連載では、Go のリポジトリ構造とブートストラッププロセスから出発しました。そこから go コマンドのビルドオーケストレーション、コンパイラの SSA パイプライン、ランタイムのアセンブリブートストラップと G-M-P スケジューラ、メモリアロケーションと GC を順にたどり、最終回ではチャネル・ネットワーク・同期インフラを扱いました。

繰り返し登場した設計テーマを改めて整理しておきましょう。

  • レイヤード・ディスパッチ: 薄いエントリーポイントがアーキテクチャ固有の実装に委譲する(コンパイラ、リンカ、ランタイムエントリ、netpoll)
  • ロックフリーな高速パス: アロケーション用の per-P mcache、スケジューリング用の per-P 実行キュー、チャネルの直接送信
  • 宣言的な制約: SSA パスの順序付け、API 互換性ファイル、ロックランキング
  • 協調的な統合: スケジューラ、GC、netpoll、チャネル操作がすべて gopark/goready を通じて連携し、個別のブロッキング機構に頼らない

Go のランタイムは、すべてのピースが噛み合うように設計された一体的なシステムです。最初のアセンブリ命令からガベージコレクタの書き込みバリアに至るまで、あらゆる部分が協調して動作するよう作られています。これらの内部実装を理解することは、単なる知的好奇心の充足にとどまりません。パフォーマンスを論理的に考え、不可解な挙動をデバッグし、ランタイムと共に動くコードを書くための確かなメンタルモデルを手に入れることができます。それこそが、より優れた Go プログラマーへの道です。