sync.Onceの内部実装から学ぶ実装アイデア
この記事は、2021年Go Advent Calendar9日目の記事です。
概要
Goで並行処理で利用されるsync
パッケージには、処理の一回のみの実行を担保するOnce
型(構造体)が提供されていて、主に並行処理の中で重複処理や競合を避けるために使えます。
このOnce
は、一見内部では複雑なことをしていそうですが、その内部実装は短く非常にシンプルになっています。
しかし、その短いコードの中では、処理の最適化、並行処理の安全性、パッケージの使いやすさなどのために、汎用的な実装上の工夫がいくつかされています。
そこで、この記事では、Once
の内部実装を追いながら、それらを見ていこうと思います。
sync.Once
とは
最初に、そもそもsync.Once
がなにかを説明します。すでにご存知の方はこのパートは飛ばしてください。
sync.Once
型は、関数が一回のみ実行されることを担保する機能を提供しています。
実際に使い方を見てみましょう。
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
f := func() {
fmt.Println("Done")
}
once.Do(f)
once.Do(f)
once.Do(f)
}
sync.Once
の変数を初期化して、Do
というメソッドに関数を渡して実行してます。
そして、複数回このDo
を呼んでいますが、実行してみると、下記のように一度しか対象の関数が実行されません。
Done
このように、関数を一度だけ実行したい場合に有用な機能です。
そして、もちろん、並行処理でも一度しか実行されません。
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
f := func() {
fmt.Println("Done")
}
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
once.Do(f)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
[The Go Playground](https://go.dev/play/p/b6QDJnP0kpE
Done
sync.Once
の内部実装
それでは、sync.Once
の内部実装を見ていきましょう。
全体像
まず、コード全体を見てみましょう。コメントを取り除くと、25行程度の非常に短い実装になっています。
しかも、Goの組み込みの機能と標準パッケージのpublicな機能しか使っておらず、言語内部専用の機能は利用していません。
package sync
import (
"sync/atomic"
)
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
それでは、順番に見ていきましょう。
sync.Once
の構造
sync.Once
は下記のような構造体となっています。
// Once is an object that will perform exactly one action.
//
// A Once must not be copied after first use.
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}
フィールドが2つだけのシンプルな構造になっています。
-
done
フィールドが実効済みかを管理するフラグの役割をしています。
uint32
になっているのは、sync/atomic
パッケージの機能を利用して、排他操作をするためです。
(sync/atomic
についてはこちらをご覧ください。) -
m
排他制御のためにsync.Mutex
を持っています。(sync.Mutex
についてはこちらをご覧ください。)
このOnce
の構造体で、注目すべき点が2つほどあるので、さらに見てみましょう。
ゼロ値で使える構造体
構造体をゼロ値でそのまま使えるという点があります。
下記のように、初期化用の関数や値の設定等が不要で、ただ変数に初期化するだけでそのまま使えるので、使いやすい作りになっています。
var once sync.Once
もちろん、このような構造体にできるケースは限られますが、できるときにはやった方が良いデザインだと思います。
Hot Path
さらに、コメントも一部見てみましょう。
done
フィールドについて、下記のコメントがあります。
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
このコメントでは、このdone
がstructの最初のフィールドである理由を説明しています。
この中で、Hot Path
という単語で出てきますが、これは頻繁に実行されるパスという意味です。
つまり、done
が一番頻繁に参照されるフィールドなので、structの最初のフィールドになっているということです。
なぜ、頻繁に参照されるフィールドが最初に来るべきかというと、structの最初のフィールドのメモリアドレスはstruct自体のメモリアドレスと同じなので、フィールドにアクセスするための処理が少なくて済むからです。
逆に、二番目以降のフィールドにアクセスする場合は、そのメモリアドレスを計算して取得するための処理が追加で必要になります。コメントでoffset
という言葉が出てきますが、offset
はstructなどのデータ構造の先頭から特定の要素の位置までの距離のことなので、ここではもしdone
が二番目以降のフィールドだったらそれを計算する必要があるということを言っています。
このように、CPUの処理を少しでも最適化するために、構造体の最初のフィールドを一番参照されるdone
にしているのでした。
以上がOnce
の構造でした。
Do
の実装
次に、Do
メソッドの内部実装も見ていきましょう。
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the atomic.StoreUint32 must be delayed until after f returns.
if atomic.LoadUint32(&o.done) == 0 {
// Outlined slow-path to allow inlining of the fast-path.
o.doSlow(f)
}
}
Do
メソッドは、Once
の中のdone
フィールドをチェックして、まだ未完了の状態であればdoSlow
という別の関数を呼び出しています。
すでに関数が実行済みである場合は、ここでチェックだけしてなにもされずに終わることになります。
done
のチェックは、sync/atomic
パッケージのLoadUint32
を使って、他の処理の割り込みを防ぎながら実行しています。
doSlow
も見てみましょう。
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
このメソッドで実際に関数が実行されるわけですが、最初にsync.Mutex
でロックをしています。
その上で、再度実行済みじゃないことを確認した上で、対象の関数f()
を実行しています。
そして、f()
の実行が終わった後に、done
フィールドを更新しています。
ここで、Mutexロックの解除とdone
の更新は、defer
で実行しています。
そのため、f
の中でpanicが発生しても、実行済みとなります。
また、defer
はLIFO(last-in-first-out)なので、後にセットされたdone
の更新処理が、Mutexロックの解除より先に実行されます。そのおかげで、f
が二回実行されることが絶対起きないようにしています。
後続処理の安全性確保: 関数の実行が終わるまですべての呼び出しがブロックされる
done
の更新とMutexのロック解除は、関数f()
の実行が終わった後に行われています。つまり、その間、他のDo
を呼び出している処理はブロックされてます。
そこについては、こちらのコメントで説明されています。
// Note: Here is an incorrect implementation of Do:
//
// if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the atomic.StoreUint32 must be delayed until after f returns.
そもそも、とにかく二回関数が呼び出されてないことだけを担保するなら、コメントにあるように、下記のコードだけで済みます。
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
f()
}
ただ、意図的にこの実装にしないで、対象の関数f()
の実行が完了するまで、すべてのDo
の実行がブロックされるようにしています。
これは、すべての後続処理が安全に実行されるようにするためのようです。特に、Do
もf()
も返り値を持たないので、後続処理はf()
が実行されたかどうかをDo
の返り値などから直接確かめる方法がありません。そこで、他のプロセスでも同時にDo
が呼ばれていたとしても、f()
が完了するまでブロックされることで、安全かつ簡潔に後続処理が書けます。
この仕様は、例えば、初期化処理にOnce
を利用した際などに有用となります。
Fast Path / Slow Path
Do
が呼び出しているメソッドは、doSlow
という名前でした。
なぜslow
なのかを見てみましょう。
ここで、Fast Path
という概念を紹介します。fast path
というのは、名前の通り、通常よりも速い(処理の少ない)ショートカットのパスということになります。
そして、そうじゃない遅いパスがSlow Path
となります。
このDo
にはこの2つのパスが存在しています。
まず、上記の通り、「f()
の実行が完了するまで、すべてのDo
が完了しない」という仕様がありました。そして、そのためにdoSlow
メソッドの中では、都度Mutexロックをかけてdone
の値をチェックする(そして最後にロックを解除する)という処理をしていました。これは、f()
がすでに実行済みの状態でDo
が呼ばれたときのことを考えると、やや冗長ではあります。
そこで、Do
は、最初にatomic.LoadUint32(&o.done)
でその時点でもう実行済みかというのを確認しています。その上で実行済みじゃなかったら、doSlow
メソッドが呼ばれています。
つまり、ここでは、
-
f()
がすでに実行済みの場合は、すぐにreturnする ->Fast Path
-
f()
が未実行(実行中含む)の場合は、doSlow
でブロック(&実行)する ->Slow Path
というフローになっています。
以上がDo
の中身でした。
まとめ
これで、sync.Once
の内部実装を全部確認しました。想定よりも短く読みやすいコードだったと思います。
しかし、その短いコードは、パッケージとしての使いやすさ、並行処理の安全な実装、処理の最適化などのために、いくつかの工夫が見受けられるのでした。
Discussion