Go, Kotlin и Erlang изнутри: как устроены конкурентность и сборка мусора
Автор блога под ником ikouchiha47 опубликовал 12 августа 2026 года подробный технический разбор моделей конкурентности и сборки мусора. Сам материал в блоге называется «Краткий обзор конкурентности и сборки мусора: Go, Kotlin, BEAM, JVM/HotSpot, GraalVM и алгоритмы сборки мусора под ними» и выстроен по разделам с контрольными вопросами и планом чтения в конце; на Hacker News же пост попал под случайно звучащим, будто автор сам не уверен в теме, заголовком-обёрткой, сам текст никакой «блуждающей» интонации не несёт. Центральный вопрос: кто в рантайме управляет переключением между задачами и что ему для этого нужно знать, чтобы сделать это безопасно, и как этот выбор определяет, останавливает ли сборка мусора один поток или весь процесс целиком. Материал разбирает и сравнивает три рантайма, Go, Kotlin (точнее, JVM и библиотеку kotlinx.coroutines) и Erlang/Elixir (виртуальная машина BEAM), целиком по первоисточникам: автор читал напрямую исходный код Go (preempt.go, signal_unix.go, proc.go, mgc.go, mbarrier.go), стандартной библиотеки Kotlin и библиотеки kotlinx.coroutines, OpenJDK/HotSpot, GraalVM/SubstrateVM и Erlang/OTP, а для алгоритмов сборки мусора, печатное издание учебника The Garbage Collection Handbook (2-е издание, Jones, Hosking, Moss, 2023) с указанием конкретных разделов и страниц.
В Go модель конкурентности, это реализация CSP, взаимодействующих последовательных процессов (Communicating Sequential Processes), концепции, которую Тони Хоар описал в статье 1978 года в журнале Communications of the ACM: независимые процессы взаимодействуют только через явную передачу сообщений по каналам, а не через общую память под блокировками. Дизайнеры Go, в первую очередь Роб Пайк, до этого работавший над экспериментальными языками Newsqueak и Alef, где уже были CSP-каналы, взяли у Хоара сам примитив общения, именованные каналы, но не полную формальную алгебру процессов и не строго синхронную семантику: буферизованные каналы Go допускают асинхронную отправку в пределах размера буфера. Планировщик Go, это модель GMP (горутина / поток ОС / процессор), спроектированная Дмитрием Вьюковым в 2012 году (go11sched): отдельный ресурс-процессор нужен, чтобы у каждого потока была своя локальная очередь горутин и потоки не боролись всякий раз за одну общую очередь. До версии 1.10 Go вытеснял горутины кооперативно, проверка «пора ли уступить» стояла только в прологах функций, поэтому горутина с циклом for {} без единого вызова функций никогда её не проходила и не могла быть остановлена; из-за этого runtime.GC() мог зависнуть навсегда, потому что полная остановка процесса требует, чтобы каждая горутина сначала дошла до безопасной точки. Начиная с версии 1.14, по итогам предложения 2019 года, Go использует принудительное, сигнальное вытеснение: рантайм посылает конкретному потоку ОС сигнал SIGURG, который может прервать выполнение в любом месте, включая бесконечный цикл без вызовов. Сигнал выбирали по четырём критериям, перечисленным в signal_unix.go, и победил именно SIGURG: внеполосные TCP-данные, для которых он изначально предназначен, на практике почти не используются, сам сигнал даже не сообщает, какой сокет его вызвал, а корректно написанное приложение и так обязано терпеть случайный SIGURG. Промежуточный вариант, добавить проверки на обратных рёбрах циклов, то есть тот же кооперативный принцип, но на каждой итерации, измерили и отвергли: он давал регресс производительности на 7,8%, тогда как сигнальный подход не стоит ничего, пока вытеснение реально не запрошено. Сама сборка мусора в Go, по собственному комментарию в mgc.go, это конкурентный, точный сборщик с пометкой и очисткой (mark-and-sweep), негенерационный и некомпактирующий; «точный» здесь значит, что сборщик всегда знает, какое слово в памяти указатель, а какое нет, и ему не приходится это угадывать, как консервативным сборщикам. Корректность конкурентной разметки обеспечивает трёхцветная абстракция (белый / серый / чёрный) и гибридный барьер записи, который не даёт уже размеченному, «чёрному» объекту получить прямую ссылку на ещё не тронутый, «белый» объект в обход сборщика.
У Kotlin ситуация принципиально другая, потому что язык не может изменить модель потоков самой JVM, поток JVM это поток операционной системы. Единственный доступный рычаг, использовать потоков меньше, то есть не держать поток занятым, пока корутина «спит». Здесь стоит различать два слоя: ключевое слово suspend, объект-продолжение (Continuation) и сама трансформация в конечный автомат с состояниями, это часть языка и стандартной библиотеки Kotlin, они работают без какой-либо библиотеки корутин вообще. А вот диспетчеры, структурированная конкурентность (Job и CoroutineScope) и, главное, сам планировщик задач (класс CoroutineScheduler), это отдельная библиотека kotlinx.coroutines. По собственному комментарию в её исходном коде, схема планировщика, локальная очередь на каждый воркер плюс кража задач у других воркеров при простое, впрямую восходит к планировщику Go авторства Дмитрия Вьюкова и была признана «достаточно справедливой», производительной и хорошо принятой, изначально послужив значимым источником вдохновения для планировщика корутин. Для сборки мусора это означает, что Kotlin на JVM использует тот же сборщик мусора, что и обычная JVM (G1, ZGC и так далее) без изменений: корутины не создают отдельной проблемы «точки безопасности», как это происходит у Go, потому что опрос точек безопасности в HotSpot, совершенно отдельный, встроенный JIT-компилятором механизм, никак не связанный с приостановкой корутин.
У самой JVM (HotSpot) вытеснение потоков устроено иначе, чем у Go, не через сигнал, а через опрос защищённой страницы памяти: JIT-компилятор вставляет в код, на входе в метод и на обратных рёбрах циклов, чтение из специальной страницы; когда виртуальной машине нужно остановить все потоки, она делает эту страницу «вооружённой», защищённой от доступа, и следующий поток, который попытается её прочитать, получает аппаратное исключение (page fault), которое HotSpot перехватывает и превращает в переход к точке безопасности. Здесь же зафиксировано важное уточнение: находиться в точке безопасности не значит быть заблокированным, поток может оставаться в безопасном, инспектируемом для сборщика мусора состоянии, не переставая при этом работать (например, во время вызова через JNI). У HotSpot есть и встроенный сторож: если конкретный поток не доходит до точки безопасности за отведённое время, виртуальная машина не ждёт бесконечно, а принудительно завершает именно этот поток сигналом SIGILL, это прямой практический ответ на тот же класс проблемы, из-за которой Go в своё время сменил весь механизм вытеснения. GraalVM Native Image (SubstrateVM) заменяет HotSpot полностью, заранее компилируя байткод в нативный бинарник, и это не меняет модель приостановки корутин Kotlin, преобразование в конечный автомат происходит ещё в компиляторе Kotlin, до GraalVM. А вот сам механизм точек безопасности в SubstrateVM, не копия механизма HotSpot: вместо общей защищённой страницы памяти мастер-поток при запросе точки безопасности инвертирует локальный счётчик каждого потока атомарной операцией сравнения-с-обменом, а сам поток лишь периодически уменьшает свой счётчик и, заметив, что он стал отрицательным, уходит в ожидание на мьютексе; общей страницы, аппаратного исключения или сигнала в этом пути вообще нет. По архитектуре это ближе к счётчику редукций BEAM, чем к странице опроса HotSpot, хотя SubstrateVM остаётся рантаймом с общей кучей и по-прежнему нуждается в глобальной остановке для сборки мусора, в отличие от BEAM.
У Erlang/BEAM исходная постановка задачи вообще была не про производительность, Джо Армстронг формулировал её как вопрос отказоустойчивости: как построить систему, способную работать практически вечно, не падая целиком. Ответ, сбои должны быть изолированы, а для этого процессы не должны делить память вообще. У каждого BEAM-процесса, это сущность, которой управляет виртуальная машина, а не процесс операционной системы, собственная куча и собственный сборщик мусора; отправка сообщения другому процессу копирует данные в его кучу (крупные бинарные данные, исключение, они лежат в общей куче со счётчиком ссылок, чтобы избежать копирования). Поскольку процессы физически не видят чужую память, самой проблемы «точки безопасности» здесь структурно не существует: планировщику не нужно проверять валидность указателей в чужой памяти, чтобы остановить один процесс. Вместо сигналов или опроса BEAM считает «редукции», по одной за каждый вызов функции или встроенной операции; когда счётчик процесса (константа CONTEXT_REDS, сейчас равная 4000, тогда как старая документация и блог-посты часто называют устаревшую цифру около 2000) доходит до нуля, интерпретатор сам возвращает управление планировщику. Поэтому даже бесконечный цикл без единого внешнего вызова, аналог зависавшего в Go for {}, не может застопорить планировщик BEAM: каждый его виток, это вызов функции самой себе, который расходует «редукции» и рано или поздно упирается в ноль. Единственное, что всё ещё способно заблокировать поток планировщика, это по-настоящему блокирующий нативный вызов (NIF), который сам не возвращает управление виртуальной машине. А поскольку сборщик мусора у каждого процесса свой, его пауза в одном процессе не видна остальным и не требует остановки всей виртуальной машины, в отличие от Go. Именно из этой изоляции, а не как отдельная философия, следует и принцип «let it crash» («пусть падает»): раз падение одного процесса физически не может испортить состояние другого, супервизор может просто перезапустить упавший процесс, не проверяя систему на прочность.
Свою сравнительную таблицу автор сводит к одному предложению: проблема «точки безопасности» существует именно там, где вытеснение недобровольное и память общая одновременно, у Go есть и то, и другое, и он платит за это картами стека и обработкой сигналов; у BEAM недобровольное вытеснение есть, но общей памяти нет, поэтому проблемы нет вовсе; у Kotlin общая память есть, но принудительного вытеснения корутин нет, поэтому он тоже обходит проблему, ценой того, что зависшую в бесконечном цикле корутину нельзя вытеснить силой. Отдельная, самая объёмная часть материала разбирает промышленные и исследовательские алгоритмы сборки мусора: от формализованной трёхцветной инвариантности через реальную последовательность фаз сборщика G1 в OpenJDK, цветные указатели ZGC и самовосстанавливающиеся указатели пересылки Shenandoah, до линии алгоритмов Azul, Pauseless и C4 на тегированных указателях, в контрасте с Compressor на защите страниц памяти (ZGC 2018 года прямо развивает более раннюю технику Azul 2005, 2011 годов) и вплоть до исследовательских коллекторов реального времени вроде Metronome, который планирует работу фиксированными квантами по 500 микросекунд на 10-миллисекундное окно, чтобы гарантировать программе минимальную долю процессорного времени, такую цель обычно задают на уровне 70%. Всё это, со ссылками на конкретные разделы и страницы The Garbage Collection Handbook.
При этом в материале нет ни одного прямого измерения, которое сравнивало бы реальную пропускную способность, задержки или паузы сборки мусора у горутин, Kotlin-корутин и BEAM-процессов между собой: единственная измеренная во всём тексте цифра производительности, это тот самый отвергнутый вариант Go с регрессом 7,8%. Автор также прямо помечает несколько мест как непроверенные лично, например, почему инженеры SubstrateVM выбрали счётчик на поток вместо страницы опроса HotSpot, и точную строку в исходниках kotlinx.coroutines, где создаётся объект DispatchedContinuation, текст называет это открытыми вопросами, а не фактами.
Ключевые факты
- Материал целиком построен на прямом чтении исходного кода рантаймов, Go (preempt.go, proc.go, mgc.go), kotlinx.coroutines, OpenJDK/HotSpot, GraalVM/SubstrateVM и Erlang/OTP, плюс учебника The Garbage Collection Handbook (2-е издание, 2023), а не на пересказе документации.
- В Go до версии 1.10 вытеснение горутин было кооперативным и проверялось только в прологах функций: горутина с бесконечным циклом for {} без вызовов функций могла навсегда заблокировать runtime.GC(). С версии 1.14 Go прерывает горутину сигналом SIGURG в любой точке, альтернативный вариант с проверками на обратных рёбрах циклов отвергли из-за регресса производительности на 7,8%.
- Erlang/BEAM вообще не сталкивается с проблемой «точки безопасности»: у каждого процесса своя память и свой сборщик мусора, а вытеснение считает «редукции», сейчас константа CONTEXT_REDS равна 4000 вызовам на процесс, тогда как старая, всё ещё часто цитируемая цифра, около 2000.
- Планировщик библиотеки kotlinx.coroutines, по признанию собственных авторов, впрямую унаследовал схему локальных очередей и кражи задач у планировщика Go Дмитрия Вьюкова; при этом паузы сборки мусора на Kotlin/JVM управляются отдельным механизмом опроса HotSpot, не связанным с корутинами.
- У HotSpot есть встроенный сторож: если поток не доходит до точки безопасности вовремя, JVM принудительно завершает именно этот поток сигналом SIGILL, а не ждёт бесконечно. GraalVM Native Image (SubstrateVM) использует для этого другой механизм, не общую защищённую страницу памяти, а собственный счётчик каждого потока.
Почему это важно
Материал не анонсирует ничего нового, ни Go, ни Kotlin, ни Erlang не меняли модель конкурентности к моменту публикации 12 августа 2026 года. Ценность в другом: автор выводит поведение каждого рантайма из одного и того же вопроса, кто решает, когда прервать задачу, и что ему для этого нужно знать о её состоянии, и показывает, что «проблема точки безопасности» (когда сборщику мусора нужно, чтобы все потоки одновременно оказались в безопасном для инспекции состоянии), это не свойство сборки мусора самой по себе, а прямое следствие двух независимых решений: недобровольное ли вытеснение и общая ли память. У Go есть и то, и другое, поэтому он платит картами стека и сигналами; у BEAM общей памяти нет, поэтому проблемы нет вовсе, хотя вытеснение недобровольное; у Kotlin общая память есть, но корутины вытесняются только добровольно, поэтому проблема тоже не возникает, ценой того, что зависшую корутину нельзя прервать силой. Это редкий случай, когда сравнение трёх экосистем построено не на маркетинге и не на бенчмарках, а на прямом чтении исходников каждой из них.
Кому это важно
В первую очередь, тем, кто пишет бэкенд или системный код на Go, Kotlin (включая Android и серверный код на kotlinx.coroutines) или Erlang/Elixir и хочет понимать, почему сборка мусора иногда «подвешивает» именно их рантайм целиком, а не один поток. Полезно и тем, кто отлаживает паузы GC или голодание планировщика: материал объясняет, какого рода зависание вообще возможно в каждой модели и почему. Отдельно пригодится тем, кто рассматривает переход на GraalVM Native Image, в тексте прямо показано, что его механизм точек безопасности физически отличается от обычного HotSpot, а не является его копией под другим именем. И тем, кто интересуется внутренним устройством современных промышленных сборщиков мусора (G1, ZGC, Shenandoah, линия Azul), им посвящена отдельная, самая длинная часть материала.
Как это применить
Из части про Go практический вывод скорее исторический, чем актуальный: начиная с версии 1.14 (2020 год) вставлять в горутины искусственные вызовы функций ради сборщика мусора больше не нужно, рантайм прерывает горутину сигналом сам, в любом месте. Из части про Kotlin полезно помнить, что suspend и объект-продолжение работают и без библиотеки kotlinx.coroutines вообще, а сам факт «корутинности» кода не спасает от того, что CPU-интенсивный, не приостанавливающийся код займёт поток целиком: диспетчер не может вытолкнуть корутину из тела, в котором нет точек приостановки. Из части про Erlang, практическое следствие изоляции процессов: единственное, что реально способно застопорить планировщик BEAM, это блокирующий нативный вызов (NIF), который не отдаёт управление обратно виртуальной машине; обычный, даже бесконечный, Erlang-код так сделать не может. Сам материал (114 минут чтения по собственной оценке автора) советует читать не подряд, а четырьмя проходами по четырём первоисточникам: сначала, какую проблему решает каждый источник, затем, где в нём несущее ограничение, затем, какой путь в нём отвергнут или какой пробел признан, и в конце, сжать каждый источник до одного предложения.
Можно ли доверять
Источник для одиночного технического блога подкреплён необычно сильно: почти каждое утверждение сопровождается прямой ссылкой на конкретный файл исходного кода, Go (preempt.go, signal_unix.go, proc.go, mgc.go, mbarrier.go), стандартная библиотека Kotlin и библиотека kotlinx.coroutines, OpenJDK/HotSpot (safepoint.hpp, safepointMechanism.hpp, safepoint.cpp), GraalVM/SubstrateVM (Safepoint.java) и Erlang/OTP (erl_process.c, erl_vm.h, beam_emu.c, erl_gc.c), а для алгоритмов сборки мусора, конкретные разделы и страницы печатного издания The Garbage Collection Handbook (2-е издание, Jones, Hosking, Moss, 2023). Там, где автор не проверил утверждение лично, это прямо помечено как открытый вопрос, а не выдано за факт: например, почему инженеры SubstrateVM выбрали счётчик на поток вместо страницы опроса HotSpot, и точная строка в kotlinx.coroutines, где создаётся объект DispatchedContinuation, оба места в тексте так и названы неподтверждёнными. При этом это личный блог одного разработчика, а не рецензируемый источник: на момент сбора данных у поста было 5 баллов и ноль комментариев на Hacker News, то есть сообщество его ещё не проверяло.
Риски и подводные камни
Материал огромный и выстроен как справочник из семи частей с перекрёстными ссылками, а не как последовательный рассказ, читать его залпом и держать всё в голове не стоит (сам автор оценивает чтение в 114 минут). Из-за масштаба легко принять один частный вывод за общий: «у BEAM проблемы точки безопасности нет вовсе» верно только для этой конкретной проблемы при сборке мусора, а не является общим утверждением о превосходстве модели BEAM над Go или Kotlin. Главное ограничение, материал не измеряет и не сравнивает реальную производительность, задержки или паузы сборки мусора между Go, Kotlin и Erlang: единственная измеренная в тексте цифра, это тот самый отклонённый вариант Go с регрессом 7,8%, а не сопоставление трёх рантаймов между собой. Выводы касаются исключительно устройства механизмов, а не того, какой рантайм быстрее под реальной нагрузкой.
«Не общайтесь, разделяя память, разделяйте память, общаясь.»
— материалы проекта Go