Forwarded from Николай Тузов
- Источник
- Перевод
Инженеры Cloudflare рассказали детективную историю о том, как они нашли редчайший баг в компиляторе Go, который проявлялся только на arm64 и только при стечении обстоятельств.
Заваривайте кофе или чаёк и устраивайтесь поудобнее, мы начинаем..
Начало истории:
Один из сервисов начал спорадически паниковать на arm64-машинах с ошибкой "traceback did not unwind completely" — признак повреждения стека. Решили, что это редкая проблема с памятью и не стали копать глубже.
Но паники продолжились.
Первая теория:
- Все критические паники происходили при раскрутке стека
- Коррелировали с recovered panic
- В коде было старое использование panic / recover для обработки ошибок
- Есть похожий баг на GitHub
Убрали panic / recover — паники прекратились. Вздохнули с облегчением.
Но через месяц паники вернулись:
Теперь до 30 в день. Без recovered panics вообще. Никакой корреляции с релизами, инфраструктурой, или положением Марса 🪐
Все крэши происходили в
(*unwinder).next
— при раскрутке стека. Два типа:1. Явная критическая ошибка от среды выполнения
2.
SIGSEGV
при разыменовании указателяЗаметили паттерн: все segfault'ы происходили при асинхронном вытеснении функции
NetlinkSocket.Receive
из библиотеки go-netlink.Что такое асинхронное вытеснение:
До Go 1.14 планировщик был кооперативным — горутины сами решали, когда отдать управление. С 1.14 появилось асинхронное вытеснение: если горутина работает больше 10ms, среда выполнения отправляет SIGURG и принудительно вызывает
asyncPreempt
.Прорыв:
Удалось получить дамп ядра и посмотреть в отладчике. Горутина была остановлена между двумя инструкциями в эпилоге функции:
nl_linux.go:779 0x555577cb287c ADD $80, RSP, RSP
nl_linux.go:779 0x555577cb2880 ADD $(16<<12), RSP, RSP
nl_linux.go:779 0x555577cb2884 RET
Вытеснение произошло между двумя ADD, которые корректируют указатель стека. Стек оказался в несогласованном состоянии!
Почему две инструкции ADD:
На arm64 непосредственный операнд в инструкции ADD — это 12 бит. Для больших значений компилятор разбивает на две операции:
ADD $x, RSP
и ADD $(y<<12), RSP
. Если прерывание происходит между ними, RSP указывает в середину стека, а не на его вершину.Простейшее воспроизведение:
//go:noinline
func big_stack(val int) int {
var big_buffer = make([]byte, 1 << 16)
// ... работа с буфером
}
func main() {
go func() {
for { runtime.GC() }
}()
for { _ = big_stack(1000) }
}
Функция со стековым кадром >64KB, GC в цикле для раскрутки стека, и бесконечные вызовы. Через пару минут — segfault!
Суть бага:
1. Асинхронное вытеснение между
ADD x, RSP
и ADD (y<<12), RSP
2. GC запускает раскрутку стека
3. Раскрутчик пытается прочитать родительский кадр по невалидному RSP
4. Крэш
Условие гонки в одну инструкцию. Невероятно редкий баг!
Исправление:
Для стеков >4KB компилятор теперь сначала строит смещение во временном регистре, потом делает одну атомарную операцию ADD. Вытеснение может произойти до или после, но никогда не во время.
Исправлено в go1.23.12, go1.24.6 и go1.25.0.
————
Очень люблю такие истории!
Месяцы расследования, работа с дампами ядра, изучение внутренностей среды выполнения и ассемблера. И в итоге — баг на уровне компилятора, который проявляется только при стечении обстоятельств: arm64 + большой стек + асинхронное вытеснение + GC
#compiler #debugging
Please open Telegram to view this post
VIEW IN TELEGRAM
The Cloudflare Blog
How we found a bug in Go's arm64 compiler
84 million requests a second means even rare bugs appear often. We'll reveal how we discovered a race condition in the Go arm64 compiler and got it fixed.
🔥38❤9👍6