Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 13969] ファイルの概要

このコミットは、Go言語の標準ライブラリである net パッケージにおける、ファイルディスクリプタ (FD) のポーリング処理の効率化を目的としています。具体的には、ネットワークI/Oを処理する pollServer のインスタンスを複数化し、FDをこれらの pollServer に分散させることで、マルチコア環境における競合(コンテンション)を軽減し、ネットワーク性能を向上させています。

コミット

commit 7014bc64b1be8f85fff75ec13f8597b6a6aed366
Author: Sébastien Paolacci <sebastien.paolacci@gmail.com>
Date:   Wed Sep 26 15:32:59 2012 -0400

    net: spread fd over several pollservers.
    
    Lighten contention without preventing further improvements on pollservers.
    Connections are spread over Min(GOMAXPROCS, NumCPU, 8) pollserver instances.
    
    Median of 10 runs, 4 cores @ 3.4GHz, amd/linux-3.2:
    
    BenchmarkTCPOneShot                171917 ns/op   175194 ns/op      1.91%
    BenchmarkTCPOneShot-2              101413 ns/op   109462 ns/op      7.94%
    BenchmarkTCPOneShot-4               91796 ns/op    35712 ns/op    -61.10%
    BenchmarkTCPOneShot-6               90938 ns/op    30607 ns/op    -66.34%
    BenchmarkTCPOneShot-8               90374 ns/op    29150 ns/op    -67.75%
    BenchmarkTCPOneShot-16             101089 ns/op   111526 ns/op     10.32%
    
    BenchmarkTCPOneShotTimeout         174986 ns/op   178606 ns/op      2.07%
    BenchmarkTCPOneShotTimeout-2       101585 ns/op   110678 ns/op      8.95%
    BenchmarkTCPOneShotTimeout-4        91547 ns/op    35931 ns/op    -60.75%
    BenchmarkTCPOneShotTimeout-6        91496 ns/op    31019 ns/op    -66.10%
    BenchmarkTCPOneShotTimeout-8        90670 ns/op    29531 ns/op    -67.43%
    BenchmarkTCPOneShotTimeout-16      101013 ns/op   106026 ns/op      4.96%
    
    BenchmarkTCPPersistent              51731 ns/op    53324 ns/op      3.08%
    BenchmarkTCPPersistent-2            32888 ns/op    30678 ns/op     -6.72%
    BenchmarkTCPPersistent-4            25751 ns/op    15595 ns/op    -39.44%
    BenchmarkTCPPersistent-6            26737 ns/op     9805 ns/op    -63.33%
    BenchmarkTCPPersistent-8            26850 ns/op     9730 ns/op    -63.76%
    BenchmarkTCPPersistent-16          104449 ns/op   102838 ns/op     -1.54%
    
    BenchmarkTCPPersistentTimeout       51806 ns/op    53281 ns/op      2.85%
    BenchmarkTCPPersistentTimeout-2     32956 ns/op    30895 ns/op     -6.25%
    BenchmarkTCPPersistentTimeout-4     25994 ns/op    18111 ns/op    -30.33%
    BenchmarkTCPPersistentTimeout-6     26679 ns/op     9846 ns/op    -63.09%
    BenchmarkTCPPersistentTimeout-8     26810 ns/op     9727 ns/op    -63.72%
    BenchmarkTCPPersistentTimeout-16   101652 ns/op   104410 ns/op      2.71%
    
    R=rsc, dvyukov, dave, mikioh.mikioh, bradfitz, remyoudompheng
    CC=golang-dev
    https://golang.org/cl/6496054

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/7014bc64b1be8f85fff75ec13f8597b6a6aed366

元コミット内容

このコミットは、Go言語のネットワークI/O処理におけるボトルネックを解消するためのものです。以前は、すべてのネットワークファイルディスクリプタ (FD) が単一の pollServer インスタンスを共有していました。これにより、特にマルチコアプロセッサを搭載したシステムで多数の同時接続を処理する際に、この単一の pollServer が競合のホットスポットとなり、性能が低下する問題がありました。

コミットの目的は、この単一の pollServer への競合を軽減し、Goの並行処理能力をより効果的に活用することです。

変更の背景

Go言語は、その軽量なゴルーチンと効率的なスケジューラによって、高い並行性を実現するように設計されています。しかし、ネットワークI/Oのようなシステムコールを伴う処理では、OSのI/O多重化メカニズム(Linuxのepoll、FreeBSD/macOSのkqueueなど)を利用します。Goの net パッケージ内部では、これらのOSレベルのI/Oイベントを抽象化し、ゴルーチンに通知する役割を pollServer が担っていました。

従来の設計では、すべてのネットワークFDが単一のグローバルな pollServer インスタンスに登録されていました。これにより、複数のゴルーチンが同時にネットワークI/Oを待機する際、この単一の pollServer の内部ロックやデータ構造へのアクセスで競合が発生し、スケーラビリティが制限されていました。特に、CPUコア数が増えるにつれて、このボトルネックは顕著になり、Goの並行処理の利点が十分に活かされない状況でした。

このコミットは、この競合を緩和し、マルチコア環境でのネットワーク性能を向上させることを目的としています。

前提知識の解説

1. ファイルディスクリプタ (File Descriptor, FD)

Unix系OSにおいて、ファイルやソケットなどのI/Oリソースを識別するために使用される整数値です。ネットワーク通信では、ソケットがFDとして扱われます。

2. ノンブロッキングI/O

通常のI/O操作は、データが利用可能になるまで(または書き込みバッファが空くまで)処理がブロックされます。ノンブロッキングI/Oでは、操作がすぐに戻り、データが利用可能でない場合はエラー(例: EAGAIN または EWOULDBLOCK)を返します。これにより、アプリケーションはI/O操作の完了を待つ間に他の処理を実行できます。

3. I/O多重化 (I/O Multiplexing)

複数のFDに対して、どれか一つでもI/O準備ができたときに通知を受け取るメカニズムです。select, poll, epoll (Linux), kqueue (FreeBSD/macOS) などがあります。これにより、単一のスレッドで多数の同時接続を効率的に処理できます。Goの pollServer は、これらのOS固有のI/O多重化メカニズムを抽象化しています。

4. pollServer (Go言語 net パッケージ内部)

Goの net パッケージ内部で使用されるコンポーネントで、ノンブロッキングI/O操作を管理し、I/Oイベントをゴルーチンに通知する役割を担います。具体的には、ネットワークFDをOSのI/O多重化メカニズム(例: epoll)に登録し、I/O準備ができたFDを監視します。I/O準備ができたFDがあると、対応するゴルーチンを再開させます。

5. 競合 (Contention)

並行プログラミングにおいて、複数のスレッドやゴルーチンが同時に共有リソース(例: ロック、データ構造)にアクセスしようとするときに発生する状況です。競合が高いと、スレッドはリソースの解放を待つ必要があり、プログラムの実行速度が低下します。

6. GOMAXPROCS

Goランタイムが同時に実行できるOSスレッドの最大数を制御する環境変数、または runtime.GOMAXPROCS() 関数です。この値は、GoスケジューラがゴルーチンをOSスレッドにマッピングする方法に影響を与えます。

7. runtime.NumCPU()

システム上の論理CPUの数を返します。

8. sync.Once

Goの sync パッケージが提供する型で、特定の関数がプログラムの実行中に一度だけ実行されることを保証します。これは、リソースの初期化など、一度だけ実行されるべき処理に非常に有用です。

技術的詳細

このコミットの核心は、GoのネットワークI/O処理における pollServer のアーキテクチャを、単一インスタンスから複数インスタンスへと変更した点にあります。

  1. 単一 pollServer の問題点: 以前のGoの net パッケージでは、すべてのネットワークFDが単一のグローバルな pollServer インスタンスに登録されていました。この pollServer は、内部でロックを使用して共有データ構造(I/Oイベントを待機しているFDのリストなど)を保護していました。マルチコア環境で多数のゴルーチンが同時にネットワークI/Oを待機すると、この単一の pollServer のロックに対する競合が激しくなり、性能上のボトルネックとなっていました。

  2. 複数 pollServer の導入: このコミットでは、pollServer のインスタンスを複数作成し、ネットワークFDをこれらのインスタンスに分散させる戦略を採用しました。これにより、I/O待機処理が複数の pollServer に分散され、単一のロックに対する競合が大幅に軽減されます。

  3. 分散戦略:

    • インスタンス数の決定: 作成される pollServer のインスタンス数は、Min(GOMAXPROCS, NumCPU, 8) で決定されます。
      • GOMAXPROCS: Goスケジューラが利用できるOSスレッド数を超えて pollServer を増やしても、Goの並行実行能力が向上するわけではないため、この値が考慮されます。
      • NumCPU: 物理的なCPUコア数も考慮されます。
      • 8: コミットメッセージに「No improvement then.」とあるように、経験的に8を超えるインスタンス数ではそれ以上の性能向上が見られないため、上限が設けられています。これは、過剰なインスタンス化が管理オーバーヘッドを増大させる可能性があるためです。
    • FDの割り当て: 各ネットワークFDは、そのFDの整数値と pollServer のインスタンス数 (pollN) を用いたモジュロ演算 (fd % pollN) によって、特定の pollServer インスタンスに割り当てられます。このシンプルなハッシュ戦略により、FDが複数の pollServer に均等に分散されることが期待されます。
  4. 遅延初期化と sync.Once: 各 pollServer インスタンスは、必要になったときに初めて初期化されます。これは sync.Once を使用して実現されており、各 pollServer が一度だけ安全に初期化されることを保証します。これにより、不要な pollServer の起動を避け、リソースの効率的な利用が図られています。

  5. 性能ベンチマーク: コミットメッセージに含まれるベンチマーク結果は、この変更が特にマルチコア環境で顕著な性能向上をもたらしたことを明確に示しています。

    • BenchmarkTCPOneShot-4, -6, -8 など、CPUコア数が増えるにつれて ns/op (操作あたりのナノ秒) が大幅に減少しており、これは処理速度が向上したことを意味します。例えば、BenchmarkTCPOneShot-8 では、操作あたりの時間が約67.75%削減されています。
    • これは、複数の pollServer が並行してI/Oイベントを処理できるようになった結果、全体のスループットが向上したことを裏付けています。

この変更は、Goのネットワークスタックの基盤を強化し、高負荷なネットワークアプリケーションにおけるスケーラビリティと応答性を大幅に改善しました。

コアとなるコードの変更箇所

主な変更は src/pkg/net/fd_unix.go に集中しています。

  1. netFD 構造体への pollServer フィールドの追加:

    type netFD struct {
        // ... 既存のフィールド ...
        // wait server
        pollServer *pollServer
    }
    

    これにより、各ネットワークFDが自身が属する pollServer インスタンスへの参照を持つようになります。

  2. グローバルな pollserver 変数の削除と複数インスタンスの導入:

    // 削除: var pollserver *pollServer
    // 削除: var onceStartServer sync.Once
    
    var pollMaxN int
    var pollservers []*pollServer
    var startServersOnce []func()
    

    単一のグローバル変数 pollserver が削除され、pollservers という pollServer のスライスが導入されました。

  3. init() 関数の変更:

    func init() {
        pollMaxN = runtime.NumCPU()
        if pollMaxN > 8 {
            pollMaxN = 8 // No improvement then.
        }
        pollservers = make([]*pollServer, pollMaxN)
        startServersOnce = make([]func(), pollMaxN)
        for i := 0; i < pollMaxN; i++ {
            k := i
            once := new(sync.Once)
            startServersOnce[i] = func() { once.Do(func() { startServer(k) }) }
        }
    }
    

    pollMaxN の計算、pollservers スライスの初期化、そして各 pollServer の遅延初期化を保証するための sync.Once を含む startServersOnce スライスの設定が行われます。

  4. startServer 関数の変更:

    // 変更前: func startServer() { ... pollserver = p ... }
    func startServer(k int) {
        p, err := newPollServer()
        if err != nil {
            panic(err) // 以前は print だった
        }
        pollservers[k] = p
    }
    

    k を引数に取り、指定されたインデックスの pollServer を初期化して pollservers スライスに格納します。エラー処理も panic に変更されています。

  5. server 関数の新規追加:

    func server(fd int) *pollServer {
        pollN := runtime.GOMAXPROCS(0)
        if pollN > pollMaxN {
            pollN = pollMaxN
        }
        k := fd % pollN
        startServersOnce[k]()
        return pollservers[k]
    }
    

    この関数が、与えられたFDに基づいてどの pollServer を使用するかを決定し、その pollServer を返します。

  6. newFD 関数での pollServer の割り当て:

    func newFD(fd, family, sotype int, net string) (*netFD, error) {
        // 変更前: onceStartServer.Do(startServer)
        // ...
        netfd.pollServer = server(fd) // 新しい割り当て
        return netfd, nil
    }
    

    newFD が作成される際に、server(fd) 関数を呼び出して適切な pollServernetfd.pollServer に設定します。

  7. netFD メソッドでの pollServer の利用変更: connect, Close, Read, Write など、pollServer を利用するすべての netFD メソッドで、グローバルな pollserver ではなく、fd.pollServer を介してアクセスするように変更されています。 例:

    // 変更前: if err = pollserver.WaitWrite(fd); err != nil {
    // 変更後: if err = fd.pollServer.WaitWrite(fd); err != nil {
    

    同様の変更が src/pkg/net/sendfile_freebsd.gosrc/pkg/net/sendfile_linux.go にも適用されています。

コアとなるコードの解説

このコミットの核となるロジックは、init() 関数と新しく追加された server(fd int) *pollServer 関数にあります。

init() 関数

func init() {
    // 1. pollServerの最大数を決定
    // runtime.NumCPU() はシステム上の論理CPU数を返す。
    // GOMAXPROCSは考慮されないが、後続のserver関数で利用される。
    pollMaxN = runtime.NumCPU()
    // 経験的に8を超えるインスタンスでは性能向上が見られないため、上限を8に設定。
    if pollMaxN > 8 {
        pollMaxN = 8 // No improvement then.
    }

    // 2. pollServerインスタンスを格納するスライスを初期化
    // pollMaxNの数だけnilポインタで初期化される。
    pollservers = make([]*pollServer, pollMaxN)

    // 3. 各pollServerの遅延初期化を制御するためのsync.Once関数スライスを初期化
    startServersOnce = make([]func(), pollMaxN)
    for i := 0; i < pollMaxN; i++ {
        k := i // クロージャ内でiの現在の値をキャプチャするために必要
        once := new(sync.Once) // 各pollServerごとに新しいsync.Onceインスタンスを作成
        // startServersOnce[k] に、k番目のpollServerを一度だけ起動する関数を割り当てる
        startServersOnce[i] = func() { once.Do(func() { startServer(k) }) }
    }
}

この init() 関数は、プログラム起動時に一度だけ実行され、pollServer のインスタンスを管理するためのグローバルなデータ構造を準備します。pollMaxN を決定し、pollservers スライスと、各 pollServer を一度だけ起動するための sync.Once を含む startServersOnce スライスを初期化します。

server(fd int) *pollServer 関数

func server(fd int) *pollServer {
    // 1. 現在のGOMAXPROCSの値を取得
    // これは、Goスケジューラが利用できるOSスレッドの数を反映する。
    pollN := runtime.GOMAXPROCS(0)
    // 2. pollServerの数をpollMaxN(CPU数または8)で制限
    // GOMAXPROCSがpollMaxNより大きい場合、pollMaxNに制限する。
    // これは、Goスケジューラのスレッド数と物理CPU数の両方を考慮した上で、
    // 経験的な上限値を超えないようにするため。
    if pollN > pollMaxN {
        pollN = pollMaxN
    }

    // 3. ファイルディスクリプタ(fd)に基づいて、使用するpollServerのインデックスを決定
    // シンプルなモジュロ演算により、FDを複数のpollServerに分散させる。
    k := fd % pollN

    // 4. 選択されたpollServerがまだ起動していなければ起動する
    // startServersOnce[k]() は、k番目のpollServerを一度だけ起動することを保証する。
    startServersOnce[k]()

    // 5. 割り当てられたpollServerインスタンスを返す
    return pollservers[k]
}

この server 関数は、新しいネットワークFDが作成されるたびに呼び出されます。FDの整数値に基づいて、どの pollServer インスタンスを使用するかを決定します。fd % pollN というシンプルなハッシュ関数を使用することで、FDが複数の pollServer に均等に分散されることを目指します。また、startServersOnce[k]() を呼び出すことで、対応する pollServer がまだ起動していなければ、この時点で一度だけ起動されることが保証されます。

これにより、各ネットワークFDは専用の pollServer インスタンスと関連付けられ、I/O操作の待機時にその pollServer を利用するようになります。結果として、単一の pollServer に集中していた競合が複数の pollServer に分散され、マルチコア環境でのネットワークI/O性能が大幅に向上します。

関連リンク

  • Go言語の net パッケージ: https://pkg.go.dev/net
  • Go言語の runtime パッケージ: https://pkg.go.dev/runtime
  • Go言語の sync パッケージ: https://pkg.go.dev/sync
  • GoのI/O多重化に関する議論(一般的な情報源): Goの内部実装に関する公式ドキュメントは少ないですが、Goのネットワークプログラミングや並行処理に関する記事で、epollkqueue といったOSのI/O多重化メカニズムがGoのランタイムでどのように利用されているかについて言及されていることがあります。

参考にした情報源リンク

  • コミットメッセージに記載されているGoのコードレビューシステム (Gerrit) のリンク: https://golang.org/cl/6496054 (現在はGitHubにリダイレクトされる可能性があります)
  • Goのソースコード: src/pkg/net/fd_unix.go (このコミットが適用された時点のバージョン)
  • Goのベンチマークに関する一般的な情報: Goの公式ドキュメントやブログ記事で、ベンチマークの読み方やGoの性能特性について解説されていることがあります。