Спустя время глядя на цикл постов понял что в нем не хватило отдельного саммари c выводами о том как добавление синхронизации и согласованности влияет на производительность. Концептуальным проблемам мы посвятили достаточно времени, а перформансу можно было и побольше.
И в этот момент мне совершенно случайно попалась статья, в которой автор буквально делает тоже самое, что и я в цикле постов - препарирует шаг за шагом механизмы синхронизации и демонстрирует бенчмарки. Она и подтолкнула меня к написанию бонус-поста.
Задача - безопасно, согласованно и максимально быстро работать из нескольких потоков c переменной uint64, как со счетчиком. Бенчмарки собрать с 4 разных процессоров.
Bench №1 - Блокирующая синхронизация / атомики.
Самая наивная из возможных реализаций - использовать Mutex. Бенчмарки говорят - стоимость одной операции += для программы из 2х потоков - 125 наносекунд. И чем больше потоков тем дороже операция. Подобное поведение мы уже рассматривали в посте про нелинейную масштабируемость мьютексов.
Можно ли сделать лучше? Да, например избавиться от мьютекса и взять:
- атомарный uint64 и явно использовать Compare and Swap.
- атомарный uint64 и довериться компилятору.
Получилось неплохо, из интересного - CAS оказался на том же уровне что и мьютекс.
Bench №2 - Бутылочное горлышко системных вызовов
Автор реализует
Ticket lock используя atomic + sched_yield(2) syscall. С помощью аналогичной связки я писал свой мьютекс на Go. Получается еще хуже чем в первом бенче, оно и понятно почему - больше взаимодействия с ОС. В настоящем мьютексе сделано поинтереснее, рассказывал об этом здесь и в статье про самописный mutex.Bench №3 - Неявное переключение контекста
Автор идет дальше и предлагает 2 варианта как бы еще подступиться:
- condvar которая бы позволила засыпать и пробуждать все ожидающие потоки
- честная блокировка с очередью потоков. Почему называется честной - потому что ресурс передается строго в порядке в котором на него претендовали потоки.
Первый вариант демонстрирует проблему
thundering herd, так как мы пробуждем все потоки, а захватит ресурс только один, а остальные сожгут CPU и снова уснут. Можно былои не будить вовсе.Второй справился лучше. Ведь мы решили проблему
thundering herd. Но всё равно никуда не годится, так как у этой реализации другая проблема - lock convoy.Bench №4 - 0 переключений контекста (без учета прерываний ОС)
Что если
sched_yield(2) заменить на ; и как следствие не отпускать ядро до момента пока у потока его не заберет ОС? Катастрофа и переход от наносекунд к микросекундам.Что мы видим? С каждым бенчем все хуже и хуже. Ощущение, что лучше чем стандартный атомик сделать невозможно. Но тут автор явно говорит - хватить демонстрировать проблемы, давайте попробуем сделать что-то реально классное. И сделал.
Bench №5 - Шардируемый счетчик
Если наша цель выжать максимум из рантайма нам нужно явно избегать конкуренции между ядрами за какие либо ресурсы. И чтобы этого добиться нужно явно код программы адаптировать к нашему железу. Автор продемонстрировал реализацию счетчика которая побеждает все предыдущие бенчмарки.
-----
Если подытожить - на перформанс корректного многопоточного приложения посягают:
- мьютексы со своей нелинейной масштабируемостью по количеству ядер;
- атомики и ассемблер. CAS и FAA инструкции;
- системные вызовы к ОС;
- явные и неявные переключения контекста.
- потребность иметь в программе строгий порядок и честность (lock convoy problem).
- наличие логики создающей ситуацию когда потоки циклически пытаются захватить ресурс, но чаще терпят неудачу (thundering herd problem).
И всего этого можно избежать заплатив цену. Что автор и продемонстрировал. Предлагаю вам взглянуть на код в конце статьи и ответить в комментариях - затащили ли бы вы подобное в production?