Каждую секунду в дата-центры Cloudflare в 330 городах отправляется 84 миллиона HTTP-запросов. Из-за этого даже самые редкие из багов возникают достаточно часто. На самом деле, именно наши масштабы позволили нам недавно обнаружить в компиляторе Go на arm64 баг, вызывающий состояние гонки в генерируемом коде.

В этом посте мы расскажем о том, как впервые столкнулись с багом, исследовали его и докопались до его первопричины.

Исследование странной паники

В нашей сети работает сервис, конфигурирующий ядро для обработки трафика продуктов наподобие Magic Transit и Magic WAN. Наш внимательно следящий за ним мониторинг внезапно начал наблюдать очень бессистемные паники на машинах arm64.

Впервые мы увидели одну из них из-за критической ошибки, сообщающей, что обратной трассировке не удалось полностью раскрутиться. Эта ошибка предполагает, что при обходе стека были нарушены инварианты, и вызвано это, скорее всего, повреждением стека. После краткого изучения мы решили, что это, вероятно, редкий случай повреждения памяти стека. Этот сервис имел достаточно неактивную плоскость управления, поэтому незапланированные перезапуски не оказывали существенного влияния, и дальнейшее изучение проблемы не будет приоритетным, если не она станет повторяться.

Но она стала повторяться. 

Количество дампов ядра в час

BLOG-2906 2

Когда мы впервые встретили этот баг, то увидели, что критические ошибки коррелировали с восстановленной паникой. Она была вызвана каким-то старым кодом, использовавшим панику/восстановление как обработку ошибок. 

На тот момент наша теория была такой: 

  1. Все критические паники возникают в раскрутке стека.

  2. Мы обнаружил�� корреляцию между повысившимся объёмом восстановленных паник с этими критическими паниками.

  3. Восстановленная паника раскручивает стеки горутин для вызова отложенных функций.

  4. В issue языка Go (73259) сообщалось о вылетах при раскрутке стека на arm64.

  5. Наверно, стоит перестать использовать панику/восстановление для обработки ошибок и дождаться устранения бага в апстриме?

Так мы и поступили, после чего отметили, что после выкатывания релиза критические паники перестали возникать. Критические паники пропали, наше теоретическое решение проблемы как будто сработало, и это уже стало не нашей проблемой. Мы подписались на issue апстрима, чтобы можно было обновиться, когда баг будет устранён, после чего выкинули это из головы.

Но оказалось, что этот баг гораздо более странный, чем мы ожидали. Забывать о нём было рано, потому что тот же класс критических паник вернулся в гораздо больших объёмах. Месяц спустя мы наблюдали до тридцати ежедневных критических паник по совершенно непонятной причине; пусть это происходило лишь на одной машине в день в менее чем 10% наших дата-центров, нас беспокоило то, что мы не понимали причину. Первым делом мы проверили количество восстановленных паник, чтобы найти совпадения с предыдущим паттерном, но их не было. Ещё интереснее было то, что мы не могли найти корреляций повышения частоты критических паник ни с чем. В чём же причина? Релиз? Инфраструктурные изменения? Ретроградный Марс?

Мы поняли, что нужно копать глубже, чтобы добраться до первопричины. Надежд и сопоставления паттернов оказалось недостаточно.

Мы встречали два класса этого бага — вылет при доступе к недопустимой памяти и проверяемая явным образом критическая ошибка.

Критическая ошибка

goroutine 153 gp=0x4000105340 m=324 mp=0x400639ea08 [GC worker (active)]:
/usr/local/go/src/runtime/asm_arm64.s:244 +0x6c fp=0x7ff97fffe870 sp=0x7ff97fffe860 pc=0x55558d4098fc
runtime.systemstack(0x0)
       /usr/local/go/src/runtime/mgc.go:1508 +0x68 fp=0x7ff97fffe860 sp=0x7ff97fffe810 pc=0x55558d3a9408
runtime.gcBgMarkWorker.func2()
       /usr/local/go/src/runtime/mgcmark.go:1102
runtime.gcDrainMarkWorkerIdle(...)
       /usr/local/go/src/runtime/mgcmark.go:1188 +0x434 fp=0x7ff97fffe810 sp=0x7ff97fffe7a0 pc=0x55558d3ad514
runtime.gcDrain(0x400005bc50, 0x7)
       /usr/local/go/src/runtime/mgcmark.go:212 +0x1c8 fp=0x7ff97fffe7a0 sp=0x7ff97fffe6f0 pc=0x55558d3ab248
runtime.markroot(0x400005bc50, 0x17e6, 0x1)
       /usr/local/go/src/runtime/mgcmark.go:238 +0xa8 fp=0x7ff97fffe6f0 sp=0x7ff97fffe6a0 pc=0x55558d3ab578
runtime.markroot.func1()
       /usr/local/go/src/runtime/mgcmark.go:887 +0x290 fp=0x7ff97fffe6a0 sp=0x7ff97fffe560 pc=0x55558d3acaa0
runtime.scanstack(0x4014494380, 0x400005bc50)
       /usr/local/go/src/runtime/traceback.go:447 +0x2ac fp=0x7ff97fffe560 sp=0x7ff97fffe4d0 pc=0x55558d3eeb7c
runtime.(*unwinder).next(0x7ff97fffe5b0?)
       /usr/local/go/src/runtime/traceback.go:566 +0x110 fp=0x7ff97fffe4d0 sp=0x7ff97fffe490 pc=0x55558d3eed40
runtime.(*unwinder).finishInternal(0x7ff97fffe4f8?)
       /usr/local/go/src/runtime/panic.go:1073 +0x38 fp=0x7ff97fffe490 sp=0x7ff97fffe460 pc=0x55558d403388
runtime.throw({0x55558de6aa27?, 0x7ff97fffe638?})
runtime stack:
fatal error: traceback did not unwind completely
       stack=[0x4015d6a000-0x4015d8a000
runtime: g8221077: frame.sp=0x4015d784c0 top=0x4015d89fd0

Ошибка сегментации

goroutine 187 gp=0x40003aea80 m=13 mp=0x40003ca008 [GC worker (active)]:
       /usr/local/go/src/runtime/asm_arm64.s:244 +0x6c fp=0x7fff2afde870 sp=0x7fff2afde860 pc=0x55557e2d98fc
runtime.systemstack(0x0)
       /usr/local/go/src/runtime/mgc.go:1489 +0x94 fp=0x7fff2afde860 sp=0x7fff2afde810 pc=0x55557e279434
runtime.gcBgMarkWorker.func2()
       /usr/local/go/src/runtime/mgcmark.go:1112
runtime.gcDrainMarkWorkerDedicated(...)
       /usr/local/go/src/runtime/mgcmark.go:1188 +0x434 fp=0x7fff2afde810 sp=0x7fff2afde7a0 pc=0x55557e27d514
runtime.gcDrain(0x4000059750, 0x3)
       /usr/local/go/src/runtime/mgcmark.go:212 +0x1c8 fp=0x7fff2afde7a0 sp=0x7fff2afde6f0 pc=0x55557e27b248
runtime.markroot(0x4000059750, 0xb8, 0x1)
       /usr/local/go/src/runtime/mgcmark.go:238 +0xa8 fp=0x7fff2afde6f0 sp=0x7fff2afde6a0 pc=0x55557e27b578
runtime.markroot.func1()
       /usr/local/go/src/runtime/mgcmark.go:887 +0x290 fp=0x7fff2afde6a0 sp=0x7fff2afde560 pc=0x55557e27caa0
runtime.scanstack(0x40042cc000, 0x4000059750)
       /usr/local/go/src/runtime/traceback.go:458 +0x188 fp=0x7fff2afde560 sp=0x7fff2afde4d0 pc=0x55557e2bea58
runtime.(*unwinder).next(0x7fff2afde5b0)
goroutine 0 gp=0x40003af880 m=13 mp=0x40003ca008 [idle]:
PC=0x55557e2bea58 m=13 sigcode=1 addr=0x118
SIGSEGV: segmentation violation

Теперь мы могли наблюдать чёткие паттерны. Обе ошибки возникали при раскрутке стека в (*unwinder).next. В одном случае мы наблюдали намеренную критическую ошибку, поскольку среда исполнения обнаруживала, что раскрутку завершить невозможно и что стек находится в плохом состоянии. В другом случае это была ошибка прямого доступа к памяти, происходившая при попытке раскрутки стека. Segfault обсуждалась в issue на GitHub, и разработчик Go идентифицировал её, как разыменование структуры m планировщика Go при раскрутке

Обзор структур планировщика Go

Для управления конкурентностью Go использует легковесный планировщик пользовательского пространства. Множество горутин распределяется по меньшему количеству потоков ядра — часто это называется планированием M:N. Любая отдельная горутина может быть привязана к любому потоку ядра. У планировщика есть три типа ядер — g (горутина), m (поток ядра, или «машина») и p (физический контекст исполнения, или «процессор»). Чтобы горутина была распределена, свободное ядро m должно получить свободное p, которое будет исполнять g. Каждое g, если оно выполняется, содержит поле своего m; в противном случае оно имеет значение nil. Этого контекста вполне достаточно для понимания нашего поста, но если вам интересны подробности, то изучите документацию по среде исполнения Go

На этом этапе уже можно выдвигать предположения о происходящем: программа вылетает, потому что мы пытаемся раскрутить невалидный стек горутины. Если при первой обратной трассировке адрес возврата пустой, то мы вызываем finishInternal и прерываем выполнение, потому что стек раскручен не полностью. Случай ошибки сегментации во второй обратной трассировке более интересен: если адрес возврата ненулевой, но не является функцией, то код раскрутки предполагает, что горутина в данный момент выполняется. Затем программа выполнит разыменование и вылетит при получении доступа к m.incgo (смещение incgo в struct m равно 0x118, ошибочный доступ к памяти).

Но что же вызывает это повреждение? Из трассировок было сложно извлечь что-то полезное — у нашего сервиса сотни, если не тысячи активных горутин. С самого начала было достаточно ясно, что паника была далека от самого бага. Все вылеты наблюдались при раскрутке стека, и если бы это было проблемой, но при каждой раскрутке стека на arm64 мы наблюдали бы её в гораздо большем количестве сервисов. Мы были практически уверены, что раскрутка стека происходила корректно, но для невалидного стека. 

На этом наше расследование начало пробуксовывать — мы придумывали гипотезы, проверяли их, пытались понять, увеличивается или снижается частота паник, или же ничего не меняется. В issue tracker языка Go на GitHub имелась известная issue, которая почти полностью соответствовала нашим симптомам, но в ней обсуждалось почти всё то, что мы уже знали. В какой-то момент, изучая трассировки стеков, мы осознали, что вылеты в issue ссылались на старую версию библиотеки, которой пользовались и мы — Go Netlink.

goroutine 1267 gp=0x4002a8ea80 m=nil [runnable (scan)]:
runtime.asyncPreempt2()
        /usr/local/go/src/runtime/preempt.go:308 +0x3c fp=0x4004cec4c0 sp=0x4004cec4a0 pc=0x46353c
runtime.asyncPreempt()
        /usr/local/go/src/runtime/preempt_arm64.s:47 +0x9c fp=0x4004cec6b0 sp=0x4004cec4c0 pc=0x4a6a8c
github.com/vishvananda/netlink/nl.(*NetlinkSocket).Receive(0x14360300000000?)
        /go/pkg/mod/github.com/!data!dog/netlink@v1.0.1-0.20240223195320-c7a4f832a3d1/nl/nl_linux.go:803 +0x130 fp=0x4004cfc710 sp=0x4004cec6c0 pc=0xf95de0

Мы выборочно проверили несколько трассировок стеков и подтвердили наличие в них этой библиотеки Netlink. Изучение наших логов показало, что мы не только использовали библиотеку, но и каждая ошибка сегментации происходила при вытеснении NetlinkSocket.Receive.

Что такое вытеснение (асинхронное)?

В доисторическую эпоху Go (младше 1.13) среда исполнения планировалась кооперативно. Горутина выполнялась, пока не решала, что готова отдать управление планировщику; обычно это происходило из-за явных вызовов runtime.Gosched() или из-за инъецированных точек передачи управления при вызовах функций/операциях ввода-вывода. Начиная с Go 1.14 среда исполнения реализует асинхронную вытесняющую многозадачность. У среды исполнения Go есть поток sysmon, отслеживающий исполнение горутин и вытесняющий те, которые выполняются дольше 10 мс (на момент написания поста). Это выполняется отправкой потоку операционной системы SIGURG, а в обработчике сигналов изменением счётчика программы и стека, чтобы имитировать вызов asyncPreempt.

На этом моменте у нас было две нечёткие теории:

  • Это баг Go Netlink, вероятно, вызванный использованием unsafe.Pointer, приводящий к неопределённому поведению, но возникающий только на arm64.

  • Это баг среды исполнения Go, но он по какой-то причине срабатывает только в NetlinkSocket.Receive.

Найдя публичный отчёт о том же баге в апстриме, мы обрели уверенность в том, что это баг среды исполнения Go. Однако увидев, что в обоих issue была замешана одна и та же функция, мы отнеслись к этому скептичнее — примечательно, что библиотека Go Netlink использует unsafe.Pointer, поэтому правдоподобным объяснением показалось повреждение памяти, хоть мы и не понимали его причин.

После безуспешного аудита кода мы зашли в тупик. Вылеты были редкими и далёкими от первопричины. Возможно, эти вылеты вызывались багом среды исполнения, возможно, причиной был баг Go Netlink. Казалось очевидным, что в этой области кода что-то не так, но аудит кода ни к чему нас не привёл.

Открытие

На этом этапе у нас было достаточно чёткое понимание того, что именно вылетало, но почти полное отсутствие понимания причин происходящего. Было ясно, что первопричина вылета раскрутки стека располагалась далеко от самого вылета и что она была связана с (*NetlinkSocket).Receive, но почему? Нам удалось создать дамп ядра при вылете в продакшене и изучить его в отладчике. Обратная трассировка подтвердила то, что мы уже знали — при раскрутке стека возникает ошибка сегментации. Суть проблемы проявлялась, когда мы изучили горутину, вытеснение которой происходило при вызове (*NetlinkSocket).Receive

(dlv) bt
0  0x0000555577579dec in runtime.asyncPreempt2
   at /usr/local/go/src/runtime/preempt.go:306
1  0x00005555775bc94c in runtime.asyncPreempt
   at /usr/local/go/src/runtime/preempt_arm64.s:47
2  0x0000555577cb2880 in github.com/vishvananda/netlink/nl.(*NetlinkSocket).Receive
   at
/vendor/github.com/vishvananda/netlink/nl/nl_linux.go:779
3  0x0000555577cb19a8 in github.com/vishvananda/netlink/nl.(*NetlinkRequest).Execute
   at 
/vendor/github.com/vishvananda/netlink/nl/nl_linux.go:532
4  0x0000555577551124 in runtime.heapSetType
   at /usr/local/go/src/runtime/mbitmap.go:714
5  0x0000555577551124 in runtime.heapSetType
   at /usr/local/go/src/runtime/mbitmap.go:714
...
(dlv) disass -a 0x555577cb2878 0x555577cb2888
TEXT github.com/vishvananda/netlink/nl.(*NetlinkSocket).Receive(SB) /vendor/github.com/vishvananda/netlink/nl/nl_linux.go
        nl_linux.go:779 0x555577cb2878  fdfb7fa9        LDP -8(RSP), (R29, R30)
        nl_linux.go:779 0x555577cb287c  ff430191        ADD $80, RSP, RSP
        nl_linux.go:779 0x555577cb2880  ff434091        ADD $(16<<12), RSP, RSP
        nl_linux.go:779 0x555577cb2884  c0035fd6        RET

Горутина была приостановлена между двумя опкодами в эпилоге функции. Так как в процессе раскрутки стека критически важно, чтобы стековый кадр находился в постоянном состоянии, мы сразу что-то заподозрили, когда увидели вытеснение посередине корректировки указателя стека. Горутина была приостановлена на 0x555577cb2880, между ADD $80, RSP, RSP и ADD $(16<<12), RSP, RSP

Мы запросили логи сервиса, чтобы подтвердить нашу теорию. Это был не единичный случай — большинство трассировок стека показывало, что выполняется вытеснение именно этого опкода. Теперь это уже не был странный вылет продакшена, который мы не можем воспроизвести. Вылет возникал, когда среда исполнения Go выполняла вытеснение между этими двумя корректировками стекового кадра. Улика найдена. 

Создаём минимальные условия для воспроизведения

Теперь мы были достаточно уверены в том, что это просто баг среды исполнения и что его можно будет воспроизвести в изолированном окружении без каких-либо зависимостей. На этом этапе теория выглядела так:

  1. Сборка мусора вызывает раскрутку стека.

  2. Асинхронное вытеснение между разделённой корректировкой указателя стека вызывает вылет.

  3. Что, если мы напишем функцию, разбивающую корректировку, и будем вызывать её в цикле?

package main

import (
	"runtime"
)

//go:noinline
func big_stack(val int) int {
	var big_buffer = make([]byte, 1 << 16)

	sum := 0
	// предотвращаем оптимизацию стека компилятором
	for i := 0; i < (1<<16); i++ {
		big_buffer[i] = byte(val)
	}
	for i := 0; i < (1<<16); i++ {
		sum ^= int(big_buffer[i])
	}
	return sum
}

func main() {
	go func() {
		for {
			runtime.GC()
		}
	}()
	for {
		_ = big_stack(1000)
	}
}

Размер функции в стековом кадре оказывается чуть больше 16 бит, поэтому на arm64 компилятор Go разделяет корректировку указателя стека на два опкода. Если среда исполнения выполнит вытеснение между этими двумя опкодами, то раскрутчик стека считает невалидный указатель стека и вылетает. 

; эпилог для main.big_stack
ADD $8, RSP, R29
ADD $(16<<12), R29, R29
ADD $16, RSP, RSP
; вытеснение между этими опкодами вызовет проблемы
ADD $(16<<12), RSP, RSP
RET

После выполнения этой программы в течение нескольких минут она запаниковала. Этого мы и ждали!

SIGSEGV: segmentation violation
PC=0x60598 m=8 sigcode=1 addr=0x118

goroutine 0 gp=0x400019c540 m=8 mp=0x4000198708 [idle]:
runtime.(*unwinder).next(0x400030fd10)
        /home/thea/sdk/go1.23.4/src/runtime/traceback.go:458 +0x188 fp=0x400030fcc0 sp=0x400030fc30 pc=0x60598
runtime.scanstack(0x40000021c0, 0x400002f750)
        /home/thea/sdk/go1.23.4/src/runtime/mgcmark.go:887 +0x290 

[...]

goroutine 1 gp=0x40000021c0 m=nil [runnable (scan)]:
runtime.asyncPreempt2()
        /home/thea/sdk/go1.23.4/src/runtime/preempt.go:308 +0x3c fp=0x40003bfcf0 sp=0x40003bfcd0 pc=0x400cc
runtime.asyncPreempt()
        /home/thea/sdk/go1.23.4/src/runtime/preempt_arm64.s:47 +0x9c fp=0x40003bfee0 sp=0x40003bfcf0 pc=0x75aec
main.big_stack(0x40003cff38?)
        /home/thea/dev/stack_corruption_reproducer/main.go:29 +0x94 fp=0x40003cff00 sp=0x40003bfef0 pc=0x77c04
Segmentation fault (core dumped)

real    1m29.165s
user    4m4.987s
sys     0m43.212s

Воспроизводимый вылет при работе с одной только стандартной библиотекой? Похоже, это окончательное свидетельство того, что наша проблема является багом среды исполнения.

Код воспроизведения был крайне конкретным! Но даже теперь, когда мы хорошо представляли суть бага и способ его устранения, часть поведения всё равно оставалась загадочной. Это условие гонки для одной команды, поэтому неудивительно, что маленькие изменения могут приводить к серьёзным последствиям. Например, этот код воспроизведения изначально писался и тестировался на Go 1.23.4, но не вылетал при компилировании с помощью 1.23.9 (версии в продакшене), даже несмотря на то, что мы могли сделать objdump двоичного файла и увидеть, что разбиение ADD всё ещё существует! У нас не было чёткого объяснения такому поведению — даже при наличии бага оставалось ещё несколько неизвестных переменных, влиявших на вероятность возникновения состояния гонки. 

Окно условия гонки в одну команду

arm64 — это архитектура набора команд с фиксированной 4-байтной длиной. Это во многом влияет на генерацию кода, но самое важное для этого бага заключается в том, что эта длина непосредственного операнда ограничена. add получает 12-битную длину, mov — 16-битную длину и так далее. Как архитектура обрабатывает это, когда операнды не умещаются в длину? Зависит от ситуации — в частности, ADD резервирует бит под «сдвиг влево на 12», поэтому любое 24-битное сложение можно разложить на два опкода. Прочие команды раскладываются аналогично или просто требуют предварительной загрузки непосредственного значения в регистр. 

Самый последний этап компилятора Go перед непосредственной выдачей машинного кода заключается в преобразовании программы в структуры obj.Prog. Это очень низкоуровневое промежуточное представление (IR), которое в основном предназначено для трансляции в машинный код. 

//https://github.com/golang/go/blob/fa2bb342d7b0024440d996c2d6d6778b7a5e0247/src/cmd/internal/obj/arm64/obj7.go#L856

// Извлекаем стековый кадр.
// ADD $framesize, RSP, RSP
p = obj.Appendp(p, c.newprog)
p.As = AADD
p.From.Type = obj.TYPE_CONST
p.From.Offset = int64(c.autosize)
p.To.Type = obj.TYPE_REG
p.To.Reg = REGSP
p.Spadj = -c.autosize

Примечательно то, что это IR не знает об ограничениях длины непосредственных операндов. Они учитываются в asm7.go, когда внутреннее промежуточное представление Go транслируется в машинный код arm64. Ассемблер классифицирует непосредственный операнд в класс констант (conclass) в зависимости от битового размера, а затем использует их для генерации команд (при необходимости и дополнительных).

Ассемблер Go использует комбинацию опкодов (mov, add) для некоторых сложений, умещающихся в 16-битные непосредственные значения, и предпочитает опкоды (add, add + lsl 12) для непосредственных значений больше 16 бит. 

Сравним стек, который (чуть больше, чем) 1<<15:

; //go:noinline
; func big_stack() byte {
; 	var big_stack = make([]byte, 1<<15)
; 	return big_stack[0]
; }
MOVD $32776, R27
ADD R27, RSP, R29
MOVD $32784, R27
ADD R27, RSP, RSP
RET

Со стеком 1<<16:

; //go:noinline
; func big_stack() byte {
; 	var big_stack = make([]byte, 1<<16)
; 	return big_stack[0]
; } 
ADD $8, RSP, R29
ADD $(16<<12), R29, R29
ADD $16, RSP, RSP
ADD $(16<<12), RSP, RSP
RET

В случае большего размера стека между опкодами ADD x, RSP, RSP есть точка, в которой указатель стека не указывает на вершину стекового кадра. Поначалу мы думали, что это было причиной повреждения памяти — при обработке асинхронного вытеснения среда исполнения записывает вызов функции в стек и повреждает середину стека. Однако эта горутина уже находится в эпилоге функции — все повреждённые данные находятся в активном процессе отбрасывания. Тогда в чём здесь проблема?

Среде исполнения Go часто бывает нужно раскрутить стек, то есть пройти назад по цепочке вызовов функций. Пример: сборка мусора использует это для нахождения в стеке живых ссылок, панике это необходимо для вычисления функций defer, а для генерации трассировки стеков необходимо выводить стек вызовов. Чтобы всё это работало, указатель стека должен быть точным при раскрутке из-за способа разыменования sp для определения вызывающей функции. Если указатель стека частично изменён, то раскрутчик будет искать вызывающую функцию посередине стека. Сами данные бессмысленны при их интерпретации в качестве указателя на родительский стековый кадр, поэтому среда исполнения с большой вероятностью вылетит. 

//https://github.com/golang/go/blob/66536242fce34787230c42078a7bbd373ef8dcb0/src/runtime/traceback.go#L373

if innermost && frame.sp < frame.fp || frame.lr == 0 {
    lrPtr = frame.sp
    frame.lr = *(*uintptr)(unsafe.Pointer(lrPtr))
}

При выполнении асинхронного вытеснения происходит запись вызова функции в стек, но родительский стековый кадр уже некорректен, потому что при асинхронном вытеснении sp скорректирован только частично. Поток вылета при этом будет выглядеть так:

  1. Асинхронное вытеснение происходит между двумя опкодами, в которые разворачивается add x, rsp.

  2. Сборка мусора запускает раскрутку стека (для проверки жизнеспособности объектов кучи).

  3. Раскрутчик начинает обходить стек п��облемной горутины и корректно выполняет раскрутку до проблемной функции.

  4. Раскрутчик разыменовывает sp для определения родительской функции.

  5. Данные за sp практически наверняка не являются функцией.

  6. Происходит вылет.

BLOG-2906 3

Выше мы видели сбойную трассировку стека, которая оказывается в (*NetlinkSocket).Receive — в этом случае сбой раскрутки стека происходит, когда он пытается определить родительский кадр. 

goroutine 90 gp=0x40042cc000 m=nil [preempted (scan)]:
runtime.asyncPreempt2()
/usr/local/go/src/runtime/preempt.go:306 +0x2c fp=0x40060a25d0 sp=0x40060a25b0 pc=0x55557e299dec
runtime.asyncPreempt()
/usr/local/go/src/runtime/preempt_arm64.s:47 +0x9c fp=0x40060a27c0 sp=0x40060a25d0 pc=0x55557e2dc94c
github.com/vishvananda/netlink/nl.(*NetlinkSocket).Receive(0xff48ce6e060b2848?)
/vendor/github.com/vishvananda/netlink/nl/nl_linux.go:779 +0x130 fp=0x40060b2820 sp=0x40060a27d0 pc=0x55557e9d2880

Обнаружив первопричину бага, мы отправили отчёт вместе с кодом воспроизведения, и вскоре баг устранили. Баг был устранён в go1.23.12go1.24.6 и go1.25.0. Раньше компилятор Go генерировал одну команду add x, rsp, оставляя задачу разбиения при необходимости непосредственных операндов на несколько опкодов ассемблеру. После этого изменения стеки больше 1<<12 будут создавать смещение во временном регистре, а затем прибавлять его к rsp в едином, неделимом опкоде. Горутина может быть вытеснена до или после изменения указателя стека, но не во время этого процесса. Благодаря этому указатель стека всегда остаётся валидным, а состояние гонки не возникает.

LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

Отлаживать эту проблему было очень интересно. Нечасто доводится сталкиваться с багами, вину за которые можно целиком возложить на компилятор. Нам понадобилось несколько недель на отладку, и в процессе мы изучили те области среды исполнения Go, о которых разработчики обычно не думают. Это любопытный случай редкого состояния гонки, баг, который можно выявить только при больших масштабах систем.