Concurrency, Synchronization and Consistency. Пост № 22. Пример False Sharing в многопоточной программе.
В последних постах было много текста и сложных материй. И не было примеров кода. Продемонстрировать RWMutex bottleneck довольно сложно(нужен процессор с большим количеством ядер, чем больше тем лучше). Но у меня все равно появилась идея как продемонстрировать False Sharing в Concurrency.
🔵 Show me code
Представим что у нас есть программа с atomic переменной, которую мы хотим увеличивать.
func worker(v *atomic. Int32, wg *sync. WaitGroup) {
for i := 0; i < 100000000; i++ {
v. Add(1)
}
if wg != nil {
wg. Done()
}
}
Напишем две версии программы.
- последовательная, в одной горутине работаем по очереди с каждым атомиком.
- параллельная, каждой горутине выдаем свой атомик.
🔵 Последовательная версия
var a, b, c, d, e atomic. Int32
list := []*atomic. Int32{&a, &b, &c, &d, &e}
for _, atom := range list {
worker(atom, nil)
}
Статистика по времени работы (time ./main)
0.84s user
0.01s system
99% cpu
0.851 total
Итог: Загрузили одно ядро на 1 секунду. Ничего особенного.
🔵 Параллельная версия
var a, b, c, d, e atomic. Int32
list := []*atomic. Int32{&a, &b, &c, &d, &e}
var wg sync. WaitGroup
wg. Add(5)
for _, atom := range list {
go func(atom *atomic. Int32) {
worker(atom, &wg)
}(atom)
}
wg. Wait()
Статистика
35.66s user
0.08s system
470% cpu
7.588 total
Итог: 5 ядер загрузили и при этом программа работала 7+ СЕКУНД. Не такого результата мы конечно ожидали.
🔵 Что же такое этот False Sharing?
Запустим код:
var a, b, c, d, e atomic. Int32
fmt. Printf("address of a: %p\n", &a)
fmt. Printf("address of b: %p\n", &b)
fmt. Printf("address of c: %p\n", &c)
fmt. Printf("address of d: %p\n", &d)
fmt. Printf("address of e: %p\n", &e)
Вывод:
address of a: 0x140000100a0
address of b: 0x140000100a4
address of c: 0x140000100a8
address of d: 0x140000100ac
address of e: 0x140000100b0
Видим что адреса переменных находятся рядом, в 4 байтах друг от друга. И это причина по которой многопоточная версия работает хуже.
Несмотря на то что у нас каждой горутине выделена своя переменная и в коде они не зависят друг от друга у нас есть есть точка синхронизации - кеши CPU. Каждое ядро кеширует не только значение своей переменной, но и значения остальных переменных - потому что кеширование осуществляется блоками, обычно по 64 Bytes. Их называют кеш-линиями. Отсюда и неявные доп. расходы на синхронизацию (хотя в коде у нас нет ничего подобного) и как следствие увеличение времени работы.
🔵 Как починить False Sharing?
Чтобы горутины работали независимо,нужно чтобы в одну кеш линию помещалась только одна переменная. Обернем в структуру и добавим ей байт до размера кеш линии (техника называется padding).
type PaddedInt32 struct {
value atomic. Int32
_ [60]byte
}
func worker_padded(v *PaddedInt32, wg *sync. WaitGroup) {
for i := 0; i < 100000000; i++ {
v.value. Add(1)
}
if wg != nil {
wg. Done()
}
}
func main() {
var a, b, c, d, e PaddedInt32
list := []*PaddedInt32{&a, &b, &c, &d, &e}
var wg sync. WaitGroup
wg. Add(5)
for _, atom := range list {
go func(atom *PaddedInt32) {
worker_padded(atom, &wg)
}(atom)
}
wg. Wait()
}
Статистика:
1.23s user
0.01s system
447% cpu
0.275 total
🔵 Выводы
Мы смогли добиться эффективного параллелизма, но заплатили цену - явное "подстраивание" кода программы под железо. В обычной разработке мы редко прибегаем к подобным трюкам. Нам это попросту не нужно, так как наши программы чаще всего io bound. Да и не в каждом продукте достаточная нагрузка.
А вот для больших компаний подобные вещи - экономия сотен тысяч долларов. И если зарплата программиста становится менее значимым фактором чем затраты на инфру внесение вот таких изменений в программу и глубокое профилирование становится оправданным шагом. Для разработчиков СУБД, языков программирования, системных программистов такие трюки это часть жизни.
Спасибо что читали, буду рад вашим реакциям и комментариям!
📖