Historia.
Pakiet sync/atomic zapewnia prymitywy wolne od blokad, które kompilują się do instrukcji sprzętowych. Gdy Go został przeniesiony na systemy 32-bitowe (x86-32, ARM32), runtime napotkał procesory, które nie miały natywnego wsparcia dla niewyrównanego dostępu atomowego 64-bitowego. Wczesne wersje pozwalały na dowolne wyrównanie, co prowadziło do błędów magistrali lub cichej korupcji danych. Aby zapewnić przenośność, zespół Go nakazał, aby adres dowolnej wartości 64-bitowej, na której operują funkcje atomic, musiał być wyrównany do 8 bajtów na architekturach 32-bitowych.
Problem.
Jeśli programista przekaże wskaźnik do int64, który nie jest wyrównany do 8-bajtowej granicy — na przykład pole na przesunięciu 4 wewnątrz struktury — operacja atomowa wykrywa to w czasie wykonywania. W budowach 32-bitowych runtime natychmiast kończy program z błędem: unaligned 64-bit atomic operation. Ta twarda awaria zapobiega połowicznemu odczytowi lub zapisowi, które mogłyby naruszyć gwarancje atomowości.
Rozwiązanie.
Komputer Go automatycznie wyrównuje pola struktury do ich naturalnego rozmiaru, ale programiści wciąż muszą poprawnie rozmieszczać pola: umieścić pola int64 na początku struktury lub upewnić się, że następują po innych typach 8-bajtowych. Alternatywnie, można użyć atomic.Int64 (dostępne od Go 1.19), które kapsułkuje wartość i gwarantuje wyrównanie za pośrednictwem systemu typów. W przypadku zmiennych globalnych linker zapewnia odpowiednie wyrównanie.
type Metrics struct { // sum jest umieszczone pierwsze, aby zapewnić 8-bajtowe wyrównanie w 32-bitowym. sum int64 count int32 } func (m *Metrics) Add(v int64) { // Bezpieczne na architekturach 32-bitowych i 64-bitowych. atomic.AddInt64(&m.sum, v) }
Scenariusz.
Usługa bramy IoT działająca na 32-bitowym ARM Cortex-A7 zbierała telemetrię. Początkowa struktura umieściła 32-bitowy DeviceID przed 64-bitowym EnergyCounter. Gorutyny o dużej przepustowości wywoływały atomic.AddInt64(&device.EnergyCounter, delta). Natychmiast po wdrożeniu, usługa awaria z runtime error: unaligned 64-bit atomic operation, ponieważ EnergyCounter znajdował się na przesunięciu 4.
Rozważane rozwiązania.
Przebudowa pól struktury.
Przeniesienie pól int64 na górę struktury zapewnia wyrównanie wciąż przy przesunięciu 0. To podejście nie zwiększa pamięci i wpisuje się w idiomatyczny układ „największe pola najpierw”. Wadą jest drobna utrata grupowania logicznego, ponieważ DeviceID nie będzie już widoczny jako pierwszy w kodzie źródłowym.
Wstawienie jawnego wyściółki.
Dodanie 4-bajtowego pola pad int32 przed EnergyCounter wymusza odpowiednie wyrównanie. Ta metoda jest jawna i samodokumentująca, ale marnuje 4 bajty na każdą strukturę. Przy milionach rekordów na urządzenie, ten koszt stał się istotny dla pamięci flash osadzonej.
Przyjęcie atomic.Int64.
Refaktoryzacja pola do typu otaczającego atomic.Int64 eliminuje problemy z wyrównaniem, ponieważ typ sam w sobie niesie wymaganie 8-bajtowego wyrównania. Jednak wymagało to refaktoryzacji każdego miejsca wywołania z atomic.AddInt64(&d.EnergyCounter, v) na d.EnergyCounter.Add(v), co wprowadza ryzyko regresji w nieprzetestowanych ścieżkach kodu.
Wybrane rozwiązanie.
Zespół wybrał przebudowę pól (Rozwiązanie 1). Umieszczając wszystkie liczniki 64-bitowe na początku struktury, osiągnęli wyrównanie bez dodatkowych kosztów pamięci ani zmian w API. To jest zgodne z przysłowiem Go: „Umieszczaj większe pola przed mniejszymi.” Dodano linter fieldalignment do CI, aby zapobiec przyszłym regresjom.
Rezultat.
Panicz zniknął w całej flocie ARM32. Usługa działała przez dwa lata bez awarii związanych z atomowością, a optymalizacja układu struktury zmniejszyła zużycie pamięci o 8% dzięki lepszemu pakowaniu pozostałych pól.
Dlaczego atomic.LoadInt64 udaje się na adresach niewyrównanych na architekturach 64-bitowych, ale powoduje panikę na 32-bitowych?
Na architekturach 64-bitowych (amd64, arm64) sprzętowy jednostka zarządzania pamięcią wspiera niewyrównany dostęp do wartości 64-bitowych, chociaż może to prowadzić do kar finansowych. Instrukcje atomowe (np. MOVQ na x86-64) nie powodują błędów na danych niewyrównanych. Z drugiej strony, architektury 32-bitowe używają sparowanych rejestrów 32-bitowych lub specyficznych instrukcji atomowych 64-bitowych (takich jak LDREXD/STREXD na ARM32), które wymagają 8-bajtowego wyrównania; w przeciwnym razie zgłaszają błąd wyrównania sprzętowego, który runtime Go tłumaczy na śmiertelny błąd „unaligned 64-bit atomic operation”.
Jak osadzenie atomic.Int64 w zdefiniowanej przez użytkownika strukturze zapewnia wyrównanie na systemach 32-bitowych bez ręcznej wyściółki?
Typ atomic.Int64 jest zdefiniowany jako struktura zawierająca int64. Kompilator Go przypisuje wymaganie wyrównania strukturze równą maksymalnemu wyrównaniu jej pól. Ponieważ int64 wymaga 8-bajtowego wyrównania, atomic.Int64 dziedziczy to wymaganie. Gdy jest osadzony jako pole, kompilator wstawia, jeśli to konieczne, wcześniejsze bajty wypełniające, aby zapewnić, że przesunięcie pola jest wielokrotnością 8. Dodatkowo, alokacje na stosie zaokrąglają rozmiar w górę do wyrównania typu, więc wskaźnik do osadzonego pola zawsze ma wyrównanie 8-bajtowe.
Dlaczego konwersja []byte na []int64 za pomocą rzutowania unsafe może prowadzić do panik wyrównania na architekturach 32-bitowych, nawet jeśli długość slice'a jest wystarczająca?
[]byte jest wspierany przez tablicę bajtów. Adres bazowy tej tablicy jest gwarantowany, aby być wyrównany dla dostępu do bajtów (wyrównanie 1-bajtowe), ale niekoniecznie dla dostępu do 8 bajtów. Podczas używania unsafe do rzutowania wskaźnika na *int64 lub zmiany rozmiaru jako []int64, pierwszy element może znajdować się pod adresem jak 0x1001, który nie jest podzielny przez 8. Przekazanie &int64Slice[0] do atomic.LoadInt64 powoduje następnie wywołanie sprawdzenia wyrównania. Bezpieczna konwersja wymaga zapewnienia, że oryginalny slice bajtów jest alokowany z wyrównanego źródła (np. za pomocą make([]int64, ...) i rzutowania na []byte w celu zapisu), lub użycie copy do wyrównanego bufora.