После изучения данного урока вы сможете:
- Сохранить состояние в безопасности;
- Использовать мьютексы и каналы ответов;
- Задействовать сервисные циклы.
Вот мы и вернулись на фабрику гоферов. Занятые гоферы все еще строят что-то, на нескольких линий производства заканчивается запас, поэтому придется сделать больше заказов.
Рекомендуем вам супер TELEGRAM канал по Golang где собраны все материалы для качественного изучения языка. Удивите всех своими знаниями на собеседовании! 😎
Мы публикуем в паблике ВК и Telegram качественные обучающие материалы для быстрого изучения Go. Подпишитесь на нас в ВК и в Telegram. Поддержите сообщество Go программистов.
Содержание статьи
К сожалению, это старомодная фабрика, на которой только одна телефонная линия для связи с внешним миром. Все производственные линии обладают своей телефонной трубкой. Гофер звонит, чтобы сделать заказ, но только начав, он понимает, что другой гофер также начинает звонить, перебивая первого гофера. Тогда другой работник делает то же самое, начинается путаница, и никто из них не может сделать заказ вообще. Если бы только они могли использовать телефон одновременно!
Общие значения (Shared values) в программах Go немного похожи на общий телефон. Если два или более горутин стараются одновременно использовать общее значение, то могут возникнуть проблемы. Хотя все и может закончиться хорошо, зависит от удачи. Возможно, гоферы никогда не будут пытаться использовать телефон одновременно. Однако вещи могут пойти неправильно по многочисленным причинам.
Возможно, два гофера, что говорят одновременно, запутают продавца на другом конце телефонной линии. Все закончится заказом неверных товаров или неверного количества предметов. Могут возникнуть и другие ошибки во время заказа. Никогда нельзя знать точно.
Это проблема общих значений в Go. Если только мы явно не будем знать, что это нормально — использовать определенные значения конкурентно, нужно предполагать, что это все-таки неправильно. Ситуация подобного рода называется состоянием гонки, потому что это напоминает горутины, что участвуют в гонке за использование значения.
На заметку: Компилятор Go включает функциональность, что пытается найти состояние гонки в коде. Если код сообщает о гонке, на это нужно обращать внимание, и исправлять программу. Подробнее по ссылке.
Внимание! Это нормально, если две горутины читают из одного элемента одновременно. Однако если вы читаете или записываете одновременно с другой записью, стоит быть готовым к неопределенному поведению и ошибке в Go.
Скажем, у нас есть группа горутин, работающий в стороне, они изучают веб страницы, делая скрапинг. Возможно, требуется вести учет посещенных страниц. (Google делает что-то похожее для составления рейтинга веб страниц в результатах поиска).
Кажется, что можно было бы использовать карту, разделенную между горутинами, содержащую статистику по ссылкам сайта. Когда горутина обрабатывает веб страницу, она может увеличить логи карты для этой страницы.
Однако, делать такого не стоит, это ошибка. Все горутины обновляют карту одновременно, что приводит к состоянию гонки. Нужен какой-то способ обойти все это. Используем мьютексы.
Мьютексы в Golang
Вернемся на фабрику гоферов, у одного из работников появилась неплохая идея. Нужно поставить стеклянную банку на середину пола фабрики, а внутри нее положить один металлический жетон. Когда гоферу нужно использовать телефон, он достает жетон из банки и держит его, пока телефонный звонок не закончится. Затем он возвращает жетон в банку. Если в банке нет жетона, когда гофер хочет сделать звонок, ему нужно будет подождать, пока жетон не будет возвращен.
Обратите внимание, что физически гофера ничего не останавливает от использования телефона без жетона. Но если он так сделает, то могут возникнуть непредвиденные последствия от двух гоферов, что говорят по телефону. Также, подумайте, что произойдет, если гофер с жетоном забудет вернуть его: никто из других работников больше не сможет использовать телефон, пока предыдущий гофер не вернет жетон.
В программе Go эквивалентом стеклянной банки является мьютекс. Слово мьютекс (mutex) является сокращением от mutual exclusion (взаимное исключение). Горутины могут использовать мьютекс для исключения возможности делать что-то одновременно. Что именно — решает программист. Как и банка на фабрике, единственное свойство «взаимного исключения» мьютекса в том факте, что нужно осторожно использовать его при получении доступа к охраняемому объекту.
У мьютексов есть два метода: Lock
и Unlock
. Вызов Lock
напоминает момент, когда жетон достается из банки. Назад положить жетон в банку можно через вызов Unlock
. Если какие-то горутины вызывают Lock
, пока мьютекс заблокирован, он подождет, пока тот не разблокируется, чтобы быть заблокированным вновь.
Для верного использования мьютекса нам нужно убедиться, что любой код с доступом к общим значениям сначала блокирует мьютекс и делает все возможное для разблокирования мьютекса. Если какой-то код не следует данному паттерну, все может закончится состоянием гонки. Из-за этого мьютексы почти всегда хранятся внутри пакета. Пакет знает, какие вещи охраняет мьютекс, но вызовы Lock
и Unlock
хорошо спрятаны за методами или функциями.
В отличие от каналов, мьютексы не встроены в язык. Они доступны через пакет sync
. Листинг 1 является программой, что блокирует и разблокирует глобальное значение мьютекса. Нам нужно инициализировать мьютекс перед его использованием — нулевое значение является разблокированным мьютексом.
Ключевое слово defer
, изученное в уроке об ошибках, может помочь с мьютексами. Даже если в функции много строк кода, вызов Unlock
будет на ровне с вызовом Lock
.
1 2 3 4 5 6 7 8 9 10 11 |
package main import "sync" // Импортирует пакет sync var mu sync.Mutex // Объявляет мьютекс func main() { mu.Lock() // Блокирует мьютекс defer mu.Unlock() // Снимает блокировку с мьютекса перед возвращением // Блокировка будет действовать пока функция не вернет (return) результат. } |
На заметку: Оператор
defer
особенно полезен, когда возвращаются несколько операторов. Безdefer
нам нужно будет вызватьUnlock
прямо перед возвращением оператора, в таком случае один из них будет легко забыть.
Давайте создадим тип, что может использовать веб-краулер для учета числа ссылок посещенных веб-страниц. Мы будем хранить карту, в которой есть URL веб-страницы, и охранять ее с мьютексом. В Листинге 2 sync.Mutex
является частью типа struct, это очень популярный паттерн.
На заметку: Это хорошая практика — держать определение мьютекса сразу над переменными, что он охраняет, и включать комментарий, чтобы связь между ними была ясна.
1 2 3 4 5 6 7 |
// Пояснения к тому, посещались ли веб страницы // Его методы могут использоваться конкурентно из нескольких горутин type Visited struct { // mu охраняет карту с посещенными страницами mu sync.Mutex // Объявление мьютекса visited map[string]int // Объявление карты из URL (строки) ключей к значениям integer } |
На заметку: В Go вы должны предполагать, что никакой метод не надежен для конкурентного использования, пока это явно не задокументировано, как мы делали выше.
Код в следующем листинге определяет метод VisitLink
, что будет вызываться, когда встречается ссылка; он возвращает количество раз, когда ссылка встречалась ранее.
1 2 3 4 5 6 7 8 9 10 |
// VisitLink отслеживает, сколько раз страницы с данным URL // была посещена, и возвращает верное число func (v *Visited) VisitLink(url string) int { v.mu.Lock() // Блокирует мьютекс defer v.mu.Unlock() // Убедитесь, что мьютекс разблокирован count := v.visited[url] count++ v.visited[url] = count // Обновляет карту return count } |
Go playground — не самое лучшее место для экспериментов с гонками состояния, потому что он держится намеренно свободным от гонок. Однако вы все-таки можете экспериментировать, вставляя вызовы для time.Sleep
между операторами. Вы можете установить Go на своем ПК в зависимости от операционной системы (Windows, Ubuntu, Debian, CentOS) и запускать примеры самостоятельно.
Попробуйте изменить Листинг 3 для использования техники, изученной в начале урока о многопоточности для запуска нескольких горутин, которые вызывают VisitLink
с разными значениями и экспериментируют со вставкой операторов Sleep
в разных местах. Также попробуйте убрать вызовы Lock
и Unlock
, чтобы посмотреть, что произойдет.
Мьютекс неплох для прямого использования, он является важным инструментом при написании методов, что должны быть доступными для использования из нескольких горутин сразу.
Вопросы для проверки:
- Что может произойти, если две горутины попытаются одновременно изменить одно и тоже значение?
- Что произойдет, если вы попытаетесь вновь заблокировать мьютекс перед его разблокированием?
- Что произойдет, если вы разблокируете его без предварительного блокирования?
- Безопасно ли одновременно вызывать методы одинакового типа из разных горутин?
Почему нужно быть осторожным с мьютексами в Golang?
В Листинге 2, когда мьютекс заблокирован, мы делаем только одну простую вещь: мы обновляем карту. Чем больше действий будет произведено во время заблокированного состояния, тем осторожнее нужно быть. Если мы блокируем мьютекс для ожидания чего-то, когда мьютекс заблокирован, мы можем случайно заблокировать и другие элементы на долгое время. Еще хуже, если мы попытаемся заблокировать уже заблокированный мьютекс, это приведет к тупику — вызов Lock
будет заблокирован навсегда.
Чтобы добиться безопасного состояния, придерживайтесь следующих правил:
- Старайтесь сделать код с мьютексом максимально простым и понятным с комментариями для подсказок;
- Не создавайте более одного мьютекса на данную часть разделенного состояния.
Мьютекс хорош для простого разделенного состояния, но он подойдет и для более серьезных вещей. На фабрике нам может потребоваться, чтобы гоферы, работающие независимо друг от друга, отвечали на запросы от других гоферов, но также работали над своим заданием. В отличие от гоферов из объединенной линии, такие гоферы полностью не отвечают на сообщения от других гоферов, они могут самостоятельно решить, что делать.
Вопрос для проверки:
Две потенциальные проблемы с блокировкой мьютекса?
Долгодействующие задачи в Golang
Рассмотрим задачу вождения марсохода на поверхности Марса. Программное обеспечение для марсохода Curiosity структурировано как набор независимых модулей, которые общаются, передавая друг другу сообщения (см. mng.bz/Z7Xa), что очень похоже на Go программы.
Модули марсохода отвечают за различные аспекты поведения машины. Попробуйте написать код Go, который управляет (очень упрощенным) марсоходом, движущимся на поверхности виртуального Марса. Поскольку у нас нет реального движка, мы справимся, обновив переменную, которая содержит координаты транспорта. Мы хотим, чтобы марсоход управлялся с Земли, поэтому он должен реагировать на внешние команды.
На заметку: Структуру кода, которую мы здесь создаем, можно использовать для любых долгоживущих задач, что действуют независимо, таких как веб сайт или аппаратный контроллер устройства.
Чтобы вести марсоход, мы запустим программу, которая будет отвечать за контроль его положения. Горутина запускается, когда запускается программное обеспечение марсохода, и остается до выключения. Поскольку он работает и оперирует независимо, мы назовем эту горутину worker, то есть рабочий.
worker
пишется как цикл for, содержащий оператор select. Цикл повторяется пока рабочий worker
жив. select
ожидает чего-то интересного. В данном случае чем-то интересным может быть команда извне. Помните, хотя работник действует независимо, мы все же хотим иметь возможность контролировать его. Или это может быть событие таймера, сообщающее рабочему, что пора двигать марсоход.
Вот основа рабочей функции, которая ничего не делает:
1 2 3 4 5 6 7 |
func worker() { for { select { // Ожидание каналов } } } |
Мы можем запустить такого работника точно так же, как мы запускали горутины в предыдущих примерах:
1 |
go worker() |
Некоторые языки программирования используют цикл обработки событий — центральный цикл, который ожидает события и вызывает функции, когда они происходят. Предоставляя горутины в качестве основного концепта, Go устраняет необходимость в центральном цикле событий. Любая рабочая горутина может рассматриваться как отдельный цикл обработки событий.
Мы хотим, чтобы марсоход периодически обновлял свою позицию. Для этого нужна рабочая горутина, которая заставляет его просыпаться, чтобы делать обновление. Для этого можно использовать time.After, который предоставляет канал, что получит значение после истечения определенного промежутка времени. Рабочий в Листинге 4 выводит значение каждую секунду. Пока вместо обновления позиции мы просто увеличиваем число. При получении события таймера, мы опять вызываем After
, чтобы в следующий раз в цикле мы подождали новый канал таймера.
1 2 3 4 5 6 7 8 9 10 11 12 |
func worker() { n := 0 next := time.After(time.Second) // Создаем начальный канал таймера for { select { case <-next: // Ожидает истечение срока таймера n++ fmt.Println(n) // Выводит число next = time.After(time.Second) // Создает другой канал таймера для другого события } } } |
На заметку: Нам не нужно использовать оператор
select
в этом примере.select
только в одном случае такой же, как самостоятельное использование операции канала. Но здесь мы используемselect
, потому что позже в этом уроке мы изменим код, чтобы ждать больше, чем просто таймера. В противном случае мы могли бы полностью избежать вызоваAfter
и использоватьtime.Sleep
.
Теперь, когда у нас есть работник, который может действовать сам по себе, давайте сделаем его более похожим на марсоход, обновив позицию а не просто число. Удобно, что в Go есть пакет image
, что предоставляет тип Point
, который мы можем использовать для представления текущего положения и направления движения марсохода. Point
— это структура, содержащая координаты X и Y c соответствующими методами. Например, метод Add
добавляет одну точку к другой.
Давайте используем ось X для представления востока-запада и ось Y для представления севера-юга. Для использования Point
мы должны сначала импортировать пакет image
:
1 |
import "image" |
Каждый раз, когда мы получаем значение на канале таймера, мы добавляем точку, представляющую текущее направление к текущей позиции, как показано в следующем листинге. Прямо сейчас марсоход всегда будет начинать движение в одном и том же месте [10, 10]
и продолжать движение на восток.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func worker() { pos := image.Point{X: 10, Y: 10} // Текущая позиция (изначально [10, 10]) direction := image.Point{X: 1, Y: 0} // Текущее направление (изначально [1, 0]) next := time.After(time.Second) for { select { case <-next: pos = pos.Add(direction) fmt.Println("текущая позиция ", pos) // Выводит текущую позицию next = time.After(time.Second) } } } |
Не очень хорошо, если марсоход может двигаться только по прямой линии. Мы хотели бы иметь возможность управлять транспортом, чтобы заставить его двигаться в разных направлениях, или остановить его, или заставить его двигаться быстрее. Нам понадобится другой канал, который мы можем использовать для отправки команд работнику. Когда рабочий получает значение в командном канале, он сможет выполнить данную команду. В Go это обычно скрытые за методами каналы, потому что каналы считаются деталью имплементации.
Тип RoverDriver
в следующем листинге содержит канал, который будет использоваться для отправки команд работнику. Мы будем использовать тип command
, который будет содержать отправленные команды.
1 2 3 4 |
// RoverDriver ведет марсоход по поверхности Марса. type RoverDriver struct { commandc chan command } |
Мы можем задействовать логику, которая создает канал и запускает работника внутри функции NewRoverDriver
, как показано в следующем листинге. Мы собираемся определить метод drive
для реализации рабочей логики. Хотя это метод, он будет функционировать так же, как функция worker
из ранней части статьи. Как метод, он имеет доступ к любым значениям в структуре RoverDriver
.
1 2 3 4 5 6 7 |
func NewRoverDriver() *RoverDriver { r := &RoverDriver{ commandc: make(chan command), } go r.drive() return r } |
Теперь нам нужно решить, какие команды отправить марсоходу. Чтобы упростить задачу, давайте задействуем только две команды: «повернуть на 90° влево» («turn 90° left») и «повернуть на 90° вправо» («turn 90° right»), как показано в следующем листинге.
1 2 3 4 5 6 |
type command int const ( right = command(0) left = command(1) ) |
На заметку: Канал может быть любого типа; тип command может быть структурным типом, содержащим произвольно сложные команды.
Теперь, когда мы определили тип RoverDriver
и функцию для создания его экземпляра, нам нужен метод drive
(рабочий, который будет управлять марсоходом), который приведен в Листинге 9. Это почти то же самое, что работник для обновления позиции, которого мы видели ранее, за исключением того, что он тоже ждет на командном канале. Когда он получает команду, он решает, что сделать. Чтобы увидеть, что происходит, мы логируем изменения по мере их появления.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// drive ответственен за вождение марсохода. Ожидается // что он начнется в горутине. func (r *RoverDriver) drive() { pos := image.Point{X: 0, Y: 0} direction := image.Point{X: 1, Y: 0} updateInterval := 250 * time.Millisecond nextMove := time.After(updateInterval) for { select { case c := <-r.commandc: // Ждет новых команд на командном канале switch c { case right: // поворот направо direction = image.Point{ X: -direction.Y, Y: direction.X, } case left: // поворот налево direction = image.Point{ X: direction.Y, Y: -direction.X, } } log.Printf("new direction %v", direction) case <-nextMove: pos = pos.Add(direction) log.Printf("moved to %v", pos) nextMove = time.After(updateInterval) } } } |
Теперь мы можем завершить тип RoverDriver
, добавив методы для управления марсоходом, как показано в Листинге 10. Мы объявим два метода, по одному для каждой команды. Каждый метод отправляет правильную команду на канал commandc
. Например, если мы вызываем метод Left
, он отправит значение команды left
, которое работник получит и изменит направление.
На заметку: Хотя эти методы контролируют направление движения марсохода, у них нет прямого доступа к значению направления, поэтому нет опасности, что они могут изменить его одновременно и рискнуть создать состояние гонки. Это означает, что нам не нужен мьютекс, потому что каналы позволяют поддерживать связь с горутиной марсохода без непосредственного изменения каких-либо его значений.
1 2 3 4 5 6 7 8 9 |
// Left поворачивает марсоход налево (90° против часовой стрелки). func (r *RoverDriver) Left() { r.commandc <- left } // Right поворачивает марсоход направо (90° по часовой стрелке). func (r *RoverDriver) Right() { r.commandc <- right } |
Теперь, когда у нас есть полностью функциональный тип RoverDriver
, Листинг 11 создает марсоход и посылает ему несколько команд. Теперь можно свободно путешествовать по Марсу!
1 2 3 4 5 6 7 8 |
func main() { r := NewRoverDriver() time.Sleep(3 * time.Second) r.Left() time.Sleep(3 * time.Second) r.Right() time.Sleep(3 * time.Second) } |
Попробуйте поэкспериментировать с типом RoverDriver
, используя разные тайминги и посылая ему разные команды.
Хотя мы сосредоточились на одном конкретном примере, этот рабочий паттерн может быть полезен во многих различных ситуациях, когда вам нужно иметь какую-то долгоживущую горутину, управляющую чем-то, оставаясь доступной для внешнего управления.
Вопросы для проверки:
- Что используется вместо цикла событий в Go?
- Какой пакет стандартной библиотеки Go предоставляет тип данных
Point
? - Какие операторы Go можно использовать для создания долгоживущих горутин?
- Какие значения Go можно посылать на канал?
Заключение
- Никогда не получайте доступ к состоянию из более чем одной горутины одновременно, если явно не помечено, что так можно сделать;
- Используйте мьютекс, чтобы убедиться, что только одна горутина получила доступ к чему-то;
- Используйте мьютекс, чтобы охранять только одну часть состояния;
- При использовании мьютекса старайтесь выполнять как можно меньше действий;
- Вы можете написать долгоживущую горутину в виде воображаемого рабочего с циклом
select
; - Скрывайте детали работника за методами.
Итоговое задание для проверки #1:
Используя Листинг 5 в качестве стартовой точки, измените код таким образом, чтобы время отсрочки становилось на половину секунды длиннее с каждым шагом.
Итоговое задание для проверки #2:
Используя тип RoverDriver
в качестве стартовой точки, определите методы Start
и Stop
и ассоциируемые команды, а также заставьте марсоход подчиняться им.
Администрирую данный сайт с целью распространения как можно большего объема обучающего материала для языка программирования Go. В IT с 2008 года, с тех пор изучаю и применяю интересующие меня технологии. Проявляю огромный интерес к машинному обучению и анализу данных.
E-mail: vasile.buldumac@ati.utm.md
Образование
Технический Университет Молдовы (utm.md), Факультет Вычислительной Техники, Информатики и Микроэлектроники
- 2014 — 2018 Universitatea Tehnică a Moldovei, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
- 2018 — 2020 Universitatea Tehnică a Moldovei, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»