Техническая часть
Перед погружением в детали конкурентного программирования следует хорошо изучить базовые сущности компьютера и ОС: процессор, потоки, процессы, системные вызовы и прерывания.
Процесс представляет собой абстракцию ОС, инкапсулирующую ресурсы определенной программы. Чаще всего под процессом понимают отдельно запущенное приложение и его компоненты: стек, регистры, открытые файлы, переменные и так далее. Каждый процесс имеет свое уникальное адресное пространство, контекст выполнения и как минимум один поток – сущность ОС для параллельного выполнения различных задач. Адресное пространство – это набор адресов, используемых процессом для обращения к памяти.
Потоками выполнения можно управлять с помощью системных вызовов и механизма прерываний. Системные вызовы предоставляют интерфейс между пользовательским процессом и ОС для обеспечения ввода / вывода, управления файлами / памятью, доступа к системным объектам.
Механизм прерываний используется для оповещения ОС о внешних событиях и передачи управления конкретному обработчику. В UNIX-подобных системах прерывания реализуются при помощи специальных сигналов из стандарта POSIX.
Теперь поговорим о том, как можно достичь многопоточности при различных конфигурациях системы. Когда мы имеем дело с одним процессором, многопоточность обеспечивается его переключением между потоками выполнения. Такое поведение создает иллюзию одновременного исполнения, так как происходит очень быстро. В многопроцессорных системах потоки имеют возможность выполняться одновременно, так как каждый процессор может обрабатывать отдельную задачу.
Несмотря на то что процессы и потоки тесно взаимодействуют друг с другом и ОС, они различаются между собой по некоторым аспектам, которые коротко отражены в следующей таблице:
Процессы | Потоки |
Существуют независимо | Существуют как составные элементы процессов |
Имеют отдельное адресное пространство, не могут получить доступ к чужой памяти. | Совместно используют адресное пространство процесса |
Обладают собственным идентификатором | Обладают идентификатором внутри работающей программы |
Потребляют больше ресурсов, чем потоки | Потребляют меньше ресурсов, чем процессы |
Переключение контекста медленнее, чем у потоков | Переключение контекста быстрее, чем у процессов |
Конкурентность и параллелизм
Язык Go проектировался специально для работы с конкурентными и параллельными вычислениями, поэтому имеет обширный функционал для этих задач. Давайте разберемся в том, что же такое конкурентность и параллелизм, а также чем они различаются.
Конкурентность – это совокупность независимо выполняющихся задач. Данная концепция подразумевает упорядочивание объектов таким образом, чтобы с помощью параллелизма они выполнялись одновременно.
Параллелизм – это одновременное выполнение вычислений. Он представляет собой конкретную форму конкурентности, строгое её подмножество. На компьютере с одним процессором параллельное выполнение не представляется возможным.
Термины конкурентности и параллелизма часто путают между собой, но на самом деле они имеют существенные различия, которые коротко описаны в следующей таблице:
Конкурентность | Параллелизм |
Одновременное взаимодействие со многими сущностями. | Одновременное выполнение многих сущностей. |
Способ проектирования кода, его свойство. | Способ выполнения кода, свойство запущенной программы. |
Одновременность процессов на логическом уровне, парадигма проектирования. | Одновременность процессов на физическом уровне, то есть их параллельное выполнение. |
Отметим также, что модель конкурентного программирования в Go основана на теории взаимодействующих последовательных процессов (Communicating Sequential Processes — CSP) известного ученого Чарльза Э. Р. Хоара. CSP использует формальные математические объекты для описания взаимодействия параллельных систем.
Конкурентность в Go
Реализация конкурентности в Go имеет ряд особенностей и тонкостей, с которыми мы познакомимся в этом пункте.
Начать стоит с того, что Go не предусматривает работу с потоками ОС напрямую. Вместо этого здесь используются горутины – независимые функции, выполняющиеся конкурентно в одном и том же адресном пространстве и являющиеся аналогом корутин в других языках. Вот несколько основных особенностей горутин, отличающих их от потоков ОС:
- Имеют динамический стек, начинающийся с 2 килобайт и способный сжиматься и расширяться по мере необходимости.
- Управляются runtime Go (средой выполнения Go), в то время как потоки управляются ядром.
- Работают быстрее потоков, так как не требуют системных вызовов для переключения контекста.
- Требуют меньше памяти, чем потоки.
- Реализуют кооперативную многозадачность (когда поток сам вызывает функцию переключения контекста), тогда как потоки – вытесняющую (когда ОС сама снимает поток с CPU).
Авторы Go предусмотрели специальные механизмы распределения горутин по ядрам ЦП для обеспечения одновременного выполнения. Одним из таких является параметр GOMAXPROCS, устанавливающий количество потоков ОС, способных одновременно выполнять код пользовательского уровня. По умолчанию GOMAXPROCS равен количеству ядер ЦП.
При запуске горутины она распределяется на свободный поток ОС при помощи планировщика Go, который управляет тремя сущностями: горутинами (G), машинами (M) и процессорами (P). Планировщик Go имеет тип m:n (m горутин планируется на n потоков) и работает по принципу work-stealing (кража работы): свободный или недостаточно нагруженный процессор ищет потоки и забирает их к себе на выполнение.
Пришло время рассмотреть практическое применение горутин в программах. Создавать горутины можно с помощью ключевого слова go. Они могут быть как анонимными, так и обычными функциями:
В коде выше функция iterate
запускается сначала в обычном режиме, а затем – параллельно с помощью ключевого слова go
. Одновременно с этим отрабатывает анонимная горутина.
Обратите внимание, что после сообщения о завершении программы устанавливается задержка в 2 секунды – это сделано для того, чтобы все горутины успели выполнить свою работу до завершения главной горутины, которой является функция main.
После выполнения программы получим следующий вывод:
Стоит отметить, что поведение горутин является непредсказуемым, так как каждая из них запускается в собственном контексте и работает независимо от других.
Состояние гонки
Состояние гонки (race condition) - это ошибка, при которой несколько сущностей (например, потоков или процессов) без синхронизации пытаются одновременно взаимодействовать с общими ресурсами. В этой ситуации результат работы программы зависит от порядка выполнения этих сущностей.
В Go состояние гонки возникает при одновременном обращении двух горутин к одной и той же области памяти, например, к переменной, когда одно из обращений является записью. Подобные ошибки являются довольно распространенными и сложными в исправлении, поэтому для помощи в их отладке авторы Go разработали встроенный race-детектор. Он вызывается через флаг -race
после команд test, run, build и install:
Помимо встроенных в язык Go утилит, для предотвращения состояний гонок используются так называемые примитивы синхронизации – это инструменты, позволяющие синхронизировать доступ к разделяемым ресурсам. Самыми популярными примитивами являются мьютексы, семафоры, атомарные операции и wait groups.
Каналы
Язык Go использует особый подход для взаимодействия с разделяемыми ресурсами с помощью их передачи через каналы, обеспечивающие безопасную коммуникацию между горутинами. Каналы представлены отдельным типом данных chan и инициализируются функцией make с двумя параметрами: тип данных и размер буфера. По умолчанию последний параметр равен нулю, в таком случае канал называется небуферизированным, иначе – буферизированным:
Каналы также бывают однонаправленными, то есть пригодными только для чтения или записи. Пример их создания приведен в коде ниже:
Для записи в канал и чтения из него используется специальный оператор <-
. Стоит отметить, что эти операции являются блокирующими. Это значит, что при перемещении данных data в небуферизированный канал ch текущая горутина блокируется до момента чтения data из ch другой горутиной.
Закрытие канала производится с помощью функции close с передачей его названия в качестве аргумента. Для проверки закрытия канала используется конструкция, аналогичная проверке наличия элемента в мапе: val, ok := <- ch
, где ok
будет true
, если канал открыт и доступен для чтения, или false
в противном случае. В некоторых случаях проверка закрытия канала заменяется циклом for-range, который считывает данные и автоматически закрывает канал.
Для демонстрации всего изученного создадим функцию squares, которая будет записывать квадраты значений от 0 до параметра num
в канал ch
и закрывать его по завершении работы:
В результате получим следующий вывод:
Deadlock
При неправильной работе с каналами может возникнуть ошибка взаимной блокировки (deadlock), при которой параллельные процессы ожидают друг друга. В Go такое происходит в тех случаях, когда все горутины заблокированы и не могут продолжить свою работу. К примеру, deadlock появится, если отправить данные в канал, но не прочитать их:
Такой простой код выведет ошибку следующего содержания:
Буферизированные каналы
В отличие от небуферизированных каналов, которые для избежания блокировки обязательно требуют получателя значений, буферизированные могут хранить данные в пределах размера буфера без блокировки:
В случае, если размер буфера больше нуля, горутина не блокируется до момента его полного заполнения. Стоит также учитывать, что операция чтения из буферизированного канала является «жадной», то есть горутина будет считывать буфер без блокировки до его опустошения.
Каналы обладают емкостью и длиной, которые можно получить с помощью функций cap и len соответственно:
Приведенный выше код выведет значения емкости и длины в процессе заполнения канала:
Select
Для управления каналами авторы Go предусмотрели специальную конструкцию select, похожу на switch-case без аргументов. Select ожидает операции отправки или получения и блокируется до тех пор, пока один из блоков case не будет разблокирован. Если доступно несколько блоков, то select выбирает произвольный.
Как и switch-case, select может содержать неблокируемый оператор default, который обеспечивает, что операции над любым каналом также будут неблокируемыми. Оператор default срабатывает в том случае, если ни один блок case не может быть выполнен.
Чтобы лучше понять работу select, напишем код, имитирующий работу веб-сервиса с балансировщиком нагрузки. Сервис будет запрашивать ответ у базы данных и сервера и выводить первое полученное сообщение:
Использование default в конструкции select позволяет избежать возникновения deadlock, так как обеспечивает неблокируемость операций с каналами. Чтобы в этом убедиться, можете запустить код выше без строки time.Sleep(3 * time.Second)
, и вы увидите, что вместо deadlock, будет выведено сообщение Ответ не получен
.
Материалы для дальнейшего изучения
Конкурентное программирования представляет собой довольно обширную область знаний, поэтому рассказать обо всех её аспектах в рамках одной статьи не представляется возможным. Помимо рассмотренных горутин и каналов в Go есть множество других инструментов проектирования конкурентных программ: контексты, мьютексы, семафоры, атомики, wait groups и другие.
Для полноценного погружения в тему рекомендуем изучить следующие материалы:
- Go Wiki: LearnConcurrency – официальный путеводитель по изучению конкурентности от начального до продвинутого уровня.
- Effective Go – Concurrency – секция про конкурентность в официальном документе по написанию эффективного Go кода.
- Concurrency is not parallelism – содержательный доклад одного из авторов Go об отличиях между конкурентностью и параллелизмом.
- Серия материалов "Go Concurrency Patterns" о паттернах конкурентного программирования на Go: Go Concurrency PatternsGo Concurrency Patterns: Pipelines and cancellationGo Concurrency Patterns: Context
- Go Concurrency Patterns
- Go Concurrency Patterns: Pipelines and cancellation
- Go Concurrency Patterns: Context
- Go concurrency exercises – практические задания для отработки паттернов конкурентного программирования на Go.
Заключение
В этой статье мы углубились в устройство ОС, познакомились с концепциями конкурентности и параллельности, разобрали основные понятия и инструменты конкурентного программирования в Go: горутины, каналы, состояния гонки, deadlock.
В следующей части самоучителя научимся тестировать программы на Go и решим несколько полезных практических задач.
Содержание самоучителя
- Особенности и сфера применения Go, установка, настройка
- Ресурсы для изучения Go с нуля
- Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
- Переменные. Типы данных и их преобразования. Основные операторы
- Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
- Функции и аргументы. Области видимости. Рекурсия. Defer
- Массивы и слайсы. Append и сopy. Пакет slices
- Строки, руны, байты. Пакет strings. Хеш-таблица (map)
- Структуры и методы. Интерфейсы. Указатели. Основы ООП
- Наследование, абстракция, полиморфизм, инкапсуляция
- Обработка ошибок. Паника. Восстановление. Логирование
- Обобщенное программирование. Дженерики
- Работа с датой и временем. Пакет time
- Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
- Конкурентность. Горутины. Каналы
Комментарии