Menu

Mutex w Golang: sync.Mutex, RWMutex, atomic i sync.Once

Jak chronić stan współdzielony między gorutynami przez sync.Mutex i sync.RWMutex, kiedy wystarczy sync/atomic, jak sync.Once wykonuje inicjalizację dokładnie raz i jakie błędy z blokadami prowadzą do zakleszczeń.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Ochrona współdzielonych danych

sync.Mutex wpuszcza do kodu między Lock a Unlock tylko jedną gorutynę naraz. Umieść mutex obok danych, które chroni, zwykle w tej samej strukturze:

To zawsze wypisuje hits: 10000. Bez blokady 100 gorutyn zapisujących tę samą mapę zwykle wywróciłoby program z fatal error: concurrent map writes. Ten błąd pochodzi z kontroli, którą runtime wykonuje na zasadzie najlepszych starań, nie da się go przechwycić, a brak awarii nie dowodzi, że kod jest poprawny.

Szczegóły, które mają znaczenie:

  • Wartość zerowa sync.Mutex jest odblokowana i gotowa. Żadnego konstruktora.
  • Metody mają odbiorcę wskaźnikowego (*Counter). Odbiorca wartościowy blokowałby kopię mutexu, co niczego nie chroni.
  • defer c.mu.Unlock() zaraz po Lock oznacza, że każda ścieżka powrotu, a także panika, zwalnia blokadę.
  • Każdy dostęp przechodzi przez blokadę, łącznie z odczytami. Odczyt bez blokady, gdy inna gorutyna zapisuje, to nadal wyścig danych.

Sekcja krytyczna ma być mała

defer zwalnia blokadę na końcu funkcji. To dobre rozwiązanie dla krótkich metod, takich jak powyższe. W dłuższej funkcji zwolnij blokadę, gdy tylko przestajesz dotykać współdzielonych danych, żeby inne gorutyny nie czekały na pracę, która blokady nie potrzebuje:

func (s *Store) Save(key string) error {
	s.mu.Lock()
	data := s.items[key] // copy what you need
	s.mu.Unlock()

	return writeToDisk(key, data) // slow I/O, outside the lock
}

Trzymanie blokady podczas wywołań sieciowych, operacji dyskowych albo wysyłki do kanału to najczęstsza przyczyna powolnego programu współbieżnego, a wysyłka do kanału pod blokadą to częsta przyczyna zakleszczenia.

RWMutex dla danych głównie czytanych

sync.RWMutex ma dwa tryby. RLock/RUnlock biorą współdzieloną blokadę odczytu, którą może naraz trzymać wiele gorutyn. Lock/Unlock biorą wyłączną blokadę zapisu, która czeka, aż wszyscy czytelnicy wyjdą.

RWMutex się opłaca, gdy dominują odczyty, a każdy odczyt wykonuje pod blokadą prawdziwą pracę. Przy maleńkich sekcjach krytycznych, np. pojedynczym odczycie z mapy, zwykły Mutex bywa równie szybki, bo blokada odczytu ma własną księgowość. Zrób benchmark, zanim wybierzesz.

Nie da się podnieść blokady odczytu do blokady zapisu. Wywołanie Lock przy trzymanym RLock w tej samej gorutynie prowadzi do zakleszczenia. Najpierw zwolnij blokadę odczytu, potem weź blokadę zapisu i ponownie sprawdź warunek, bo w międzyczasie inny zapisujący mógł zmienić dane.

sync/atomic dla pojedynczych wartości

Dla jednego licznika lub flagi sync/atomic jest prostsze i tańsze niż mutex. Warto używać typowanych opakowań (Go 1.19):

Operacje atomowe chronią jedną wartość naraz. Gdy tylko dwie wartości muszą zmieniać się razem (saldo i liczba transakcji, mapa i jej rozmiar), użyj mutexu. Między dwiema osobnymi operacjami atomowymi mogą się wcisnąć inne gorutyny.

sync.Once

sync.Once wykonuje funkcję dokładnie raz, niezależnie od tego, ile gorutyn wywoła ją jednocześnie. Każdy, kto wywoła Do, czeka, aż pierwsze wywołanie się zakończy. To standardowy sposób na leniwą inicjalizację:

Każda linia „runs once” pojawia się dokładnie raz. Jeśli funkcja przekazana do Do wywoła panikę, Once i tak uznaje ją za wykonaną i nigdy nie ponawia. sync.OnceValues robi to samo dla funkcji zwracających dwie wartości, zwykle wartość i błąd.

Mutex, kanał czy sync.Map

SytuacjaUżyj
Struktura lub mapa, którą kilka gorutyn aktualizuje w miejscusync.Mutex w strukturze
Głównie odczyty, sporadyczne zapisy, odczyty wykonują prawdziwą pracęsync.RWMutex
Pojedynczy licznik lub flagasync/atomic
Jednorazowa inicjalizacjasync.Once, sync.OnceValue
Przekazywanie danych z jednej gorutyny do drugiejkanał
Cache, którego klucze są zapisywane raz i czytane wiele razy, albo gorutyny pracujące na rozłącznych kluczachsync.Map

sync.Map nie jest ogólnym zamiennikiem mapy z blokadą. Nie ma parametrów typu, więc wartości wracają jako any, i jest szybsza tylko w dwóch przypadkach z tabeli. Zacznij od mutexu i zwykłej mapy.

Częste błędy

  • Kopiowanie mutexu. Przekazanie przez wartość struktury zawierającej sync.Mutex albo użycie odbiorcy wartościowego kopiuje blokadę. go vet zgłasza passes lock by value albo copies lock value.
  • Dwukrotne blokowanie w jednej gorutynie. Mutexy w Go nie są reentrant. Jeśli Inc wywołuje Get i obie biorą blokadę, Inc blokuje się na zawsze. Niech metody publiczne blokują, a prywatne funkcje pomocnicze zakładają, że blokada jest wzięta.
  • Brak zwolnienia blokady przy wczesnym powrocie. Używaj defer, chyba że masz powód, żeby tego nie robić.
  • Blokowanie w różnej kolejności. Jeśli jedna gorutyna bierze blokadę A, a potem B, a druga B, a potem A, obie mogą czekać na siebie w nieskończoność. Zawsze zdobywaj kilka blokad w tej samej kolejności.
  • Wystawianie chronionych danych na zewnątrz. Zwrócenie wewnętrznej mapy z metody pozwala wywołującym czytać ją i zapisywać bez blokady. Zwracaj kopię (maps.Clone, Go 1.21) albo pojedynczą wartość.
  • Ochrona tylko zapisów. Odczyty bez blokady równoległe z zapisami pod blokadą to nadal wyścigi. Uruchamiaj testy przez go test -race.

Najczęściej zadawane pytania

Czym jest mutex w Go?

sync.Mutex to blokada, która pozwala tylko jednej gorutynie naraz wykonywać kod między mu.Lock() a mu.Unlock(). Używasz jej do ochrony danych, które czyta i zapisuje kilka gorutyn, np. mapy albo struktury. Jej wartość zerowa to odblokowany mutex gotowy do użycia.

Kiedy używać RWMutex zamiast Mutex?

Gdy odczytów jest znacznie więcej niż zapisów, a każdy odczyt trzyma blokadę przez odczuwalny czas. RLock wpuszcza naraz dowolną liczbę czytelników, a Lock czeka na wyłączny dostęp. Przy krótkich sekcjach krytycznych zwykły Mutex bywa równie szybki albo szybszy, więc zmierz, zanim zmienisz.

Czy mapa w Go jest bezpieczna przy współbieżnym użyciu?

Nie. Współbieżne odczyty są w porządku, ale zapis równoległy z jakimkolwiek innym odczytem lub zapisem to wyścig danych, a runtime zwykle go wykrywa i wywraca program z fatal error: concurrent map writes (albo concurrent map read and map write). Chroń mapę przez sync.Mutex albo sync.RWMutex lub użyj sync.Map w jej konkretnych zastosowaniach.

Czy sync.Mutex w Go jest reentrant?

Nie. Jeśli gorutyna, która trzyma blokadę, ponownie wywoła Lock, zablokuje się na zawsze, czekając na samą siebie. Ułóż kod tak, żeby metody eksportowane brały blokadę i wywoływały nieeksportowane funkcje pomocnicze, które zakładają, że blokada jest już wzięta.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ