Изучаем многопоточное программирование в Go по картинкам
Last updated
Was this helpful?
Last updated
Was this helpful?
Скорее всего, вы уже слышали о языке программирования Go, популярность его постоянно растет, что вполне обоснованно. Этот язык простой, быстрый и опирается на прекрасное сообщество. Один из самых любопытных аспектов языка — это модель многопоточного программирования. Примитивы, положенные в ее основу, позволяют создавать многопоточные программы легко и просто. Эта статья предназначена для тех, кто хочет изучить эти примитивы: горутины и каналы. И, через иллюстрации, я покажу, как с ними работать. Надеюсь, это будет для вас хорошим подспорьем в дальнейшем изучении.
Однопоточные и многопоточные программы
Однопоточные программы вы, скорее всего, уже писали. Обычно это выглядит так: есть набор функций для выполнения различных задач, каждая функция вызывается только тогда, когда предыдущая подготовила для нее данные. Таким образом, программа работает последовательно. Именно таким будет наш первый пример — программа добывающая руду. Наши функции будут искать, добывать и перерабатывать руду. Руда в шахте в нашем примере представлена списками строк, функции принимают их в качестве параметров и возвращают список «обработанных» строк. Для однопоточной программы наше приложение будет спроектировано следующим образом: В этом примере всю работу выполняет один поток(гофер Гари). Три основные функции: поиск, добыча и обработка выполняются последовательно друг за другом.
Если напечатать результат работы каждой функции, мы получим следующее:
Простой дизайн и реализация является плюсом однопоточного подхода. Но что делать, если вы хотите запускать и выполнять функции независимо друг от друга? Тут вам на помощь приходит многопоточное программирование. Такой подход к добыче руды гораздо эффективнее. Теперь несколько потоков(гоферов) работают независимо, и Гари делает только часть работы. Один гофер ищет руду, другой добывает, а третий переплавляет, и все это потенциально одновременно. Для того чтобы реализовать такой подход, в коде нам нужны две вещи: создавать гоферов-обработчиков независимо друг от друга и передавать между ними руду. В Go для этого существуют горутины и каналы.
Горутины
Горутины можно представлять себе как «легковесные потоки», чтобы создать горутину нужно просто поставить ключевое слово go пред кодом вызова функции. Чтобы продемонстрировать насколько это просто, давайте создадим две функции поиска, вызовем их с ключевым слово go и будем печатать сообщение каждый раз когда они найдут «руду» в своем руднике.
Вывод нашей программы будет таким:
Как можно видеть, нет никакого порядка в том, какая функция первой «найдет руду»; функции поиска работают одновременно. Если запускать пример несколько раз, то порядок будет отличаться. Теперь мы можем запускать многопоточные (многогоферные) программы, и это серьезный прогресс. Но что делать, когда нам нужно наладить связь между независимыми горутинами? Наступает время для магии каналов.
Каналы
Вывод ниже наглядно демонстрирует, что наш Добытчик трижды получает из канала руду по одной порции за раз.
Итак, теперь мы умеем пересылать данные между разными горутинами(гоферами), но прежде чем начать писать сложную программу, давайте разберемся с некоторыми важными свойствами каналов.
Блокировки
В некоторых ситуациях при работе с каналами горутина может быть заблокирована. Это необходимо, чтобы горутины могли синхронизироваться друг с другом прежде чем они начнут или продолжат работу.
Блокировка при записи
Блокировка при чтении
Небуферизованные каналы
Буферизованные каналы
Порядок вывода в такой программе будет следующий:
Чтобы избежать лишних усложнений, мы не будет использовать буферизованные каналы в нашей программе. Но важно помнить, такие типы каналов тоже доступны для использования. Также важно отметить, что буферизованные каналы не всегда избавляют вас от блокировок. Например, если гофер-разведчик в десять раз быстрее, чем гофер-шахтер, и они связаны чрез буферизованные канал емкостью 2, то гофер-разведчик будет каждый раз заблокирован при отправке, если в канале уже есть два фрагмента данных.
Собираем все вместе
Такая программа выведет следующее:
По сравнению с нашим первым примером это серьезное улучшение, теперь все функции выполняются независимо, каждая в своей горутине. А также у нас появился конвеер из каналов, по которому передается руда сразу после обработки. Для сохранения фокуса на базовом понимании работы каналов и горутин я опустил некоторые моменты, что может привести к сложностям с запуском программы. В завершении, я хочу подробнее остновиться на этих особенностях языка, так как они помогают в работе с горутинами и каналами.
Анонимные горутины
Таким образом, если нам нужно вызывать функцию только в одном месте, мы можем запустить ее в отдельной горутине, не заботясь заранне о ее декларации.
Функция main это горутина
Чтение из канала в цикле for-range
В нашем примере в функции гофера-добытчика мы использовали цикл for, чтобы выбрать из канала три элемента. Но что делать, если заранее не известно сколько, данных может быть в канале? В таких случаях вы можете использовать канал, как аргумент для цикла for-range, так же как с коллекциями. Обновленная функция может выглядеть так:
Таким образом, добытчик руды прочитает все, что разведчик ему пошлет, использование кнала в цикле это гарантирует. Обратите внимание, что после того, как все данные из канала обработаны, цикл заблокируется на чтении; чтобы избежать блокировки, нужно закрыть канал вызовом close(channel).
Неблокирующе чтение из канала
Используя конструкцию select-case, можно избежать блокирующего чтения из канала. Ниже приведен пример использования этой конструкции: горутина прочитает из канала данные, если только они там есть, в противном случае выполняется блок default:
После запуска этот код выведет следующее:
Неблокирующая запись в канал
Блокировки при записи в канал можно избежать, используя ту же самую конструкцию select-case. Внесем небольшую правку в предыдущий пример:
Что изучать дальше
Ссылки
Каналы позволяют горутинам обмениваться данными. Это своеобразная труба, через которую горутины могут посылать и принимать информацию от других горутин. Чтение и запись в канал осуществляется при помощи оператора-стрелочки (<-), который указывает направление движения данных.
Теперь нашему гоферу-разведчику не нужно накапливать руду, он может сразу передать ее дальше, используя каналы. Я обновил пример, теперь код искателя и добытчика руды — анонимные функции. Не слишком заморачивайтесь, если раньше не сталкивались с ними, просто имейте ввиду, что каждая из них вызывается с ключевым словом go, следовательно, будет выполнятся в собственной горутине. Самое важное здесь то, что горутины передают данные между собой используя канал oreChan. А с анонимными функциями мы разберемся ближе к концу.
Когда горутина (гофер) посылает данные в канал, она блокируется до тех пор, пока другая горутина не прочитает данные из канала.
Аналогично блокировке при записи в канал, горутина может быть заблокирована при чтении из канала до тех пор, пока в него ничего не запишут. Если блокировки, на первый взгляд, кажутся вам чем-то сложным, можно представить их как «передачу денег» между двумя горутинами (гоферами). Когда один гофер хочет передать или получить деньги, то ему приходится ждать второго участника сделки. Разобравшись с блокировками горутин на каналах, давайте обсудим два разных типа каналов: буферизованные и небуферизованные. Выбирая тот или иной тип, мы во многом определяем поведение программы.
Во всех предыдущих примерах мы использовали именно такие каналы. По таким каналам можно передать только один фрагмент данных за раз (с блокировкой, как описано выше).
Потоки в программе не всегда могут быть идеально синхронизированы. Допустим, в нашем пример случилось так, что гофер-разведчик нашел три части руды, а гофер-шахтер за то же время успел добыть всего одну часть из найденных запасов. Вот для того, чтобы гофер-разведчик не тратил большую часть своего времени, ожидая когда шахтер закончит свою работу, мы будем использовать буферизованные каналы. Давайте начнем с создания канала емкостью 3.
В буферизоваванный канал мы можем послать несколько фрагментов данных, без необходимости чтения их другой горутиной. Это основное отличие от небуферизованных каналов.
Итак, вооружившись горутинами и каналами, мы можем написать программу, используя все преимущества многопоточного программирования в Go.
Подобно тому, как мы запускаем обычную функцию в горутине, мы можем объявить анонимную функцию сразу после ключевого слова go и вызвать ее, используя следующий синтаксис:
Да, фунция main действительно работает в своей собственной горутине. И, что более важно, после ее завершения все остальные горутины так же завершаются. Именно по этой причине мы поместили вызов таймера в конце нашей функции main. Этот вызов создает канал и посылает в него данные спустя 5 секунд.
Помните, что горутина заблокируется при чтении из канала, пока в него что-нибудь не пошлют? Это в точности то, что происходит при добавлении указанного кода. Основная горутина заблокируется, давая другим горутиам 5 секунд времени на работу. Такой способ хорошо работает, но, обычно, для проверки завершения работы всех горутин используют другой подход. Для передачи сигнала о завершении работы создается специальной канал, основная горутина блокируется на чтении из него и, как только дочерняя горутина завершает свою работу, она делает запись в этот канал; основная горутина разблокируется и программа завершится.
Есть большое количество статей и докладов, которые гораздо подробнее освещают работу с каналами и горутинам. И теперь, кода у вас есть четкое представление о том, для чего и как используются эти инструменты, вы можете получить максимальную отдачу от следующих материалов: