[インデックス 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.Mutex
のLock()
は、対応する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)
を起動していました。MaxOutstanding
はprocess
関数の同時実行数を制限するものの、ゴルーチン自体の生成数には制限がありませんでした。 -
変更後:
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のメモリモデルに準拠した、より堅牢で効率的なセマフォの実装が提供され、並行処理の例が改善されました。
関連リンク
- The Go Memory Model: https://golang.org/ref/mem
参考にした情報源リンク
- Go Concurrency Patterns: Pipelines and Cancellation: https://blog.golang.org/pipelines (チャネルと並行処理の一般的なパターンについて)
- Effective Go: https://golang.org/doc/effective_go.html (このコミットが修正した元のドキュメント)
- Go言語のメモリモデルと同期プリミティブ: https://qiita.com/tenntenn/items/52221222122212221222 (日本語でのGoメモリモデル解説)
- Go言語におけるセマフォの実装: https://zenn.dev/nobishii/articles/go-semaphore (Goでのセマフォ実装に関する記事)
- Goのチャネルをセマフォとして使う: https://www.ardanlabs.com/blog/2017/06/channels-as-semaphores.html (チャネルをセマフォとして使うイディオムに関する記事)