Привет, Хабр! На связи Илья Данилкин, тимлид в Авито Авто. В этом материале я собрал подборку важных новостей из мира Go. Разберём, откуда берутся типичные утечки горутин и чем встроенный в рантайм детектор принципиально отличается от uber-go/goleak. Посмотрим, как устроен механизм GOEXPERIMENT и какие эксперименты стоит попробовать. Измерим, сколько реально даёт флаг GOAMD64 на современном железе. А также расскажу, почему языковые модели перестали быть просто предметом обсуждения и стали участниками самого процесса разработки — от поиска уязвимостей до профилирования сборщика мусора.
Содержание

Утечки горутин: как сборщика мусора научили ловить то, что он сам не мог собрать
Redowan Delowar сообщает, что пропоузал о добавлении в runtime/pprof нового профиля — goroutineleak — принят. В Go 1.26 профиль уже доступен как эксперимент (GOEXPERIMENT=goroutineleakprofile), а дальше войдёт в стандартную библиотеку без флага; официальный разбор есть и в блоге Go. Это не сторонняя библиотека и не линтер, а встроенный в рантайм механизм, который использует сборщик мусора, чтобы доказательно находить горутины, заблокированные навсегда.
Почему это вообще проблема, а не теоретическая придирка? Горутина не собирается сборщиком мусора, пока жив хотя бы один указатель на структуры, которые её удерживают — стек, канал, на котором она заблокирована, мьютекс, который она ждёт. GC прекрасно чистит память, но у него нет представления о «зависшем навсегда потоке исполнения» как о категории мусора. Формально горутина, заблокированная на канале, жива: у неё есть стек, у неё есть состояние, теоретически её может кто-то разбудить. Проблема в том, что для конкретной утечки этого «кого-то» уже никогда не будет.
Горутина утекла, если она заблокирована на канале или локе, до которых не может дотянуться ни одна другая работающая (runnable) горутина — ни напрямую, ни транзитивно через другие горутины.
Такая горутина будет висеть до конца жизненного цикла процесса, потихоньку съедая память под стек и создавая шум в рантайме.
Анатомия трёх типовых утечек
Прежде чем говорить про детектор, стоит закрепить, какие паттерны он призван ловить. Все три — не экзотика, а стандартные грабли, на которые наступает практически любой Go-разработчик, работающий с конкурентностью.
1. Early return и «осиротевший» отправитель
Утечка происходит по классической схеме: набор задач шлёт результаты в небуферизованный канал через wg.Go, а вызывающий код читает только до первой ошибки и возвращает управление раньше времени.
func run(tasks []func() error) error { errs := make(chan error) // unbuffered var wg sync.WaitGroup for _, task := range tasks { wg.Go(func() { errs <- task() }) // (1) } for range tasks { if err := <-errs; err != nil { return err // (2) } } wg.Wait() return nil }
Суть проблемы в том, что небуферизованный канал синхронный, и отправка блокируется, пока кто-то не прочитает. Если код-получатель ушёл по early return после первой же ошибки, все остальные горутины, которые ещё не успели отправить результат, зависают на send навсегда. Никто их больше не читает.
Существующий инструментарий — uber-go/goleak — ловит такие случаи в тестах, сравнивая снимки списка горутин до и после. Решение здесь не в изменении логики раннего выхода (она часто и есть корректное поведение), а в развязке отправителей и получателя:
– дать каналу буфер, равный максимально возможному числу отправителей;
– дочитать (drain) все оставшиеся результаты перед возвратом, даже если они уже не нужны.
2. Гонка реплик за первым ответом
Аналогичная утечка горутин возникает, когда вы отправляете запрос нескольким репликам и сохраняете только первый ответ. Запрос уходит в несколько реплик одновременно, каждая из них при получении результата пытается первой отправить его в общий небуферизованный канал. Как только приходит первый ответ, вызывающий код возвращает управление. Все более медленные реплики блокируются на отправке навсегда — читать их уже некому.
func replicate(replicas []func() string) string { results := make(chan string) // unbuffered for _, r := range replicas { go func() { results <- r() }() // (1) } return <-results // (2) }
Решение подобно предыдущему: буфер канала должен быть рассчитан на все реплики, а не на одну. Правка неказистая и очевидная, но именно её обычно забывают: в момент написания кода мысль занята логикой выбора первого ответа, а не судьбой опоздавших горутин.
3. Забытый close
Третий паттерн — это производитель потоковых данных, который отдаёт значения через range по каналу, но забывает закрыть канал по завершении. range по каналу — это блокирующий цикл: он продолжает тянуть значения, пока канал не будет явно закрыт. Если функция-producer возвращается без close(out), читающая горутина остаётся в вечном ожидании следующего значения, которое никогда не придёт.
func stream(work []int) { out := make(chan int) go func() { for v := range out { // (1) handle(v) } }() for _, v := range work { out <- v } // (2) no close(out) }
Фикс также тривиален по формулировке — делать close(out) после последней отправки, но он легко теряется, когда функция обрастает несколькими путями выхода и хотя бы один из них не проходит через закрытие канала.
Утечки очевидны, как только вы их заметите, но их легко упустить из виду при раннем возврате или по мере роста окружающего кода. goleak обнаруживает их в тестах. В продакшене у вас есть обычный профиль /debug/pprof/goroutine. Он показывает, на чём заблокирована каждая горутина, но не говорит, разблокируется ли она когда-нибудь, — поэтому приходится гадать, какие из них зависли навсегда, а какие просто ждут работы.
Этот список далеко не исчерпывающий, и не каждая утечка находится в вашем собственном коде — её может принести и зависимость. Uber каталогизировал эти шаблоны в своём Go-монорепозитории.
Профиль утечек: теперь их умеет находить сама stdlib
Профиль, как и goleak, пришёл из Uber — его спроектировали Vlad Saioc и Milind Chabbi. Обнаружение построено на работе GC: горутина считается утёкшей, если она заблокирована на канале или локе, до которых не может добраться ни одна работающая горутина — ни напрямую, ни через другую горутину, которую работающая могла бы разблокировать. Ничто не может её разбудить, и сборщик мусора помечает её.
goleak и профиль goroutineleak отвечают на разные вопросы:
goleak |
goroutineleak profile |
|
на какой вопрос отвечает |
что ещё работает, хотя не должно |
что больше никогда не заработает |
как решает |
снимком списка горутин, без доказательства |
доказательством достижимости с помощью сборщика мусора |
где работает |
в тестах, при тирдауне |
в живом процессе |
ложные срабатывания |
есть — на живом сервере |
нет: только доказательно застрявшие горутины |
Разница — в том, где каждый из них работает. При завершении теста ничего не должно оставаться запущенным, и вернуть всё, что осталось, — ровно то, что нужно от goleak. На рабочем сервере ситуация обратная: большинство его горутин намеренно заблокированы в ожидании следующего запроса, и goleak не может отличить их от реальной утечки.
Профиль же доказывает утечку. Он начинает с горутин, которые всё ещё могут работать, отслеживает то, до чего они могут добраться, и «спасает» любую заблокированную горутину, чей канал или лок всё ещё в игре. Всё, что осталось, недостижимо ни для кого — оно зависло навсегда. Uber уже пытался ловить утечки в продакшене сэмплирующим инструментом, но сэмплирование судит по косвенным признакам и даёт ложные срабатывания. GC сообщает только о тех горутинах, которые доказательно зависли, — это и есть гарантия отсутствия ложных срабатываний.
В отличие от goleak, у профиля нет готовых хелперов VerifyNone(t) и VerifyTestMain(m) — в разделе про тесты показано, как собрать свои.
API очень маленький. Нет нового типа или функции, только профиль с именем goroutineleak. Он регистрируется автоматически, и стандартные инструменты pprof читают его как любой другой профиль.
Получить доступ к профилю можно четырьмя обычными способами.
1. Из вашего кода
Вы находите профиль и записываете его. Начните с debug=0, который пишет сжатый protobuf в файл:
func main() { f, _ := os.Create("leak.pb.gz") pprof.Lookup("goroutineleak").WriteTo(f, 0) }
pprof.Lookup возвращает профиль, а WriteTo запускает цикл сборки мусора для обнаружения утечек перед записью. Откройте файл с помощью go tool pprof — так же, как CPU- или heap-профиль.
Второй аргумент WriteTo — уровень debug. 1 и 2 дают текст, который можно писать напрямую в os.Stdout. При debug=1 обработчик сигнала позволяет выводить утечки по запросу через kill -USR1 <pid>:
// kill -USR1 <pid> to dump leaks sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGUSR1) go func() { for range sig { pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1) } }()
Текст прямо указывает на утекшие горутины:
goroutineleak profile: total 2 1 @ ... # 0x... main.leakSend.func1+0x27 formats/main.go:15 1 @ ... # 0x... main.leakRange.func1+0x33 formats/main.go:21
debug=2 — это полный дамп горутин, где утекшие помечены суффиксом (leaked):
goroutine 7 [chan send (leaked)]: main.leakSend.func1() formats/main.go:15 +0x28 created by main.leakSend in goroutine 1 formats/main.go:15 +0x6c goroutine 8 [chan receive (leaked)]: main.leakRange.func1() formats/main.go:21 +0x34 created by main.leakRange in goroutine 1 formats/main.go:20 +0x6c
Обычный дамп показывает просто [chan send] и [chan receive]; суффикс (leaked) добавляет именно профиль.
2. В тестах
Один хелпер запускает обнаружение и возвращает то, что зависло:
func leaked() (string, bool) { p := pprof.Lookup("goroutineleak") if p == nil { return "", false // experiment off, nothing to detect } var b bytes.Buffer p.WriteTo(&b, 1) return b.String(), p.Count() > 0 }
Оберните его в verifyNone и вызывайте через defer:
// verifyNone mirrors goleak.VerifyNone. func verifyNone(t testing.T) { t.Helper() if report, ok := leaked(); ok { t.Fatalf("leaked goroutines:\n%s", report) } } func TestRun(t testing.T) { defer verifyNone(t) // ... exercise the code under test ... }
Для всего набора тестов напишите verifyTestMain и вызывайте его из TestMain:
// verifyTestMain mirrors goleak.VerifyTestMain. func verifyTestMain(m testing.M) { code := m.Run() if code == 0 { if report, ok := leaked(); ok { fmt.Fprintf(os.Stderr, "leaked goroutines:\n%s", report) code = 1 } } os.Exit(code) } func TestMain(m testing.M) { verifyTestMain(m) }
3. Через HTTP
Импорт net/http/pprof регистрирует профиль в мультиплексоре по умолчанию. Обслуживайте этот мультиплексор с nil-обработчиком, и эндпоинт будет доступен без дополнительного кода:
// registers /debug/pprof/goroutineleak on http.DefaultServeMux import _ "net/http/pprof" http.ListenAndServe("localhost:6060", nil)
Затем считайте профиль из эндпоинта:
$ curl 'localhost:6060/debug/pprof/goroutineleak?debug=1' goroutineleak profile: total 1 1 @ ... # 0x... main.main.func1+0x27 server/main.go:14
4. С помощью go tool pprof
go tool pprof читает его как любой другой профиль — можно указать на эндпоинт или на сохранённый дамп с debug=0:
$ go tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak' Type: goroutineleak flat flat% sum% cum cum% 1 100% 100% 1 100% runtime.gopark 0 0% 100% 1 100% main.main.func1 0 0% 100% 1 100% runtime.chansend 0 0% 100% 1 100% runtime.chansend1
GC найдёт не каждую утечку — это цена гарантии отсутствия ложных срабатываний. Чтобы не пометить горутину, которая ещё может проснуться, сборщик мусора оставляет в покое все горутины, чьи каналы или локи всё ещё достижимы.

GOEXPERIMENT: как обкатывают рантайм
В релизы Go часто попадают экспериментальные функции. Они могут принимать различные формы: иногда это совершенно новые пакеты в стандартной библиотеке, иногда — изменения в компиляторе или среде выполнения, а очень редко — критические изменения в поведении самого Go. Материал подготовлен на основе поста Alex Edwards.
В большинстве случаев цель экспериментов — получить реальную обратную связь от пользователей, прежде чем фича выйдет официально. Если она вызывает регрессии или получает негативные отзывы, её изменят или вообще отменят.
Экспериментальные функции поставляются выключенными по умолчанию. Вы сами выбираете, хотите ли протестировать функцию, — обычно через переменную окружения GOEXPERIMENT.
Существуют три основных состояния экспериментов:
1. Выключен по умолчанию и находится на стадии оценки
2. Выключен по умолчанию и приостановлен/неактивен
3. Включён по умолчанию с временной возможностью отключения
Постоянные эксперименты
В Go есть несколько экспериментальных функций, которые на самом деле не являются «экспериментами» в обычном смысле слова. Они отключены по умолчанию, но не ждут оценки. Хотя они управляются переменной GOEXPERIMENT так же, как и другие эксперименты, по сути это дополнительные возможности Go, которые могут понадобиться в специализированных ситуациях. Будем называть их постоянными экспериментами.
Например, есть функция отслеживания полей, которая фиксирует, к каким полям структуры осуществляется доступ. Она доступна уже десять лет, и нет никаких планов когда-либо сделать её общедоступной. Или есть статическое ранжирование блокировок — диагностическая функция для обнаружения потенциальных взаимоблокировок в рантайме Go.
Какие эксперименты доступны прямо сейчас
Список всех доступных экспериментов можно получить командой $ go doc goexperiment.Flags.
Какие эксперименты включены по умолчанию, можно узнать из исходного кода src/internal/buildcfg/exp.go — по объявлению базовой переменной в функции ParseGOEXPERIMENT().
Начиная с Go 1.26, доступны следующие постоянные эксперименты:
название эксперимента |
описание |
статус |
FieldTrack |
Отслеживает, к каким полям структуры осуществляется доступ |
По умолчанию выключен, останется навсегда |
StaticLockRanking |
Проверка порядка получения блокировок для выявления взаимоблокировок |
По умолчанию выключен, останется навсегда |
CgoCheck2 |
Проверка правил передачи указателей cgo; слишком затратно запускать по умолчанию |
По умолчанию выключен, останется навсегда |
BoringCrypto |
Заменяет стандартную криптографию Go на BoringSSL, прошедший проверку FIPS; неактуален с Go 1.24 |
По умолчанию выключен, скоро будет удалён |
PreemptibleLoops |
Позволяет планировщику прерывать горутины на обратных рёбрах цикла; неактуален с Go 1.14, но всё ещё может быть полезен на платформах, где другие способы вытеснения не поддерживаются |
По умолчанию выключен, останется навсегда |
Текущие эксперименты, отключённые по умолчанию, и их статусы:
название эксперимента |
описание |
статус |
HeapMinimum512KiB |
Уменьшает минимальный размер кучи с 4 МБ до 512 КБ; полезно в условиях ограниченных ресурсов |
Отключён по умолчанию, вероятно, неактуален |
Arenas |
Реализация арен памяти |
Отключён по умолчанию, приостановлен из-за негативных отзывов |
NewInliner |
Переписанный инлайнер компилятора с улучшенной эвристикой для мест вызова функций |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.22 |
JSONv2 |
Новый пакет encoding/json/v2 с улучшенным кодированием/декодированием JSON |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.25 |
RuntimeSecret |
Новый пакет runtime/secret с функциями для обнуления памяти; доступен только на Linux amd64/arm64 |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.26 |
GoroutineLeakProfile |
Добавляет тип pprof-профиля goroutineleak |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.26 |
SIMD |
Новый пакет simd/archsimd, даёт доступ к архитектурно-специфичным SIMD-операциям; доступен только на amd64 |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.26 |
RuntimeFreegc |
Позволяет немедленно повторно использовать память, не дожидаясь цикла сборки мусора, когда это безопасно |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.26 |
SizeSpecializedMalloc |
Включает реализации malloc, специализированные под каждый класс размера |
Отключён по умолчанию, на стадии оценки, доступен с Go 1.26 |
А вот эксперименты, которые в данный момент включены по умолчанию:
название эксперимента |
описание |
статус |
LoopVar |
Область видимости переменной цикла для каждой итерации |
Включён по умолчанию с Go 1.22, возможность отключения сохранена для редких случаев |
Dwarf5 |
Генерация отладочной информации в формате DWARF 5; уменьшает размер бинарного файла |
Включён по умолчанию, с временной возможностью отключения, которая может быть убрана в будущих релизах |
RandomizedHeapBase64 |
Рандомизирует базовый адрес кучи при запуске в качестве меры безопасности |
Включён по умолчанию, с временной возможностью отключения, которую ожидаемо уберут в будущих релизах |
GreenTeaGC |
Новый сборщик мусора с улучшенной производительностью; недоступен на darwin/ios/aix |
Включён по умолчанию, с временной возможностью отключения, которую ожидаемо уберут в Go 1.27 |
RegabiWrappers |
ABI-обёртки для вызовов между функциями ABI0 и ABIInternal; только на 64-битных архитектурах |
Включён по умолчанию; отключение работает только на s390x и будет убрано в Go 1.27 |
RegabiArgs |
Включает передачу аргументов/результатов через регистры во всех скомпилированных функциях Go; только на 64-битных архитектурах |
Включён по умолчанию; отключение работает только на s390x и будет убрано в Go 1.27 |
Как включить и отключить эксперименты
Эксперименты управляются переменной окружения GOEXPERIMENT. Если хотите попробовать эксперименты, отключённые по умолчанию, укажите их названия в GOEXPERIMENT через запятую, строчными буквами. Например, собрать приложение с включёнными JSONv2 и GoroutineLeakProfile можно так:
$ GOEXPERIMENT=jsonv2,goroutineleakprofile go build ./...
Если есть эксперимент, включённый по умолчанию, который вы хотите отключить, добавьте no перед его названием (тоже строчными). Например, собрать приложение с отключёнными GreenTeaGC и RandomizedHeapBase64:
$ GOEXPERIMENT=nogreenteagc,norandomizedheapbase64 go build ./...
Можно смешивать включённые и выключенные эксперименты:
$ GOEXPERIMENT=jsonv2,nogreenteagc go build ./...
Обратите внимание: если собирать один и тот же пакет с разными значениями GOEXPERIMENT, Go считает это разными сборками и хранит для них отдельные записи в кэше сборок.
В примерах выше использовалась команда go build, но тот же шаблон работает с go run и go test. Например, напишем программу, использующую экспериментальный пакет encoding/json/v2:
package main import ( "encoding/json/v2" "fmt" ) type Person struct { Name string json:"name" Age int json:"age" City string json:"city" } func main() { p := Person{Name: "Ada", Age: 36, City: "Vienna"} data, _ := json.Marshal(p, json.StringifyNumbers(true)) fmt.Println(string(data)) }
Если запустить программу обычным способом, она не скомпилируется, и вы получите сообщение об ошибке:
$ go run main.go package command-line-arguments imports encoding/json/v2: build constraints exclude all Go files in /usr/local/go/src/encoding/json/v2
Но если включить эксперимент JSONv2, программа будет работать как положено:
$ GOEXPERIMENT=jsonv2 go run main.go {"name":"Ada","age":"36","city":"Vienna"}
Какие эксперименты действительно стоит попробовать
GreenTeaGC: если используете Go 1.26, он уже включён по умолчанию. Но если заметите проблемы с производительностью или поведением, помните, что его всё ещё можно отключить.
Dwarf5: если используете Go 1.25 или новее, он уже включён по умолчанию. Его тоже пока можно отключить.
JSONv2: если пишете много кода, работающего с JSON, стоит поэкспериментировать с новым пакетом encoding/json/v2, ознакомиться с его возможностями и сообщить о проблемах.
GoroutineLeakProfile: это полезный параметр, который стоит включить, если подозреваете утечку горутин и нужно её отладить.
RuntimeSecret: стоит поэкспериментировать и сообщить о результатах, если пишете криптографический код или обрабатываете конфиденциальные данные.
RuntimeFreegc: если приложение сильно нагружает сборщик мусора, стоит прогнать бенчмарки с включённым параметром и посмотреть, улучшится ли производительность.
GOAMD64: сколько даёт современное железо

Daniel Lemire, профессор информатики в Университете Квебека (TELUQ) и известный специалист по производительности и SIMD, написал пост о том, сколько на практике дают микроархитектурные уровни amd64 в Go. При компиляции программы на Go для 64-битного процессора Intel или AMD компилятор по умолчанию ориентируется на набор инструкций, которому больше 20 лет. Полученный бинарный файл работает практически на любом чипе x64, но при этом не использует ни одной инструкции, добавленной после 2003 года.
Каждый уровень микроархитектуры включает в себя набор расширений, наличие которых можно считать гарантированным:
Уровни микроархитектуры AMD64
Уровень |
Добавляет (приблизительно) |
1 |
базовый набор AMD64 (SSE2) |
2 |
popcnt, SSE4.2 |
3 |
AVX2 |
4 |
AVX-512 (F/BW/DQ/VL) |
По мнению автора, эта лестница уже несколько устарела: её заморозили примерно в 2020 году, а железо с тех пор ушло вперёд. Пришлось бы добавить новейшие расширения AVX-512 (VBMI, VBMI2, VNNI, BF16, FP16, VPOPCNTDQ), которые поддерживаются современными серверными и потребительскими чипами, но не требуются для v4. Хотя уровни 1–4 — полезный общий язык, реалистичная цель «использовать все возможности этого процессора» сегодня потребовала бы как минимум v5, а возможно, всю схему стоило бы заменить более точным определением возможностей CPU.
В любом случае инструментарий Go даёт доступ к уровням 1–4 через переменную окружения GOAMD64. GOAMD64=v3 сообщает компилятору, что он может использовать всё вплоть до AVX2 включительно. По умолчанию используется v1 — наименьший общий знаменатель.
Но тут встаёт вопрос: если взять реальную, критичную к производительности библиотеку и перекомпилировать её на каждом уровне, сколько действительно можно выиграть? Берём Roaring Bitmaps — сжатую битовую структуру данных, используемую в базах данных и поисковых системах. Она хранит набор 32-битных целых чисел, разбивая 32-битное пространство на блоки по 65 536 значений (ключом служат старшие 16 бит), а каждый блок хранит в контейнере, содержащем только младшие 16 бит. Контейнер бывает трёх типов, и библиотека всегда выбирает тот, который меньше:
– array-контейнер: отсортированный список 16-битных значений, используется, когда блок разреженный (не более нескольких тысяч элементов);
– bitmap-контейнер: плоский битовый вектор размером 8 КБ (65 536 бит, по одному на каждое возможное значение), используется, когда блок плотный;
– run-контейнер: список интервалов [начало, длина], используется, когда установленные биты идут подряд.
Автор взял последнюю версию библиотеки и прогнал её собственный набор бенчмарков четыре раза — по одному разу для каждого уровня, по восемь замеров в каждом случае. Всё это на одном процессоре Intel Xeon Gold 6548N (Emerald Rapids, поддерживает все четыре уровня, включая AVX-512) под Go 1.26.2 с Roaring v2.18.2.
Popcount (population count, вес Хэмминга) — это просто число битов, установленных в единицу, в машинном слове. Roaring постоянно на него опирается: мощность bitmap-контейнера, то есть количество значений в нём, — это сумма popcount его 1024 64-битных слов. Современные процессоры x86 имеют специальную инструкцию popcnt, которая делает это за одну операцию, но она доступна только начиная с уровня v2 (SSE4.2, 2008). Без неё компилятору приходится генерировать многоинструкционную последовательность побитовых операций.
Самый очевидный результат — подсчёт установленных битов в bitmap-контейнере. В базовой версии v1 инструкция popcnt недоступна, поэтому Go генерирует программный резервный вариант. Как только переходим к v2, popcnt становится доступна, и время выполнения сокращается почти вдвое:

Это снижение на 43%, и оно бесплатное: никаких изменений в исходном коде, только флаг компилятора. v3 и v4 ничего нового не дают — одной инструкции popcnt уже достаточно; с точки зрения компилятора Go, AVX2 и AVX-512 здесь ничего не добавляют.
Ускорение popcount — очевидный выигрыш. А что насчёт остальной части библиотеки? Ещё один очевидный выигрыш — создание контейнера из плотной битовой карты. Бенчмарк FromDense для array-контейнеров берёт необработанный битовый вектор размером 8 КБ и строит для него наиболее компактный контейнер: считает popcount каждого слова, чтобы узнать мощность множества, а затем сканирует позиции установленных битов. Этот цикл подсчёта и сканирования по словам — именно то, что компилятор может автоматически векторизовать, как только станут доступны 256-битные регистры, поэтому выигрыш будет и после v2:

В v2 уже достигается сокращение времени на 21% за счёт скалярных инструкций popcnt/tzcnt, а в v3 (AVX2) это сокращение почти удваивается, достигая 38%. Как и в случае с popcount, v4 ничего не добавляет.
Операции с множествами демонстрируют ту же закономерность. Бенчмарк IntersectionCardinality считает, сколько общих значений у двух битовых карт: для bitmap-контейнеров он выполняет попарное AND над словами и подсчитывает результат. Здесь v2 по сути ничего не даёт, но v3 позволяет компилятору расширить цикл AND-and-count до 256-битных регистров, сокращая время на 22%:

Вывод автора: v2 — практически бесплатный выигрыш, и такой бинарник запустится почти на любом x86-64 процессоре, выпущенном за последние 15 лет (но на более старом CPU он упадёт при старте — это стоит помнить). v3 имеет смысл, если вы контролируете железо. А v4 в Go пока почти ничего не даёт: компилятор плохо использует AVX-512.

LLM как симптом зрелости
Самый резонансный материал этого сезона — статья «Отчёты об уязвимостях больше не являются чем-то особенным» Filippo Valsorda, бывшего лида безопасности в команде Go. Его тезис: годами репорты об уязвимостях считались особым видом обращений — исследователь оказывал проекту услугу, предоставляя дефицитную экспертизу и временную конфиденциальность, поэтому взамен ему полагались быстрая реакция и признание заслуг. Но если языковые модели способны находить примерно те же самые баги, что и опытный исследователь, — а Valsorda утверждает, что к 2026 году это уже так, — то ни экспертиза, ни конфиденциальность больше не дефицитный ресурс: атакующие точно так же могут прогнать код через LLM. Настоящим узким местом становится не поиск потенциальных проблем, а триаж — отделение реальных уязвимостей от шума.
Материал спровоцировал бурную дискуссию на Hacker News и Lobsters. Там указывали на проблему дублирования репортов: один и тот же баг в проекте вроде containerd независимо находят сразу несколько исследователей, — что как раз показывает: LLM воспроизводимо натыкаются на одни и те же проблемы вне зависимости от того, кто их запускает. А slop никуда не делся, просто теперь он лучше замаскирован под правдоподобный отчёт.
В этом же контексте показателен и курьёзный, но символический кейс из самого Go: уязвимость обхода проверки checksum в module proxy (потенциально позволяющая недоверенному прокси подсунуть изменённые модули или даже сам тулчейн) была обнаружена именно языковой моделью [нужна ссылка на issue/CVE] — живая иллюстрация тезиса Valsorda не в теории, а на практике, причём в инфраструктуре самого языка.
LLM как напарник в профилировании и отладке GC
Второй пример — это не поиск багов, а анализ производительности, который традиционно требует отдельной, довольно узкой экспертизы: чтения CPU-профилей, трейсов, ассемблерных дампов.
Инженер Red Hat описал эксперимент, где Claude выступил партнёром по анализу производительности нового сборщика мусора Go — Green Tea GC — на архитектуре POWER10 с использованием набора бенчмарков sweet. Штатный go tool pprof умеет находить узкие места вплоть до конкретных инструкций, но интерпретация профиля и формулирование гипотез — по-прежнему ручная, требующая опыта работа. По описанию автора, модель помогала находить конкретные направления для оптимизации прямо в горячих точках, размеченных pprof, — вплоть до предложений вроде подстройки GC, снижающих число событий mark assist.
go tool pprof BleveIndexBatch100-1212217505-cpu.prof File: bleve-index-bench Build ID: 81f203eb5714d9755a42c833024d5f60afd94e03 Type: cpu Time: 2026-03-10 16:10:56 IST Duration: 5.40s, Total samples = 15.18s (280.91%) Entering interactive mode (type "help" for commands, "o" for options) (pprof) top 10 Showing nodes accounting for 8830ms, 58.17% of 15180ms total Dropped 208 nodes (cum <= 75.90ms) Showing top 10 nodes out of 103 flat flat% sum% cum cum% 3170ms 20.88% 20.88% 4260ms 28.06% runtime.tryDeferToSpanScan 1100ms 7.25% 28.13% 2970ms 19.57% runtime.scanObjectSmall 840ms 5.53% 33.66% 900ms 5.93% github.com/blevesearch/segment.segmentWords 710ms 4.68% 38.34% 7180ms 47.30% runtime.scanSpan 670ms 4.41% 42.75% 2940ms 19.37% runtime.scanObjectsSmall 520ms 3.43% 46.18% 970ms 6.39% github.com/blevesearch/bleve/index/store/gtreap.itemCompare 520ms 3.43% 49.60% 3610ms 23.78% github.com/steveyen/gtreap.(*Treap).union 440ms 2.90% 52.50% 2070ms 13.64% github.com/steveyen/gtreap.(*Treap).split 430ms 2.83% 55.34% 450ms 2.96% runtime.extractHeapBitsSmall 430ms 2.83% 58.17% 1690ms 11.13% runtime.mallocgcSmallScanNoHeader
Есть в этом и сюжет в сюжете: сам объект профилирования — Green Tea GC — совсем свежий компонент рантайма. Его архитектура, ориентированная на блоки памяти и обрабатывающая мелкие объекты (до 512 байт) на гранулярности 8 KiB span, задумана ради лучшей cache locality. Разработчики оптимизируют рантайм языка, а LLM помогает разработчикам понять, что именно они оптимизировали.
У этих историй общий знаменатель: LLM не заменяют экспертизу Go-инженера, а меняют то, на что эта экспертиза тратится. Меньше времени уходит на ручной разбор потока security-репортов и на чтение сырых профилей и трейсов. Больше времени остаётся на то, что модель пока не умеет делать хорошо: решать, какая из десяти находок реально важна и что с ней делать дальше.
При этом важна и обратная сторона: те же материалы прямо предупреждают, что slop никуда не делся, а допуск LLM к операциям в рабочем окружении добавляет новых вопросов про безопасность самого тулчейна. Так что расширение возможностей приходит с расширением того, что стоит перепроверять вручную.