20 августа 2024

🦫 Самоучитель по Go для начинающих. Часть 15. Конкурентность. Горутины. Каналы

Энтузиаст-разработчик, автор статей по программированию.
В 15-й части самоучителя мы разберем работу базовых сущностей ОС для погружения в парадигму конкурентного программирования, а затем изучим основные способы её реализации в Go с помощью горутин и каналов.
🦫 Самоучитель по Go для начинающих. Часть 15. Конкурентность. Горутины. Каналы

Техническая часть

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

Процесс представляет собой абстракцию ОС, инкапсулирующую ресурсы определенной программы. Чаще всего под процессом понимают отдельно запущенное приложение и его компоненты: стек, регистры, открытые файлы, переменные и так далее. Каждый процесс имеет свое уникальное адресное пространство, контекст выполнения и как минимум один поток – сущность ОС для параллельного выполнения различных задач. Адресное пространство – это набор адресов, используемых процессом для обращения к памяти.

Потоками выполнения можно управлять с помощью системных вызовов и механизма прерываний. Системные вызовы предоставляют интерфейс между пользовательским процессом и ОС для обеспечения ввода / вывода, управления файлами / памятью, доступа к системным объектам.

Механизм прерываний используется для оповещения ОС о внешних событиях и передачи управления конкретному обработчику. В UNIX-подобных системах прерывания реализуются при помощи специальных сигналов из стандарта POSIX.

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

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

Процессы Потоки
Существуют независимо Существуют как составные элементы процессов
Имеют отдельное адресное пространство, не могут получить доступ к чужой памяти. Совместно используют адресное пространство процесса
Обладают собственным идентификатором Обладают идентификатором внутри работающей программы
Потребляют больше ресурсов, чем потоки Потребляют меньше ресурсов, чем процессы
Переключение контекста медленнее, чем у потоков Переключение контекста быстрее, чем у процессов
🦫 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🦫🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🦫🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

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

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

Конкурентность – это совокупность независимо выполняющихся задач. Данная концепция подразумевает упорядочивание объектов таким образом, чтобы с помощью параллелизма они выполнялись одновременно.

Параллелизм – это одновременное выполнение вычислений. Он представляет собой конкретную форму конкурентности, строгое её подмножество. На компьютере с одним процессором параллельное выполнение не представляется возможным.

Термины конкурентности и параллелизма часто путают между собой, но на самом деле они имеют существенные различия, которые коротко описаны в следующей таблице:

Конкурентность Параллелизм
Одновременное взаимодействие со многими сущностями. Одновременное выполнение многих сущностей.
Способ проектирования кода, его свойство. Способ выполнения кода, свойство запущенной программы.
Одновременность процессов на логическом уровне, парадигма проектирования. Одновременность процессов на физическом уровне, то есть их параллельное выполнение.

Отметим также, что модель конкурентного программирования в Go основана на теории взаимодействующих последовательных процессов (Communicating Sequential Processes — CSP) известного ученого Чарльза Э. Р. Хоара. CSP использует формальные математические объекты для описания взаимодействия параллельных систем.

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

Реализация конкурентности в Go имеет ряд особенностей и тонкостей, с которыми мы познакомимся в этом пункте.

Начать стоит с того, что Go не предусматривает работу с потоками ОС напрямую. Вместо этого здесь используются горутины – независимые функции, выполняющиеся конкурентно в одном и том же адресном пространстве и являющиеся аналогом корутин в других языках. Вот несколько основных особенностей горутин, отличающих их от потоков ОС:

  1. Имеют динамический стек, начинающийся с 2 килобайт и способный сжиматься и расширяться по мере необходимости.
  2. Управляются runtime Go (средой выполнения Go), в то время как потоки управляются ядром.
  3. Работают быстрее потоков, так как не требуют системных вызовов для переключения контекста.
  4. Требуют меньше памяти, чем потоки.
  5. Реализуют кооперативную многозадачность (когда поток сам вызывает функцию переключения контекста), тогда как потоки – вытесняющую (когда ОС сама снимает поток с CPU).

Авторы Go предусмотрели специальные механизмы распределения горутин по ядрам ЦП для обеспечения одновременного выполнения. Одним из таких является параметр GOMAXPROCS, устанавливающий количество потоков ОС, способных одновременно выполнять код пользовательского уровня. По умолчанию GOMAXPROCS равен количеству ядер ЦП.

При запуске горутины она распределяется на свободный поток ОС при помощи планировщика Go, который управляет тремя сущностями: горутинами (G), машинами (M) и процессорами (P). Планировщик Go имеет тип m:n (m горутин планируется на n потоков) и работает по принципу work-stealing (кража работы): свободный или недостаточно нагруженный процессор ищет потоки и забирает их к себе на выполнение.

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

        func iterate(str string) {
	for i := 0; i < 3; i++ {
		fmt.Println(str, i)
	}
}

func main() {
	iterate("Обычный вызов:") // синхронно
	go iterate("Вызов с go:") // параллельно

	go func(str string) {
		fmt.Println(str)
	}("Анонимная функция с go")

	time.Sleep(2 * time.Second)
}
    

В коде выше функция iterate запускается сначала в обычном режиме, а затем – параллельно с помощью ключевого слова go. Одновременно с этим отрабатывает анонимная горутина.

Обратите внимание, что после сообщения о завершении программы устанавливается задержка в 2 секунды – это сделано для того, чтобы все горутины успели выполнить свою работу до завершения главной горутины, которой является функция main.

После выполнения программы получим следующий вывод:

        Обычный вызов: 0
Обычный вызов: 1
Обычный вызов: 2
Вызов с go: 0
Вызов с go: 1
Вызов с go: 2
Анонимная функция с go
    

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

Состояние гонки

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

В Go состояние гонки возникает при одновременном обращении двух горутин к одной и той же области памяти, например, к переменной, когда одно из обращений является записью. Подобные ошибки являются довольно распространенными и сложными в исправлении, поэтому для помощи в их отладке авторы Go разработали встроенный race-детектор. Он вызывается через флаг -race после команд test, run, build и install:

        $ go test -race packagename
$ go run -race code.go
$ go build -race program
$ go install -race packagename
    

Помимо встроенных в язык Go утилит, для предотвращения состояний гонок используются так называемые примитивы синхронизации – это инструменты, позволяющие синхронизировать доступ к разделяемым ресурсам. Самыми популярными примитивами являются мьютексы, семафоры, атомарные операции и wait groups.

Каналы

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

        bufChannel := make(chan int, 1) // буферизированный канал
unbufChannel := make(chan int) // небуферизированный канал
    

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

        readingChannel := make(<-chan int) // канал для чтения
writingChannel := make(chan<- int) // канал для записи
    

Для записи в канал и чтения из него используется специальный оператор <-. Стоит отметить, что эти операции являются блокирующими. Это значит, что при перемещении данных data в небуферизированный канал ch текущая горутина блокируется до момента чтения data из ch другой горутиной.

Закрытие канала производится с помощью функции close с передачей его названия в качестве аргумента. Для проверки закрытия канала используется конструкция, аналогичная проверке наличия элемента в мапе: val, ok := <- ch, где ok будет true, если канал открыт и доступен для чтения, или false в противном случае. В некоторых случаях проверка закрытия канала заменяется циклом for-range, который считывает данные и автоматически закрывает канал.

Для демонстрации всего изученного создадим функцию squares, которая будет записывать квадраты значений от 0 до параметра num в канал ch и закрывать его по завершении работы:

        func squares(num int, ch chan int) {
	for i := 0; i < num; i++ {
		ch <- i * i // запись в канал
	}

	close(ch) // закрытие канала
}

func main() {
	ch := make(chan int) // инициализация канала

	go squares(5, ch)

	// чтение данных до закрытия канала
	for val := range ch {
		fmt.Println(val)
	}

	fmt.Println("Завершение...")
}
    

В результате получим следующий вывод:

        0
1
4
9
16
Завершение...

    

Deadlock

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

        func main() {
	ch := make(chan string)
	ch <- "message"
	fmt.Println("Завершение...")
}
    

Такой простой код выведет ошибку следующего содержания:

        fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        /path/to/folder/main.go:7 +0x28
exit status 2
    

Буферизированные каналы

В отличие от небуферизированных каналов, которые для избежания блокировки обязательно требуют получателя значений, буферизированные могут хранить данные в пределах размера буфера без блокировки:

        unbufChannel := make(chan int)
unbufChannel <- 1 // успешная запись в канал
unbufChannel <- 2 // блокировка, ожидание чтения из канала

bufChannel := make(chan int, 2)
bufChannel <- 1 // успешная запись в канал
bufChannel <- 2 // успешная запись в канал
bufChannel <- 3 // блокировка, ожидание чтения из каналаа
    

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

Каналы обладают емкостью и длиной, которые можно получить с помощью функций cap и len соответственно:

        func send(ch chan int) {
	ch <- 1
	ch <- 2
	ch <- 3 // на этом значении происходит блокировка горутины
	close(ch)
}

func main() {
	ch := make(chan int, 2)
	go send(ch)
	fmt.Println("Емкость и длина канала:", cap(ch), len(ch))
	for i := range ch {
		fmt.Printf("Полученное значение %d, емкость %d, длина %d\\\\n", i, cap(ch), len(ch))
	}
}

    

Приведенный выше код выведет значения емкости и длины в процессе заполнения канала:

        Емкость и длина канала: 2 0
Полученное значение 1, емкость 2, длина 2
Полученное значение 2, емкость 2, длина 1
Полученное значение 3, емкость 2, длина 0

    

Select

Для управления каналами авторы Go предусмотрели специальную конструкцию select, похожу на switch-case без аргументов. Select ожидает операции отправки или получения и блокируется до тех пор, пока один из блоков case не будет разблокирован. Если доступно несколько блоков, то select выбирает произвольный.

Как и switch-case, select может содержать неблокируемый оператор default, который обеспечивает, что операции над любым каналом также будут неблокируемыми. Оператор default срабатывает в том случае, если ни один блок case не может быть выполнен.

Чтобы лучше понять работу select, напишем код, имитирующий работу веб-сервиса с балансировщиком нагрузки. Сервис будет запрашивать ответ у базы данных и сервера и выводить первое полученное сообщение:

        func database(ch chan string) {
	ch <- "запуск базы данных..."
}

func server(ch chan string) {
	ch <- "запуск сервера..."
}

func main() {
	fmt.Println("Запуск main...")

	ch1 := make(chan string)
	ch2 := make(chan string)

	go database(ch1)
	go server(ch2)

	time.Sleep(3 * time.Second) // без задержки отработает оператор default

	select {
	case response := <-ch1:
		fmt.Println("Ответ от базы данных:", response)
	case response := <-ch2:
		fmt.Println("Ответ от сервера:", response)
	default:
		fmt.Println("Ответ не получен")
	}

	fmt.Println("Завершение main...")
}


    

Использование default в конструкции select позволяет избежать возникновения deadlock, так как обеспечивает неблокируемость операций с каналами. Чтобы в этом убедиться, можете запустить код выше без строки time.Sleep(3 * time.Second), и вы увидите, что вместо deadlock, будет выведено сообщение Ответ не получен.

Материалы для дальнейшего изучения

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

Для полноценного погружения в тему рекомендуем изучить следующие материалы:

Заключение

В этой статье мы углубились в устройство ОС, познакомились с концепциями конкурентности и параллельности, разобрали основные понятия и инструменты конкурентного программирования в Go: горутины, каналы, состояния гонки, deadlock.

В следующей части самоучителя научимся тестировать программы на Go и решим несколько полезных практических задач.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ