Concurrency на примерах. Собственная реализация Mutex на Go + сравнение с sync.Mutex. Часть 1
- четверг, 16 октября 2025 г. в 00:00:07
Всем привет!
Сегодня хочу поделиться с вами заметкой о своем опыте написания с нуля примитивов синхронизации на чистом Go, совместимых c реализациями из стандартной библиотеки.
Цель заметки - на понятных примерах посмотреть как работает под капотом то чем мы пользуемся регулярно как разработчики, а также разобраться с популярными проблемами возникающими при написании многопоточных программ.
Самым популярным примитивом синхронизации как в Go так и в других ЯП является Mutex.
Мью́текс (англ. mutex, от mutual exclusion — «взаимное исключение») — примитив синхронизации, обеспечивающий взаимное исключение исполнения критических участков кода. (Википедия)
Как реализовать? Берем пакет atomic и пишем буквально 15 строчек
type SimpleMutex struct {
v atomic.Int32
}
func (m *SimpleMutex) Lock() {
for {
if m.v.CompareAndSwap(0, 1) {
return
}
}
}
func (m *SimpleMutex) Unlock() {
m.v.Store(0)
}
Пакет atomic в Go и его аналоги в других ЯП - фундамент на котором реализуются concurrency примитивы. Без него невозможно реализовывать детерминированные и конкуррентные программы, поэтому мы тоже им воспользуемся. Ссылки на доп. материалы оставлю в конце статьи.
Протестируем реализацию на простой задачке - счетчик с которым конкурентно работают N горутин:
// реализация планируется не одна,
// поэтому я заранее описал интерфейсы для примитивов
type MutexI interface {
Lock()
Unlock()
}
type WaitGroupI interface {
Add(delta int)
Wait()
Done()
}
func cpuTask(mu MutexI, wg WaitGroupI, goroutines int, iterations int) int {
var count int
wg.Add(goroutines)
for i := range goroutines {
go func(id int) {
for range iterations {
mu.Lock()
count++
mu.Unlock()
}
wg.Done()
}(i)
}
wg.Wait()
return count
}
func main() {
goroutines := 1000
iterations := 10000
count := cpuTask(&SpinMutex{}, &sync.WaitGroup{}, goroutines, iterations)
fmt.Println("GOMAXPROCS=", runtime.GOMAXPROCS(0), "passed:", count == goroutines*iterations)
}
Мьютекс работает корректно, и гарантирует взаимное исключение, а что по перформансу?
go build main.go
time GOMAXPROCS=1 ./main
Отчетливо видна тенденция, программа работает медленнее с добавлением новых ресурсов. Выглядит как провал. Давайте разбираться в причинах.
На самом деле код выше это не совем мьютекс. Наш примитив синхронизации ближе к тому что назывется спинлок - блокировка с активным ожиданием доступа к критическому участку кода. Такой примитив синхронизации очень полезен в случаях когда мы отчетливо знаем что блокировка короткая и усыплять горутину / поток надолго задействуя ОС неэффективно.
Почему наш код c добавлением ресурсов работае только хуже? Все дело в параллелизме. С увеличением GOMAXPROCS горутины захватывают все больше ядер процессора и вхолостую расходуют ресурс процессора. Времени на синхронизацию требуется больше чем на основную логику программы. И это классическая проблема concurrency - ресурсное голодание (starvation).
Основная ошибка нашей реализации - горутины очень долго держат CPU вместо того чтобы его отпустить его. Чтобы это поправить можно вставить time.Sleep в нашу реализацию и точно станет полегче (имитацию усыпления потока). Будет работать корректно, но главный минус этого решения - выбор на сколько нам засыпать. И пропорционально этому значению наша программа будет медленнее или быстрее. Поэтому мы воспользуемся runtime.Gosched() и будем делегировать планировщику эту задачу.
Итого: пробуем захватить мьютекс, в случае неудачи даем сигнал планировщику усыпить горутину.
type MutexWithPause struct {
v atomic.Int32
}
func (m *MutexWithPause) Lock() {
for {
if m.v.CompareAndSwap(0, 1) {
return
}
runtime.Gosched()
}
}
func (m *MutexWithPause) Unlock() {
m.v.Store(0)
}
И снова запустим нашу программу со счетчиком.
Видим что ресурсное голодание улетучилось и программа работает лучше.
Попробуем оценить насколько быстрее или медленнее наша реализация по сравнению с sync.Mutex из стандартной библиотеки.
Программа с самописным мьютексом работает быстрее чем со стандартным. В чем причина? Sync.Mutex в отличии от нашей реализации взаимодействует с примитивами операционной системы и можно увидеть рост system time с ростом GOMAXPROCS.
Выглядит так что самое время делать contribution в гошку, или нет? :)
А что если добавить в наш код паузу? Изменится ли результат или останется прежним? Для ускорения тестирования уменьшу количество горутин и итераций.
Горутин - 100
Итераций - 100
Итого: Эмулируем через time.Sleep I/O профиль нагрузки.
func ioTask(mu MutexI, wg WaitGroupI, goroutines int, iterations int) int {
var count int
wg.Add(goroutines)
for i := range goroutines {
go func(id int) {
for range iterations {
mu.Lock()
count++
time.Sleep(time.Millisecond) // Единственное отличие - эмулируем IO через принудительную паузу
mu.Unlock()
}
wg.Done()
}(i)
}
wg.Wait()
return count
}
Результаты:
Видим что результат с точки зрения времени примерно одинаковый. При этом отчетливо видно - наши горутины беспощадно жгут ядра процессора попытке захватить Mutex. Об эффективности нашей реализации не может быть и речи. sync.Mutex одержал безоговорочную победу в этом раунде.
Несмотря на то что в заметке есть тест в котором четко видно, что велосипед победил стандартную реализацию не стоит делать вывод о том что такой подход можно без оглядки и последствий затащить в production. Как мы увидели, со сменой профиля нагрузки эффективность нашей программы очень снизилась. В этом и есть магия реализации стандартных библиотек - она работает нормально при любой погоде :). В следующей части попробуем избавиться от недостатков нашей самопальной реализации, которые проявились на IO задаче.
Важно отметить что подобный код (спинлок) вполне реально встретить в production, особенно в программах с четким профилем - вычисления. В программах числодробилках паузы ОС на переключение между потоками слишком дорогие и оказывают влияние на скорость работы. И как следствие. несколько раз покрутиться в цикле не засыпая, чтобы дождаться своей очереди уже не выглядит плохой идеей.
Например в POSIX есть отдельная реализация спинлоков и ее можно использовать не велосипедируя собственную.
Если вам интересна тема конкурентноого программирования я приглашаю вас в свой Telegram. В прошлом году сделал большой цикл постов о том как concurrency работает на всех уровнях, начиная с железа и заканчивая виртуальными потоками языков программирования - CPU, Memory Models, Concurrency, Multiprocess, Multithreading и Async
В этом году я пишу продолжение - Concurrency, Synchronization and Consistency - о том как писать корректные и детерминированные конкурентные программы.
Большое спасибо что дочитали до конца, делитесь обратной связью в комментариях.