sync と channel の選び方
C 言語を書くとき、一般的に私たちは共有メモリを使って通信します。複数のスレッドが同じデータを操作する場合、データの安全性を確保し、スレッド間の同期をコントロールするために、ミューテックスロックを使い、ロックとアンロックによって処理を行います。
しかし、Go 言語では「通信によってメモリを共有する」ことが推奨されており、channel を使ってクリティカルセクションの同期メカニズムを実現します。
ただし、Go 言語の channel は比較的高級なプリミティブに属しているため、性能面では sync パッケージのロックメカニズムに比べて劣ります。興味のある方は、簡単なベンチマークテストを書いて効果を確かめてみてください。コメント欄で議論しても良いでしょう。
また、sync パッケージを使って同期をコントロールする場合、構造体オブジェクトの所有権を失うことなく、複数のゴルーチン間でクリティカルセクションのリソースへ安全にアクセスできます。したがって、要件がこのようなケースに合致する場合は、sync パッケージを使った同期のほうが合理的かつ効率的です。
なぜ sync パッケージで同期をコントロールするのか?
- 構造体のコントロール権を失わず、かつ複数ゴルーチンが安全にクリティカルセクションのリソースへ同期アクセスしたい場合
- パフォーマンス要件がより高い場合
sync の Mutex と RWMutex
sync パッケージのソースコードを見ると、以下のような構造体があります:
- Mutex
- RWMutex
- Once
- Cond
- Pool
- atomic パッケージのアトミック操作
この中で最もよく使われるのが Mutex です。特に channel の使い方に慣れていない最初の頃は、Mutex がとても使いやすく感じるでしょう。次いで RWMutex の使用頻度はやや低めです。
皆さんは Mutex と RWMutex のパフォーマンスについて意識したことがありますか?ほとんどの人はデフォルトでミューテックスロックを使っているでしょう。ここで、二つの性能を比較するデモを書いてみます。
var (
mu sync.Mutex
murw sync.RWMutex
tt1 = 1
tt2 = 2
tt3 = 3
)
// Mutexでデータ読み取りを制御
func BenchmarkReadMutex(b *testing.B) {
b.RunParallel(func(pp *testing.PB) {
for pp.Next() {
mu.Lock()
_ = tt1
mu.Unlock()
}
})
}
// RWMutexでデータ読み取りを制御
func BenchmarkReadRWMutex(b *testing.B) {
b.RunParallel(func(pp *testing.PB) {
for pp.Next() {
murw.RLock()
_ = tt2
murw.RUnlock()
}
})
}
// RWMutexでデータ読み書きを制御
func BenchmarkWriteRWMutex(b *testing.B) {
b.RunParallel(func(pp *testing.PB) {
for pp.Next() {
murw.Lock()
tt3++
murw.Unlock()
}
})
}
3 つのシンプルなベンチマークテストを書きました。
- ミューテックスロックでデータを読み取る
- 読み書きロックの読みロックでデータを読み取る
- 読み書きロックでデータを読み書きする
$ go test -bench . bbb_test.go --cpu 2
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz
BenchmarkReadMutex-2 39638757 30.45 ns/op
BenchmarkReadRWMutex-2 43082371 26.97 ns/op
BenchmarkWriteRWMutex-2 16383997 71.35 ns/op
$ go test -bench . bbb_test.go --cpu 4
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz
BenchmarkReadMutex-4 17066666 73.47 ns/op
BenchmarkReadRWMutex-4 43885633 30.33 ns/op
BenchmarkWriteRWMutex-4 10593098 110.3 ns/op
$ go test -bench . bbb_test.go --cpu 8
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz
BenchmarkReadMutex-8 8969340 129.0 ns/op
BenchmarkReadRWMutex-8 36451077 33.46 ns/op
BenchmarkWriteRWMutex-8 7728303 158.5 ns/op
$ go test -bench . bbb_test.go --cpu 16
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz
BenchmarkReadMutex-16 8533333 132.6 ns/op
BenchmarkReadRWMutex-16 39638757 29.98 ns/op
BenchmarkWriteRWMutex-16 6751646 173.9 ns/op
$ go test -bench . bbb_test.go --cpu 128
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz
BenchmarkReadMutex-128 10155368 116.0 ns/op
BenchmarkReadRWMutex-128 35108558 33.27 ns/op
BenchmarkWriteRWMutex-128 6334021 195.3 ns/op
これらの結果から分かるように、並列度が小さい場合はミューテックスロックと読み書きロック(読ロック)のパフォーマンスはほぼ同じです。しかし、並列度が大きくなると、読ロックのパフォーマンスはほとんど変わりませんが、ミューテックスロックおよび書き込み時のロックは並列度の増加とともに性能が低下します。
したがって、読み書きロックは「読取りが多く書き込みが少ない」シーンに適しています。高並列でデータを読む場合、複数のゴルーチンが同時に読ロックを取得できるため、ロック競合と待機時間を減らすことができます。
一方、ミューテックスロックでは、複数のゴルーチンが並列で動作していても、同時にロックを取得できるのは 1 つのゴルーチンだけなので、他のゴルーチンはブロックされて待たされ、パフォーマンスに影響を与えます。
例として、通常のミューテックスロックを使った場合にどんな問題が発生するか見てみましょう。
sync を使う際の注意点
普段 sync パッケージのロックを使うときに注意すべきなのは、「既に使用された Mutex や RWMutex をコピーしないこと」です。
シンプルなデモを書いてみます:
var mu sync.Mutex
// syncのミューテックスロックや読み書きロックは、一度使った後はオブジェクトをコピーしないこと。
// コピーしたい場合は未使用状態のときに限る。
func main() {
go func(mm sync.Mutex) {
for {
mm.Lock()
time.Sleep(time.Second * 1)
fmt.Println("g2")
mm.Unlock()
}
}(mu)
mu.Lock()
go func(mm sync.Mutex) {
for {
mm.Lock()
time.Sleep(time.Second * 1)
fmt.Println("g3")
mm.Unlock()
}
}(mu)
time.Sleep(time.Second * 1)
fmt.Println("g1")
mu.Unlock()
time.Sleep(time.Second * 20)
}
興味がある方は実際に動かしてみてください。出力結果を見ると、「g3」が一度も表示されません。これは、g3 のゴルーチンがデッドロックに陥ってアンロックできないことを意味しています。
このような現象が起こる理由を説明します。まず、Mutex の内部構造を見てみましょう。
//...
// A Mutex must not be copied after first use.
//...
type Mutex struct {
state int32
sema uint32
}
Mutex の内部には state(ミューテックスの状態)と sema(ミューテックス制御用のセマフォ)があります。初期化時はどちらも 0 ですが、ロックすると state は Locked 状態に変わります。このとき、あるゴルーチンが Mutex をコピーして自分のゴルーチンでロックすると、デッドロックが発生してしまいます。これが特に注意すべきポイントです。
複数のゴルーチンで Mutex を使う場合は、クロージャを使うか、ロックを含む構造体のアドレスまたはポインタを渡すことで、このような予期せぬ結果を防げます。
sync.Once
sync パッケージの他のメンバーについては、皆さんはどのくらい使っていますか?比較的利用頻度が高いのは sync.Once でしょう。sync.Once の使い方や注意点を見てみましょう。
C や C++を書くとき、プログラムのライフサイクル内で唯一のインスタンスが必要な場合はシングルトンパターンを使います。ここで、sync.Once はシングルトンパターンにとても適しています。
sync.Once は、プログラム実行中に指定した関数が必ず一度だけ実行されることを保証します。各パッケージの init 関数よりも柔軟です。
ここで注意が必要なのは、sync.Once で実行する関数が panic を起こした場合でも、「一度実行した」とみなされます。以降、sync.Once に再度ロジックが入ってきても、関数ロジックを実行できません。
通常、sync.Once はオブジェクトリソースの初期化やクリーンアップ処理に使い、重複操作を避けるために活用します。サンプルを見てみましょう。
- メイン関数で 3 つのゴルーチンを起動し、sync.WaitGroup で子ゴルーチンの終了を管理・待機します。
- メイン関数はすべてのゴルーチン起動後、2 秒待ってからインスタンスを生成・取得します。
- 各ゴルーチンも同じくインスタンスを取得しようとします。
- いずれか 1 つのゴルーチンが Once に入ってロジックを実行した後、panic が発生します。
- panic が発生したゴルーチンは例外をキャッチします。この時点でグローバルの instance はすでに初期化されており、他のゴルーチンは Once 内の関数に入ることはできません。
type Instance struct {
Name string
}
var instance *Instance
var on sync.Once
func GetInstance(num int) *Instance {
defer func() {
if err := recover(); err != nil {
fmt.Println("num %d ,get instance and catch error ... \n", num)
}
}()
on.Do(func() {
instance = &Instance{Name: "Leapcell"}
fmt.Printf("%d enter once ... \n", num)
panic("panic....")
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
ins := GetInstance(i)
fmt.Printf("%d: ins:%+v , p=%p\n", i, ins, ins)
wg.Done()
}(i)
}
time.Sleep(time.Second * 2)
ins := GetInstance(9)
fmt.Printf("9: ins:%+v , p=%p\n", ins, ins)
wg.Wait()
}
実行結果を見てみると、0 番のゴルーチンが Once に入り panic を発生させています。そのため、0 番ゴルーチンで GetInstance 関数が返す値は nil になります。
他のゴルーチンやメインゴルーチンで GetInstance を呼び出した場合は、正常に instance のアドレスを取得できます。同じアドレスであることから、グローバルインスタンスは 1 回だけ初期化されていることが分かります。
$ go run main.go
0 enter once ...
num %d ,get instance and catch error ...
0
0: ins:<nil> , p=0x0
1: ins:&{Name:Leapcell} , p=0xc000086000
2: ins:&{Name:Leapcell} , p=0xc000086000
9: ins:&{Name:Leapcell} , p=0xc000086000
私たちはLeapcell、Goプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ