Go: Leistung von RwMutex vs Mutex über mehrere Szenarien
Min-jun Kim
Dev Intern · Leapcell

Forschung und Analyse zur Leistung von Golang-Sperren
Im Bereich der Softwareentwicklung ist das Testen der Leistung von Golang-Sperren eine praktische Aufgabe. Kürzlich warf ein Freund eine Frage auf: Sollte man bei der Durchführung von Thread-sicheren Lese- und Schreiboperationen auf einem Slice eine Read-Write-Sperre (rwlock) oder eine Mutex-Sperre (mutex) wählen, und welche Sperre hat eine bessere Leistung? Diese Frage hat eine eingehende Diskussion ausgelöst.
I. Hintergrund und Zweck des Lock Performance Tests
Im Szenario der Multi-Thread-Programmierung ist die Gewährleistung der Thread-Sicherheit von Daten von großer Bedeutung. Für Lese- und Schreiboperationen auf Datenstrukturen wie Slices kann die Wahl eines geeigneten Sperrmechanismus die Leistung des Programms erheblich beeinflussen. Ziel dieser Forschung ist es, eine Referenz für Entwickler bei der Auswahl eines Sperrmechanismus in praktischen Anwendungen zu bieten, indem die Leistung von Read-Write-Sperren und Mutex-Sperren in verschiedenen Szenarien verglichen wird.
II. Leistungsanalyse verschiedener Sperrmechanismen in verschiedenen Szenarien
(I) Theoretische Diskussion über den Leistungsvergleich zwischen Read-Write-Sperren (Rwmutex) und Mutex-Sperren (Mutex)
In welchen Szenarien Read-Write-Sperren besser abschneiden als Mutex-Sperren, ist eine Frage, die eine eingehende Analyse verdient. Während der Sperr- (Lock) und Entsperr- (Unlock) Prozesse von Sperren, wenn es keine Input-Output (IO)-Logik und komplexe Berechnungslogik gibt, sind Mutex-Sperren theoretisch möglicherweise effizienter als Read-Write-Sperren. Derzeit gibt es verschiedene Design- und Implementierungsmethoden für Read-Write-Sperren in der Community, und die meisten von ihnen werden durch die Abstraktion von zwei Sperren und einem Reader-Zähler erreicht.
(II) Referenz für den Leistungsvergleich von Sperren in der C++-Umgebung
Zuvor wurde der Leistungsvergleich zwischen Mutex-Sperren (Lock) und Read-Write-Sperren (rwlock) in der C++-Umgebung durchgeführt. Im Szenario einer einfachen Zuweisungslogik stimmen die Benchmark-Testergebnisse mit den Erwartungen überein, d. h. die Leistung von Mutex-Sperren ist besser als die von Read-Write-Sperren. Wenn die Zwischenlogik eine leere IO-Lese-Schreiboperation ist, ist die Leistung von Read-Write-Sperren höher als die von Mutex-Sperren, was auch dem allgemeinen Wissen entspricht. Wenn die Zwischenlogik eine Map-Lookup ist, zeigen Read-Write-Sperren ebenfalls eine höhere Leistung als Mutex-Sperren. Dies liegt daran, dass die Map eine komplexe Datenstruktur ist. Beim Nachschlagen eines Schlüssels ist es notwendig, den Hash-Code zu berechnen, den entsprechenden Bucket im Array über den Hash-Code zu finden und dann den relevanten Schlüssel aus der verketteten Liste zu finden. Die spezifischen Leistungsdaten sind wie folgt:
- Einfache Zuweisung:
- raw_lock dauert 1.732199s;
- raw_rwlock dauert 3.420338s
- IO-Operation:
- simple_lock dauert 13.858138s;
- simple_rwlock dauert 8.94691s
- map:
- lock dauert 2.729701s;
- rwlock dauert 0.300296s
(III) Leistungstest von sync.rwmutex und sync.mutex in der Golang-Umgebung
Um die Leistung von Read-Write-Sperren und Mutex-Sperren in der Golang-Umgebung eingehend zu untersuchen, wurden die folgenden Tests durchgeführt. Der Testcode ist wie folgt:
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 }
Die Testergebnisse sind wie folgt: In den Szenarien, in denen Mutex und Rwmutex in mehreren Goroutinen verwendet werden, werden die drei Testszenarien Read-Only, Write-Only und Read-Write jeweils getestet. Die Ergebnisse zeigen, dass es scheint, dass nur im Write-Only-Szenario die Leistung von Mutex etwas höher ist als die von Rwmutex.
- nur lesen:
- testRwmutexReadOnly cost: 455.566965ms
- testMutexReadOnly cost: 2.13687988s
- schreiben und lesen:
- testRwmutexWriteRead cost: 1.79215194s
- testMutexWriteRead cost: 2.62997403s
- nur schreiben:
- testRwmutexWriteOnly cost: 2.6378979159s
- testMutexWriteOnly cost: 2.39077869s
Wenn die Lese- und Schreiblogik der Map durch die globale Inkrementierung und Dekrementierung des Zählers ersetzt wird, sind die Testergebnisse ähnlich wie in der obigen Situation, d. h. im Write-Only-Szenario ist die Leistung von Mutex etwas höher als die von Rwlock.
- nur lesen:
- testRwmutexReadOnly cost: 10.483448ms
- testMutexReadOnly cost: 10.808006ms
- schreiben und lesen:
- testRwmutexWriteRead cost: 12.405655ms
- testMutexWriteRead cost: 14.571228ms
- nur schreiben:
- testRwmutexWriteOnly cost: 13.453028ms
- testMutexWriteOnly cost: 13.782282ms
III. Quellcodeanalyse von sync.RwMutex in Golang
Die Struktur von sync.RwMutex in Golang umfasst eine Lesesperre, eine Schreibsperre und einen Reader-Zähler. Der größte Unterschied zu den üblichen Implementierungsmethoden in der Community besteht darin, dass Atomic-Anweisungen (atomic) für Operationen am Reader-Zähler verwendet werden. Die spezifische Strukturdefinition ist wie folgt:
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) Der Prozess des Erwerbs der Lesesperre
Der Erwerb der Lesesperre verwendet direkt atomic für Subtraktionsoperationen. Wenn readerCount kleiner als 0 ist, bedeutet dies, dass eine Schreiboperation aussteht, und zu diesem Zeitpunkt muss auf die Lesesperre gewartet werden. Die Code-Implementierung ist wie folgt:
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) Der Prozess der Freigabe der Lesesperre
Die Freigabe der Lesesperre verwendet auch atomic, um den Zähler zu bearbeiten. Wenn keine Reader vorhanden sind, wird die Schreibsperre freigegeben. Der relevante Code ist wie folgt:
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) Der Prozess des Erwerbs und der Freigabe der Schreibsperre
Beim Erwerb der Schreibsperre wird zunächst beurteilt, ob eine Leseoperation vorliegt. Wenn eine Leseoperation vorliegt, wird gewartet, bis sie nach Abschluss der Leseoperation aufgeweckt wird. Beim Freigeben der Schreibsperre wird die Lesesperre gleichzeitig freigegeben, und dann werden die auf die Lesesperre wartenden Goroutinen aufgeweckt. Der relevante Code ist wie folgt:
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. Zusammenfassung und Vorschläge
Das Problem der Lock Contention war schon immer eine der wichtigsten Herausforderungen für High-Concurrency-Systeme. Für das obige Szenario, in dem die Map in Kombination mit dem Mutex verwendet wird, ist es in Go-Versionen ab 1.9 möglich, sync.Map als Ersatz zu verwenden. In Szenarien, in denen Leseoperationen häufig und Schreiboperationen selten sind, hat sync.Map erhebliche Vorteile gegenüber der Kombination aus sync.RwMutex und Map.
Nach eingehender Recherche zum Implementierungsprinzip von sync.Map kann festgestellt werden, dass die Schreibleistung relativ gering ist. Obwohl Leseoperationen durch die Copy-on-Write-Methode ein lockfreies Lesen erreichen können, sind Schreiboperationen immer noch mit dem Sperrmechanismus verbunden. Um den Druck der Lock Contention zu verringern, kann die segmentierte Sperrmethode ähnlich wie bei Javas ConcurrentMap als Referenz verwendet werden.
Zusätzlich zu segmentierten Sperren können Atomic Compare and Swap (Atomic CAS)-Anweisungen auch verwendet werden, um optimistische Sperren zu implementieren, wodurch das Problem der Lock Contention effektiv gelöst und die Leistung des Systems in High-Concurrency-Szenarien verbessert wird.
Leapcell: Die Next-Gen Serverless-Plattform für Golang-App-Hosting
Abschließend möchte ich die am besten geeignete Plattform für die Bereitstellung von Golang-Diensten empfehlen: Leapcell
1. Multi-Language Support
- Entwickeln Sie mit JavaScript, Python, Go oder Rust.
2. Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung - keine Anfragen, keine Gebühren.
3. Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
4. Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
5. Mühelose Skalierbarkeit und hohe Leistung
- Autoskalierung zur einfachen Bewältigung hoher Parallelität.
- Null Betriebsaufwand - konzentrieren Sie sich einfach auf den Aufbau.
Erfahren Sie mehr in der Dokumentation!
Leapcell Twitter: https://x.com/LeapcellHQ