Concurrency на примерах. Собственная реализация Mutex на Go + сравнение с sync.Mutex

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всем привет!

Сегодня хочу поделиться с вами заметкой о своем опыте написания с нуля примитивов синхронизации на чистом Go, совместимых c реализациями из стандартной библиотеки.

Цель заметки - на понятных примерах посмотреть как работает под капотом то чем мы пользуемся регулярно как разработчики, а также разобраться с популярными проблемами возникающими при написании многопоточных программ.

Введение. Пишем собственный Mutex

Самым популярным примитивом синхронизации как в 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)
}

Мьютекс работает корректно, и гарантирует взаимное исключение, а что по перформансу? Здесь все довольно печально.

Время исполнения программы при разных значения GOMAXPROCS
Время исполнения программы при разных значения GOMAXPROCS
Как собрать такие же цифры со своей программы?

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)
}

И снова запустим нашу программу со счетчиком.

Результаты запуска программы с Gosched
Результаты запуска программы с Gosched

Видим что ресурсное голодание улетучилось и программа работает лучше.

Битва "велосипеда" и эталонной реализации

Попробуем оценить насколько быстрее или медленнее наша реализация по сравнению с sync.Mutex из стандартной библиотеки.

Sync.Mutex
Sync.Mutex
Gosched
Gosched

Программа с самописным мьютексом работает быстрее чем со стандартным. В чем причина? Sync.Mutex в отличии от нашей реализации взаимодействует с примитивами операционной системы и можно увидеть рост system time с ростом GOMAXPROCS.

Выглядит так что самое время делать contribution в гошку, или нет? :)

Раунд 2. IO задача

Предыдущий пример был примером классической CPU задачи. Мы только прибавляли единички к переменной, что является довольно быстрой операцией. А что если добавить в наш код паузу? Изменится ли результат или останется прежним? Для ускорения тестирования уменьшу количество горутин и итераций.

  • Горутин - 100

  • Итераций - 100

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.Nanosecond) // Единственное отличие - эмулируем IO через принудительную паузу

				mu.Unlock()
			}
			wg.Done()
		}(i)
	}

	wg.Wait()

	return count
}

Результаты:

Gosched
Gosched
Sync Mutex
Sync Mutex

Видим что результат с точки зрения времени примерно одинаковый. При этом отчетливо видно - наша реализация беспощадно жжет ядра процессора в попытке захватить Mutex и является неэффективной с точки зрения ресурсов.

Выводы

Несмотря на то что в заметке есть тест в котором четко видно, что велосипед победил стандартную реализацию не стоит делать вывод о том что такой подход можно без последствий затащить в production. Как мы увидели, со сменой профиля нагрузки эффективность нашей программы очень снизилась. В этом и есть магия реализации стандартных библиотек - она работает нормально при любой погоде :).

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

Например в POSIX есть отдельная реализация спинлоков и ее можно использовать не велосипедируя собственную.


Если вам интересна тема конкурентноого программирования я приглашаю вас в свой Telegram. В прошлом году сделал большой цикл постов о том как concurrency работает на всех уровнях, начиная с железа и заканчивая виртуальными потоками языков программирования - CPU, Memory Models, Concurrency, Multiprocess, Multithreading и Async

В этом году я пишу продолжение - Concurrency, Synchronization and Consistency - о том как писать корректные и детерминированные конкурентные программы.

Большое спасибо что дочитали до конца, делитесь обратной связью в комментариях.

Источник: https://habr.com/ru/articles/956690/


Интересные статьи

Интересные статьи

Привет! Меня зовут Сергей Востриков, я руковожу направлением Маркет и интеграций в Битрикс. Моя команда развивает решения для разработчиков тиражных решений и индивидуальных кастомизаций. Сегодня я хо...
Привет, Хабр!Сегодня мы будем строить сервис для автоматической обработки сделок в Bitrix24, используя Flask и Node.js.
Я разработчик, в то время работал в начальником отдела разработки в небольшой "компании одного продукта", пилил что-то типа ERP, посвященной ремонту больших парков грузовиков-бульдозеров-экскаваторов,...
В наше время многие компании используют различные сервисы для связи с клиентами и партнерами. Один из самых популярных мессенджеров - WhatsApp, стал незаменимым инструментом коммуникации в бизнесе. Од...
Не знаю как вам, но для меня нет лучшего начала дня, чем потрепаться о программировании. Кровь кипит при виде удачной критики одного из "жирных" языков, которым пользуются плебеи, мучаясь с ним н...