Как стать автором
Обновить

Танцы с мьютексами в Go

Время на прочтение9 мин
Количество просмотров66K
Автор оригинала: Ralph Caraveo III
Перевод обучающей статьи разработчика из SendGrid о том, когда и зачем можно и нужно использовать «традиционные» методы синхронизации данных в Go.

Уровень чтения: средний (intermediate) — эта статья подразумевает, что вы знакомы с основами Go и моделью concurrency, и, как минимум, знакомы с подходами к синхронизации данных методами блокировок и каналов.

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

Когда я впервые начал работать с языком программирования Go, я моментально влюбился в слоган «Не общайтесь разделением памяти. Разделяйте память через общение.» (Don’t communicate by sharing memory; share memory by communicating.) Для меня это означало писать весь конкурентный (concurrent) код «правильным» путем, используя каналы всегда-всегда. Я считал, что используя потенциал каналов, я гарантированно избегаю проблем с конкурентностью, блокировками, дедлоками и т.д.

По мере того, как я прогрессировал в Go, учился писать код на Go более идиоматически и изучал лучшие практики, я регулярно натыкался на большие кодовые базы, где люди регулярно использовали примитивы sync/mutex, а также sync/atomic, и несколько других «низкоуровневых» примитивов синхронизации «старой школы». Мои первые мысли были — ну, они явно делают это неверно, и, очевидно, они не смотрели ни одного выступления Роба Пайка о плюсах реализации конкуретного кода с помощью каналов, в которых он часто рассказывает о дизайне, основанном на труде Тони Хоара Communicating Sequential Processes.

Но реальность была сурова. Go-сообщество цитировало этот слоган там и тут, но заглядывая во многие open source проекты, я видел, что мьютексы повсюду и их много. Я боролся с этой загадкой некоторое время, но, в итоге, я увидел свет в конце тоннеля, и настало время засучить рукава и отложить каналы в сторонку. Теперь, быстро перемотаем на 2015 год, в котором я пишу на Go уже около 2.5 лет, в течение которых у меня было прозрение или даже два касательно более традиционных примитивов синхронизации вроде блокировок мьютексами. Давайте, спросите меня сейчас, в 2015? Ей, @deckarep, ты всё еще пишешь конкурентные программы используя только лишь каналы? Я отвечу — нет, и вот почему.

Во-первых, давайте не забывать о важности прагматичности. Когда речь заходит о защите состояния объекта методом блокировок или каналов, давайте зададимся вопросом — «Какой же метод я должен использовать?». И, как оказалось, есть очень хороший пост, который замечательно отвечает на этот вопрос:
Используйте тот метод, который наиболее выразителен и/или прост в вашем случае.

Частая ошибка новичков в Go это переиспользовать каналы и горутины просто потому что это возможно, и/или потому что это весело. Не бойтесь использовать sync.Mutex, если он решает вашу проблему лучше всего. Go прагматичен в том, чтобы давать вам те инструменты решения задачи, которые подходят лучше, и не навязывает вам лишь один подход.

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

Когда использовать Каналы: передача владения данными, распределение вычислений и передача асинхронных результатов.

Когда использовать Мьютексы: кэши, состояния.

В конце концов, каждое приложение разное, и может потребоваться немного экспериментов и ложных стартов. Указания выше лично мне помогают, но позвольте мне объяснить их чуть более подробно. Если вам нужно защитить доступ к простой структуре данных, такой как слайс, или map, или что-нибудь своё, и если интерфейс доступа к этой структуре данных прост и прямолинеен — начинайте с мьютекса. Это также помогает спрятать «грязные» подробности кода блокировки в вашем API. Конечные пользователи вашей структуры не должны заботиться о том, как она делает внутреннюю синхронизацию.

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

Многопоточность не сложна — сложны блокировки.

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

Номер 1: Определяя структуру, в которой мьютекс должен защищать одно или больше значений, помещайте мьютекс выше тех полей, доступ к которым, он будет защищать. Вот пример этой идиомы в исходном коде Go. Имейте ввиду, что это всего лишь договорённость, и никак не влияет на логику кода.
var sum struct {
    sync.Mutex     // <-- этот мьютекс защищает
    i int          // <-- это поле под ним
}

Номер 2: держите блокировку не дольше, чем она на самом деле требуется. Пример — если возможно, не держите мьютекс во время IO-вызова. Наоборот, постарайтесь защищать ваши данные только минимально необходимое время. Если вы сделаете как-нибудь вот так в веб-обработчике, вы просто потеряете преимущества конкурентности, сериализовав доступ к обработчику:
// В коде ниже подразумевается, что `mu` существует только
// для защиты переменной cache
// NOTE: Простите за игнор ошибок, это для краткости примера

// Не делайте так, если это возможно
func doSomething(){
    mu.Lock()
    item := cache["myKey"]
    http.Get() // какой-нибудь дорогой IO-вызов
    mu.Unlock()
}
// Вместо этого, делайте как-нибудь так
func doSomething(){
    mu.Lock()
    item := cache["myKey"]
    mu.Unlock()
    http.Get() // Это может занять время, но нам ок
}

Номер 3: Используйте defer, чтобы разблокировать мьютекс там где у функции есть несколько точек выхода. Для вас это означает меньше ручного кода и может помочь избежать дедлоков, когда кто-то меняет код через 3 месяца и добавляет новую точку выхода, упустив из виду блокировку.
func doSomething() {
	mu.Lock()
	defer mu.Unlock()
        err := ...
	if err != nil {
		//log error
		return // <-- разблокировка произойдет здесь
	}

        err = ...
	if err != nil {
		//log error
		return // <-- или тут
	}
	return // <-- и, конечно, тут тоже
}

При этом, постарайтесь не зависеть вслепую от defer во всех случаях подряд. К примеру, следующий код — это ловушка, в которую вы можете попасться, если вы думаете, что defer-ы выполняются не при выходе из функции, а при выходе из области видимости (scope):
func doSomething(){
    for {
        mu.Lock()
        defer mu.Unlock()
         
        // какой-нибудь интересный код
        // <-- defer не будет выполнен тут, как кто-нибудь *может* подумать
     }
   // <-- он(и) будут исполнены тут, при выходе из функции
}
// И поэтому в коде выше будет дедлок!


Наконец, не забывайте, что defer можно вообще не использовать в простых случаях без множественных точек выхода. Отложенные выполнения (defer) имеют небольшие накладные расходы, хотя зачастую ими можно пренебречь. И рассматривайте это, как очень преждевременную и, зачастую, лишнюю оптимизацию.

Номер 4: точная (fine-grained) блокировка может давать лучшую производительность ценой более сложного кода для управления ею, в то время, как более грубая блокировка может быть менее производительна, но делать код проще. Но опять же, будьте прагматичны в оценках дизайна. Если вы видите, что «танцуете с мьютексами», то, скорее всего, это подходящий момент для рефакторинга и перехода на синхронизацию посредством каналов.

Номер 5: Как упоминалось выше, хорошей практикой является инкапсулировать используемый метод синхронизации. Пользователи вашего пакета не должны заботится, каким именно образом вы защищаете данные в вашем коде.

В примере ниже, представьте, что мы представляем метод get(), который будет выбирать код из кэша только если в нём есть хотя бы одно значение. И поскольку мы должны блокировать как обращение к содержимому, так и подсчет значений, этот код приведет к дедлоку:
package main

import (
	"fmt"
	"sync"
)

type DataStore struct {
	sync.Mutex // ← этот мьютекс охраняет кэш ниже
	cache      map[string]string
}

func New() *DataStore {
	return &DataStore{
		cache: make(map[string]string),
	}
}

func (ds *DataStore) set(key string, value string) {
	ds.Lock()
	defer ds.Unlock()
	ds.cache[key] = value
}

func (ds *DataStore) get(key string) string {
	ds.Lock()
	defer ds.Unlock()
	if ds.count() > 0 { // <-- count() тоже блокируется!
		item := ds.cache[key]
		return item
	}
	return ""
}

func (ds *DataStore) count() int {
	ds.Lock()
	defer ds.Unlock()
	return len(ds.cache)
}

func main() {
	/* Выполнение кода ниже приведет к дедлоку, так как метод get() заблокируется и метод count() также заблокируется перед тем как get() разблокирует мьютекс
	 */
	store := New()
	store.set("Go", "Lang")
	result := store.get("Go")
	fmt.Println(result)
}

Поскольку мьютексы в Go нерекурсивны, предложенное решение может выглядеть так:
package main

import (
	"fmt"
	"sync"
)

type DataStore struct {
	sync.Mutex // ← этот мьютекс защищает кэш ниже
	cache      map[string]string
}

func New() *DataStore {
	return &DataStore{
		cache: make(map[string]string),
	}
}

func (ds *DataStore) set(key string, value string) {
	ds.cache[key] = value
}

func (ds *DataStore) get(key string) string {
	if ds.count() > 0 {
		item := ds.cache[key]
		return item
	}
	return ""
}

func (ds *DataStore) count() int {
	return len(ds.cache)
}

func (ds *DataStore) Set(key string, value string) {
	ds.Lock()
	defer ds.Unlock()
	ds.set(key, value)
}

func (ds *DataStore) Get(key string) string {
	ds.Lock()
	defer ds.Unlock()
	return ds.get(key)
}

func (ds *DataStore) Count() int {
	ds.Lock()
	defer ds.Unlock()
	return ds.count()
}

func main() {
	store := New()
	store.Set("Go", "Lang")
	result := store.Get("Go")
	fmt.Println(result)
}

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

Номер 6: В примерах выше мы использовали простой sync.Mutex, который может только блокировать и разблокировать. sync.Mutex предоставляет одинаковые гарантии, вне зависимости от того читает ли горутина данные или пишет. Но существует также sync.RWMutex, который даёт более точную семантику блокировок для кода, который только обращается к данным. Когда же использовать RWMutex вместо стандартного Mutex?

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

// я могу смело использовать RLock() для счетчика, так как он не меняет данные
func count() {
	rw.RLock()         // <-- заметьте букву R в RLock (read-lock)
	defer rw.RUnlock() // <-- заметьте букву R в RUnlock()
	return len(sharedState)
}

// Но я должен использовать Lock() для set(), который меняет данные
func set(key string, value string) {
	rw.Lock()                // <-- заметьте, тут мы берем "обычный" Lock (write-lock)
	defer rw.Unlock()        // <-- а тут Unlock(), без R
	sharedState[key] = value // <-- изменяет состояние(данные)
}

В коде выше мы подразумеваем, что переменная `sharedState` — это некий объект, возможно map, в котором мы можем считать его длинну. Поскольку функция count() гарантирует, что наш объект не изменяется, то мы можем смело вызывать её параллельно из любого количества ридеров (горутин). В некоторых сценариях это может уменьшить количество горутин в состоянии блокировки и потенциально дать прирост производительности в сценарии, где происходит много read-only обращений к данным. Но помните, если у вас есть код, меняющий данные, как в set(), вы обязаны использовать rw.Lock() вместо rw.RLock().

Номер 7: познакомьтесь с адски крутым и встроенным race-детектором в Go. Этот детектор заработал себе репутацию, найдя состояния гонки даже в стандартной библиотеке Go в своё время. Именно поэтому он встроен в инструментарий Go и есть немало выступлений и статей о нём, которые расскажут про него лучше, чем я.
  • если вы ещё не запускаете свои unit/integration тесты с включенным рейс-детектором в вашем CI — настройте это прямо сейчас
  • если ваши тесты не тестируют параллельный доступ к вашему API/коду — детектор вам сильно не поможет
  • не запускайте программу с race-детектором в продакшене, там есть накладные расходы, которые уменьшают производительность
  • если race-детектор нашел состояние гонки — это реальная гонка
  • состояния гонки могут быть и при синхронизации через каналы, если вы неосторожны
  • никакие блокировки в мире вас не спасут, если горутины читают или пишут разделяемые данные вне пределов критической секции
  • если авторы Go могут иногда писать код, в котором есть гонки, то вы тоже можете

Я надеюсь, эта статья даёт достаточно ёмкое представление о том, как и когда использовать мьютексы в Go. Пожалуйста, экспериментируйте с низкоуровневыми примитивами синхронизации в Go, делайте ошибки, научитесь на них, цените и понимайте инструментарий. И прежде всего, будьте прагматичны в вашем коде, используйте правильные инструменты для каждого конкретного случая. Не бойтесь, как боялся я вначале. Если бы я всегда слушал все негативные вещи, которые говорят про блокировки, я бы сейчас не был в этом бизнесе, создавая крутейшие распределённые системы используя такие крутые технологии, как Go.
Примечание: я люблю обратную связь, так что если вы находите этот материал полезным, пинганите меня, твитните или дайте мне конструктивный отзыв.
Спасибо и хорошего кодинга!
Теги:
Хабы:
+24
Комментарии22

Публикации

Изменить настройки темы

Истории

Работа

Go разработчик
123 вакансии

Ближайшие события

PG Bootcamp 2024
Дата16 апреля
Время09:30 – 21:00
Место
МинскОнлайн
EvaConf 2024
Дата16 апреля
Время11:00 – 16:00
Место
МоскваОнлайн
Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн