Перевод обучающей статьи разработчика из 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.
Примечание: я люблю обратную связь, так что если вы находите этот материал полезным, пинганите меня, твитните или дайте мне конструктивный отзыв.
Спасибо и хорошего кодинга!

Комментарии (21)


  1. evnuh
    26.11.2015 18:49

    Супер. У меня всего два c половиной вопроса
    1) Каналы изнутри сами построены на мьютексах?
    2) Когда мне использовать мьютекс, а когда буфферизованный канал размером 1?
    Тут мне кажется, что разницы между

    mutex.Lock()
    xxx
    mutex.Unlock()
    

    и
    <- chan
    xxx
    1 -> chan
    

    нет, но второй вариант безопаснее. То есть каналы не сложнее мьютекса в самом коде, но безопаснее.

    2.5) рантайм Go отлично шедулит горутины когда они засыпают на ожидании данных из канала — делает ли он так же и для мьютексов?


    1. creker
      26.11.2015 19:04
      +1

      Буферизированный канал, по сути, семафор. На счет безопасности, не вижу разницы. Разве что код с мьютексом будет куда очевиднее и скорее всего быстрее.


    1. aml
      26.11.2015 20:07

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

      По сути ваших примеров кода — аналог мьютекса так можно сделать. За исключением всяких крайних случаев типа двойного вызова Unlock.


    1. zuborg
      26.11.2015 20:22

      1) Каналы используют блокировки, но технически они не являются sync.Mutex блокировкой. Будет точнее сказать, что блокировки для реализации каналов, мютексов, семафоров, рлоков и т.д. используют атомарные процессорные инструкции (типа CaS).
      2) Разницы нет в достигнутом результате, но семантически первый вариант корректнее. Откуда мне знать, что у Вас используется именно буферизированный канал размером 1? А когда я вижу mutex.Lock() — то я знаю что это ексклюзивная блокировка.
      2.5) «отлично» это слишком сильно сказано. Неплохо — это факт. Но накладные расходы на шедулинг каналов все ещё не дают признать их самым предпочтительным средством разделения доступа. А мютексы рантайм тоже шедулит, если встречает лок.


    1. mayorovp
      26.11.2015 21:08

      Если я правильно понимаю, то при работе с каналами невозможно обнаружить взаимоблокировку автоматически, в отличии от мьютексов.

      В этом плане мьютексы безопаснее каналов.


      1. evnuh
        26.11.2015 21:18

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


  1. rafuck
    27.11.2015 03:30

    defer mutex.unlock — это, конечно, хорошо, только… Я правильно понимаю, что «правильный» lock_guard в Go сделать не получится?


    1. aml
      27.11.2015 10:11
      +1

      defer unlock даёт те же гарантии, что и lock_guard — при выходе из функции (любом, даже через panic) мьютекс освободится. Я бы сказал, что defer — это оригинальная идея, которая позволяет избежать использования guard'ов и предоставить аналогичную функциональность вообще для любых объектов. Открыли соединение — defer закрыть. Получили блокировку — defer отпустить. Для случаев с более сложной логикой лучше не танцевать с мьютексами, см. статью.


      1. mayorovp
        27.11.2015 12:02

        Те же, да не совсем.

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

        А забыть написать `defer mutex.Unlock()` можно запросто.


        1. aml
          27.11.2015 13:00
          +2

          Так же можно забыть и safe_guard создать. Учитывая, что defer unlock пишется на следующей строчке после lock, забыть его ну очень сложно.


          1. mkpankov
            27.11.2015 13:12

            Нельзя, если это единственный интерфейс работы с объектом, защищённый мьютексом.

            И вообще, в случае mutex guard за эту часть дизайна отвечает разработчик языка/стандартной библиотеки, а не конечный разработчик.


            1. aml
              27.11.2015 13:31

              Кто ж блокировками заставляет пользователя заниматься? Пользователю снаружи виден метод, а его реализация уже делает все необходимые блокировки. И если она это делает, то делает lock() и defer unlock(). Сразу. Рядом. Без шансов забыть unlock. Об этом речь идёт.


              1. mkpankov
                27.11.2015 15:49

                Кто ж кто ж, автор статьи пользуется же.

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


          1. mayorovp
            27.11.2015 13:32

            Это другой вопрос. Я писал лишь о том, что defer и lock_guard — это не вполне эквивалентные механизмы.


        1. mirrr
          27.11.2015 17:34
          +2

          Можно и mutex.Lock() забыть написать. И вообще забыть на работу пойти.
          Особенно в пятницу.


  1. santeri
    29.11.2015 15:11
    -8

    что хорошего в этом го? чем он лучше других ЯП?
    где нормальные инструменты под него? а не только подсветка синтаксиса.

    err :=…
    if err != nil {
    //log error
    return // < — разблокировка произойдет здесь
    }

    так вроде не пишут, не?

    if err := ..; err != nil {

    вместо решения проблем, похоже некоторым нравится их создавать


    1. milast
      30.11.2015 07:22
      +6

      Что хорошего в этом комментарии? Чем он полезен для посетителей?
      Где нормальное аргументирование своих слов? А не только «разнос» без всякого разбора в ситуации.


  1. vintage
    05.12.2015 01:09
    -2

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

    Копипаста и никем не проверяемые конвенции. Славно. В том же D достаточно объявить класс как synchronized и все публичные методы автоматом будут завёрнуты в локи, а прямой доступ к полям вообще запрещён извне.


    1. divan0
      05.12.2015 01:17
      +2

      Вам никто не запрещает писать на D. Но это хаб о Go.


      1. vintage
        05.12.2015 02:13
        -4

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


        1. divan0
          05.12.2015 03:08
          +2

          Ничего страшного.