# Niskopoziomowa synchronizacja w Go: atomiki, bariery i przeuporządkowanie instrukcji
Rozumienie mechanizmów synchronizacji na poziomie procesora to nie akademicki luksus, lecz praktyczna konieczność dla programistów wielowątkowych aplikacji w Go. W tym artykule omawiamy, jak CPU optymalizuje wykonywanie kodu, dlaczego psuje to oczekiwaną logikę w programach współbieżnych i jakie prymitywy sprzętowe pomagają przywrócić porządek.
Jak procesor oszukuje programistę: reordering i out-of-order execution
Gdy piszesz x = 1; ready = true, oczekujesz ścisłej kolejności. Procesor — nie. Przestawia niezależne instrukcje, by wypełnić przestoje w potoku. Nazywa się to instruction reordering — przeuporządkowanie instrukcji. Cel: maksymalizować wykorzystanie bloków wykonawczych i ukrywać opóźnienia pamięci.
CPU korzysta z wykonywania poza kolejnością (out-of-order execution): instrukcje wykonują się nie według kolejności w kodzie, lecz w miarę gotowości ich operandów. Na przykład:
MOVQ A(SB), AX ; zagruzka from pamyati
MOVQ B(SB), BX ; zagruzka from pamyati
ADDQ DX, CX ; slozhenie registtrov
Procesor może zacząć od ADDQ, bo operandy są już w rejestrach, a ładowania wysłać w tło — te zajmą setki taktów. Logiczny wynik dla bieżącego wątku się nie zmieni, ale dla innych — może.
Etapy potoku, gdzie rodzi się chaos:
- Fetch → Decode → Register Rename → Dispatch → Execute → Commit
Kluczowy moment — etap Dispatch. Tu scheduler wybiera instrukcje z gotowymi operandami, ignorując oryginalną kolejność. Reorder Buffer (ROB) gwarantuje, że zatwierdzanie instrukcji do stanu architektonicznego odbywa się w właściwej sekwencji — ale tylko dla bieżącego rdzenia.
Memory reordering: kiedy store buffer staje się zdrajcą
Problem pogłębia się na poziomie pamięci. Zapis do RAM nie następuje natychmiast — trafia do store buffer, a stamtąd asynchronicznie spływa do pamięci podręcznej. Dla jednego rdzenia:
x = 1
ready = true
Dla innego rdzenia może być widoczne jako:
ready = true
x = 0
Ponieważ zapis ready=true mógł wcześniej opuścić store buffer. To memory reordering — przeuporządkowanie operacji pamięci, widoczne dla innych wątków. Klasyczny przykład wyścigu:
G1:
x = 1
ready = true
G2:
if ready {
print(x) // może napechatat 0!
}
Bariery pamięci: MFENCE i inne narzędzia przywracania porządku
Rozwiązanie — memory fences (bariery pamięci). To instrukcje wymuszające synchronizację widoczności operacji między rdzeniami. Na x86 główny barer — MFENCE:
MOVQ $1, x(SB)
MFENCE ; barer
MOVQ $1, ready(SB)
MFENCE gwarantuje, że wszystkie operacje zapisu przed nim będą widoczne dla wszystkich rdzeni przed rozpoczęciem operacji po nim. Zakazuje czterech typów przeuporządkowania:
- Store → Store
- Load → Load
- Load → Store
- Store → Load
Ważne: x86 ma stosunkowo ścisły model pamięci — wiele przeuporządkowań jest już sprzętowo zabronionych. Ale Store→Load nadal możliwe, dlatego MFENCE jest niezbędne do ścisłej synchronizacji.
Lista typów barier na różnych architekturach:
- x86: MFENCE (pełny), SFENCE (store→store), LFENCE (load→load)
- ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
- RISC-V: FENCE (elastyczny, z określeniem kierunków)
Operacje atomowe: wysokopoziomowa abstrakcja nad LOCK
Go ukrywa bariery za operacjami atomowymi z pakietu sync/atomic. Pod maską używają instrukcji LOCK, która czyni operacje read-modify-write niepodzielnymi dla wszystkich rdzeni. Przykład XADDQ:
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ delta+8(FP), AX
MOVQ AX, CX
LOCK
XADDQ AX, 0(BX) ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET
LOCK działa poprzez blokadę linii pamięci podręcznej w protokole MESI:
- Rdzeń przejmuje wyłączne prawo do linii pamięci podręcznej
- Wykonuje operację
- Uwalnia linię — zmiany stają się widoczne dla innych rdzeni
Atomowość ≠ uporządkowanie! Operacja atomowa gwarantuje niepodzielność, ale niekoniecznie tworzy memory barrier. Na x86 proste load/store są atomowe, ale nie zapewniają ordering.
Piszemy własną atomową AND: praktyka na Go Assembler
Standardowa biblioteka Go nie oferuje atomic.And. Napiszemy sami. Stwórzmy plik Xand8_x86.s:
#include "textflag.h"
TEXT ·And8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX // BX = ukazatel
MOVB mask+8(FP), AX // AX = maska (8 bit)
LOCK
ANDB AX, 0(BX) // *ptr &= mask
RET
I odpowiadający main.go:
package main
import "fmt"
func And8(ptr *uint8, mask uint8)
func main() {
var smth uint8 = 3
And8(&smth, uint8(2))
fmt.Println("Result:", smth) // wyprowadzi 2
}
Kompilujemy i uruchamiamy przez go run . — plik ASM zostanie automatycznie podchwycony. Zwróć uwagę: używamy ANDB z prefiksem LOCK, choć na x86 nie ma instrukcji XAND. To dozwolone — LOCK jest kompatybilny z podstawowymi operacjami logicznymi.
Co ważne
- Instruction reordering — optymalizacja CPU zmieniająca kolejność wykonywania niezależnych instrukcji dla zwiększenia wydajności.
- Memory reordering — desynchronizacja widoczności operacji zapisu między rdzeniami z powodu store buffer.
- Memory fences (bariery) — instrukcje wymuszające synchronizację kolejności operacji pamięci między wątkami.
- Atomarnye operatsii — niepodzielne działania read-modify-write, realizowane przez LOCK i kapsułkujące bariery.
- LOCK + ANDB — działający sposób na zaimplementowanie atomowej operacji bitowej, brakującej w standardowej bibliotece Go.
— Editorial Team
Brak komentarzy.