1. Po co potrzebny jest RWMutex: historia czytelników i pisarzy
Wyobraźcie sobie małą bibliotekę. Jest w niej jeden rzadki podręcznik — to nasze wspólne dane. Ludzie przychodzą go czytać znacznie częściej niż przepisywać albo doklejać nowe strony. Jeśli bibliotekarz będzie wpuszczał do środka tylko po jednej osobie (jak Mutex), czytanie stanie się wolne: czytelnicy będą stali w kolejce nawet wtedy, gdy wcale sobie nie przeszkadzają.
sync.RWMutex został wymyślony właśnie dla takich sytuacji: pozwala wielu czytelnikom wchodzić jednocześnie, ale pisarza wpuszcza tylko jednego i tylko wtedy, gdy w środku nie ma czytelników. Na papierze brzmi to jak „przyspieszacz odczytu”, ale w praktyce ma swoją cenę: kod staje się bardziej złożony, a błędy — ciekawsze (w złym sensie).
API: read-lock i write-lock
Najważniejsza idea RWMutex polega na tym, że ma on dwa typy blokad: do odczytu i do zapisu. I nie jest to „dla ładnego wyglądu”, tylko realny kontrakt, którego trzeba przestrzegać w kodzie. Jeśli go złamiecie, albo złapiecie wyścig, albo dostaniecie zawieszenie, albo jedno i drugie — niezły zestaw.
Oto minimalna „ściąga z metod”, żeby się nie mylić:
| Co robimy | Jaka blokada | Metody | Kto może wchodzić równolegle |
|---|---|---|---|
| Tylko czytamy dane | Read lock | |
Kilku czytelników jednocześnie |
| Zmieniamy dane | Write lock | |
Tylko jeden pisarz, czytelnicy czekają |
I jeszcze ważna rzecz: zero value dla RWMutex jest „gotowe do pracy”. To znaczy, że można po prostu zadeklarować pole var mu sync.RWMutex i korzystać z niego bez new/make.
Ale kopiować struktury z RWMutex po rozpoczęciu użycia nadal nie wolno: dostaniecie dwie kopie zamka, które „myślą”, że chronią to samo, a w praktyce — każdy działa osobno.
2. Miniaplikacja: TaskStore na RWMutex
Żeby nie omawiać zamków w próżni, kontynuujmy naszą linię aplikacji z zadaniami. Załóżmy, że mamy pamięciowe repozytorium zadań, które jest często odczytywane (pokazanie listy, pobranie po id) i rzadziej zapisywane (utworzenie zadania, oznaczenie jako wykonane). To typowa sytuacja „dużo odczytów / mało zapisów” — kandydat do RWMutex.
Zacznijmy od modelu i struktury magazynu:
package main
import "sync"
type Task struct {
ID int
Done bool
}
type TaskStore struct {
mu sync.RWMutex
tasks map[int]Task
}
Zwróćcie uwagę: tasks to map. A map w Go nie jest bezpieczny wątkowo. Nawet „tylko zerknę jednym okiem” może skończyć się paniką albo wyścigiem, jeśli równolegle ktoś zapisuje. Dlatego jeśli map jest współdzielony przez kilka goroutine, prawie zawsze żyje pod zamkiem.
Stwórzmy konstruktor, który gwarantuje, że map został utworzony:
package main
func NewTaskStore() *TaskStore {
return &TaskStore{tasks: make(map[int]Task)}
}
Dalej — odczyt po id. To czyste czytanie, więc używamy RLock:
package main
func (s *TaskStore) Get(id int) (Task, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
return t, ok
}
Teraz zapis: na przykład utworzenie albo zapisanie zadania (upraszczamy: przyjmujemy gotowe zadanie). Zapis musi odbywać się pod Lock():
package main
func (s *TaskStore) Put(t Task) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[t.ID] = t
}
I jeszcze jeden zapis: oznaczmy zadanie jako wykonane. Tu ważny niuans: czytamy, a potem zapisujemy, ale dla map to i tak jest „zapis w efekcie końcowym”, więc bierzemy write lock:
package main
func (s *TaskStore) MarkDone(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return false
}
t.Done = true
s.tasks[id] = t
return true
}
3. Co liczy się jako odczyt: aliasing i „żywe” kolekcje
Na początku wydaje się, że zasada jest prosta: „pod RLock czytamy, pod Lock piszemy”. Ale w prawdziwym życiu pojawia się subtelny moment: możecie odczytać referencję do wewnętrznych danych, a modyfikować je już po wyjściu z RLock. Formalnie wasza metoda „czytała”, ale faktycznie oddała na zewnątrz dostęp do wnętrza bez ochrony. To właśnie klasyczny błąd aliasing.
Z map zdarza się to rzadziej, bo map jest typem referencyjnym i można zwrócić sam map na zewnątrz. Wtedy kod zewnętrzny zacznie pisać do niego bez zamka. Ze slice'ami jest jeszcze ciekawiej: zwracacie []Task, a kod zewnętrzny robi append albo zmienia element, i wszystko, „ochrona” znika.
Dlatego przy typie bezpiecznym współbieżnie jest dobra zasada projektowa: nie oddawać na zewnątrz „żywych” wewnętrznych kolekcji, jeśli nie chcecie, by były zarządzane z zewnątrz. Najczęściej zwraca się „snapshot” — kopię.
Pokażmy poprawny List(): wewnątrz pod RLock robimy kopię zadań, a potem pracujemy na kopii bez zamka.
package main
func (s *TaskStore) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
Z punktu widzenia współbieżności to świetne: trzymamy RLock tylko tak długo, jak czytamy map i zapełniamy slice. Gdy kopia jest gotowa — można puścić zamek.
4. Kiedy RWMutex jest przydatny
Najuczciwsza odpowiedź na pytanie „kiedy brać RWMutex?” brzmi: kiedy już rozumiecie swój profil obciążenia. Jeśli naprawdę macie dużo współbieżnych odczytów i są one krótkie, to RWMutex pozwala tym odczytom nie przepychać się łokciami. Wtedy zyskujecie dzięki równoległości czytelników.
Bardzo typowy przykład to magazyn konfiguracji albo cache „ostatniego stanu”, który czyta każda goroutine, a aktualizowany jest rzadko. Tam RWMutex może być dobry: czytelnicy idą równolegle, aktualizacja odbywa się szybko i blokuje wszystkich tylko na krótko.
Warto podkreślić jedną rzecz: RWMutex to nie „ulepszony Mutex”. To osobne narzędzie, które zaczyna działać na plus, gdy spełnione są dwa warunki: odczytów jest dużo, a czas odczytu jest mały.
Jeśli odczyt jest długi (na przykład pod RLock sortujecie duży slice, formatujecie JSON albo robicie I/O), zamieniacie „przyspieszacz” w „spowalniacz z kolejką”, bo pisarz będzie czekał zbyt długo.
5. Kiedy RWMutex szkodzi
Bardzo kuszące jest powiedzieć: „skoro RWMutex daje równoległe odczyty, to zawsze go używajmy”. Ale to jak kupowanie sportowego auta do wożenia ziemniaków po polnej drodze. Teoretycznie można. Praktycznie — będzie więcej kłopotów niż pożytku.
Szkoda spowodowana przez RWMutex zwykle objawia się na trzy sposoby.
Po pierwsze, komplikuje kod i zwiększa szansę błędu. Z Mutex macie jedną parę metod, a tutaj są dwie i łatwo je pomylić. Do tego pojawia się „pół-zapis”: wydaje się, że tylko czytacie, ale w rzeczywistości zmieniacie pole struktury, modyfikujecie element slice'a albo dopisujecie do map — i to już jest naruszenie kontraktu.
Po drugie, RWMutex ma narzut. Jeśli współbieżności jest mało albo odczyty prawie się nie pokrywają w czasie, możecie nie przyspieszyć, tylko spowolnić się przez bardziej złożony mechanizm.
Po trzecie, istnieją efekty „głodzenia” (starvation) i oczekiwania. W RWMutex czytelnicy mogą trzymać RLock długo i przez to opóźniać pisarza. A kiedy pisarz już czeka, nowi czytelnicy zwykle zaczynają być hamowani (zależy to od implementacji i warunków), więc możecie dostać nieprzyjemny profil opóźnień: „to szybko, to nagle wszystko stanęło”. Nawet jeśli formalnie nie ma deadlocka, użytkownik odbiera to jako „usługa się zawiesiła”.
6. Zakazany trik: upgrade RLock → Lock
Częsta życiowa sytuacja: chcecie zrobić „sprawdzę pod RLock, a jeśli danych nie ma — utworzę”. I wtedy ręka ciągnie do czegoś w stylu „wewnątrz RLock wywołam Lock”. Tak robić nie wolno: RWMutex nie jest reentrantny, a próba wzięcia write lock, podczas gdy trzymacie read lock (nawet w tej samej goroutine), bardzo łatwo prowadzi do zakleszczenia. Czekacie, aż odejdą czytelnicy, a jednym z czytelników jesteście wy.
Poprawny schemat zwykle wygląda jak „podwójne sprawdzenie”: najpierw czytamy pod RLock, potem zwalniamy, potem bierzemy Lock i sprawdzamy jeszcze raz, bo w międzyczasie ktoś mógł zapisać.
Mini-przykład „utwórz zadanie, jeśli go nie ma” (uproszczony):
package main
func (s *TaskStore) Ensure(id int) {
s.mu.RLock()
_, ok := s.tasks[id]
s.mu.RUnlock()
if ok {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
s.tasks[id] = Task{ID: id}
}
}
Tak, to wygląda trochę dłużej niż „chcę zrobić upgrade locka”. Za to działa przewidywalnie i nie wymaga magii.
7. Praktyka: snapshot pod RLock i praca poza zamkiem
Kiedy używacie RWMutex, warto pamiętać o prostej myśli: zamek chroni nie linijkę kodu, tylko inwariant danych. Jeśli w strukturze macie kilka powiązanych pól, które muszą być spójne, to zapis powinien zmieniać je razem pod jednym Lock, a odczyt powinien patrzeć na spójny snapshot pod RLock.
Bardzo pomaga wzorzec „pod zamkiem — tylko dane, poza zamkiem — ciężka praca”. Na przykład, jeśli chcecie posortować listę zadań po id, nie musicie trzymać RLock podczas sortowania. Pod RLock robicie kopię (snapshot), zwalniacie zamek, sortujecie kopię. Skracacie czas trzymania blokady i zmniejszacie opóźnienia dla pisarzy.
Schematycznie można to przedstawić tak:
flowchart TD
A[Trzeba zwrócić listę zadań] --> B[Bierzemy RLock]
B --> C[Kopiujemy dane do nowego slice'a]
C --> D[Zwalniamy RLock]
D --> E[Sortujemy/filtrujemy kopię bez zamka]
E --> F[Zwracamy wynik]
I tak: jeśli ciągle musicie sobie tłumaczyć, dlaczego tutaj RLock, a tutaj Lock, i czujecie, że „to jest za trudne”, bardzo możliwe, że na razie potrzebny wam zwykły Mutex. Zaczynanie od Mutex jest całkowicie normalną strategią, bo zmniejsza powierzchnię błędów.
8. Typowe błędy przy pracy z sync.RWMutex
Błąd nr 1: zapis pod RLock (nawet „mały”).
Najczęstsza pułapka to sytuacja, gdy pod read lock robicie coś, co technicznie jest zapisem: m[k]++, slice[i] = ..., append(slice, ...), zmiana pola struktury. RLock pozwala na równoległe odczyty, ale nie chroni przed równoległymi zapisami, więc sami sprowadzacie do kodu data race. Jeśli istnieje choć minimalna szansa zmiany — potrzebny jest Lock.
Błąd nr 2: pomieszanie par RLock/RUnlock i Lock/Unlock.
To wygląda zabawnie, dopóki się nie wydarzy. Potem wygląda jak „dlaczego to zawisło w miejscu”. Ratuje dyscyplina: stawiać defer od razu po udanym Lock()/RLock(), a także trzymać metody małe, żeby zamek był „na widoku”.
Błąd nr 3: trzymanie RLock zbyt długo i wykonywanie pod nim ciężkiej pracy.
Czasem programista bierze RLock, pobiera dane i zaczyna pod tym RLock sortować, formatować, budować duży response albo nawet robić I/O. W efekcie pisarze czekają „wieczność”, a czytelnicy zaczynają zwalniać, gdy pisarz już stoi w kolejce. Lepiej zrobić snapshot pod RLock, zwolnić zamek i kontynuować przetwarzanie na kopii.
Błąd nr 4: próba upgrade'u RLock → Lock bez zwalniania read locka.
Intuicyjnie wydaje się, że „już jestem wewnątrz, teraz tylko wzmocnię blokadę”. W RWMutex to nie działa tak, jak by się chciało. Bardzo łatwo o deadlock: czekacie na odejście czytelników, ale jednym z czytelników jesteście wy sami. Poprawna droga to zwolnić RLock, potem wziąć Lock i zwykle ponownie sprawdzić warunek.
Błąd nr 5: oddawanie na zewnątrz wewnętrznych map/[]T i zakładanie, że zamek dalej chroni.
Zamek chroni tylko to, co dzieje się wewnątrz waszych metod. Jeśli oddaliście na zewnątrz wewnętrzny map albo slice, kod zewnętrzny może zmieniać je kiedy chce i jak chce, obchodząc waszą synchronizację. Leczy się to albo „snapshotami” (kopiami), albo API w stylu Get/Set/List, gdzie dostęp jest tylko przez metody właściciela stanu.
GO TO FULL VERSION