Основы параллельного программирования на Go: горутины для начинающих

Подробное руководство по конкурентности в Go, использующее простые жизненные аналогии для объяснения сложных механизмов. Ученики освоят синтаксис ключевого слова 'go', поймут работу планировщика и научатся управлять жизненным циклом параллельных задач.

1. Что такое конкурентность: аналогия с поваром на кухне

Что такое конкурентность: аналогия с поваром на кухне

Представьте, что вы решили приготовить праздничный ужин: запечь курицу, нарезать салат и сварить соус. Если вы будете действовать строго последовательно — сначала полностью приготовите курицу (полтора часа), затем начнете мыть овощи для салата (десять минут) и только потом возьметесь за соус (еще пятнадцать минут), — ваш ужин растянется на неоправданно долгое время. Более того, пока вы режете помидоры, готовая курица успеет остыть. В реальной жизни ни один повар так не работает. Пока птица томится в духовке, руки свободны для ножа, а глаза приглядывают за сотейником.

В программировании ситуация идентична. Большинство современных программ не должны ждать завершения одной долгой операции (например, скачивания файла из интернета), чтобы позволить пользователю нажать на кнопку «Отмена» или начать вводить текст. Способность программы управлять несколькими задачами одновременно называется конкурентностью, а в языке Go главным инструментом для этого являются горутины.

Последовательное выполнение против конкурентного

Чтобы понять, какую магию привносят горутины, нужно сначала увидеть, как работает «обычный» код. В стандартном императивном программировании инструкции выполняются одна за другой. Это называется синхронным или последовательным выполнением.

Рассмотрим простую программу, которая имитирует приготовление двух блюд:

В этом примере функция main выступает в роли шеф-повара. Она вызывает cookChicken(). Программа «замирает» на строке time.Sleep(3 * time.Second). Шеф-повар буквально стоит перед духовкой и три секунды (в реальности — часа) ничего не делает. Только когда курица готова, управление возвращается в main, и повар приступает к салату. Общее время выполнения составит минимум 4 секунды.

Проблема здесь не в лени повара, а в структуре управления. Программа не знает, что во время time.Sleep процессор простаивает и мог бы заняться чем-то полезным.

Горутины: ваши невидимые помощники

Горутина (goroutine) — это легковесный поток выполнения, управляемый средой исполнения Go (Go runtime). Если обычная функция — это приказ повару «сделай это сейчас», то запуск горутины — это звонок помощнику: «займись этим в фоновом режиме, а я продолжу свои дела».

Чтобы превратить обычный вызов функции в горутину, достаточно добавить ключевое слово go перед именем функции.

Теперь, когда Go видит слово go, он не ждет завершения функции. Он создает новую горутину, передает ей задачу и мгновенно переходит к следующей строке кода.

Механика запуска и планировщик

Когда вы запускаете программу на Go, автоматически создается одна главная горутина — функция main. Она является «скелетом» вашего приложения. Если main завершается, вся программа немедленно прекращает работу, даже если другие горутины еще не закончили свои задачи.

Представьте, что main — это владелец ресторана. Если владелец закрывает ресторан и уходит домой, повара на кухне (другие горутины) вынуждены бросить ножи и уйти, даже если салат не дорезан.

Давайте попробуем изменить наш код:

Если вы запустите этот код, вы, скорее всего, увидите в консоли только одну фразу: «Ужин подан!». Почему?

  • Главная горутина main запустила cookChicken в фоне.
  • Она же запустила chopSalad в фоне.
  • Она немедленно перешла к fmt.Println.
  • Функция main завершилась.
  • Программа закрылась.
  • Повара даже не успели достать доски для резки, как ресторан закрылся. Это критически важный нюанс: горутины работают конкурентно, но они зависят от жизненного цикла главной функции.

    Почему горутины — это не потоки ОС

    Многие языки программирования (Java, C++, Python) используют системные потоки (threads) для параллелизма. Однако потоки операционной системы — штука дорогая. Каждый поток требует около 1–2 Мегабайт памяти для своего стека. Если вы захотите запустить 100 000 потоков, вашему серверу может не хватить оперативной памяти.

    Горутины устроены иначе:

  • Память: Горутина начинается всего с 2 Килобайт памяти. Это в 500-1000 раз меньше, чем у потока ОС. Стек горутины может расти и уменьшаться динамически.
  • Переключение контекста: Когда процессор переключается с одного системного потока на другой, это занимает много времени (нужно сохранить все регистры, состояние памяти и т.д.). Переключение между горутинами происходит внутри Go и выполняется гораздо быстрее.
  • Планировщик (Scheduler): В Go встроен собственный планировщик. Он работает по принципу . Это значит, что он распределяет горутин на доступных ядер процессора (системных потоков).
  • > «Горутины позволяют нам думать о тысячах независимых задач, не беспокоясь о том, сколько ресурсов потребляет каждая из них». > > Эффективный Go (Effective Go)

    Конкурентность vs Параллелизм

    Часто эти понятия путают, но для программиста на Go важно их разделять.

    Конкурентность (Concurrency) — это про структуру кода. Это когда вы разбиваете одну большую задачу на несколько независимых частей, которые могут* выполняться одновременно. Это как если бы у вас был один повар, который быстро переключается между плитой, столом для резки и мойкой. Он делает задачи одновременно в широком смысле, но в каждую конкретную миллисекунду он занят чем-то одним. * Параллелизм (Parallelism) — это про физическое выполнение. Это когда у вас есть два повара, и они реально одновременно (в одну и ту же наносекунду) режут два разных овоща.

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

    Использование WaitGroup: как дождаться помощников

    Чтобы программа не завершалась раньше времени, нам нужен механизм синхронизации. Мы должны сказать функции main: «Подожди, пока все повара закончат работу, прежде чем закрывать ресторан».

    Самый простой способ сделать это — использовать sync.WaitGroup. Это своего рода счетчик.

  • Мы прибавляем единицу, когда отправляем помощника на задание.
  • Помощник вычитает единицу, когда заканчивает.
  • Главная функция ждет, пока счетчик не станет равным нулю.
  • Разберем обновленный пример:

    Разбор синтаксиса sync.WaitGroup

  • var wg sync.WaitGroup: Создание переменной типа «группа ожидания». По умолчанию счетчик равен 0.
  • wg.Add(2): Мы сообщаем группе, что ожидаем завершения двух процессов. Важно вызывать Add в главной горутине до запуска самих горутин, чтобы избежать ситуации, когда Wait сработает раньше, чем горутина успеет запуститься.
  • &wg: Мы передаем указатель на группу ожидания в функции. Это критично: если передать wg по значению (без значка &), функция получит копию счетчика, и оригинал в main никогда не изменится.
  • defer wg.Done(): Ключевое слово defer заставляет программу выполнить wg.Done() в самый последний момент перед выходом из функции. Это гарантирует, что мы «отчитаемся» о завершении, даже если внутри функции произойдет ошибка.
  • wg.Wait(): Это «стоп-кран». Программа не пойдет дальше этой строки, пока счетчик внутри wg не обнулится.
  • Анатомия анонимных горутин

    Часто нам нужно запустить небольшое действие в фоне, не создавая отдельную именованную функцию. В таких случаях используются анонимные функции.

    Обратите внимание на (i) в конце блока функции. Мы не просто объявляем функцию, мы ее тут же вызываем. При этом крайне важно передавать текущее значение переменной цикла i в качестве аргумента. Если этого не сделать и использовать i напрямую из внешнего контекста, может возникнуть «проблема замыкания»: к моменту, когда горутина запустится, цикл может уже закончиться, и все горутины увидят последнее значение i (например, число 3). Передача аргумента «замораживает» значение для каждой конкретной горутины.

    Планировщик Go: что происходит «под капотом»

    Чтобы эффективно писать на Go, полезно представлять, как работает его планировщик, известный как G-M-P модель.

    * G (Goroutine): Сама горутина. Это просто структура данных, содержащая информацию о коде, который нужно выполнить, и текущем состоянии (стеке). * M (Machine): Поток операционной системы. Это реальный «рабочий», который выполняет инструкции на физическом процессоре. * P (Processor): Логический ресурс (контекст), необходимый для выполнения горутин. Количество обычно равно количеству ядер вашего процессора.

    Процесс выглядит так: чтобы горутина () начала выполняться, она должна быть привязана к логическому процессору (), который, в свою очередь, запущен на потоке ОС ().

    Если одна горутина блокируется (например, делает системный вызов или ждет ввода от пользователя), планировщик Go проявляет свою мощь. Он «отцепляет» поток от процессора и создает (или берет из запаса) новый поток , чтобы остальные горутины могли продолжать работу на этом процессоре. Как только заблокированная горутина «просыпается», она снова ставится в очередь на выполнение.

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

    Проблема общего ресурса: когда повара сталкиваются

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

    В программировании это называется состоянием гонки (Race Condition). Это происходит, когда две или более горутины одновременно пытаются изменить одну и ту же переменную.

    Операция saltAmount++ на самом деле состоит из трех шагов:

  • Прочитать значение из памяти.
  • Прибавить 1.
  • Записать результат обратно.
  • Если две горутины сделают шаг 1 одновременно, они обе прочитают 0. Обе прибавят 1 и обе запишут 1. В итоге вместо 2 мы получим 1. Одна ложка соли «потерялась».

    Для решения этой проблемы в Go есть каналы (Channels) и мьютексы (Mutex), но это темы для глубокого изучения в следующих главах. Сейчас важно запомнить золотое правило Go:

    > «Не общайтесь через разделяемую память; разделяйте память через общение».

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

    Практические нюансы использования горутин

    1. Горутины не бесплатны, но очень дешевы

    Хотя мы говорили, что можно запускать сотни тысяч горутин, это не значит, что их нужно плодить бездумно. Каждая горутина все равно потребляет память и требует времени планировщика. Если задача выполняется микросекунды (например, сложение двух чисел), накладные расходы на создание горутины могут превысить пользу от её параллельного запуска.

    2. Утечки горутин

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

    3. Порядок выполнения не гарантирован

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

    Вы можете увидеть «Три, Раз, Два» или любую другую комбинацию. Планировщик сам решает, кому дать время процессора в данный момент. Если порядок важен — используйте инструменты синхронизации.

    Когда стоит использовать горутины?

    Горутины идеально подходят для:

  • I/O-задач (Ввод-вывод): Работа с сетью, чтение файлов, запросы к базе данных. Пока одна горутина ждет ответа от сервера в Калифорнии, сотни других могут обрабатывать запросы пользователей.
  • Фоновых задач: Отправка email-уведомлений, генерация отчетов, очистка логов.
  • Разделения сложной логики: Когда программу можно представить как конвейер, где разные части обрабатывают данные независимо.
  • В Go конкурентность — это не «продвинутая фишка для экспертов», а базовый кирпичик языка. Понимание того, как запустить горутину и как дождаться её завершения с помощью WaitGroup, открывает дверь к созданию по-настоящему быстрых и эффективных приложений.

    Главное — помнить об ответственности: запустив «помощника» через go, вы должны четко понимать, когда и как он закончит свою работу и не оставит ли он после себя «грязную посуду» в виде занятой памяти или незакрытых соединений.