РозробкаБудь-який рівень

Go

Go — компільована мова, чиє середовище виконання запускає тисячі дешевих горутин на кількох потоках ОС, тож код, що блокується, лишається швидким. Через цей задум Go-сервіс читається згори вниз без жодного колбека — і через нього ж його типовий збій не повільний запит, а горутина, що чекає вічно. Сторінка починається з усіх місць, де горутина чекає, і того, що її відпускає; далі — планувальник у контейнері, життя й витоки горутин, спільна пам’ять і детектор гонок, помилки, релізи до Go 1.27, пам’ять і збирач сміття, п’ять задокументованих кейсів і те, де все це вироджується.

Поточна версія
Go 1.27 · 19 серп. 2026
Модель
Горутини на кількох потоках ОС
Підтримка
Дві останні версії; нова — щопівроку

Де чекає горутина — і що її відпускає

Node-сервер мусить питати, який потік платить за кожну роботу. Go-сервер — ні: коли горутина чекає — на канал, на блокування, на мережу, — середовище паркує її й запускає на тому самому потоці іншу горутину, тож очікування коштує кілька кілобайтів пам’яті й більше нічого. Через це ризик переїжджає в інше місце. Корисне питання до рядка на Go — не «чи він швидкий?», а «якщо тут доведеться чекати, що мене розбудить — і що буде, якщо ніщо так і не розбудить?». Горутина, яку ніщо не розбудить, — не помилка: ні рядка в лозі, ні падіння. Вона лежить у пам’яті разом зі стеком і всім, на що посилається, доки процес не перезапустять. Ось чотирнадцять місць, де чекає горутина. Наступні розділи посилаються на рядки за номерами.

#Де чекаєЩо її відпускаєЯкщо ніщо не відпуститьЗахід
1ch <- v у небуферизований каналОтримувач забирає значенняВитік — зазвичай воркер, чий викликач уже повернувсяБуфер на кількість відправників або select з ctx.Done()
2v := <-chНадсилання або close(ch)ВитікСторона, що надсилає, закриває канал, коли закінчила
3for v := range chКожне надсилання; цикл завершує лише close(ch)Витік після останнього значенняКанал закриває рівно один власник
4Надсилання чи отримання з nil-каналуНіщо й ніколиЧекає вічно — так задуманоСтворюйте канали через make; nil-канал — спосіб вимкнути гілку select
5Надсилання в закритий каналНе чекає — панікуєПроцес зупиняєтьсяЗакриває лише сторона, що надсилає, і лише раз
6mu.Lock()Unlock() в іншій горутиніСтає кожна горутина, якій потрібне це блокуванняdefer mu.Unlock() наступним же рядком
7wg.Wait()Лічильник доходить до нуляВитік; лічильник нижче нуля — панікаwg.Go(f) (Go 1.25) замість ручних Add і Done
8select без defaultБудь-яка з його гілокВитік, якщо жодна гілка ніколи не спрацюєЗавжди додавайте case <-ctx.Done()
9Читання з мережевого з’єднанняДані або дедлайнКлієнт, що нічого не надсилає, тримає горутину й сокетconn.SetReadDeadline; http.Server{ReadHeaderTimeout: …}
10Вихідний виклик через http.DefaultClientВідповідь — тайм-ауту в типового клієнта немаєЧекає стільки, скільки віддалений серверhttp.Client з Timeout або запит із контекстом
11Вільне з’єднання з пулу database/sqlЗ’єднання, повернене в пулКожен запит чекає за результатами, які ніхто не закривdefer rows.Close(); пул під розмір бази
12<-ctx.Done()cancel() або дедлайнНа context.Background() не спрацює ніколиПохідний context.WithTimeout і виклик cancel — go vet перевіряє
13time.After чи time.Ticker у цикліСпрацювання таймераДо Go 1.23 кожен незупинений таймер лежав у пам’яті до спрацюванняgo 1.23 чи новіше в go.mod; Stop() для кожного тікера
14Блокуючий системний виклик чи виклик у C через cgoПовернення з викликуСередовище запускає новий потік; понад 10 000 потоків програма падаєОбмежте, скільки таких викликів іде одночасно

Планувальник: чому чекати дешево і що з цим робить контейнер

Горутина — не потік. Вона стартує зі стеком на кілька кілобайтів, що росте за потреби, тож сервер запускає одну горутину на запит чи на з’єднання й не тримає їх у пулі. Середовище виконує горутини на справжніх потоках операційної системи, а його планувальник має три частини, названі в коді літерами. G — горутина. M — machine, потік ОС. P — processor: дозвіл виконувати Go-код із власною чергою готових до роботи горутин. Таких P рівно GOMAXPROCS, тож саме це число визначає, скільки горутин працює в одну мить; решта чекає в чергах.

Горутина коштує кілька кілобайтів, тож програми на Go створюють її на кожен запит чи з’єднання, а не тримають пул. Середовище виконання розкладає їх на невелику кількість потоків ОС: кожен логічний процесор P має власну чергу виконання й працює на одному потоці M, а GOMAXPROCS визначає, скільки їх працює одночасно. Коли горутина робить блокуючий виклик, середовище від’єднує потік і передає чергу іншому, тож решта продовжує працювати, — і саме тому прямолінійний блокуючий код у Go швидкий, а в однопотоковому середовищі — ні.

Коли горутина чекає на канал, блокування чи таймер, планувальник паркує її, а P бере з черги наступну; потік не зупиняється ніколи. Очікування мережі теж не коштує потоку. Середовище передає кожен сокет інтерфейсу готовності операційної системи — epoll у Linux, kqueue у macOS, той самий механізм, що й у Node, — і будить горутину, коли надходять її дані. P із порожньою чергою краде половину черги зайнятого, тож робота розходиться ядрами без того, щоб хтось її розподіляв. А з Go 1.14 планувальник уміє перервати горутину посеред щільного циклу; до того цикл без викликів функцій міг тримати свій потік і зупиняти збирач сміття для всього процесу.

Рядок 14 — єдиний вид очікування, який планувальник не може сховати. Блокуючий системний виклик, наприклад читання файлу, чи виклик у C через cgo тримає справжній потік, доки не повернеться. Середовище від’єднує P від цього потоку й віддає іншому — запускаючи новий, якщо вільного немає, — тож інші горутини працюють далі. Ціна — один потік ОС на кожен виклик у процесі. Тисяча одночасних повільних читань файлів — це тисяча потоків, а понад 10 000, типову межу з debug.SetMaxThreads, програма падає. Обмежуйте такі виклики семафором — досить буферизованого каналу на N місць, — а не дозволяйте кількості запитів вирішувати за вас.

GOMAXPROCS у контейнері

До Go 1.25 GOMAXPROCS за замовчуванням дорівнював кількості ядер машини — а всередині контейнера це хибне число. Ліміт CPU у Kubernetes, що дорівнює 2, не дає контейнеру двох ядер. Він дає квоту: 200 мс процесорного часу в кожному вікні по 100 мс, і стежить за нею планувальник Linux. Go-процес на 64-ядерному вузлі однаково запускав 64 P. Коли робота приходила сплеском — або коли збирач сміття, що забирає чверть P під власних воркерів, починав цикл, — 64 потоки витрачали всю квоту приблизно за 3 мс. Після цього ядро ОС зупиняло кожен потік контейнера на решту 97 мс. Власний опис команди Go: тротлінг «повністю зупиняє виконання застосунку до кінця періоду тротлінгу».

Ліміт CPU в Kubernetes — це квота, а не кількість ядер: ліміт 2 означає 200 мс процесорного часу в кожному періоді по 100 мс, і стежить за цим планувальник Linux. До Go 1.25 Go-процес визначав GOMAXPROCS за ядрами машини, тож на 64-ядерному вузлі сплеск роботи — чи цикл збирання сміття — міг піти на 64 потоки й витратити квоту приблизно за 3 мс. Тоді ядро ОС зупиняє кожен потік контейнера до кінця періоду, і кожен запит, що приходить у цей час, чекає. Go 1.25 бере GOMAXPROCS з ліміту контейнера, тож та сама квота розподіляється на весь період. Числа ілюстративні.

Go 1.25 читає ліміт CPU контейнера, ставить GOMAXPROCS рівним йому з округленням угору й періодично перевіряє знову, бо оркестратор може змінити ліміт, поки процес працює. Враховується саме ліміт, а не запит CPU (request). Автоматичне значення вимикається, щойно GOMAXPROCS задано вручну, тож деплой, який задав його багато років тому, лишається з тим, що задав. До 1.25 те саме давав пакет Uber automaxprocs, який імпортували заради побічного ефекту; на 1.25 і новіших його можна прибрати. Симптом старого значення варто впізнавати, бо він досі живе в старих образах: латентності сходинками приблизно по 100 мс, графік CPU далеко під лімітом і лічильник тротлінгових періодів cgroup, що росте.

Кожній горутині потрібен власник: витоки, context і errgroup

Горутина не має батька. Функція, що її запустила, може повернутися, а горутина працює далі; ніщо в мові не пов’язує ці два життя. Тому рядки 1–3 і 8 — найчастіший витік у Go. Примітки до релізу Go 1.26 показують хрестоматійний випадок. Функція запускає по горутині на кожен елемент роботи, кожна надсилає результат у небуферизований канал, а функція збирає результати в циклі. Коли один результат виявляється помилкою, функція повертається раніше — і кожна горутина, що ще тримає результат, назавжди блокується на надсиланні, бо ніхто більше не отримує.

Це витік із приміток до релізу Go 1.26. Функція запускає по горутині на кожен елемент роботи й збирає їхні результати з каналу, повертаючись, щойно один із них виявиться помилкою. У небуферизованому каналі надсилання завершується лише тоді, коли хтось отримує, — а після раннього повернення не отримає ніхто, тож кожен воркер, що закінчив пізніше, лишається заблокованим на весь час життя процесу разом зі своїм стеком і всім, на що посилається. Дайте каналу по місцю на кожного відправника — і кожне надсилання завершиться, читає хтось чи ні. З Go 1.27 такі заблоковані горутини показує профіль goroutineleak.

Витік росте з трафіком, а не з часом, тож переживає всі тести й з’являється за тиждень після запуску як графік пам’яті, що лише зростає. Виправлень три, і кожне відповідає своїй формі коду. Якщо викликач може перестати читати, дайте каналу буфер на кількість відправників, щоб кожне надсилання завершувалося, слухає хтось чи ні. Якщо робота має зупинятися, коли викликач здався, передайте context і зробіть кожен блокуючий крок select, що чекає ще й на ctx.Done(). А для схеми «запусти N задач, зупинись на першій помилці» беріть errgroup.WithContext з golang.org/x/sync: він скасовує спільний контекст на першому збої, а Wait повертає цю помилку, коли завершилася кожна горутина.

Як знайти витік у продакшені

Перший сигнал — число: runtime.NumGoroutine(), експортоване як метрика. У здорового сервісу воно росте й падає разом із трафіком; у сервісу з витоком — повзе вгору храповиком і ніколи не повертається. Другий — профіль горутин, /debug/pprof/goroutine з net/http/pprof: він групує тисячі горутин за рядком, на якому вони застрягли, тож витік видно як один рядок, що тримає сорок тисяч горутин. Go 1.27 додає третій, створений саме для цього. Профіль goroutineleak, який зробив Влад Сайок з Uber, за допомогою збирача сміття знаходить горутини, заблоковані на каналі чи блокуванні, до яких не може дотягнутися жодна робоча горутина, — тобто такі, яких ніщо й ніколи не відпустить. У 1.26 це був експеримент, у 1.27 — стабільна можливість на /debug/pprof/goroutineleak. Витоку через глобальну змінну він не бачить, і документація так і каже. У тестах пакет Uber goleak валить кожен тест, що лишає по собі горутини.

Що ви бачитеЩо це зазвичай означаєКуди дивитися
Пам’ять і кількість горутин ростуть із трафіком і не падаютьВитік — рядки 1–3, 7 чи 8Профіль горутин, згрупований за рядками; goroutineleak на 1.27
p99 латентності сходинками близько 100 мс, CPU далеко під лімітомТротлінг CPU — GOMAXPROCS більший за ліміт контейнераЛічильники тротлінгу cgroup; версія Go; GOMAXPROCS
fatal error: all goroutines are asleep - deadlock!Кожна горутина чекає на іншуДамп стеків, надрукований разом із нею
fatal error: concurrent map writesДві горутини писали в одну мапу без блокуванняОбидва стеки в повідомленні; потім запуск із -race
Вихідні виклики висять хвилинамиКлієнт без тайм-ауту (рядок 10)Як збудовано http.Client
Контейнер отримує OOMKilled, хоча купа виглядала нормальноGOGC дозволив купі вирости понад лімітGOMEMLIMIT — див. розділ про пам’ять

Спільна пам’ять: гонки даних, мапи і змінна циклу

Гасло Go — «не спілкуйтеся через спільну пам’ять; діліться пам’яттю, спілкуючись», і новачки часто читають його як правило все робити каналами. Це порада про володіння, а не заборона блокувань. Коли значення передається від однієї горутини до іншої, канал — найзрозуміліший спосіб: хто отримав значення, той ним і володіє. Коли кільком горутинам просто треба читати й оновлювати одну спільну структуру — кеш, лічильник, мапу сесій, — sync.Mutex коротший, швидший і куди легший для розуміння, ніж протокол на каналах. Беріть канали, щоб передавати роботу й сигналізувати; беріть м’ютекс, щоб захищати стан.

Так чи інак, боятися варто гонки даних: дві горутини одночасно торкаються тієї самої пам’яті, і принаймні одна з них пише. Компілятор цього не зупиняє, і програма не падає в мить, коли це стається. Вона дає хибне значення, «розірване» читання значення з кількох слів, як-от зрізу чи інтерфейсу, або падіння деінде набагато пізніше. Відповідь Go — інструмент. Збірка з -race обвішує перевірками кожен доступ до пам’яті й показує обидва конфліктні стеки, коли гонка справді стається під час запуску. За оцінкою самої документації це коштує в 5–10 разів більше пам’яті й у 2–20 разів більше часу, тож місце йому в та на невеликій частці продакшн-трафіку, а не в кожному процесі, — і він може повідомити лише про ті гонки, які сталися під час ваших тестів.

Наскільки поширені гонки у великій кодовій базі на Go, виміряли у 2022 році. Uber пів року ганяв детектор гонок по своєму Go-монорепозиторію — близько 50 мільйонів рядків і приблизно 2100 сервісів — і знайшов близько 2000 гонок даних, з яких приблизно 1100 виправили 210 інженерів. Автори також порахували, що Go-сервіси мають приблизно у вісім разів більше паралельності, ніж Java-сервіси Uber. Перелічені ними патерни — саме те, що варто шукати: змінна, захоплена за посиланням у замиканні горутини, — змінна циклу, err, іменований результат; зріз, до якого роблять append з кількох горутин; мапа, у яку пишуть одночасно; sync.Mutex, скопійований за значенням, тож кожна копія нічого не охороняє; і WaitGroup, чий Add виконується всередині горутини, а не перед нею.

Змінна циклу до і після Go 1.22

До Go 1.22 цикл for оголошував свою змінну один раз, і кожна ітерація її перевикористовувала. Замикання чи вказівник, що її захоплювали, — горутина, запущена в циклі, &v, збережений у зрізі, — бачили те, що змінна містила останнім, а не значення своєї ітерації. Це була найпоширеніша помилка в Go взагалі, і допис команди Go 2023 року про її виправлення цитує продакшн-приклад із Let’s Encrypt (у кейсах нижче). З Go 1.22 кожна ітерація отримує свіжу змінну. Але зміна діє помодульно, за рядком go у go.mod: модуль, де досі написано go 1.21, на новому компіляторі зберігає стару поведінку. Перевірте цей рядок, перш ніж покладатися на виправлення, і не чіпайте рядків v := v у старому коді, доки він не зміниться.

Мова: помилки як значення, малий задум і релізи

У Go немає винятків. Функція, що може зазнати невдачі, повертає помилку поруч із результатом, а викликач перевіряє її наступним рядком. Про це сперечаються більше, ніж про будь-що інше в Go, і суперечка зазвичай проминає, що це дає: кожне місце, де може статися збій, видно на тому самому рядку, і жоден прихований шлях не виносить керування з функції. Ціна — повторюваність: та сама перевірка if err != nil на три рядки скрізь.

Сучасний Go змушує цю повторюваність нести інформацію. Обгортання через fmt.Errorf("load user %d: %w", id, err) додає контекст і зберігає початкову помилку всередині, тож збій десятьма шарами нижче приходить одним зрозумілим реченням, а не «connection refused». errors.Is питає, чи є певна помилка десь у цьому ланцюгу; errors.As — і його узагальнена форма errors.AsType, додана в Go 1.26, — дістає помилку заданого типу; errors.Join (Go 1.20) поєднує кілька. Взірець, до якого варто прагнути: низькорівневий код додає контекст і повертає, а рівно один шар біля краю програми вирішує — залогувати, повторити чи перетворити на код статусу.

Решта мови мала навмисно, і пропуски — це задум, а не недогляд. Спадкування немає: типи вбудовують інші типи й задовольняють інтерфейс просто тим, що мають його методи, нічого не оголошуючи. Немає конструкторів, перевантаження операторів, аргументів за замовчуванням, а перші дванадцять років не було й дженериків. Сенс у тому, що Go-код незнайомця схожий на ваш власний, і саме це тримає велику кодову базу читабельною для людей, які її не писали.

РішенняЩо ви отримуєтеЧим жертвуєте
Неявні інтерфейсиСпоживач визначає потрібний йому інтерфейс; пакети лишаються незв’язанимиЗнайти всі реалізації — справа інструментів, а не grep
Без спадкуванняКомпозиція через вбудовування; немає крихкої ієрархії, яку треба простежувати вгоруПатерни на ієрархіях класів доводиться переспроєктовувати, а не перекладати
Помилки як значенняКожен шлях збою видно; немає невидимого розкручування стекаПовторюваність і реальний ризик загубити контекст на кожному кроці
Дженерики, стримані (1.18 → 1.27)Типобезпечні контейнери й помічники; з 1.27 — узагальнені методи типуЖодних параметрів типу в методах інтерфейсу; конструкції рівня типів і далі воюють із мовою
gofmtОдна розкладка для всього Go-коду; форматування ніколи не обговорюютьВибору немає — у цьому й суть
Один статичний бінарникРозгортання копіюванням файлу; образи на кілька мегабайтівКрос-компіляція потребує обережності, щойно через cgo заходять C-бібліотеки

Стандартна бібліотека незвично повна для бекенда: -сервер і клієнт, , TLS, інтерфейси SQL, шаблони, тестування, фазинг і профілювання постачаються разом із компілятором, тож робочий сервіс справді може мати короткий go.mod. Go 1.27 замінив рушій під encoding/json на новий encoding/json/v2 — суворіший за замовчуванням у новому пакеті й значно швидший у декодуванні в обох, — зберігши поведінку старого пакета.

Релізи, обіцянка сумісності і який Go перед вами

Go випускає мажорний реліз кожні шість місяців, у лютому й серпні, і підтримує кожен, доки не вийдуть два новіші, — приблизно рік. Патч-релізи (1.27.1, 1.27.2…) несуть виправлення безпеки лише для двох підтримуваних ліній. Від Go 1, з 2012 року, команда обіцяє, що код, написаний під давніший реліз, і далі компілюється й працює. Коли виправлення мусить змінити поведінку, воно виходить за налаштуванням GODEBUG, яке зберігає стару поведінку для модулів, що оголошують старішу версію Go. Таблиця перелічує релізи, які ви найімовірніше зустрінете в коді.

ВерсіяВипущеноЩо ви зустрінете в кодіПідтримка
1.18бер. 2022Дженерики, фазинг у go test, воркспейсиНі
1.19серп. 2022GOMEMLIMIT — м’який ліміт пам’яті для збирача сміттяНі
1.20лют. 2023errors.Join; покриття для цілих програмНі
1.21серп. 2023min, max, clear; log/slog; рядок go стає мінімумом, а тулчейн завантажує себе самНі
1.22лют. 2024Нова змінна на кожну ітерацію; for i := range 10; шаблони з методами й підстановками в http.ServeMuxНі
1.23серп. 2024Ітератори — range по функції; незупинені таймери збираютьсяНі
1.24лют. 2025Узагальнені псевдоніми типів; рядки tool у go.mod; os.Root; testing.B.Loop; мапи на Swiss tableНі
1.25серп. 2025GOMAXPROCS з урахуванням контейнера; sync.WaitGroup.Go; testing/synctestЗавершено в серп. 2026
1.26лют. 2026new(expr); errors.AsType; збирач сміття Green Tea за замовчуваннямДо Go 1.28, очікується в лют. 2027
1.27серп. 2026Узагальнені методи; профіль goroutineleak; encoding/json/v2До Go 1.29, очікується в серп. 2027

Пам’ять: GOGC, GOMEMLIMIT і ліміт контейнера

Збирач сміття в Go налаштований на короткі паузи, а не на максимальну пропускну здатність. Він позначає живі об’єкти, поки програма працює далі, і зупиняє всі горутини лише на короткі фази, зазвичай значно коротші за мілісекунду. Платить він за це процесором. Поки триває цикл, збирач забирає приблизно чверть P під власних воркерів, а горутину, що швидко виділяє пам’ять, змушують допомагати з позначанням — це «асист», який видно як латентність того запиту, що вона обслуговувала. Go 1.26 зробив новий збирач Green Tea типовим, і примітки до релізу очікують, що він зменшить витрати на збирання сміття на 10–40% у програмах, які сильно на нього спираються.

Як часто запускається цикл, вирішує одне число. GOGC, за замовчуванням 100, означає, що купа може вирости на 100% від живих даних, перш ніж почнеться наступний цикл: сервіс, що тримає 1 ГБ живих об’єктів, працює з купою близько 2 ГБ. Подвоєння GOGC приблизно вдвічі зменшує витрати збирача на CPU й додає ще стільки ж пам’яті. На машині з вільною пам’яттю це прийнятна угода, а в контейнері — небезпечна, бо GOGC нічого не знає про ліміт контейнера. Сервіс із 1,5 ГБ живих даних у контейнері на 2 ГБ дозволяє купі йти до 3 ГБ, перш ніж збирач задумається про запуск, — і ядро вбиває його дорогою.

GOGC каже збирачу, наскільки купа може перерости живі дані перед наступним циклом, — за замовчуванням на 100%, тож при 1,5 ГБ живих даних купа може дійти до 3 ГБ. Про контейнер він нічого не знає. У контейнері на 2 ГБ купа перетинає ліміт задовго до того, як збирач запустився б, і ядро вбиває процес. GOMEMLIMIT, доданий у Go 1.19, — м’який ліміт на всю пам’ять, якою керує середовище: що ближче до нього загальний обсяг, то частіше працює збирач, хоч би що казав GOGC. Ставте його рівним ліміту контейнера мінус 5–10% на пам’ять, якої середовище не бачить. Числа ілюстративні.

Go 1.19 додав другу половину: GOMEMLIMIT, м’який ліміт на всю пам’ять, якою керує середовище. Що ближче до нього загальний обсяг, то частіше працює збирач, хоч би що казав GOGC. Ставте його рівним ліміту контейнера мінус запас — посібник зі збирача сміття Go радить 5–10% на пам’ять, якої середовище не бачить, як-от C-бібліотеки, — тож GOMEMLIMIT=1800MiB у контейнері на 2 ГіБ. Ліміт м’який навмисно. Якщо самі живі дані переростуть його, середовище обмежить збирач приблизно половиною CPU замість безперервного збирання, тож справжній витік однаково закінчиться вбивством через брак пам’яті — лише пізніше й після відрізка високого CPU, на який варто мати окремий алерт.

Постачання: один бінарник і коректна зупинка

Операційна історія й пояснює, чому Go забрав собі інфраструктуру. go build видає один виконуваний файл без середовища, яке треба встановлювати, а з CGO_ENABLED=0 він злінкований статично, тож образ контейнера може складатися з самого бінарника — кілька мегабайтів на основі scratch чи distroless. Він стартує за мілісекунди, а GOOS і GOARCH крос-компілюють його під іншу платформу з будь-якої машини. Docker, Kubernetes, Terraform і Prometheus написані на Go саме з цих причин. В образі scratch немає ні оболонки, ні сертифікатів центрів сертифікації, тож скопіюйте туди їхній набір, інакше перший вихідний HTTPS-виклик упаде.

Коректна зупинка займає шість рядків. signal.NotifyContext перетворює SIGTERM на скасований контекст; коли той спрацьовує, srv.Shutdown(ctx) перестає приймати з’єднання, закриває простійні й чекає на запити в польоті до дедлайну, який ви йому дали. Зробіть цей дедлайн коротшим за пільговий період оркестратора — у Kubernetes типово 30 секунд — і передайте той самий контекст далі, щоб і довга робота це помітила. І заразом задайте тайм-аути сервера: http.Server, збудований як гола структура, їх не має, а це рядок 9, що чекає на повільного клієнта.

П’ять кейсів і те, чого в кожному бракувало

Кожен кейс задокументували ті, з ким він стався: інженерні блоги, звіт про інцидент центру сертифікації, наукова стаття. Разом вони покривають час, двічі — збирач сміття, змінну циклу й гонки даних. Twitch і Uber розказано вище, там, де живе їхній механізм; решта три — далі.

КейсЩо сталосяЧого бракувало
Cloudflare, 1 січ. 2017Високосна секунда зробила виміряну тривалість від’ємною; DNS-компонент на ній запанікувавМонотонний годинник — його додав Go 1.9
Twitch, квіт. 20198–10 циклів GC на секунду при малій купі; баласт на 10 ГіБ скоротив їх на 99%Ліміт пам’яті, зрозумілий збирачу, — GOMEMLIMIT, 2022
Discord, лют. 2020Сплески латентності кожні дві хвилини, бо GC сканував величезний кеш; сервіс переїхав на RustРозкладка купи, яку збирачу не треба було обходити
Let’s Encrypt, лют. 2020Вказівник на змінну циклу пропускав перевірки CAA; зачеплено близько 3 мільйонів сертифікатівЗмінна на кожну ітерацію — типово з Go 1.22
Uber, черв. 2022Близько 2000 гонок даних за пів року у 2100 сервісахДетектор гонок у CI від самого початку

Перший кейс — годинник. Наприкінці 2016 року до останньої хвилини року додали високосну секунду, і настінний годинник на серверах Cloudflare відступив назад. RRDNS, DNS-сервер Cloudflare, написаний на Go, вимірював, як швидко відповідають вищі резолвери, віднімаючи одне показання time.Now() від іншого, — і через високосну секунду одна з цих тривалостей вийшла від’ємною. Пізніше код, що обирав резолвер із вагами за цими вимірами, отримав від’ємне число й запанікував. Паніку перехопив recover, тож процес вижив, але пошук CNAME не вдавався: у пік помилки повертали близько 0,2% DNS-запитів до Cloudflare і менш як 1% HTTP-запитів, а виправлення розгорнули по всьому світу за сім годин. Тодішній time.Now() у Go читав лише настінний годинник, який може стрибати. Go 1.9 у серпні 2017 року змусив кожен time.Time нести ще й показання монотонного годинника, тож time.Since і Sub більше не йдуть назад. Урок переживає виправлення: тривалість вимірюють, мітку часу зчитують, і беруться вони з різних годинників.

Четвертий кейс — змінна циклу в продакшені. 29 лютого 2020 року Let’s Encrypt виявив, що Boulder, його програмне забезпечення центру сертифікації, написане на Go, перевіряв записи CAA — DNS-запис, у якому власник домену вказує, які центри можуть видавати для нього сертифікати, — лише для одного імені в сертифікаті з кількома іменами. Коли запит містив N імен, які треба було перевірити повторно, Boulder перевіряв одне з них N разів. Блог Go згодом процитував код: цикл копіював ключ, kCopy := k, але передавав &v у функцію, що зберігала вказівники всередину нього, тож кожен результат вказував на ту саму змінну. Зачепило близько 3 мільйонів сертифікатів. Let’s Encrypt замінив і відкликав близько 1,7 мільйона до дедлайну 5 березня, а решті дав спливти, бо вони жили 90 днів. Go 1.22 зробив змінну циклу окремою для кожної ітерації, що прибирає весь цей клас помилок, — для модулів, чий go.mod це оголошує.

Де він вироджується

Go щедрий в один бік і мовчазний в інший. Паралельність настільки дешева, що нею користуються не замислюючись, тож просто написаний сервіс масштабується далеко. Але ніщо в мові не каже, коли горутина закінчується, стандартна бібліотека не ставить тайм-аутів, а детектор гонок повідомляє лише про те, що сталося під час тестів. Майже кожен збій — одне з цього:

  • Горутини без власника, що течуть щоразу, коли їхній викликач перестає слухати (рядки 1–3, 8).
  • context, прийнятий параметром і проігнорований, тож скасування ніколи не доходить до роботи (рядок 12).
  • http.Server чи http.Client, збудовані без тайм-аутів (рядки 9–10).
  • Незакриті результати запитів, через які пул з’єднань висихає й кожен запит чекає (рядок 11).
  • Спільні мапи й зрізи, у які пишуть кілька горутин, а -race у CI ніколи не запускають.
  • Образ контейнера, зібраний до Go 1.25, що працює з GOMAXPROCS, рівним кількості ядер хоста.
  • Жодного GOMEMLIMIT у контейнері, чий ліміт менший за подвоєну живу купу.
  • panic і recover у ролі винятків для звичайних, очікуваних збоїв.
  • Помилки без контексту, через які збій приходить як «connection refused» без жодного натяку, звідки.
  • Рядок go у go.mod, старший за виправлення, на які команда розраховує, — змінну циклу, таймери.

Чесна межа — збирач сміття й свідомо мала система типів. Сервіс, чия купа — десятки мільйонів довгоживучих вказівників (величезний кеш у пам’яті, індекс), платить за кожен цикл процесором і латентністю, які налаштування може зменшити, але не прибрати, — саме звідси пішов Discord. А домен, який хоче закодувати свої правила в типах, з вичерпним зіставленням і без nil, відчує типи Go кліткою; це територія Rust — ціною значно крутішої мови. Для мережевих сервісів, шлюзів та інструментів, які пише більшість команд, угода працює навпаки, і тому так багато інфраструктури під ними написано на Go.

Практична перевірка займає десять хвилин. Запустіть go version для продакшн-бінарника й знайдіть його рядок у таблиці релізів. Прочитайте рядок go у go.mod. Подивіться на кількість горутин на дашборді за тиждень. Порівняйте GOMAXPROCS і GOMEMLIMIT з лімітами контейнера. Знайдіть у коді http.Client{ і http.Server{ і прочитайте їхні тайм-аути. І переконайтеся, що CI запускає тести з -race.

Коли це застосовувати

Застосовуйте, коли

  • Мережеві сервіси та API з високою паралельністю — саме під це навантаження проєктували середовище.
  • Інфраструктура й -інструменти, де один бінарник без залежностей вартий більше за будь-яку можливість мови.
  • Контейнери й serverless-функції, які мають стартувати за мілісекунди й лишатися малими.
  • Команди, що цінують один читабельний стиль вище за виразність, — мова забезпечує його за вас.

Уникайте, коли

  • Величезні довгоживучі купи з безліччю вказівників, де проблемою є будь-яка пауза збирача, — Rust звільняє пам’ять у відому мить.
  • Домени з глибоким моделюванням через типи, де свідомо мала система типів відчувається кліткою.
  • Наука про дані й машинне навчання, де бібліотеки є в Python, а тут — ні.
  • Швидка CRUD-розробка, якій першого ж дня потрібні адмінка, і автентифікація, — Go не дає нічого з цього.

Джерела й метод

На чому стоїть ця стаття

  • 19 джерел
  • 2 задокументовані збої
  • Перевірено 13 жовтня 2026 р.

Практики й походження термінів

усі місця, де чекає горутина, планувальник, GC і версії

Дослідження з Claude. Пошук і звірку першоджерел зроблено з Claude (Anthropic). Відбір, текст і перевірку фактів — редакція Anatomy of IT Product.

Було корисно?

Поділіться з тим, хто працює над тією ж задачею.