Go: 複数のシナリオにおけるRwMutexとMutexのパフォーマンス
Min-jun Kim
Dev Intern · Leapcell

Golangロックのパフォーマンスに関する研究と分析
ソフトウェア開発の分野では、Golangのロックのパフォーマンスをテストすることは実用的なタスクです。最近、友人から質問がありました。スライスに対してスレッドセーフな読み書き操作を実行する場合、読み書きロック(rwlock)とミューテックスロック(mutex)のどちらを選択すべきか、そしてどちらのロックの方がパフォーマンスが高いのか?この質問がきっかけとなり、詳細な議論が始まりました。
I. ロックパフォーマンステストの背景と目的
マルチスレッドプログラミングのシナリオでは、データのスレッド安全性を確保することが非常に重要です。スライスなどのデータ構造に対する読み書き操作の場合、適切なロック機構を選択することで、プログラムのパフォーマンスに大きな影響を与える可能性があります。この調査の目的は、さまざまなシナリオでの読み書きロックとミューテックスロックのパフォーマンスを比較することにより、開発者が実際のアプリケーションでロック機構を選択するための参考情報を提供することです。
II. さまざまなシナリオにおける異なるロック機構のパフォーマンス分析
(I) 読み書きロック(Rwmutex)とミューテックスロック(Mutex)のパフォーマンス比較に関する理論的な考察
読み書きロックがミューテックスロックよりも優れたパフォーマンスを発揮するシナリオは何か、という問いは、詳細な分析に値します。ロックのロック(lock)およびアンロック(unlock)のプロセス中に、入出力(io)ロジックや複雑な計算ロジックがない場合、理論的には、ミューテックスロックの方が読み書きロックよりも効率的である可能性があります。現在、コミュニティにはさまざまな読み書きロックの設計および実装方法があり、そのほとんどは2つのロックとリーダーカウンターを抽象化することによって実現されています。
(II) C++環境におけるロックのパフォーマンス比較の参考
以前に、C++環境でミューテックスロック(lock)と読み書きロック(rwlock)のパフォーマンス比較が実施されました。単純な代入ロジックのシナリオでは、ベンチマークテストの結果は期待どおりであり、ミューテックスロックのパフォーマンスが読み書きロックよりも優れています。中間ロジックが空のioの読み書き操作である場合、読み書きロックのパフォーマンスはミューテックスロックよりも高くなります。これは、一般的な知識とも一致しています。中間ロジックがマップのルックアップである場合、読み書きロックはミューテックスロックよりも高いパフォーマンスを示します。これは、マップが複雑なデータ構造であるためです。キーをルックアップするときは、ハッシュコードを計算し、ハッシュコードを介して配列内の対応するバケットを見つけ、次にリンクリストから関連するキーを見つける必要があります。具体的なパフォーマンスデータは次のとおりです。
- 単純な代入:
- raw_lockの実行時間は1.732199秒です。
- raw_rwlockの実行時間は3.420338秒です
- io操作:
- simple_lockの実行時間は13.858138秒です。
- simple_rwlockの実行時間は8.94691秒です
- map:
- lockの実行時間は2.729701秒です。
- rwlockの実行時間は0.300296秒です
(III) Golang環境でのsync.rwmutexとsync.mutexのパフォーマンステスト
Golang環境での読み書きロックとミューテックスロックのパフォーマンスを深く探求するために、次のテストを実施しました。テストコードは次のとおりです。
package main import ( "fmt" "sync" "time" ) var ( num = 1000 * 10 gnum = 1000 ) func main() { fmt.Println("only read") testRwmutexReadOnly() testMutexReadOnly() fmt.Println("write and read") testRwmutexWriteRead() testMutexWriteRead() fmt.Println("write only") testRwmutexWriteOnly() testMutexWriteOnly() } func testRwmutexReadOnly() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.get(in) } }() } w.Wait() fmt.Println("testRwmutexReadOnly cost:", time.Now().Sub(t1).String()) } func testRwmutexWriteOnly() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.set(in, in) } }() } w.Wait() fmt.Println("testRwmutexWriteOnly cost:", time.Now().Sub(t1).String()) } func testRwmutexWriteRead() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { if i%2 == 0 { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.get(in) } }() } else { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.set(in, in) } }() } } w.Wait() fmt.Println("testRwmutexWriteRead cost:", time.Now().Sub(t1).String()) } func testMutexReadOnly() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.get(in) } }() } w.Wait() fmt.Println("testMutexReadOnly cost:", time.Now().Sub(t1).String()) } func testMutexWriteOnly() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.set(in, in) } }() } w.Wait() fmt.Println("testMutexWriteOnly cost:", time.Now().Sub(t1).String()) } func testMutexWriteRead() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { if i%2 == 0 { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.get(in) } }() } else { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.set(in, in) } }() } } w.Wait() fmt.Println("testMutexWriteRead cost:", time.Now().Sub(t1).String()) } func newRwmutex() *rwmutex { var t = &rwmutex{} t.mu = &sync.RWMutex{} t.ipmap = make(map[int]int, 100) for i := 0; i < 100; i++ { t.ipmap[i] = 0 } return t } type rwmutex struct { mu *sync.RWMutex ipmap map[int]int } func (t *rwmutex) get(i int) int { t.mu.RLock() defer t.mu.RUnlock() return t.ipmap[i] } func (t *rwmutex) set(k, v int) { t.mu.Lock() defer t.mu.Unlock() k = k % 100 t.ipmap[k] = v } func newMutex() *mutex { var t = &mutex{} t.mu = &sync.Mutex{} t.ipmap = make(map[int]int, 100) for i := 0; i < 100; i++ { t.ipmap[i] = 0 } return t } type mutex struct { mu *sync.Mutex ipmap map[int]int } func (t *mutex) get(i int) int { t.mu.Lock() defer t.mu.Unlock() return t.ipmap[i] } func (t *mutex) set(k, v int) { t.mu.Lock() defer t.mu.Unlock() k = k % 100 t.ipmap[k] = v }
テスト結果は次のとおりです。 ミューテックスとrwmutexが複数のgoroutineで使用されるシナリオでは、読み取り専用、書き込み専用、および読み書きの3つのテストシナリオがそれぞれテストされます。結果は、書き込み専用のシナリオでのみ、ミューテックスのパフォーマンスがrwmutexよりもわずかに高いように見えることを示しています。
- only read:
- testRwmutexReadOnly のコスト: 455.566965ms
- testMutexReadOnly のコスト: 2.13687988s
- write and read:
- testRwmutexWriteRead のコスト: 1.79215194s
- testMutexWriteRead のコスト: 2.62997403s
- write only:
- testRwmutexWriteOnly のコスト: 2.6378979159s
- testMutexWriteOnly のコスト: 2.39077869s
さらに、マップの読み書きロジックをカウンターのグローバルなインクリメントおよびデクリメントに置き換えると、テスト結果は上記と似た状況になります。つまり、書き込み専用のシナリオでは、ミューテックスのパフォーマンスがrwlockよりもわずかに高くなります。
- only read:
- testRwmutexReadOnly のコスト: 10.483448ms
- testMutexReadOnly のコスト: 10.808006ms
- write and read:
- testRwmutexWriteRead のコスト: 12.405655ms
- testMutexWriteRead のコスト: 14.571228ms
- write only:
- testRwmutexWriteOnly のコスト: 13.453028ms
- testMutexWriteOnly のコスト: 13.782282ms
III. Golangにおけるsync.RwMutexのソースコード分析
Golangのsync.RwMutexの構造には、読み取りロック、書き込みロック、およびリーダーカウンターが含まれています。コミュニティの一般的な実装方法との最大の違いは、リーダーカウンターの操作にアトミック命令(atomic)を使用することです。具体的な構造定義は次のとおりです。
type RWMutex struct { w Mutex // held if there are pending writers writerSem uint32 // semaphore for writers to wait for completing readers readerSem uint32 // semaphore for readers to wait for completing writers readerCount int32 // number of pending readers readerWait int32 // number of departing readers }
(I) 読み取りロックの取得プロセス
読み取りロックの取得は、アトミックを使用して減算操作を直接使用します。 readerCountが0より小さい場合、書き込み操作が待機中であることを示し、この時点で、読み取りロックを待つ必要があります。コード実装は次のとおりです。
func (rw *RWMutex) RLock() { if race.Enabled { _ = rw.w.state race.Disable() } if atomic.AddInt32(&rw.readerCount, 1) < 0 { // A writer is pending, wait for it. runtime_Semacquire(&rw.readerSem) } if race.Enabled { race.Enable() race.Acquire(unsafe.Pointer(&rw.readerSem)) } }
(II) 読み取りロックの解放プロセス
読み取りロックの解放も、アトミックを使用してカウントを操作します。リーダーがいない場合、書き込みロックが解放されます。関連するコードは次のとおりです。
func (rw *RWMutex) RUnlock() { if race.Enabled { _ = rw.w.state race.ReleaseMerge(unsafe.Pointer(&rw.writerSem)) race.Disable() } if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 { if r+1 == 0 || r+1 == -rwmutexMaxReaders { race.Enable() throw("sync: RUnlock of unlocked RWMutex") } // A writer is pending. if atomic.AddInt32(&rw.readerWait, -1) == 0 { // The last reader unblocks the writer. runtime_Semrelease(&rw.writerSem, false) } } if race.Enabled { race.Enable() } }
(III) 書き込みロックの取得と解放のプロセス
書き込みロックを取得するプロセスでは、最初に読み取り操作があるかどうかを判断します。読み取り操作がある場合、読み取り操作が完了した後でウェイクアップされるのを待ちます。書き込みロックを解放するとき、読み取りロックも同時に解放され、次に読み取りロックを待機しているgoroutineがウェイクアップされます。関連するコードは次のとおりです。
func (rw *RWMutex) Lock() { if race.Enabled { _ = rw.w.state race.Disable() } // First, resolve competition with other writers. rw.w.Lock() // Announce to readers there is a pending writer. r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders // Wait for active readers. if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { runtime_Semacquire(&rw.writerSem) } if race.Enabled { race.Enable() race.Acquire(unsafe.Pointer(&rw.readerSem)) race.Acquire(unsafe.Pointer(&rw.writerSem)) } } func (rw *RWMutex) Unlock() { if race.Enabled { _ = rw.w.state race.Release(unsafe.Pointer(&rw.readerSem)) race.Release(unsafe.Pointer(&rw.writerSem)) race.Disable() } // Announce to readers there is no active writer. r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) if r >= rwmutexMaxReaders { race.Enable() throw("sync: Unlock of unlocked RWMutex") } // Unblock blocked readers, if any. for i := 0; i < int(r); i++ { runtime_Semrelease(&rw.readerSem, false) } // Allow other writers to proceed. rw.w.Unlock() if race.Enabled { race.Enable() } }
IV. まとめと提案
ロックの競合の問題は、高並行システムが直面する主要な課題の1つです。上記のマップがミューテックスと組み合わせて使用されるシナリオでは、Goバージョン1.9以降では、syncMapを代替として使用することを検討できます。読み取り操作が頻繁で、書き込み操作が少ないシナリオでは、sync.Mapのパフォーマンスは、sync.RwMutexとマップの組み合わせよりも大きな利点があります。
sync.Mapの実装原理に関する詳細な調査の後、その書き込み操作のパフォーマンスが比較的低いことがわかりました。読み取り操作はcopy on writeメソッドによるロックフリーの読み取りを実現できますが、書き込み操作には依然としてロック機構が含まれます。ロック競合のプレッシャーを軽減するために、JavaのConcurrentMapと同様のセグメント化されたロックメソッドを参考にすることができます。
セグメント化されたロックに加えて、アトミックな比較とスワップ(atomic cas)命令を使用して、楽観的なロックを実装することもできます。これにより、ロックの競合の問題を効果的に解決し、高並行性シナリオでのシステムのパフォーマンスを向上させることができます。
Leapcell:Golangアプリホスティング用の次世代サーバーレスプラットフォーム
最後に、Golangサービスの展開に最適なプラットフォームをお勧めします。 Leapcell
1. 複数言語のサポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ支払い、リクエストは不要、料金はかかりません。
3. 比類のない費用対効果
- アイドル料金なしの従量課金制。
- 例:平均応答時間60msで、25ドルで694万件のリクエストをサポートします。
4. 合理化された開発者エクスペリエンス
- 楽なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能な洞察のためのリアルタイムのメトリクスとロギング。
5. 簡単なスケーラビリティと高パフォーマンス
- 高い並行性を簡単に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロ — 構築に集中するだけです。
Leapcell Twitter: https://x.com/LeapcellHQ