Этот пост является текстовой версией моего видео про конкурентное программирование:

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

В материале мы разберёмся как с базовыми инструментами для «параллельных» вычислений (почему я взял это слово в скобки, вы узнаете чуть позже), так и с примитивами синхронизации горутин. Хоть далеко не всех из них являются частью языка, они присутствуют в стандартной библиотеке.

⚠️ Это обзорный материал, поэтому я не буду погружаться сильно глубоко в каждую из рассматриваемых тем. Для этого в будущем появятся отдельные подробные материалы. Также материал может содержать неточности при использовании тех или иных терминов для упрощения погружения в описываемые концепции.

Что такое параллельные вычисления

Википедия гласит:

В информатике параллели́зм — это свойство систем, при котором несколько вычислений выполняются одновременно, и при этом, возможно, взаимодействуют друг с другом.

Примечание. В русскоязычной литературе нередко путаются термины «параллелизм» и «конкурентность». Оба термина означают одновременность процессов, но первый — на физическом уровне (параллельное исполнение нескольких процессов, нацеленное только на повышение скорости исполнения за счёт использования соответствующей аппаратной поддержки), а второй — на логическом (парадигма проектирования систем, идентифицирующая процессы как независимые, что в том числе позволяет их исполнять физически параллельно, но в первую очередь нацелено на упрощение написания многопоточных программ и повышение их устойчивости).

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

Чтобы компьютер мог исполнять несколько задач одновременно, процессор можно сделать многоядерным. Тогда он сможет исполнять одновременно не более $N$ программ, где $N$ — это количество ядер.

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

Как этого можно достичь? Вспомните, как вы подходите к готовке ужина, состоящего из нескольких блюд. Вряд ли вы будете ждать, пока одно блюдо запечётся в духовке, чтобы приступить к варке супа. Чтобы не терять время впустую, после включения духовки вы включаете плиту и начинаете заниматься супом.

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

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

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

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

Процессы, потоки, корутины и горутины

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

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

Горутины можно сравнить с green threads в Java или с корутинами в других языках. Горутина — это как системный поток, но с некоторыми важными отличиями:

  • Горутина гораздо легковеснее потока, поэтому их можно создавать миллионами и не страдать от этого;
  • Горутины контролируются на уровне программы, а не операционной системы;
  • На одном потоке может исполняться несколько горутин.

Иными словами, Go скрывает от разработчика тяжелую работу с потоками операционной системы, беря эту ношу на себя, а в замен предоставляет горутины — гораздо более удобный и гибкий инструмент для работы с конкурентными вычислениями.

Ввиду того, что на уровне языка нет разделения на потоки и горутины, эти два термины могут использоваться как синонимы в контексте языка Go.

Ещё одно важное отличие асинхронности в Go от асинхронности в других языках — строго говоря любые функции могут использоваться как синхронно, так и асинхронно. В таких языках как Python и JavaScript объявление функции, которая исполняется асинхронно необходимо сопровождать ключевым словом async. При этом вызов такой функции нужно предворять ключевым словом await, и тогда вызывающая функция тоже обязана быть асинхронной (т.е. использовать ключевое слово async). Другой вариант — использовать Futures и Promises, однако это целая отдельная концепция, в которую необходимо погружаться. Go предлагает более простой подход: если функция вызывается напрямую, то она ведёт себя как синхронная. А если перед вызовом функции стоит ключевое слово go, то функция исполняется в отдельной горутине, т.е. становится асинхронной, при этом тело функции можно никак не изменять:

1
2
3
4
5
6
7
8
9
func foo() {
	fmt.Println("Hello, world!")
}

// Функцию foo можно вызвать синхронно:
foo() // => Hello, world!

// Или асинхронно:
go foo() // => Hello, world!

Такой подход позволяет избежать async-await-вируса. Конечно, одинаковые по своей сути функции, которые пишутся для асинхронного и синхронного вызовов всё равно будут часто отличаться, но язык не обязывает вас писать такие функции по-разному, когда это не требуется. Это позволяет вам строить достаточно гибкие API, подходящие под разные юзкейсы.

Как планировщик Go управляет горутинами

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

Кооперативная многозадачность предполагает, что контекст переключается в тот момент, когда текущая активная задача явно объявляет о том, что она готова отдать ресурс другой задаче.

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

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

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

Разработчик может явно объявить о том, что в том или ином месте можно переключиться на другую горутину с помощью вызова функции runtime.Gosched(), но делать это стоит, только если вы понимаете, что делаете, и в этом действительно есть необходимость. Компилятор сам расставит переключения контекста в следующих местах:

  • Операции с сетью;
  • Системные вызовы;
  • Вызовы функций;
  • Блокировки.

Алгоритм расстановки переключений контекста может меняться от версии к версии, поэтому как-то полагаться на конкретные места в программе в этом смысле не стоит.

Синхронизация горутин

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

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

Если просто дать возможность каждому потоку писать в лог, скорее всего на выходе вы получите кашу из символов. Произойдёт это потому что пока поток $A$ начинает писать, в потоке $B$ происходит новое событие, и тот тоже начинает писать в то же самое место. Чтобы этого не происходило, необходимо, чтобы $B$ ничего не писал, пока не закончит писать $A$. Т.е. потоки нужно синхронизировать.

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

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

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

1
2
3
4
5
6
7
8
// Объявление канала
ch := make(chan int)

// Запись в канал
ch <- 1

// Чтение из канала
v := <-ch

Расположение символа <- соответствует направлению движения данных. Т.е. при записи в канал <- ставится после имени переменной-канала, а при чтении перед именем.

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

Давайте взглянем на каналы в действии. Для этого подойдёт пример из A Tour of Go:

 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
package main

import "fmt"

func sum(s []int, c chan int) {
	sum := 0
	for _, v := range s {
		sum += v
	}

	// Записываем сумму в c
	c <- sum 
}

func main() {
	s := []int{7, 2, 8, -9, 4, 0}

	// Инициализируем канал
	c := make(chan int)

	// Делим слайс пополам:
	go sum(s[:len(s)/2], c)
	go sum(s[len(s)/2:], c)

	// Читаем из c
	x, y := <-c, <-c

	fmt.Println(x, y, x+y)
}

Код выше суммирует числа в слайсе, распределяя эту операцию по двум горутинам.

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

Каналы могут быть буферизованными. Или, иными словами, иметь ёмкость. Это значит, что запись в канал будет блокирующей, только если в канал будет записано $N$ элементов, где $N$ — ёмкость. В свою очередь чтение будет заблокировано, только если канал будет пуст.

Чтобы канал был буферизованным, необходимо указать его ёмкость при инициализации:

1
ch := make(chan int, 2)

Такие свойства буферизованных каналов действительно могут быть полезны, поскольку блокировка при каждой записи или каждом чтении — это не всегда удобно. Однако, как с и небуферизованными каналами, стоит пользоваться этим осторожно. Если из канала нигде не производится чтение, и он переполняется, происходит взаимная блокировка потоков, которую невозможно разрешить. Такое явление называется deadlock. Воспроизвести подобную ситуацию очень просто. Представим, что у нас есть следующая программа:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
	ch := make(chan string, 4)

	ch <- "hello"
	ch <- "darkness"
	ch <- "my"
	ch <- "old"

	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

Что будет, если добавить ещё одну запись в канал, например, строку "friend"? Программа упадёт при выполнении с ошибкой похожего содержания:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/tmp/sandbox3271111304/prog.go:12 +0x8c

Закрытие канала

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

1
v, ok := <-ch

ok == false, когда канал закрыт.

Запись в закрытый канал вызывает панику. По этой причине не стоит закрывать канал на читающей стороне. Лучше это сделать в том же месте, где канал создаётся или передаётся другой горутине. Также закрытие канала является опциональным, поэтому использовать close имеет смысл, когда вам нужно явно сообщить читающей стороне, что новых значений не будет. Например, чтобы for-range прекратил читать значения из канала.

for-range

По каналам можно итерироваться примерно так же, как по слайсам или мапам:

1
2
3
for v := range ch {
	fmt.Println(v)
}

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

for-range хорошо подходит для случаев, когда вы ожидаете из канала не одно, а множество периодических значений. Например, это удобно при написании Telegram-ботов:

 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
31
32
33
34
package main

import (
	"log"

	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)

func main() {
	bot, err := tgbotapi.NewBotAPI("MyAwesomeBotToken")
	if err != nil {
		log.Panic(err)
	}

	bot.Debug = true

	log.Printf("Authorized on account %s", bot.Self.UserName)

	u := tgbotapi.NewUpdate(0)
	u.Timeout = 60

	updates := bot.GetUpdatesChan(u)

	for update := range updates {
		if update.Message != nil { // If we got a message
			log.Printf("[%s] %s", update.Message.From.UserName, update.Message.Text)

			msg := tgbotapi.NewMessage(update.Message.Chat.ID, update.Message.Text)
			msg.ReplyToMessageID = update.Message.MessageID

			bot.Send(msg)
		}
	}
}

https://github.com/go-telegram-bot-api/telegram-bot-api

select

Конструкция select позволяет подождать в текущей горутине значение из нескольких каналов. Синтаксически select похож на switch:

 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
package main

import "fmt"

func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

func main() {
	c := make(chan int)
	quit := make(chan int)
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	fibonacci(c, quit)
}

Однако логика обработки различных кейсов у select отличается от switch. Во-первых, первым сработает тот кейс, для которого запись в ожидаемый канал произойдет первой. Во-вторых, кейс default срабатывает, если в момент вызова конструкции select ни один из каналов ни готов для чтения:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
tick := time.Tick(100 * time.Millisecond)
boom := time.After(500 * time.Millisecond)
for {
	select {
	case <-tick:
		fmt.Println("tick.")
	case <-boom:
		fmt.Println("BOOM!")
		return
	default:
		fmt.Println("    .")
		time.Sleep(50 * time.Millisecond)
	}
}

В-третьих, в случае если несколько каналов доступны для чтения, рантаймом будет выбран случайный кейс, поэтому опираться на порядок объявления кейсов нет смысла.

Аксиомы каналов

Если у вас есть сложности с запоминанием деталей, связанных с каналами, можно обратиться к так называемым аксиомам каналов, сформулированным Дэйвом Чейни:

  • Запись в неициализированный канал блокирует поток навсегда;
  • Чтение из неинициализированного канала блокирует поток навсегда;
  • Запись в закрытый канал вызывает панику;
  • Чтение из закрытого канала даёт нулевое значение мгновенно.

Конкурентный доступ и состояние гонки

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

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

  • Активно: когда игрок непосредственно тратит их, например, в магазине предметов;
  • Пассивно: автоматические расходы на работу юнитов и доходы от производственных зданий.

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

Представьте следующую ситуацию. У игрока в данный момент на балансе 5 монет. При следующем обновлении игрового мира с него будут списаны деньги за работу зданий и юнитов в размере 3 монет. В тот же момент, когда началось списывание средств, игрок нажимает на кнопку «Купить за 4 монеты» в игровом магазине, желая купить в свою армию ещё один юнит. С точки зрения здравого смысла он не может так поступить — у него всего 5 монет, а потратить необходимо в сумме 7. Если кнопка покупки была нажата в тот момент, когда 3 монеты начали списываться, но баланс всё ещё не был обновлён, мы получаем состояние гонки: две разные горутины одновременно прочитали, что у игрока 5 монет. Однако после того как каждая горутина завершит расчёты, на балансе у игрока останется -2 монеты, чего мы не можем допустить.

В данном случае обнаружить гонку в программе было довольно просто. Но иногда в своей работе мы сталкиваемся с совсем неочевидными случаями гонки, которые с одной стороны трудно обнаружить, а с другой стороны они вызывает баги, которые крайне трудно локализовать. Для облегчения задачи обнаружения состояния гонки уже в первых версиях Go появился race detector или детектор гонок. Достаточно при запуске тестов или компиляции добавить флаг --race, после чего компилятор будет следить за тем, как происходит доступ к различным местам в памяти программы. Если компилятор обнаруживает гонку, он сообщает об этом, печатая ворнинг.

Есть пара важных моментов про рейс-детектор. Во-первых, он никогда не ошибается. В том смысле, что рейс-детектор может не найти все гонки, но все гонки, о которых он сообщает гарантированно являются гонками (true positive). Во-вторых, включенный рейс-детектор добавляет заметные накладные расходы на исполнение программы, поэтому если для вашего приложения критична производительность, имеет смысл включать флаг --race в первую очередь на тестах, в том числе нагрузочных.

Окей, с рейс-детектором или без, мы обнаружили состояние гонки. Теперь нам необходимо избавиться от данного эффекта. Для этого нам стоит обратиться к примитивам синхронизации.

Примитивы синхронизации

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

Мьютекс

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

Мьютекс — это объект, который может находиться в двух состояниях: разблокированном и заблокированном. Алгоритм работы с мьютексом обычно такой:

  1. В потоке $A$ вызывается метод $M$, модифицирующий данные, доступ к которым необходимо синхронизировать;
  2. Внутри $M$ блокируется мьютекс;
  3. Выполняется остальная часть функции;
  4. Мьютекс разблокируется.

Если во время того, как мьютекс всё ещё заблокирован $M$ будет вызвана из другого потока (назовём его $B$), произойдёт попытка повторно заблокировать уже заблокированный мьютекс. В таком случае выполнение потока $B$ блокируется, пока мьютекс не будет разблокирован перед завершением вызова $M$ в потоке $A$. Таким образом, если $M$ производит изменение в данных, это произойдёт последовательно даже при «одновременном вызове» $M$ из разных горутин.

В виде кода на Go это может выглядеть примерно так:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package main

import (
	"errors"
	"log"
	"math/rand"
	"sync"
	"time"
)

func main() {
	var (
		wg     sync.WaitGroup
		wallet = &playerWallet{coins: 5}
	)

	rand.Seed(time.Now().Unix())

	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)

		payForUnitsAndBuildings(wallet)
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)

		buyAnotherUnitInShop(wallet)
	}()

	wg.Wait()
}

func payForUnitsAndBuildings(w *playerWallet) {
	if err := w.spendCoins(3); err != nil {
		log.Println(err)
	}
}

func buyAnotherUnitInShop(w *playerWallet) {
	if err := w.spendCoins(4); err != nil {
		log.Println(err)
	}
}

var errInsufficientFunds = errors.New("insufficient funds")

type playerWallet struct {
	coins int64
	mu    sync.Mutex
}

func (w *playerWallet) spendCoins(amount int64) error {
	w.mu.Lock()
	defer w.mu.Unlock()

	if w.coins-amount < 0 {
		return errInsufficientFunds
	}

	w.coins -= amount
	log.Printf("spent %d coin(s), balance: %d", amount, w.coins)
	return nil
}

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

Техника безопасности

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

Во-первых, никогда не встраивайте мьютекс в структуру, как здесь:

1
2
3
type MyAwesomeStruct struct {
	sync.Mutex
}

При встраивании мьютекс станет экспортированным полем, и таким образом будет доступен для контроля за пределами методов структуры.

Во-вторых, никогда не храните ссылку на мьютекс в поле структуры. Иначе во всех копиях структуры будет храниться ссылка на один и тот же мьютекс. Думаю, нет необходимости объяснять, какие вас ждут сюрпризы в таком случае.

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

RWMutex

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

 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
type playerWallet struct {
	coins int64
	mu    sync.RWMutex
}

func (w *playerWallet) getCoins() int64 {
	w.mu.RLock()
	coins := w.coins
	w.mu.RUnlock()

	return coins
}

func (w *playerWallet) spendCoins(amount int64) error {
	w.mu.Lock()
	defer w.mu.Unlock()

	if w.coins-amount < 0 {
		return ErrInsufficientFunds
	}

	w.coins -= amount
	log.Printf("spent %d coin(s), balance: %d", amount, w.coins)
	return nil
}

Атомики (атомарные счётчики)

Ещё один примитив синхронизации, который хорошо подходит к примеру с балансом монет у игрока — это атомарные счётчики из пакета sync/atomic. Атомики интересны прежде всего тем, что они позволят конкурентно читать и писать данные без блокировок (как это возможно — отдельная интересная тема, сейчас мы её опустим). В примере, рассмотренном выше мы можем избавиться от мьютекса, а, например, содержимое метода spendCoins переписать следующим образом:

1
2
3
4
5
6
7
8
9
func (w *playerWallet) spendCoins(amount int64) error {
	if atomic.LoadInt64(&w.coins)-amount < 0 {
		return ErrInsufficientFunds
	}

	atomic.AddInt64(&w.coins, -amount)
	log.Printf("spent %d coin(s), balance: %d", amount, w.coins)
	return nil
}

Атомики — это довольно низкоуровневый примитив синхронизации. Возможно поэтому их даже вынесли в отдельный подпакет. Они часто используются как кирпичики для построения других примитивов синхронизации. Если у вас нет действительно хороших причин для использования атомиков, воспользуйтесь лучше более высокоуровневыми инструментами из пакета sync. С атомиками легко наломать дров — достаточно где-то один раз обратиться к переменной-счётчику напрямую, не используя функции-обёртки типа LoadT, AddT, и т.д., где $T$ — это тип.

sync.Map

sync.Map — это примерно как map[any]any, но она готова к конкуретному доступу, т.е. её нет необходимости обкладывать блокировками. Т.к. sync.Map — не встроенный в язык тип, а обёртка над map[any]any, обращение к элементам по ключу не сработает — для этого необходимо использовать методы Load, Store, Delete и их вариации:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var sm sync.Map

sm.Store("foo", int64(42))

fortyTwoAny, ok := sm.Load("foo")
if !ok {
	return errors.New("key '42' does not exist")
}

fortyTwo, ok := fortyTwoAny.(int64)
if !ok {
	return errors.New("42 is not int64")
}

fmt.Println(fortyTwo)

sm.Delete("foo")

WaitGroup

WaitGroup ждёт, пока набор горутин завершит работу. Главная горутина, т.е. та, в которой была создана WaitGroup, должна содержать вызовы метода wg.Add() (wg — это экземпляр WaitGroup), чтобы сообщить вейт-группе, сколько горутин необходимо дождаться. Каждая горутина вызывает wg.Done(), чтобы сообщить вейт-группе, что одна из горутин закончила работу, и счётчик можно понизить:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var (
	wg   sync.WaitGroup
	urls = []string{
		"http://www.golang.org/",
		"http://www.google.com/",
		"http://www.example.com/",
	}
)

for _, url := range urls {
	wg.Add(1)
	
	go func(url string) {
		defer wg.Done()
		http.Get(url)
	}(url)
}

wg.Wait()

wg.Wait() используется, чтобы заблокировать текущую горутину, пока внутренний счётчик вейт-группы не достигнет нуля за счёт вызовов wg.Done().

WaitGroup полезен, когда вам необходимо выполнить несколько независимых долгих операций параллельно, при этом вам необходим результат каждой из горутин.

Достойны упоминания

sync.Pool

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

sync.Cond

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

Полезные ссылки