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

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

このコミットは、Go言語の公式ドキュメントである effective_go.html 内のセマフォの例を修正するものです。元の例がGoのメモリモデルに正しく準拠していなかったため、その動作が意図したものではなかった問題を解決しています。具体的には、セマフォの取得と解放のロジックを、チャネルの送受信の順序とメモリモデルの保証に合致するように変更し、さらに Serve 関数の実装も、リソースの無制限な消費を防ぐために改善されています。

コミット

commit 9dfcfb938552cfc601562db1f6e6a97534d4e563
Author: Rob Pike <r@golang.org>
Date:   Tue Mar 12 10:53:01 2013 -0700

    effective_go.html: fix semaphore example
    It didn't work properly according to the Go memory model.
    Fixes #5023.
    
    R=golang-dev, dvyukov, adg
    CC=golang-dev
    https://golang.org/cl/7698045

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

https://github.com/golang/go/commit/9dfcfb938552cfc601562db1f6e6a97534d4e563

元コミット内容

元の effective_go.html のセマフォの例では、handle 関数内でセマフォの取得をチャネルへの送信 (sem <- 1) で行い、解放をチャネルからの受信 (<-sem) で行っていました。また、Serve 関数は、リクエストごとに新しいゴルーチンを無制限に生成していました。

<pre>
var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
    sem <- 1    // Wait for active queue to drain.
    process(r)  // May take a long time.
    <-sem       // Done; enable next request to run.
}

func Serve(queue chan *Request) {
    for req := range queue {
        go handle(req)
    }
}
</pre>

変更の背景

この変更の背景には、Goのメモリモデルの理解と、それに基づいた並行処理の安全性の確保があります。元のセマフォの例は、Goのメモリモデルが保証する「happens before」関係に違反していました。具体的には、セマフォとしてバッファ付きチャネルを使用する場合、リソースの取得(セマフォのP操作に相当)はチャネルからの受信によって行われるべきであり、リソースの解放(V操作に相当)はチャネルへの送信によって行われるべきです。これは、Goのメモリモデルにおいて、チャネルへの送信がチャネルからの受信よりも「happens before」関係を確立するためです。

元のコードでは、sem <- 1 がセマフォの取得として機能していましたが、これはメモリモデルの観点から見ると、リソースが実際に利用可能になったことを保証するものではありませんでした。チャネルへの送信は、受信側が準備できていなくても非同期に行われる可能性があるため、セマフォの取得として使用すると、意図しない競合状態やリソースの過剰な消費を引き起こす可能性がありました。

また、Serve 関数がリクエストごとに無制限にゴルーチンを生成する問題も指摘されました。MaxOutstanding で同時実行数を制限しているにもかかわらず、ゴルーチン自体の生成数には制限がなかったため、リクエストが殺到するとシステムリソースを枯渇させる可能性がありました。

これらの問題に対処するため、セマフォのロジックとゴルーチンの生成戦略が修正されました。

前提知識の解説

Goのメモリモデル (Go Memory Model)

Goのメモリモデルは、複数のゴルーチンが共有データにアクセスする際の動作を定義します。これは、並行プログラムにおけるデータ競合を防ぎ、予測可能な動作を保証するために非常に重要です。Goのメモリモデルの核心は「happens before」関係です。

  • happens before: あるイベントAがイベントBよりも「happens before」である場合、Aのメモリ操作はBのメモリ操作よりも前に完了することが保証されます。これは、時間的な順序だけでなく、因果関係も示します。
  • 同期イベント: Goのメモリモデルでは、特定の同期イベント(チャネル操作、sync パッケージのプリミティブなど)が「happens before」関係を確立します。
    • チャネル操作:
      • チャネルへの送信は、そのチャネルからの受信よりも「happens before」です。つまり、送信された値は受信側で必ず可視になります。
      • バッファなしチャネルの送信は、対応する受信が完了するまでブロックされます。
      • バッファ付きチャネルの送信は、バッファに空きがある限りブロックされません。
    • Mutex: sync.MutexLock() は、対応する Unlock() よりも「happens before」です。

ゴルーチン (Goroutines)

ゴルーチンはGoの軽量な並行実行単位です。OSのスレッドよりもはるかに軽量で、数千、数万のゴルーチンを同時に実行できます。go キーワードを使って関数呼び出しの前に置くことで、新しいゴルーチンを起動します。

チャネル (Channels)

チャネルは、ゴルーチン間で値を送受信するための通信メカニズムです。チャネルは、Goの並行処理における主要な同期プリミティブでもあります。

  • バッファなしチャネル: 送信側は受信側が準備できるまでブロックされ、受信側は送信側が準備できるまでブロックされます。これにより、送信と受信の間で同期が取られます。
  • バッファ付きチャネル: 指定された数の値をバッファに格納できます。バッファが満杯になるまで送信はブロックされず、バッファが空になるまで受信はブロックされません。

セマフォ (Semaphore)

セマフォは、並行プログラミングにおいて共有リソースへのアクセスを制御するための同期メカニズムです。通常、カウンタを持ち、リソースが利用可能かどうかを示します。

  • P操作 (Wait/Acquire): セマフォのカウンタをデクリメントし、カウンタが負になる場合はブロックします。リソースの取得を試みます。
  • V操作 (Signal/Release): セマフォのカウンタをインクリメントし、ブロックされているプロセスがあれば解放します。リソースの解放を通知します。

Goでは、バッファ付きチャネルをセマフォとして利用する一般的なイディオムがあります。チャネルの容量がセマフォのカウンタの最大値となり、チャネルへの送信がV操作、チャネルからの受信がP操作に対応します。

技術的詳細

このコミットの技術的な核心は、Goのメモリモデルにおけるチャネルの「happens before」保証を正しく利用してセマフォを実装することです。

元のコードでは、セマフォの取得に sem <- 1 (チャネルへの送信) を使用していました。Goのメモリモデルでは、チャネルへの送信は、そのチャネルからの受信よりも「happens before」です。しかし、セマフォのP操作(リソースの取得)は、リソースが実際に利用可能であることを確認し、それから処理を進めるべきです。チャネルへの送信は、バッファに空きがあれば受信側が準備できていなくても完了してしまいます。このため、sem <- 1 をセマフォの取得として使うと、複数のゴルーチンが同時に sem <- 1 を実行し、バッファが満杯になるまでブロックされないため、MaxOutstanding の制限を超えて process 関数が実行されてしまう可能性がありました。これは、セマフォが意図する「同時実行数の制限」という目的を達成できません。

正しいセマフォの実装では、リソースの取得(P操作)はチャネルからの受信 (<-sem) で行われるべきです。チャネルからの受信は、チャネルに値が送信されるまでブロックされます。これにより、リソースが実際に利用可能になったことを保証できます。そして、リソースの解放(V操作)はチャネルへの送信 (sem <- 1) で行われます。

修正後の handle 関数では、まず <-sem でセマフォを取得し、process(r) を実行した後、sem <- 1 でセマフォを解放しています。これにより、process(r) の実行は、セマフォが利用可能になった後にのみ行われることが保証されます。

また、init 関数で MaxOutstanding の数だけチャネルに値を送信して、セマフォを初期化しています。これにより、チャネルのバッファが初期状態で満たされ、MaxOutstanding 個のリソースが利用可能であることを示します。

さらに、Serve 関数の問題も解決されました。元の Serve 関数は、リクエストが来るたびに新しいゴルーチンを無制限に起動していました。これは、MaxOutstanding の制限が process 関数の同時実行数にのみ適用され、ゴルーチン自体の数には適用されないため、リクエストが殺到するとシステムリソース(メモリなど)を枯渇させる可能性がありました。

修正後の Serve 関数では、go func() { ... } の前に <-sem を追加することで、新しいゴルーチンを起動する前にセマフォを取得するように変更されました。これにより、同時に起動される handle ゴルーチンの数が MaxOutstanding に制限され、リソースの無制限な消費が防がれます。

この変更は、Goの並行処理における「同期」と「通信」の原則をより深く理解し、メモリモデルの保証を適切に利用することの重要性を示しています。

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

doc/effective_go.html ファイルの以下の部分が変更されました。

--- a/doc/effective_go.html
+++ b/doc/effective_go.html
@@ -2893,18 +2893,26 @@ means waiting until some receiver has retrieved a value.\n <p>\n A buffered channel can be used like a semaphore, for instance to\n limit throughput.  In this example, incoming requests are passed\n-to <code>handle</code>, which sends a value into the channel, processes\n-the request, and then receives a value from the channel.\n+to <code>handle</code>, which receives a value from the channel, processes\n+the request, and then sends a value back to the channel\n+to ready the "semaphore" for the next consumer.\n The capacity of the channel buffer limits the number of\n-simultaneous calls to <code>process</code>.\n+simultaneous calls to <code>process</code>,\n+so during initialization we prime the channel by filling it to capacity.\n </p>\n <pre>\n var sem = make(chan int, MaxOutstanding)\n \n func handle(r *Request) {\n-    sem <- 1    // Wait for active queue to drain.\n-    process(r)  // May take a long time.\n-    <-sem       // Done; enable next request to run.\n+    <-sem          // Wait for active queue to drain.\n+    process(r)     // May take a long time.\n+    sem <- 1       // Done; enable next request to run.\n+}\n+\n+func init() {\n+    for i := 0; i < MaxOutstanding; i++ {\n+        sem <- 1\n+    }\n }\n \n func Serve(queue chan *Request) {\n@@ -2914,8 +2922,37 @@ func Serve(queue chan *Request) {\n     }\n }\n </pre>\n+\n+<p>\n+Because data synchronization occurs on a receive from a channel\n+(that is, the send "happens before" the receive; see\n+<a href="/ref/mem">The Go Memory Model</a>),\n+acquisition of the semaphore must be on a channel receive, not a send.\n+</p>\n+\n+<p>\n+This design has a problem, though: <code>Serve</code>\n+creates a new goroutine for\n+every incoming request, even though only <code>MaxOutstanding</code>\n+of them can run at any moment.\n+As a result, the program can consume unlimited resources if the requests come in too fast.\n+We can address that deficiency by changing <code>Serve</code> to\n+gate the creation of the goroutines.\n+</p>\n+\n+<pre>\n+func Serve(queue chan *Request) {\n+    for req := range queue {\n+        <-sem\n+        go func() {\n+            process(req)\n+            sem <- 1\n+        }\n+    }\n+}</pre>\n+\n <p>\n-Here's the same idea implemented by starting a fixed\n+Another solution that manages resources well is to start a fixed\n number of <code>handle</code> goroutines all reading from the request\n channel.\n The number of goroutines limits the number of simultaneous\n@@ -2924,6 +2961,7 @@ This <code>Serve</code> function also accepts a channel on which\n it will be told to exit; after launching the goroutines it blocks\n receiving from that channel.\n </p>\n+\n <pre>\n func handle(queue chan *Request) {\n     for r := range queue {\

コアとなるコードの解説

handle 関数の変更

  • 変更前:

    func handle(r *Request) {
        sem <- 1    // Wait for active queue to drain.
        process(r)  // May take a long time.
        <-sem       // Done; enable next request to run.
    }
    

    ここでは、sem <- 1 でセマフォを取得しようとしていました。これはチャネルへの送信であり、バッファに空きがあればすぐに完了してしまいます。そのため、process(r)MaxOutstanding の制限を超えて並行実行される可能性がありました。

  • 変更後:

    func handle(r *Request) {
        <-sem          // Wait for active queue to drain.
        process(r)     // May take a long time.
        sem <- 1       // Done; enable next request to run.
    }
    

    セマフォの取得が <-sem (チャネルからの受信) に変更されました。これにより、チャネルに値が送信されるまで handle ゴルーチンはブロックされ、process(r) の実行が MaxOutstanding の制限内で同期されることが保証されます。セマフォの解放は sem <- 1 (チャネルへの送信) で行われます。

init 関数の追加

func init() {
    for i := 0; i < MaxOutstanding; i++ {
        sem <- 1
    }
}

この init 関数は、プログラムの起動時に一度だけ実行されます。MaxOutstanding の数だけ sem チャネルに値を送信することで、セマフォを初期化しています。これにより、チャネルのバッファが初期状態で満たされ、MaxOutstanding 個のリソースが利用可能であることを示します。handle 関数が最初に <-sem を実行したときに、これらの初期値のいずれかを受信して処理を開始できるようになります。

Serve 関数の変更

  • 変更前:

    func Serve(queue chan *Request) {
        for req := range queue {
            go handle(req)
        }
    }
    

    この実装では、queue からリクエストが来るたびに無制限に新しいゴルーチン go handle(req) を起動していました。MaxOutstandingprocess 関数の同時実行数を制限するものの、ゴルーチン自体の生成数には制限がありませんでした。

  • 変更後:

    func Serve(queue chan *Request) {
        for req := range queue {
            <-sem
            go func() {
                process(req)
                sem <- 1
            }()
        }
    }
    

    新しい Serve 関数では、go func() { ... } の前に <-sem が追加されました。これにより、新しいゴルーチンを起動する前にセマフォを取得するようになりました。セマフォが利用可能になるまでゴルーチンの起動がブロックされるため、同時に実行される process 関数のゴルーチン数が MaxOutstanding に制限されます。また、process(req) の実行後に sem <- 1 でセマフォを解放するロジックが、新しく起動されたゴルーチン内に移動しました。これにより、リソースの無制限な消費を防ぎ、システム全体の安定性を向上させます。

これらの変更により、Goのメモリモデルに準拠した、より堅牢で効率的なセマフォの実装が提供され、並行処理の例が改善されました。

関連リンク

参考にした情報源リンク