Thread-sicheres Struct-Design
Teil des Abschnitts Objektorientierte Programmierung der GO-Journey von Coddy — Lektion 65 von 107.
Nachdem Sie nun Mutexe und WaitGroups verstehen, lassen Sie uns diese kombinieren, um Structs zu entwerfen, die sicher von mehreren Goroutines gleichzeitig verwendet werden können. Eine threadsichere Struct kapselt die Synchronisierung innerhalb ihrer Methoden, sodass sich die Aufrufer nicht um das Sperren (Locking) kümmern müssen.
Das Muster ist einfach: Betten Sie einen Mutex in Ihr Struct ein und sperren Sie ihn in jeder Methode, die auf den gemeinsamen Zustand zugreift:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Beachten Sie, dass selbst die schreibgeschützte Value()-Methode den Mutex sperrt. Ohne dies könnte eine Goroutine lesen, während eine andere schreibt, was zu einer Data Race führt. Wenn Lesevorgänge weitaus häufiger sind als Schreibvorgänge, verwenden Sie stattdessen sync.RWMutex und rufen Sie RLock() für Lesevorgänge auf.
Ein wichtiges Designprinzip: den Mutex privat halten. Durch die Verwendung eines kleingeschriebenen Feldnamens (mu) verhindern Sie, dass externer Code direkt darauf zugreift. Die gesamte Synchronisierung erfolgt über Ihre Methoden, was Ihnen die volle Kontrolle über die Threadsicherheit gibt.
Schützen Sie bei Structs mit mehreren Feldern alle zusammengehörigen Felder mit demselben Mutex, um einen konsistenten Zustand zu gewährleisten:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}Sowohl balance als auch history werden atomar aktualisiert – keine Goroutine kann einen inkonsistenten Zustand beobachten, in dem sich das eine geändert hat, das andere jedoch nicht.
Aufgabe
EinfachLassen Sie uns ein threadsicheres Bankkontosystem bauen, das die korrekte Kapselung der Synchronisation innerhalb von Struct-Methoden demonstriert. Ihr Konto wird gleichzeitige Einzahlungen, Auszahlungen und Kontostandsabfragen sicher verarbeiten, ohne den Aufrufern Details zur Sperrung (Locking) offenzulegen.
Sie werden Ihren Code auf zwei Dateien aufteilen:
account.go: Definieren Sie Ihr threadsicheres Bankkonto.Erstellen Sie ein
BankAccount-Struct mit einem eingebettetensync.Mutex, einembalance-Feld (int) und einemtransactions-Slice, das alle erfolgreichen Operationen als Strings aufzeichnet.Implementieren Sie diese Methoden:
NewBankAccount(initial int) *BankAccount- Erstellt ein neues Konto mit dem angegebenen Anfangsguthaben und einem leeren Transaktions-SliceDeposit(amount int)- Fügt den Betrag zum Guthaben hinzu und zeichnet die Transaktion als+[amount]aufWithdraw(amount int) bool- Wenn ausreichend Guthaben vorhanden ist, zieht es den Betrag ab, zeichnet-[amount]auf und gibttruezurück. Andernfalls wirdfalsezurückgegeben, ohne etwas zu ändernBalance() int- Gibt den aktuellen Kontostand zurückHistory() []string- Gibt eine Kopie des Transaktions-Slices zurück
Jede Methode, die auf die Felder des Structs zugreift, muss den Mutex sperren, um Threadsicherheit zu gewährleisten. Verwenden Sie
deferzum Entsperren. Halten Sie den Mutex und alle Felder unexportiert (kleingeschrieben), sodass externer Code Ihre Methoden verwenden muss.main.go: Verarbeiten Sie Bankoperationen und demonstrieren Sie Ihr threadsicheres Konto.Lesen Sie das Anfangsguthaben und dann die Anzahl der Operationen ein. Lesen Sie für jede Operation den Typ (
deposit,withdrawoderbalance) und für deposit/withdraw den Betrag ein.Geben Sie die Ergebnisse für jede Operation aus:
deposit: Geben SieDeposited [amount], Balance: [new balance]auswithdraw: Geben SieWithdrew [amount], Balance: [new balance]aus, wenn erfolgreich, oderWithdrawal failed: insufficient funds, wenn nichtbalance: Geben SieCurrent balance: [balance]aus
Geben Sie nach allen Operationen den Transaktionsverlauf aus, wobei jeder Eintrag in einer neuen Zeile steht, vorangestellt von
History:nur für den ersten Eintrag.
Die folgenden Eingaben werden bereitgestellt:
- Zeile 1: Anfangsguthaben (Ganzzahl)
- Zeile 2: Anzahl der Operationen (Ganzzahl)
- Folgende Zeilen: Für jede Operation der Typ (
deposit,withdrawoderbalance) und für deposit/withdraw der Betrag in der nächsten Zeile
Zum Beispiel, gegeben:
100
5
deposit
50
balance
withdraw
30
withdraw
200
balanceIhre Ausgabe sollte wie folgt aussehen:
Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30Das Schlüsselprinzip hierbei ist, dass die gesamte Synchronisation innerhalb Ihrer BankAccount-Methoden verborgen ist. Aufrufer verwenden einfach Deposit(), Withdraw() und Balance(), ohne jemals über Sperren nachzudenken – Ihr Struct kümmert sich intern um die Threadsicherheit.
Spickzettel
Eine thread-sichere Struct kapselt die Synchronisierung innerhalb ihrer Methoden, indem sie einen Mutex einbettet und diesen in jeder Methode sperrt, die auf den gemeinsamen Zustand zugreift:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Wichtige Prinzipien:
- Sperren Sie den Mutex in allen Methoden, die auf den gemeinsamen Zustand zugreifen, einschließlich schreibgeschützter Methoden, um Data Races zu verhindern
- Verwenden Sie
defer, um sicherzustellen, dass der Mutex entsperrt wird, selbst wenn die Funktion vorzeitig beendet wird - Halten Sie den Mutex privat (Feldname in Kleinschreibung), damit externer Code nicht direkt darauf zugreifen kann
- Verwenden Sie bei leseintensiven Workloads
sync.RWMutexund rufen SieRLock()für Lesevorgänge auf
Schützen Sie bei Structs mit mehreren Feldern alle zusammengehörigen Felder mit demselben Mutex, um einen konsistenten Zustand zu gewährleisten:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}Dies stellt sicher, dass sowohl balance als auch history atomar aktualisiert werden – keine Goroutine kann einen inkonsistenten Zustand beobachten.
Probier es selbst
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
// Anfangssaldo lesen
initialStr, _ := reader.ReadString('\n')
initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))
// Anzahl der Operationen lesen
numOpsStr, _ := reader.ReadString('\n')
numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))
// Das Bankkonto erstellen
account := NewBankAccount(initial)
// Jede Operation verarbeiten
for i := 0; i < numOps; i++ {
opType, _ := reader.ReadString('\n')
opType = strings.TrimSpace(opType)
switch opType {
case "deposit":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Deposit aufrufen und das Ergebnis ausgeben
// Format: "Deposited [amount], Balance: [new balance]"
case "withdraw":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Withdraw aufrufen und das entsprechende Ergebnis ausgeben
// Wenn erfolgreich: "Withdrew [amount], Balance: [new balance]"
// Wenn fehlgeschlagen: "Withdrawal failed: insufficient funds"
_ = amount // Diese Zeile bei der Implementierung entfernen
case "balance":
// TODO: Balance aufrufen und das Ergebnis ausgeben
// Format: "Current balance: [balance]"
}
}
// TODO: Transaktionsverlauf ausgeben
// Der erste Eintrag sollte das Präfix "History: " haben
// Nachfolgende Einträge sollten in neuen Zeilen ohne Präfix stehen
}
Diese Lektion enthält ein kurzes Quiz. Starte die Lektion, um es zu beantworten und deinen Fortschritt zu speichern.
Alle Lektionen in Objektorientierte Programmierung
1Grundlagen der Go OOP
Externe DateienGo Workspace & ModulePackages & ImportsExportierte vs. nicht-exportierte NamenEinführung in OOP in GoStructs als KlassenMethoden für Structs definierenPointer- vs. Value-ReceiverStruct-InitialisierungKonstruktor-FunktionenZusammenfassung – Einfacher Taschenrechner4Interfaces
Einführung in InterfacesImplizite ImplementierungInterface als VertragLeeres Interface (any)Type AssertionType SwitchInterface-KompositionStringer & Error InterfacesRückblick - Formen-Rechner7Kapselung
Exportierte vs. nicht exportierte FelderKapselung auf Paket-EbeneGetter- & Setter-MethodenInformation Hiding in GoZusammenfassung - Studierendendatensätze10Generics (Go 1.18+)
Einführung in GenericsTypparameterType ConstraintsGenerische StructsWorkaround für generische MethodenZusammenfassung – Generische Collection2Vertiefung: Typen & Structs
Basis- & zusammengesetzte TypenBenutzerdefinierte TypdefinitionenStruct TagsAnonyme StructsVerschachtelte StructsZero Values & StandardwerteZusammenfassung - Kontaktbuch5Komposition statt Vererbung
Warum Go keine Vererbung hatGrundlagen des Struct EmbeddingMethod PromotionEinbetten mehrerer StructsEmbedding vs. AggregationShadowing eingebetteter MethodenZusammenfassung – Mitarbeiter-Hierarchie8Fehlerbehandlung & OOP
Das error-InterfaceEigene FehlertypenError Wrapping (fmt.Errorf)Sentinel-Fehlererrors.Is() und errors.As()Panic, Defer und RecoverZusammenfassung – Datei-Parser3Zeiger & Speicher
Zeiger-Grundlagen in GoZeiger auf StructsWertübergabe vs. ReferenzübergabeDie new() FunktionGarbage Collection in GoWiederholung – Linked List Builder6Polymorphismus in Go
Polymorphismus über InterfacesDuck Typing in GoInterface-ErfüllungsregelnPolymorphe CollectionsDependency InjectionZusammenfassung – Payment Processor9Nebenläufigkeit & OOP
Goroutines GrundlagenChannels & KommunikationGepufferte vs. ungepufferte ChannelsSelect-Anweisungsync.Mutex & sync.RWMutexsync.WaitGroupThread-sicheres Struct-DesignZusammenfassung - Worker Pool