Powrót do strony głównej

Synchronizacja w Go: atomiki i bariery pamięci

Analiza mechanizmów synchronizacji na poziomie procesora: instruction i memory reordering, memory fences, operacje atomowe. Praktyczny przykład implementacji atomic.And w Go Assembler.

Nisko-poziomowa synchronizacja w Go: od CPU do atomików
Advertisement 728x90

# 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:

Google AdInline article slot
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.

Google AdInline article slot

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:

Google AdInline article slot
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

Advertisement 728x90

Czytaj dalej