Уровень чтения: средний (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.
Примечание: я люблю обратную связь, так что если вы находите этот материал полезным, пинганите меня, твитните или дайте мне конструктивный отзыв.
Спасибо и хорошего кодинга!
Комментарии (21)
rafuck
27.11.2015 03:30defer mutex.unlock — это, конечно, хорошо, только… Я правильно понимаю, что «правильный» lock_guard в Go сделать не получится?
aml
27.11.2015 10:11+1defer unlock даёт те же гарантии, что и lock_guard — при выходе из функции (любом, даже через panic) мьютекс освободится. Я бы сказал, что defer — это оригинальная идея, которая позволяет избежать использования guard'ов и предоставить аналогичную функциональность вообще для любых объектов. Открыли соединение — defer закрыть. Получили блокировку — defer отпустить. Для случаев с более сложной логикой лучше не танцевать с мьютексами, см. статью.
mayorovp
27.11.2015 12:02Те же, да не совсем.
lock_guard — это такой способ блокирования мьютекса, что даже самый криворукий программист не сможет забыть его разблокировать.
А забыть написать `defer mutex.Unlock()` можно запросто.aml
27.11.2015 13:00+2Так же можно забыть и safe_guard создать. Учитывая, что defer unlock пишется на следующей строчке после lock, забыть его ну очень сложно.
mkpankov
27.11.2015 13:12Нельзя, если это единственный интерфейс работы с объектом, защищённый мьютексом.
И вообще, в случае mutex guard за эту часть дизайна отвечает разработчик языка/стандартной библиотеки, а не конечный разработчик.aml
27.11.2015 13:31Кто ж блокировками заставляет пользователя заниматься? Пользователю снаружи виден метод, а его реализация уже делает все необходимые блокировки. И если она это делает, то делает lock() и defer unlock(). Сразу. Рядом. Без шансов забыть unlock. Об этом речь идёт.
mkpankov
27.11.2015 15:49Кто ж кто ж, автор статьи пользуется же.
Он пользователь, и для него интерфейс мьютекса — такой. Возможность забыть разблокировку есть.
mayorovp
27.11.2015 13:32Это другой вопрос. Я писал лишь о том, что defer и lock_guard — это не вполне эквивалентные механизмы.
mirrr
27.11.2015 17:34+2Можно и mutex.Lock() забыть написать. И вообще забыть на работу пойти.
Особенно в пятницу.
santeri
29.11.2015 15:11-8что хорошего в этом го? чем он лучше других ЯП?
где нормальные инструменты под него? а не только подсветка синтаксиса.
err :=…
if err != nil {
//log error
return // < — разблокировка произойдет здесь
}
так вроде не пишут, не?
if err := ..; err != nil {
вместо решения проблем, похоже некоторым нравится их создаватьmilast
30.11.2015 07:22+6Что хорошего в этом комментарии? Чем он полезен для посетителей?
Где нормальное аргументирование своих слов? А не только «разнос» без всякого разбора в ситуации.
vintage
05.12.2015 01:09-2> Обратите внимание в этом коде, что для каждого не-экспортированного метода есть аналогичный экспортированный. Эти методы работают как публичный API, и заботятся о блокировках на этом уровне. Далее они вызывают неэкспортированные методы, которые вообще не заботятся о блокировках. Это гарантирует, что все вызовы ваших методов извне будут блокироваться лишь раз и лишены проблемы рекурсивной блокировки.
Копипаста и никем не проверяемые конвенции. Славно. В том же D достаточно объявить класс как synchronized и все публичные методы автоматом будут завёрнуты в локи, а прямой доступ к полям вообще запрещён извне.
evnuh
Супер. У меня всего два c половиной вопроса
1) Каналы изнутри сами построены на мьютексах?
2) Когда мне использовать мьютекс, а когда буфферизованный канал размером 1?
Тут мне кажется, что разницы между
и
нет, но второй вариант безопаснее. То есть каналы не сложнее мьютекса в самом коде, но безопаснее.
2.5) рантайм Go отлично шедулит горутины когда они засыпают на ожидании данных из канала — делает ли он так же и для мьютексов?
creker
Буферизированный канал, по сути, семафор. На счет безопасности, не вижу разницы. Разве что код с мьютексом будет куда очевиднее и скорее всего быстрее.
aml
1. Каналы хитрые. Некоторые операции делаются с оптимистичными блокировками, некоторые чисто на мьютексах.
2. Каналы нужно использовать по-другому. Это не слепая замена мьютексам. Надо на каждый разделяемый ресурс запускать отдельную горутину, которой присылать команды на изменение данных и запросы на чтение. Эта горутина просто исполняет все, о чем её просят, гарантированно последовательно.
По сути ваших примеров кода — аналог мьютекса так можно сделать. За исключением всяких крайних случаев типа двойного вызова Unlock.
zuborg
1) Каналы используют блокировки, но технически они не являются sync.Mutex блокировкой. Будет точнее сказать, что блокировки для реализации каналов, мютексов, семафоров, рлоков и т.д. используют атомарные процессорные инструкции (типа CaS).
2) Разницы нет в достигнутом результате, но семантически первый вариант корректнее. Откуда мне знать, что у Вас используется именно буферизированный канал размером 1? А когда я вижу mutex.Lock() — то я знаю что это ексклюзивная блокировка.
2.5) «отлично» это слишком сильно сказано. Неплохо — это факт. Но накладные расходы на шедулинг каналов все ещё не дают признать их самым предпочтительным средством разделения доступа. А мютексы рантайм тоже шедулит, если встречает лок.
mayorovp
Если я правильно понимаю, то при работе с каналами невозможно обнаружить взаимоблокировку автоматически, в отличии от мьютексов.
В этом плане мьютексы безопаснее каналов.
evnuh
Спасибо, я почему-то как раз считал именно наоборот, поэтому и думал, что каналы безопаснее. Спасибо. Это ключевой момент, как мне кажется, должен быть упомянут в статье.