Введение
В последнее время вынужден наблюдать легкий тремор среди коллег, которым предстоят собеседования по Go - и все, конечно, понимают, что вопросов на тему главной распиаренной фичи Go не избежать. Конечно, вы все читали эти статьи - с картинками и буквами M, G, P в кружочках, знаете про GOMAXPROCS (причем, скорее всего, не то, что надо), но стоит интервьюеру углубиться в детали, например, спросить - а зачем вообще нужны эти горутины и почему не хватало потоков? - тут-то и начинается невразумительное мычание.
Попытаюсь вооружить - уверен, нижестоящее объяснение произведет впечатление на интервьюера, и вы феерически пройдете собеседование. И, следуя правилам блога - простыми словами.
Автор, разумеется, исходит из того, что у читателя присутствует базовое понимание устройства операционных систем, и он отличает кучу от стека.
Переключение контекста
Начнем с противоположного - вспомним, что происходит при переключении контекста потока ОС, чем чревато, в порядке возрастания проблем:
- Нужно сохранить регистры CPU - сохраняем их в Process Control Block - специальную область в памяти ядра ОС, которая содержит всю информацию о состоянии потока ОС. Но это не очень затратно.
- А что с кешами? Особенно если переключение прошло на поток другого процесса - данные в них не релевантны теперь, мы будем получать промахи (cache misses) и ждать, пока prefetch механизм постепенно заполнит кеш данными текущего потока.
- Опять же - если поток другого процесса, то у нас новое виртуальное адресное пространство, а значит, перегрузка TLB (Translation Lookaside Buffer) буфера.
- И главное - когда планировщик ОС решает вытеснить текущий поток? На основании своих эвристик, но типичный пример - это блокирующий системный вызов сервисов ядра ОС, например, чтение/запись с диска или сокета, или межпроцессные взаимодействия - отправка и получение сообщений, или синхронизация критических секций через мьютексы и семафоры, когда поток блокируется в ожидании освобождения ресурса другим потоком. И проблема тут в том, что изначально выделенный планировщиком квант времени не используется весь - а лишь часть до подобного вызова.
Наши маленькие друзья горутины
Итак, в чем же идея горутин? Максимально эффективно использовать поток ОС путем избегания затрат на переключение контекста, описанное выше. Разберемся, как это достигается.
Go runtime, в отличие от планировщика ОС, полностью контролирует выполнение горутин. Он знает, где и когда произойдут блокирующие вызовы, и, вместо того чтобы позволить ОС приостановить поток, превращает эти вызовы в асинхронные операции.
Когда одна горутина блокируется, например, ожидая I/O, Go runtime не ждет, пока ОС вытеснит поток. Вместо этого он моментально переключает выполнение на другую горутину из локальной очереди текущего потока, фактически подменяя контекст выполнения. Этот процесс можно сравнить с оператором GOTO, где управление передается новой горутине без вмешательства ОС.
Таким образом, планировщик Go оптимизирует многозадачность на уровне пользовательского пространства, минимизируя взаимодействие с ядром и лишая ОС повода вытеснять текущий поток.
Затраты же на переключение горутины совсем невелики - ибо горутина это, по сути, слепок маленького стека (начинается с 2кб, но может динамически расти до 1GB при необходимости). При вытеснении горутины нужно сохранить минимальный контекст: всего три регистра процессора - Program Counter (PC, указывает на текущую инструкцию), Stack Pointer (SP, указатель на вершину стека) и Base Pointer (BP, указатель на базу стека), плюс содержимое самого стека. Никаких сложных структур данных ядра, никаких переключений адресного пространства, никаких сбросов кэша - всё происходит в пространстве пользователя и в рамках одного потока ОС.
Однако переключать горутину можно не везде - для этого в Go существует концепция safe points (безопасных точек). Это специальные места в коде, где runtime может безопасно приостановить горутину и переключиться на другую. Почему это важно? Представьте, что мы прервем горутину прямо посреди обновления сложной структуры данных или в момент, когда часть указателей временно невалидна - это может привести к повреждению данных или сложно отлаживаемым ошибкам.
Go компилятор автоматически вставляет safe points в определенных местах кода:
- При вызове функции - в прологе каждой функции есть проверка необходимости переключения
- В начале каждой итерации цикла for - чтобы длительные циклы не захватывали процессор
- При операциях выделения памяти в куче - так как это потенциально длительная операция
- При операциях с каналами и других блокирующих вызовах
- При возврате из функции
В этих точках горутина как бы “спрашивает” планировщик: “А не пора ли меня изгонять?” Упрощенно можем это проиллюстрировать таким псевдокодом:
|
|
При этом Go гарантирует, что горутина не сможет “захватить” процессор надолго, так как safe points расставлены достаточно часто в коде, а блокирующие операции автоматически преобразуются в асинхронные.
Но, конечно, есть ситуации, когда поток с горутинами все-таки может быть заблокирован - всех проблем описанный выше подход не решает. Например:
- Есть системные вызовы, которые невозможно сделать асинхронными (например, вызовы cgo библиотек и файловых операций на определенных ОС)
- Интенсивные CPU операции (вычисление числа Фибоначчи или сортировки)
- Сборка мусора, то есть наша любимая фаза Stop the World
И что в этом случае? А в этом случае runtime Go создаст новый поток - и будет так делать сколько угодно, если нет доступных работающих потоков на данный момент. То есть GOMAXPROCS никак не ограничивает максимальное количество потоков в системе - лишь указывает, сколько потоков могут одновременно работать.
То есть если вы поставите этот параметр в 1, и этот единственный поток будет заблокирован, то runtime создаст новый поток - если и этот заблокируется, то опять новый, и таким образом сколько их будет всего предсказать нельзя.
Итоги
Итак, что же делает горутины такими эффективными:
- Минимальные затраты на переключение контекста - только три регистра и маленький стек
- Кооперативная многозадачность через safe points - горутины сами решают когда уступить процессор
- Умная обработка блокирующих вызовов - Go runtime превращает их в асинхронные операции
- Эффективное использование потоков ОС - GOMAXPROCS потоков обрабатывают тысячи горутин
При этом Go берет на себя всю сложность управления этим механизмом - программисту достаточно просто написать go myFunction()
, и runtime сам позаботится об эффективном выполнении кода.
Именно поэтому горутины стали одной из самых успешных реализаций легковесных потоков (green threads) в современных языках программирования. Они позволяют писать высокопроизводительный конкурентный код, при этом оставляя его простым для понимания и поддержки.
А что же у других?
В Java 21 появились виртуальные потоки (Project Loom) - аналог горутин, но они всё ещё следуют классической модели потоков, что делает их менее эффективными. Зато они полностью совместимы с существующим Thread API.
Rust пошел совсем другим путем - там нет встроенного runtime для асинхронного выполнения, вместо этого используется система типов с Future и async/await. Популярные runtime вроде tokio или async-std нужно подключать отдельно. Такой подход дает больше контроля, но требует больше явного кода.
Думается, что Go нашел золотую середину - простой и понятный API как в Java, но при этом эффективная реализация и продуманный runtime как в Rust.