Конкурентность

Что это такое

Горутины и каналы — две опоры, на которых держится конкурентность в Go. Горутина — функция, которая выполняется независимо, а планировщик раскладывает горутины по пулу потоков операционной системы. Канал — типизированный путь, по которому между горутинами ходят значения. Язык советует предпочитать передачу данных совместному использованию памяти, а инструменты для обоих подходов — каналы, мьютексы, группы ожидания, контексты — есть в стандартной библиотеке и в самом языке.

Почему это важно

Конкурентность определяет, как служба на Go обслуживает множество запросов сразу, и она же — место, где живут самые трудные ошибки: гонки, взаимные блокировки и горутины, которые никогда не завершаются. Запуск горутины настолько дёшев, что превращается в обычное действие, а не в обдуманное решение, поэтому нетрудно завести по горутине на запрос и потерять их из виду. Каналы, контексты и детектор гонок делают взаимодействие явным и проверяемым, и именно осознанное обращение с ними сохраняет конкурентной программе возможность отладки.

Ключевые идеи

Горутина дешева, но не бесплатна

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

go handle(req)          -> выполняется независимо
defer wg.Done()         -> сообщает ожидающему о завершении

Каналы переносят значения, а закрытие сигналит о завершении

Передать работу горутине и вернуть результат проще всего через канал типизированного значения. Отправка и приём синхронизируют обе стороны, поэтому канал не только переносит данные, но и упорядочивает события. Закрытие сообщает, что значений больше не будет, и принимающая сторона это видит; закрывать вправе только отправитель, потому что двойное закрытие или отправка после закрытия приводят к панике.

Небуферизованные и буферизованные каналы различаются тем, кто ждёт

На небуферизованном канале отправитель ждёт, пока приёмник не будет готов, — это рандеву. Буферизованный канал размера n даёт выполнить n отправок прежде, чем отправитель начнёт ждать: прибытие отделяется от обработки, и потому нужна политика на случай заполнения. Неограниченный буфер ответом не служит: выбирать приходится между замедлением производителя и отбрасыванием работы.

небуферизованный: отправитель ждёт приёмника               -> жёсткая синхронизация
буферизованный n: отправитель ждёт только при заполнении   -> очередь с тем же вопросом обратного давления

Совместное использование памяти допустимо, с взаимным исключением

Мьютекс защищает участок кода так, что одновременно его выполняет только одна горутина. Когда общая структура и есть суть, а передавать её через каналы неудобно — кэш, счётчик, пул соединений, — верный инструмент именно мьютекс. Дисциплина простая: каждое чтение и каждую запись защищённых данных выполняют внутри блокировки, а саму блокировку удерживают недолго.

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

Контексты несут отмену и сроки

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

Взаимные блокировки и утечки следуют из необдуманного блокирования

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

Практика

Распараллельте и соберите с ограниченным пулом

Напишите программу, которая обрабатывает список элементов конкурентно с фиксированным числом работников; работа пусть идёт через канал, а о завершении всех сообщит группа ожидания.

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

Найдите гонку с помощью детектора

Заведите намеренно несинхронизированный счётчик, который увеличивают две горутины, запустите тесты с включённым детектором гонок и прочитайте отчёт.

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

Частые ошибки

  • Запуск горутины без способа её остановить — она утекает, когда её работа теряет смысл; исправить: дайте каждой горутине путь отмены.
  • Закрытие канала со стороны приёмника — отправка после закрытия вызывает панику; исправить: пусть закрывает отправитель.
  • Использование неограниченной очереди, чтобы уйти от обратного давления — память растёт, пока процесс не умрёт; исправить: ограничьте буфер и решите, что происходит при его заполнении.
  • Чтение общего состояния вне блокировки — гонка невидима, пока не испортит данные; исправить: защищайте каждое обращение и запускайте детектор гонок.
  • Игнорирование контекста в длинном цикле или блокирующем вызове — отменённая работа продолжает потреблять ресурсы; исправить: проверяйте контекст перед блокировкой и внутри циклов.

Что читать дальше

  • Инструментарий (05-backend-data/05-go/01-the-toolchain) — команды тестирования и обнаружения гонок, на которые опирается этот дочерний урок.
  • Go (05-backend-data/05-go) — язык, в который встроена эта модель конкурентности.
  • Конкурентность и асинхронное программирование (01-cs-fundamentals/05-concurrency-async-programming) — общие понятия гонок, взаимных блокировок и отмены.