Deep Engineering
Продвинутый·Опубликовано·30 МИН

sync и atomic в Go: два совета, которые разваливаются при замере

Собеседование идёт лестницей: что не так с обычным инкрементом — чем atomic отличается от мьютекса — когда брать RWMutex — что делает Once — почему копию мьютекса ловит go vet. Замерено: без конкуренции мьютекс дороже atomic вдвое, но с ростом числа претендентов он дорожает в 9,6 раза, а atomic — в 1,0; RWMutex же на короткой секции проигрывает обычному Mutex.

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: теряется не всегда — и это опаснее, чем если бы терялось всегда. Код «работает», пока не изменится нагрузка: восемь горутин по 10 000 инкрементов в одном из прогонов дали ровно 80 000, то есть потери не случилось вовсе. И отсюда же разделение труда, которое стоит назвать вслух: мьютекс защищает целостность участка кода — сколько угодно полей и любую логику между ними; атомарная операция подходит там, где состояние ограничено и меняется одним неделимым действием.

Дальше — числа, и они переворачивают два расхожих совета. Гарантии при этом даёт не слово «замок», а модель памяти: happens-before — и «я поставил мьютекс, значит безопасно» верно, только если под ним весь доступ. Без конкуренции мьютекс дороже atomic вдвое — 13,35 нс против 6,49; но с ростом числа претендентов с 2 до 16 мьютекс дорожает в 9,6 раза, а atomic — в 1,0: мьютекс паркует проигравшего, атомарная операция повторяет попытку. «На чтениях бери RWMutex» на короткой секции неверно: при пустой секции RLock проигрывает Lock (98,78 против 24,01 нс), и выигрыш появляется только на длинной — 671,01 против 1463,55. sync.Once после первого вызова бесплатен — 1,87 нс, в пределах шума от «вообще ничего не делать». А копия структуры с мьютексом — это другой мьютекс; ловит go vet проверкой copylocks, и она уже входит в go test. Числа сняты на одной машине и одной версии Go: устойчивы направления и кратности, а не сами наносекунды.

Порог входа
Перед уроком достаточно понимать
  • несколько горутин могут работать с одними и теми же данными одновременно;
  • переменная живёт в общей памяти: её видит не только та горутина, которая её создала;
  • n++ выглядит в коде одним действием — и на этом ощущении держится половина ошибок темы.
Заранее знать не нужно
  • что такое happens-before и что про это говорит модель памяти Go;
  • Mutex, RWMutex, atomic, Once, WaitGroup;
  • проверка copylocks в go vet и строки кеша.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Что не так с n++ из горутин?» — разминка.
  2. «Чем atomic отличается от мьютекса?» — здесь начинают отвечать «быстрее», и это половина ответа.
  3. «Что быстрее и насколько?» — вопрос-ловушка: правильный ответ начинается со встречного вопроса «при какой конкуренции».
  4. «Когда брать RWMutex?» — ещё одна ловушка: «когда много чтений» неверно.
  5. «Зачем sync.Once, если можно флаг?» — про happens-before, а не про скорость.
  6. «Почему нельзя копировать мьютекс?» — и чем это ловится.

Дальше урок идёт по этой лестнице. Спина у неё одна: синхронизация — это не скорость, а гарантии; а скорость зависит от конкуренции, а не от примитива.

База: состязание за данные — и почему n++ не одно действие

Ситуация, из которой растёт вся тема, выглядит безобидно: две горутины работают с одной и той же переменной, и хотя бы одна из них её меняет. Это и называется состязанием за данные.

Кажется, что беды тут быть не может: инкремент — одна строка. Но одна строка в исходнике ничего не говорит о неделимости. n++ — это три действия:

  1. прочитать текущее значение из памяти;
  2. прибавить к нему единицу;
  3. записать результат обратно.

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

Так это и выглядит на прогоне. Восемь горутин по 10 000 инкрементов обычной переменной:

GO
n := 0
// восемь горутин: for i := 0; i < 10000; i++ { n++ }

Прогон bench/gosync/practice.go печатает, что итог меньше 80 000. Тот же счётчик атомарно даёт ровно 80 000.

Оговорка к этому замеру, и она важнее самого замера. В скрипте чтение и запись разведены явно — между ними стоит runtime.Gosched(). Сделано это не для наглядности, а потому, что первая редакция писала просто n++, и один из прогонов дал ровно 80 000: потери не случилось. Это не опровержение гонки, а её главное свойство — она проявляется не всегда. Именно поэтому такую ошибку не находят тестами: код «работает», пока не изменится нагрузка, версия Go или число ядер.

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

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования «что не так с n++ из горутин». Дальше — про то, чем именно закрывают этот разрыв: сначала мьютекс, потом блокировка чтения-записи, атомарные операции, однократное выполнение и, в самом конце, цена, которую платят не планировщику, а железу.

Механизм 1: гарантии даёт не замок, а happens-before

Начать стоит не с инструмента, а с того, откуда вообще берётся слово «безопасно». Оно не из слова «замок», а из модели памяти:

For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns.

перевод

Для любой переменной sync.Mutex или sync.RWMutex l и n < m вызов n метода l.Unlock() синхронизирован до возврата из вызова m метода l.Lock().

The Go Memory Model
контракт языкаМодель памяти Go. Всё, что ниже про наносекунды, — отдельный сорт утверждений, и он начнётся на Механизме 3.

Отсюда практическое следствие, которое стоит назвать вслух: мьютекс защищает не переменную, а инвариант. Разница не словесная.

«Защищает переменную» подсказывает, что достаточно взять замок вокруг каждого обращения к полю. Этого мало: если у структуры два поля, которые обязаны быть согласованы — длина и содержимое, баланс и журнал, — то под замком должна находиться вся операция, которая переводит их из одного согласованного состояния в другое.

GO
// защищает переменную — и не работает
mu.Lock(); n := len(items); mu.Unlock()
mu.Lock(); v := items[n-1]; mu.Unlock()   // между вызовами items уже другой
 
// защищает инвариант
mu.Lock()
v := items[len(items)-1]
mu.Unlock()

И вторая половина того же: если хоть одна горутина читает поле мимо замка, гарантии нет вовсе — компилятор и процессор вправе переупорядочить что угодно, и -race это найдёт.

Какое ребро создаёт каждый примитив

Дальше в уроке пять разных инструментов, и все они делают одно: создают ребро happens-before между двумя точками в разных горутинах. Отличаются они только тем, между какими.

примитивребро: что «до»что «после»
MutexUnlock()возврат из следующего Lock()
RWMutexUnlock()возврат из следующего RLock()
atomicзаписьчтение, увидевшее эту запись
Onceзавершение f()возврат из любого Do(f)
WaitGroupвызовы Done()возврат из Wait()
каналотправказавершение приёма

Эта таблица — и есть весь урок в свёрнутом виде. Всё остальное объясняет, чем эти рёбра отличаются по цене и по тому, что именно ими можно защитить.

Механизм 2: atomic — это не «быстрый мьютекс»

Разница не в скорости, а в том, что именно защищается.

Атомарная операция защищает одну ячейку и делает с ней одно неделимое действие: прибавить, обменять, сравнить-и-обменять. Ни двух полей, ни инварианта между ними она не покрывает.

Мьютекс защищает участок кода — сколько угодно полей и любую логику между ними.

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

Отсюда правило выбора, и оно не про производительность: atomic годится ровно тогда, когда защищаемое состояние — одно машинное слово. Счётчик, флаг, указатель на неизменяемый снимок. Как только появляется «прочитать А, решить, записать Б» — это мьютекс, и никакая атомарность его не заменит.

Сам пакет от себя отговаривает:

These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package.

перевод

Эти функции требуют большой осторожности при использовании. За исключением особых низкоуровневых применений, синхронизацию лучше делать каналами или средствами пакета sync.

Пакет sync/atomic

Отдельно про форму записи: с Go 1.19 есть типыatomic.Int64, atomic.Bool, atomic.Pointer[T]. Их стоит предпочитать функциям (atomic.AddInt64(&x, 1)) по двум причинам: тип нельзя случайно прочитать неатомарно, и он сам решает проблему выравнивания на 32-битных платформах, где int64 требует 8-байтового выравнивания.

Механизм 3: «что быстрее» — неполный вопрос

Вот замер, ради которого урок и написан.

Без конкуренции мьютекс дороже atomic вдвое: 13,35 нс против 6,49. Это честное число, и на нём обычно всё и заканчивается.

Но спрашивают-то про конкуренцию. Один счётчик, разное число горутин:

горутинмьютексatomicкратность
222,45 нс24,01 нс0,93
494,7826,003,64
8135,2726,125,18
16215,4224,788,69
рост×9,6×1,0
наблюдение замераbench/gosync, go1.24.7 linux/amd64, два ядра. Числа сняты на одной машине и одной версии Go; устойчиво направление обеих колонок — мьютекс дорожает с конкуренцией, атомарная операция почти нет, — а не сами кратности.

Смотреть надо не на отдельные ячейки — они шумят, — а на последнюю строку. Мьютекс с ростом конкуренции дорожает почти на порядок, атомарная операция почти не дорожает. Причина в парковке: мьютекс снимает проигравшую горутину с процессора, и цена ожидания входит в замер; атомарная операция никого не паркует, а повторяет попытку.

Чего это не значит. Не значит, что atomic бесплатен под нагрузкой: ячейка одна на всех, и кеш-линия под ней ходит между ядрами. Просто цена платится ожиданием шины, а не планировщиком.

И главный практический вывод: если за одну ячейку дерутся шестнадцать горутин, дело уже не в примитиве. Дешевле всего убрать саму конкуренцию — счётчик на горутину со сложением в конце, шардирование по ключу, накопление локально. Замена мьютекса на atomic — это оптимизация в пределах порядка; устранение конкуренции — в пределах двух.

И граница у этих чисел названа тут же: они сняты на двух ядрах и одной версии Go. Переносить отсюда нужно не «9,6» и не «8,7», а причину: мьютекс платит парковкой проигравшего, атомарная операция — согласованием между ядрами. Своё число, если оно нужно для решения, снимают на своей нагрузке и своём железе, а не берут из чужой таблицы.

Механизм 4: RWMutex не бесплатная замена Mutex

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

Выбор между Mutex и RWMutex определяют три величины, и ни одна из них не «сколько читателей в коде»:

величиначто значитв чью пользу
длина секциисколько времени проводят под замкомдлинная — за RWMutex
доля чтенийкакая часть входов только читаетвысокая — за RWMutex
состязаниесколько горутин рвутся к замку одновременносильное — за RWMutex

Ловушка в том, что RLock дороже Lock: он считает читателей и проверяет, не ждёт ли писатель. Эта надбавка платится всегда, а выигрыш от параллельных читателей появляется, только когда есть что параллелить, — то есть когда секция достаточно длинная.

наблюдение замераbench/gosync, go1.24.7 linux/amd64, два ядра. На двухъядерной машине состязание слабое, поэтому колонка RWMutex здесь в невыгодных условиях: устойчив перелом, а не его место.
работа под замкомRWMutexMutexкто быстрее
пусто98,78 нс24,01 нсMutex
10 итераций52,8824,57Mutex
100 итераций164,64113,40Mutex
1000 итераций671,011463,55RWMutex

Причина простая: RLock делает больше работы, чем Lock — считает читателей и проверяет, не ждёт ли писатель. На короткой секции эта разница и есть всё время операции, а параллельность читателей окупить её не успевает. Выигрыш появляется только тогда, когда под замком проводят достаточно времени.

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

Про RWMutex стоит знать ещё две вещи:

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

Механизм 5: Once, WaitGroup и то, что копировать нельзя

sync.Once после первого вызова бесплатен. Замерено: 1,87 нс против 1,92 у своей атомарной проверки и 2,23 у «не делать ничего вовсе» — все три в пределах шума друг от друга. То есть заменять Once ручным флагом ради скорости нечем, а ошибиться в ручном варианте легко: между проверкой и установкой флага успевает вклиниться вторая горутина.

И Once даёт то, чего ручной флаг не даёт сам по себе:

For any call to once.Do(f), f is synchronized before the return from any call of once.Do(f).

перевод

Для любого вызова once.Do(f) функция f синхронизирована до возврата из любого вызова once.Do(f).

The Go Memory Model

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

WaitGroup: два правила. Add вызывается до запуска горутины, а не внутри неё — иначе Wait может успеть раньше. И счётчик не должен уходить в минус: лишний Done — это паника.

И то, что копировать нельзя. У Mutex, RWMutex, Once и WaitGroup в документации стоит одна и та же фраза:

A Mutex must not be copied after first use.

перевод

Мьютекс нельзя копировать после первого использования.

Пакет sync

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

Ловится это go vet, проверкой copylocks, чьё описание переводится как «проверка на замки, ошибочно переданные по значению». Она входит в набор, который go test запускает по умолчанию, и сообщение выглядит как passes lock by value. Три самых частых места: приём структуры по значению, получатель-значение у метода и присваивание.

Глубже: строки кеша — почему параллельность не бесплатна

Есть ещё одна причина, по которой «просто взять atomic на каждое поле» не даёт ожидаемого ускорения, и она лежит ниже уровня языка — в устройстве железа.

Процессор работает не с байтами, а со строками кеша — блоками по 64 байта. Если два счётчика, которые обновляют разные ядра, попали в одну строку, каждая запись одного аннулирует строку у другого, и она перегоняется между ядрами:

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

Практически из этого нужно одно: atomic снимает блокировку, но не делает запись бесплатной. Ребро happens-before всё равно требует согласования между ядрами — просто через протокол кеш-когерентности, а не через планировщик.

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

Как отвечать на собеседовании

Короткий ответ: n++ — это три действия, а не одно: прочитать, прибавить, записать; две горутины успевают вклиниться друг другу между ними, и обновление теряется. Дальше выбор простой: мьютекс защищает целостность участка кода — сколько угодно полей и любую логику между ними, — а атомарная операция годится там, где состояние ограничено и меняется одним неделимым действием: счётчик, флаг, указатель. И то и другое даёт не скорость, а гарантию: что одна горутина увидит работу другой целиком, а не наполовину.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «что не так с n++» отвечайте тремя операциями. «Чтение, сложение, запись; два обновления сливаются в одно, и итог меньше ожидаемого — у меня восемь горутин по 10 000 давали меньше 80 000».

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

На «что быстрее» задайте встречный вопрос. «При какой конкуренции? Без неё мьютекс дороже вдвое; при шестнадцати горутинах на один счётчик — почти на порядок, потому что паркует проигравшего. И если конкуренция такая, лечить надо её, а не примитив».

Про RWMutex скажите про длину секции. «RLock дороже Lock, и на короткой секции RWMutex проигрывает; выигрыш появляется, когда под замком проводят достаточно времени».

Про Once назовите гарантию, а не цену. «Он даёт happens-before: увидевший флаг видит и результат инициализации. Быстрый путь у него и так бесплатный».

Про копирование назовите инструмент. «Копия мьютекса приезжает свободной; ловит go vet проверкой copylocks, она уже в go test».

Дальше спросят

Спросят дальше

Что такое sync.Map и когда он нужен?

Короткий ответ

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

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

Спросят дальше

Зачем sync.Pool, если есть сборщик мусора?

Короткий ответ

Чтобы переиспользовать дорогие в создании объекты — буферы, срезы, парсеры — и снять с аллокатора часть работы. Классическое применение — bytes.Buffer в обработчике HTTP.

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

Спросят дальше

Чем atomic.Value отличается от atomic.Pointer?

Короткий ответ

atomic.Value появился раньше и хранит any, поэтому проверяет тип в рантайме: положить в него значения разных типов — паника. atomic.Pointer[T] появился в Go 1.19, типизирован и проверяется компилятором.

Практически для новой публикации снимка конфигурации сегодня берут atomic.Pointer[Config]: он и безопаснее, и не требует упаковки значения в интерфейс. atomic.Value остаётся в старом коде и там, где тип действительно неизвестен на этапе компиляции.

Спросят дальше

Мьютекс справедливый? Кто получит замок следующим?

Короткий ответ

У sync.Mutex два режима. В обычном захватывает тот, кому повезло, — и это быстро, потому что уже работающая на процессоре горутина не платит за переключение. Но если горутина ждёт дольше миллисекунды, мьютекс переходит в режим голодания: замок передаётся напрямую первому в очереди, и новые претенденты в очередь встают, а не пытаются перехватить.

Отсюда практическое: строить логику на порядке захвата нельзя, но и бесконечного голодания бояться не стоит — рантайм его лечит сам. И граница: два режима и порог в миллисекунду — устройство текущей реализации sync.Mutex (проверено на go1.24.7), а не обещание языка; язык порядка захвата не гарантирует вовсе, и именно поэтому на нём ничего не строят.

Спросят дальше

Как правильно ограничить число одновременных операций?

Короткий ответ

Буферизованным каналом как семафором — это идиома:

GO
sem := make(chan struct{}, 10)
for _, job := range jobs {
    sem <- struct{}{}
    go func(j Job) {
        defer func() { <-sem }()
        handle(j)
    }(job)
}

Ёмкость канала и есть предел одновременности. Альтернатива — golang.org/x/sync/semaphore с весами, если операции разной «тяжести», или errgroup.SetLimit, если нужен ещё и сбор первой ошибки. Чего делать не стоит — крутить свой счётчик на atomic: правильно дождаться освобождения им не выйдет.

Спросят дальше

Что произойдёт при повторном Unlock?

Короткий ответ

Паника: sync: unlock of unlocked mutex. Мьютекс в Go не считает уровни вложенности и не запоминает владельца — он не рекурсивный.

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

Частые заблуждения

Утверждение

n++ атомарен: это же одна операция в коде

На самом деле

Это три операции: прочитать, прибавить, записать. Два обновления сливаются в одно — прогон даёт меньше 80 000 там, где ожидалось ровно столько. Одна строка в исходнике ничего не говорит о неделимости.

Утверждение

atomic — это просто быстрый мьютекс

На самом деле

Разница не в скорости, а в объёме защиты: атомарная операция покрывает одну ячейку и одно действие, мьютекс — участок кода любой длины. «Прочитать А, решить, записать Б» атомарностью не заменяется никак.

Утверждение

мьютекс дороже atomic вдвое

На самом деле

Это верно только без конкуренции: 13,35 нс против 6,49. С ростом числа претендентов с 2 до 16 мьютекс дорожает в 9,6 раза, а atomic — в 1,0, и кратность доходит до 8,7. Вопрос «что быстрее» без «при какой конкуренции» ответа не имеет.

Утверждение

atomic не имеет цены при конкуренции

На самом деле

Имеет, просто другую. Горутину она не паркует, но ячейка одна на всех, и кеш-линия под ней ходит между ядрами. Если за одну ячейку дерутся шестнадцать горутин, лечить надо конкуренцию — счётчик на горутину, шардирование, — а не примитив.

Утверждение

много чтений — значит RWMutex

На самом деле

На короткой секции RWMutex проигрывает: RLock делает больше работы, чем Lock. Замерено: при пустой секции 98,78 нс против 24,01. Выигрыш появляется только на длинной — 671,01 против 1463,55.

Утверждение

sync.Once дорогой, лучше свой флаг

На самом деле

После первого вызова он бесплатен: 1,87 нс — в пределах шума от «ничего не делать». А главное, он даёт happens-before: увидевший флаг гарантированно видит результат инициализации. Ручной флаг без атомарных операций такой гарантии не даёт, и это ошибка корректности, а не скорости.

Утверждение

структуру с мьютексом можно передать по значению

На самом деле

Копия не наследует состояния: копия заблокированного мьютекса приезжает свободной, и защита исчезает молча. В документации у Mutex, RWMutex, Once и WaitGroup стоит одна фраза — копировать после первого использования нельзя. Ловит go vet проверкой copylocks.

Утверждение

мьютекс защищает переменную

На самом деле

Он защищает участок кода, а связь с переменной существует только в голове автора. Если хоть одна горутина читает поле мимо замка — гарантий нет, и модель памяти это формулирует через happens-before, а не через «переменная под защитой».

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Потеряет ли обновления обычный инкремент, сколько даст атомарный, сколько раз выполнится функция под sync.Once и что станет с копией структуры, у которой внутри мьютекс?
fmt.Println(unsynced())
fmt.Println(syncedAtomic())
fmt.Println(onceCount())
a, b := copiedStruct()
fmt.Println(a, b)

Практика · оцените

Один счётчик, без конкуренции. Во сколько раз mu.Lock/Unlock с инкрементом дороже атомарного Add?
раза

Проверка знаний

Вопрос 1 из 6

Восемь горутин делают по 10 000 инкрементов обычной переменной int. Что окажется в ней в конце?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. The Go Memory Model — SynchronizationОфициальная документация. Основание, на котором стоит вся тема: гарантии дают не «блокировки», а отношения happens-before. Про мьютекс: «For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns» (Для любой переменной sync.Mutex или sync.RWMutex l и n < m вызов n метода l.Unlock() синхронизирован до возврата из вызова m метода l.Lock()). Про atomic: «The APIs in the sync/atomic package … behave like a sequentially consistent atomic operation» (Интерфейсы пакета sync/atomic … ведут себя как последовательно согласованные атомарные операции). Про Once: «For any call to once.Do(f), f is synchronized before the return from any call of once.Do(f)» (Для любого вызова once.Do(f) функция f синхронизирована до возврата из любого вызова once.Do(f)).https://go.dev/ref/mem
  2. Пакет sync — Mutex, RWMutex, Once, WaitGroupОфициальная документация. Правило, из которого растёт половина ошибок: «A Mutex must not be copied after first use» (Мьютекс нельзя копировать после первого использования) — та же фраза стоит у RWMutex, Once и WaitGroup. Про RWMutex сказано и то, что его блокировка не рекурсивна: «If any goroutine calls Lock while the lock is already held by one or more readers, concurrent calls to RLock will block until the writer has acquired (and released) the lock» (Если какая-либо горутина вызывает Lock, пока замок уже удерживается одним или несколькими читателями, параллельные вызовы RLock будут блокироваться, пока писатель не захватит (и не освободит) замок).https://pkg.go.dev/sync
  3. Пакет sync/atomicОфициальная документация. Оговорка, которую стоит уметь процитировать: «These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package» (Эти функции требуют большой осторожности при использовании. За исключением особых низкоуровневых применений, синхронизацию лучше делать каналами или средствами пакета sync). И про типы вместо функций: «The swap operation, implemented by the SwapT functions, is the atomic equivalent of: old = *addr; *addr = new; return old» (Операция обмена, реализуемая функциями SwapT, — это атомарный эквивалент: old = *addr; *addr = new; return old).https://pkg.go.dev/sync/atomic
  4. go vet — проверка copylocksОфициальная документация. Инструмент, который ловит самую тихую ошибку темы. Описание проверки: «check for locks erroneously passed by value» (проверка на замки, ошибочно переданные по значению). Проверка входит в набор, запускаемый `go test` по умолчанию, — то есть у большинства она уже работает и её сообщение `passes lock by value` стоит уметь прочитать.https://pkg.go.dev/cmd/vet