Understanding sync.Once in Go
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Einleitung
In bestimmten Szenarien müssen wir einige Ressourcen initialisieren, wie z. B. Singleton-Objekte, Konfigurationen usw. Es gibt mehrere Möglichkeiten, die Ressourceninitialisierung zu implementieren, z. B. durch Definieren von Variablen auf Paketebene, Initialisieren in der Funktion init
oder in der Funktion main
. Alle drei Ansätze können die Nebenläufigkeitssicherheit und die vollständige Ressourceninitialisierung beim Start des Programms gewährleisten.
Manchmal ziehen wir es jedoch vor, eine verzögerte Initialisierung zu verwenden, bei der Ressourcen nur dann initialisiert werden, wenn sie wirklich benötigt werden. Dies erfordert Nebenläufigkeitssicherheit, und in solchen Fällen bietet Go's sync.Once
eine elegante und threadsichere Lösung. Dieser Artikel stellt sync.Once
vor.
Grundlegende Konzepte von sync.Once
Was ist sync.Once
sync.Once
ist eine Synchronisationsprimitive in Go, die sicherstellt, dass eine bestimmte Operation oder Funktion in einer nebenläufigen Umgebung nur einmal ausgeführt wird. Sie stellt nur eine exportierte Methode zur Verfügung: Do
, die eine Funktion als Parameter akzeptiert. Nach dem Aufruf der Do
-Methode wird die bereitgestellte Funktion ausgeführt, und zwar nur einmal, auch wenn mehrere Goroutinen sie gleichzeitig aufrufen.
Anwendungsszenarien von sync.Once
sync.Once
wird hauptsächlich in den folgenden Szenarien verwendet:
- Singleton-Muster: Stellt sicher, dass nur ein globales Instanzobjekt vorhanden ist, wodurch doppelte Ressourcenerstellung verhindert wird.
- Verzögerte Initialisierung: Während der Programmausführung können Ressourcen bei Bedarf dynamisch über
sync.Once
initialisiert werden. - Operationen, die nur einmal ausgeführt werden müssen: Zum Beispiel das Laden der Konfiguration, das Bereinigen von Daten usw., die nur einmal ausgeführt werden müssen.
Anwendungsbeispiele von sync.Once
Singleton-Muster
Im Singleton-Muster müssen wir sicherstellen, dass eine Struktur nur einmal initialisiert wird. Dieses Ziel kann mit sync.Once
leicht erreicht werden.
package main import ( "fmt" "sync" ) type Singleton struct{} var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() s := GetInstance() fmt.Printf("Singleton instance address: %p\n", s) }() } wg.Wait() }
Im obigen Code verwendet die Funktion GetInstance
once.Do()
, um sicherzustellen, dass instance
nur einmal initialisiert wird. In einer nebenläufigen Umgebung, in der mehrere Goroutinen gleichzeitig GetInstance
aufrufen, führt nur eine Goroutine instance = &Singleton{}
aus, und alle Goroutinen erhalten dieselbe Instanz s
.
Verzögerte Initialisierung
Manchmal möchten wir bestimmte Ressourcen nur dann initialisieren, wenn sie benötigt werden. Dies kann mit sync.Once
erreicht werden.
package main import ( "fmt" "sync" ) type Config struct { config map[string]string } var ( config *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { fmt.Println("init config...") config = &Config{ config: map[string]string{ "c1": "v1", "c2": "v2", }, } }) return config } func main() { // Beim ersten Mal, wenn eine Konfiguration benötigt wird, wird config initialisiert cfg := GetConfig() fmt.Println("c1: ", cfg.config["c1"]) // Beim zweiten Mal ist config bereits initialisiert und wird nicht erneut initialisiert cfg2 := GetConfig() fmt.Println("c2: ", cfg2.config["c2"]) }
In diesem Beispiel wird eine Config
-Struktur definiert, um einige Konfigurationseinstellungen zu enthalten. Die Funktion GetConfig
verwendet sync.Once
, um die Config
-Struktur beim ersten Aufruf zu initialisieren. Auf diese Weise wird Config
nur initialisiert, wenn sie wirklich benötigt wird, wodurch unnötiger Overhead vermieden wird.
Implementierungsprinzip von sync.Once
type Once struct { // Gibt an, ob die Operation ausgeführt wurde done uint32 // Mutex, um sicherzustellen, dass nur eine Goroutine die Operation ausführt m Mutex } func (o *Once) Do(f func()) { // Prüfen, ob done 0 ist, was bedeutet, dass f noch nicht ausgeführt wurde if atomic.LoadUint32(&o.done) == 0 { // Rufe den langsamen Pfad auf, um die Fast-Path-Inlining in Do zu ermöglichen o.doSlow(f) } } func (o *Once) doSlow(f func()) { // Sperren o.m.Lock() defer o.m.Unlock() // Doppelte Prüfung, um zu vermeiden, dass f mehrfach ausgeführt wird if o.done == 0 { // Setze done nach der Ausführung der Funktion defer atomic.StoreUint32(&o.done, 1) // Führe die Funktion aus f() } }
Die sync.Once
-Struktur enthält zwei Felder: done
und m
.
done
ist eineuint32
-Variable, die verwendet wird, um anzugeben, ob die Operation bereits ausgeführt wurde.m
ist ein Mutex, der verwendet wird, um sicherzustellen, dass nur eine Goroutine die Operation ausführt, wenn gleichzeitig darauf zugegriffen wird.
sync.Once
bietet zwei Methoden: Do
und doSlow
. Die Do
-Methode ist der Kern; sie akzeptiert eine Funktion f
. Zuerst wird der Wert von done
mit der atomaren Operation atomic.LoadUint32
geprüft (um die Nebenläufigkeitssicherheit zu gewährleisten). Wenn done
gleich 0
ist, bedeutet dies, dass die Funktion f
noch nicht ausgeführt wurde, und doSlow
wird dann aufgerufen.
Innerhalb der doSlow
-Methode wird zuerst die Mutex-Sperre m
erworben, um sicherzustellen, dass nur eine Goroutine f
gleichzeitig ausführen kann. Anschließend wird eine zweite Prüfung der Variablen done
durchgeführt. Wenn done
immer noch 0
ist, wird die Funktion f
ausgeführt und done
wird mit der atomaren Speicheroperation atomic.StoreUint32
auf 1
gesetzt.
Warum gibt es eine separate doSlow
-Methode?
Die doSlow
-Methode existiert hauptsächlich zur Leistungsoptimierung. Durch die Trennung der langsamen Pfadlogik von der Do
-Methode kann der schnelle Pfad in Do
vom Compiler inline ausgeführt werden, was die Leistung verbessert.
Warum wird eine doppelte Prüfung verwendet?
Wie aus dem Quellcode ersichtlich ist, wird der Wert von done
zweimal geprüft:
- Erste Prüfung: Bevor die Sperre erworben wird, wird mit
atomic.LoadUint32
geprüft, obdone
gesetzt ist. Wenn der Wert1
ist, bedeutet dies, dass die Operation bereits ausgeführt wurde, sodassdoSlow
übersprungen wird, wodurch unnötige Sperrkonflikte vermieden werden. - Zweite Prüfung: Nachdem die Sperre erworben wurde, wird
done
erneut geprüft. Dies stellt sicher, dass keine andere Goroutine die Funktion während der Zeit, in der die Sperre erworben wurde, ausgeführt hat.
Die doppelte Prüfung hilft, Sperrkonflikte in den meisten Fällen zu vermeiden und die Leistung zu verbessern.
Erweiterte sync.Once
Die von sync.Once
bereitgestellte Do
-Methode gibt keinen Wert zurück, was bedeutet, dass nachfolgende Aufrufe von Do
die Initialisierung nicht wiederholen, wenn die übergebene Funktion einen Fehler zurückgibt und die Initialisierung fehlschlägt. Um dieses Problem zu beheben, können wir eine benutzerdefinierte Synchronisationsprimitive implementieren, die sync.Once
ähnelt.
package main import ( "sync" "sync/atomic" ) type Once struct { done uint32 m sync.Mutex } func (o *Once) Do(f func() error) error { if atomic.LoadUint32(&o.done) == 0 { return o.doSlow(f) } return nil } func (o *Once) doSlow(f func() error) error { o.m.Lock() defer o.m.Unlock() var err error if o.done == 0 { err = f() // Setze done nur, wenn kein Fehler aufgetreten ist if err == nil { atomic.StoreUint32(&o.done, 1) } } return err }
Der obige Code implementiert eine erweiterte Once
-Struktur. Im Gegensatz zur Standard-sync.Once
ermöglicht diese Version der an die Do
-Methode übergebenen Funktion, einen Fehler zurückzugeben. Wenn die Funktion keinen Fehler zurückgibt, wird done
gesetzt, um anzugeben, dass die Funktion erfolgreich ausgeführt wurde. Bei nachfolgenden Aufrufen wird die Funktion nur dann übersprungen, wenn sie zuvor erfolgreich abgeschlossen wurde, wodurch unbehandelte Initialisierungsfehler vermieden werden.
Warnhinweise zu sync.Once
Deadlock
Aus der Analyse des sync.Once
-Quellcodes wissen wir, dass er ein Mutex-Feld m
enthält. Wenn wir Do
rekursiv innerhalb eines anderen Do
-Aufrufs aufrufen, führt dies zu mehreren Versuchen, dieselbe Sperre zu erwerben. Da ein Mutex nicht wiederbetretbar ist, führt dies zu einem Deadlock.
func main() { once := sync.Once{} once.Do(func() { once.Do(func() { fmt.Println("init...") }) }) }
Initialisierungsfehler
Initialisierungsfehler bezieht sich hier auf einen Fehler, der während der Ausführung der an Do
übergebenen Funktion auftritt. Die Standard-sync.Once
bietet keine Möglichkeit, solche Fehler zu erkennen. Um dieses Problem zu lösen, können wir die zuvor erwähnte erweiterte Once
verwenden, die die Fehlerbehandlung und bedingte Wiederholungen unterstützt.
Fazit
Dieser Artikel hat eine detaillierte Einführung in sync.Once
in der Go-Programmiersprache gegeben, einschließlich seiner grundlegenden Definition, Anwendungsszenarien, Anwendungsbeispiele und Quellcodeanalyse.
In der tatsächlichen Entwicklung wird sync.Once
häufig verwendet, um das Singleton-Muster und die verzögerte Initialisierung zu implementieren.
Obwohl sync.Once
einfach und effizient ist, kann eine falsche Verwendung zu unerwarteten Problemen führen, daher muss es mit Sorgfalt verwendet werden.
Zusammenfassend ist sync.Once
eine sehr nützliche Nebenläufigkeitsprimitive in Go, die Entwicklern hilft, threadsichere Operationen in verschiedenen nebenläufigen Szenarien durchzuführen. Immer wenn Sie auf eine Situation stoßen, in der eine Operation nur einmal initialisiert werden soll, ist sync.Once
eine ausgezeichnete Wahl.
Wir sind Leapcell, Ihre erste Wahl für das Hosting von Go-Projekten.
Leapcell ist die Next-Gen Serverless Plattform für Webhosting, Async Tasks und Redis:
Multi-Sprachen Unterstützung
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Unbegrenzte Projekte kostenlos bereitstellen
- Zahlen Sie nur für die Nutzung - keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützen 6,94 Mio. Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeit-Metriken und -Protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Verarbeitung hoher Nebenläufigkeit.
- Null Betriebsaufwand - konzentrieren Sie sich einfach auf den Aufbau.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ