Недавно "орлиный глаз" (то есть я) заметил, что в std::atomic у методов есть wait/notify методы.
Я думал, что std::atomic это сугубо user-space примитивы синхронизации, но согласно cppref (да и быстрым тестам) - оказалось, что wait стопает поток. Как указано в cppref - реализация (при наличии поддержки в платформе) предполагает использование futex-ов.
Futex - это специальный набор системных вызовов, которые, упрощённо, работают поверх хеш-таблицы с ключами в виде адресов памяти, а хранить могут очередь тредов, ждущих события.
Таким образом, можно реализовать критическую секцию (мьютекс), по такому алгоритму
используем atomic-flag, чтобы указать - взята блокировка или нет
если уже взята - уходим в wait события по адресу самого этого флага
при release делаем notify
В удачном случае (когда контеншена на самом деле нет) - нужно делать только 1 системный вызов (notify), что лучше чем posix-мьютексы, требующие двух системных вызовов (и на взятие лока и на release).
В общем, захотелось мне замерить какой вариант сколько стоит.
Замеряемые варианты
TBasicElem - pyaload без блокировки
struct TBasicElem { static constexpr std::string_view Name = "noLock"; uint8_t Data; void Lock() {} void UnLock() {} };
TMutexElem - std::mutex в качестве примитива синхронизации
struct TMutexElem { static constexpr std::string_view Name = "std::mutex"; std::mutex m; uint8_t Data; void Lock() {m.lock();} void UnLock() {m.unlock();} };
TMutexPtrElem - std::unique_ptrstd::mutex - простой и часто используемый способ сделать объект с мьютексом movable
struct TMutexPtrElem { static constexpr std::string_view Name = "std::unique_ptr<std::mutex>"; std::unique_ptr<std::mutex> m; uint8_t Data; TMutexPtrElem () { m = std::make_unique<std::mutex>(); } void Lock() {m->lock();} void UnLock() {m->unlock();} };
TAtomicFlagElem - std::atomic, горячий цикл попыток захватить блокировку
struct TAtomicFlagElem { static constexpr std::string_view Name = "std::atomic<bool>"; std::atomic<bool> flag = false; uint8_t Data; void Lock() { while(true) { bool expected = false; if (flag.compare_exchange_weak(expected, true, std::memory_order_acquire)) { break; } } } void UnLock() {flag.store(false, std::memory_order_release);} };
TAtomicFlagWaitNotify - std::atomic, но в случае неуспеха вызывается wait, на release всегда вызывается notify. В общем futex
struct TAtomicFlagWaitNotify { static constexpr std::string_view Name = "std::atomic<bool> wait + notify"; std::atomic<bool> flag = false; uint8_t Data; void Lock() { while(true) { bool expected = false; if (flag.compare_exchange_weak(expected, true, std::memory_order_acquire)) { break; } else { flag.wait(true, std::memory_order_acquire); } } } void UnLock() { flag.store(false, std::memory_order_release); flag.notify_all(); } };
TAtomicFlagPtrWaitNotify - тоже самое, только std::unique_ptr<std::atomic> - делаем объект movable
struct TAtomicFlagPtrWaitNotify { static constexpr std::string_view Name = "std::atomic<bool> ptr wait + notify"; std::unique_ptr<std::atomic<bool>> flag = std::make_unique<std::atomic<bool>>(false); uint8_t Data; void Lock() { while(true) { bool expected = false; if (flag->compare_exchange_weak(expected, true, std::memory_order_acquire)) { break; } else { flag->wait(true, std::memory_order_acquire); } } } void UnLock() { flag->store(false, std::memory_order_release); flag->notify_all(); } };
Как измеряем
Тестирующий код
template<class T> struct TDataHolder { using TElem = T; char* Data; TElem* EffectiveDataPtr = nullptr; TElem* EffectiveEndPtr = nullptr; ~TDataHolder() { size_t accum = 0; for(auto ptr = EffectiveDataPtr; ptr != EffectiveEndPtr; ++ptr) { accum += ptr->Data; } // just to be sure that modifications of Data are visible outside std::cout << "checksum " << accum << std::endl; delete[] Data; } TDataHolder(float mbs) { std::srand(2026); const size_t recommendedSizeBytes = mbs * 1024 * 1024 + AlignmentShift; Data = new char[recommendedSizeBytes]; EffectiveDataPtr = (decltype(EffectiveDataPtr))(size_t(Data) / AlignmentShift * AlignmentShift + AlignmentShift); EffectiveEndPtr = EffectiveDataPtr; while(size_t(EffectiveEndPtr + 1) < size_t(Data + recommendedSizeBytes)) { EffectiveEndPtr += 1; } assert(size_t(EffectiveDataPtr) / AlignmentShift * AlignmentShift == size_t(EffectiveDataPtr)); std::cout << "\n\ninit " << TElem::Name << std::endl; std::cout << "- sizeof " << sizeof(TElem) << std::endl; std::cout << "- alignof " << alignof(TElem) << std::endl; std::cout << "- pool size is " << (EffectiveEndPtr - EffectiveDataPtr) / 1024. / 1024 << "mb elems " << std::endl; std::cout << "- it is " << (size_t(EffectiveEndPtr) - size_t(EffectiveDataPtr)) / 1024. / 1024 << " mbs capacity " << std::endl; for(auto ptr = EffectiveDataPtr; ptr != EffectiveEndPtr; ++ptr) { int rand = std::rand(); new(ptr) TElem; ptr->Data = rand % std::numeric_limits<std::remove_reference_t<decltype(ptr->Data)>>::max(); } } size_t DoAction(size_t window, size_t shift, size_t subelems, std::string_view title, bool forward = true) { assert(EffectiveDataPtr + 1 == (TElem*)( size_t(EffectiveDataPtr) + sizeof(TElem))); while(WaitFlag.load()) {} size_t actionsDone = 0; auto actionsStarted = std::chrono::high_resolution_clock::now(); for(size_t index = shift; int64_t(index) < EffectiveEndPtr - EffectiveDataPtr; index += window) { for(size_t j = 0; j < subelems; ++j) { TElem& current = forward ? EffectiveDataPtr[index + j] : EffectiveDataPtr[(EffectiveEndPtr - EffectiveDataPtr) - index - j - 1]; current.Lock(); current.Data = current.Data * current.Data; current.UnLock(); actionsDone += 1; } } auto actionsFinished = std::chrono::high_resolution_clock::now(); { std::lock_guard g(PrintLock); std::cout << TElem::Name << ":" << title << "\n window=" << window << "(bytes=" << sizeof(TElem) * window << ")" << " shift=" << shift << " (bytes " << sizeof(TElem) * shift << ")" << " subelems=" << subelems << " (bytes " << sizeof(TElem) * subelems << ")" << "\n -- done " << actionsDone / 1024. / 1024 << "mb actions, in " << (actionsFinished - actionsStarted).count() / 1e6 << "mln ticks" << "\n -- actions cost is " << (actionsFinished - actionsStarted).count() / (actionsDone + 0.0) << " ticks" << std::endl; } return actionsDone; } };
Кратко опишу, что тут происходит:
создаём пул данных на 128мб, з��полненных "защищённым типом" - лок + payload(1 байт)
рабочая нагрузка состоит в том чтобы взять 1 такой элемент, заблочить, модифицировать payload, разлочить
и настройки итерирования - призванные мочь настроить разные аспекты контеншена и объёма работы в рамках одной кеш-линии
замеры делаются 3 раза:
в основном потоке, без всякого contention
два потока, первый делает тоже самое что в основном, а второй делает это "со смещением"
в замере на двух потоках фактически пересечения по взятым блокировкам нет (формально это саксесс ветки, но может быть скрытый контеншен из-за соседства данных)
есть два основных сценария:
а потрогать 1 элемент в кеш линии, а два конкурирующих потока работают с разными элементами одной кеш линии
б потрогать элементы в разных кеш линиях
и дополнительный сценарий:
потоки обрабатывают пул с разных сторон - первый с начала в конец, а второй с конца в начало
этот режим нужен для случаев, когда мы храним не элемент синхронизации, а указатель на него. Таким методом я хочу добиться того, что элементы аллокатором были выделены далеко друг от друга
Замерил я в нескольких окружениях (ноут, виртуалка и контейнер на голом железе), закономерности были общие. Убедился что нет каких-то смещений из-за окружения и дальше смотрел в основном на вариант с ноута.
Результаты
Дамп замеров
https://github.com/ilnurkh/blog/blob/main/test_locks/report.txt
init std::atomic<bool> ptr wait + notify - sizeof 16 - alignof 8 - pool size is 8mb elems - it is 128 mbs capacity std::atomic<bool> ptr wait + notify:each second;main thread window=2(bytes=32) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 4mb actions, in 71.1067mln ticks -- actions cost is 16.9532 ticks std::atomic<bool> ptr wait + notify:each second; t1 window=2(bytes=32) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 4mb actions, in 88.5455mln ticks -- actions cost is 21.1109 ticks std::atomic<bool> ptr wait + notify:each second; t2 window=2(bytes=32) shift=1 (bytes 16) subelems=1 (bytes 16) -- done 4mb actions, in 91.193mln ticks -- actions cost is 21.7421 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline;main thread window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 22.9172mln ticks -- actions cost is 21.8556 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline; t1 window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 62.4131mln ticks -- actions cost is 59.5218 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline; t2 window=8(bytes=128) shift=4 (bytes 64) subelems=1 (bytes 16) -- done 1mb actions, in 74.0776mln ticks -- actions cost is 70.6459 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline different sides iter;main thread window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 24.498mln ticks -- actions cost is 23.3632 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline different sides iter; t1 window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 25.2427mln ticks -- actions cost is 24.0733 ticks std::atomic<bool> ptr wait + notify:nonintersected cacheline different sides iter; t2 window=8(bytes=128) shift=4 (bytes 64) subelems=1 (bytes 16) -- done 1mb actions, in 31.3194mln ticks -- actions cost is 29.8685 ticks init std::atomic<bool> wait + notify - sizeof 2 - alignof 1 - pool size is 64mb elems - it is 128 mbs capacity std::atomic<bool> wait + notify:each second;main thread window=2(bytes=4) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 32mb actions, in 566.135mln ticks -- actions cost is 16.8721 ticks std::atomic<bool> wait + notify:each second; t1 window=2(bytes=4) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 32mb actions, in 2192.2mln ticks -- actions cost is 65.3328 ticks std::atomic<bool> wait + notify:each second; t2 window=2(bytes=4) shift=1 (bytes 2) subelems=1 (bytes 2) -- done 32mb actions, in 2192.45mln ticks -- actions cost is 65.3401 ticks std::atomic<bool> wait + notify:nonintersected cacheline;main thread window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 22.849mln ticks -- actions cost is 21.7905 ticks std::atomic<bool> wait + notify:nonintersected cacheline; t2 window=64(bytes=128) shift=32 (bytes 64) subelems=1 (bytes 2) -- done 1mb actions, in 56.766mln ticks -- actions cost is 54.1363 ticks std::atomic<bool> wait + notify:nonintersected cacheline; t1 window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 62.6769mln ticks -- actions cost is 59.7733 ticks std::atomic<bool> wait + notify:nonintersected cacheline different sides iter;main thread window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 21.3978mln ticks -- actions cost is 20.4065 ticks std::atomic<bool> wait + notify:nonintersected cacheline different sides iter; t2 window=64(bytes=128) shift=32 (bytes 64) subelems=1 (bytes 2) -- done 1mb actions, in 20.959mln ticks -- actions cost is 19.988 ticks std::atomic<bool> wait + notify:nonintersected cacheline different sides iter; t1 window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 21.7367mln ticks -- actions cost is 20.7297 ticks init std::atomic<bool> - sizeof 2 - alignof 1 - pool size is 64mb elems - it is 128 mbs capacity std::atomic<bool>:each second;main thread window=2(bytes=4) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 32mb actions, in 298.41mln ticks -- actions cost is 8.89331 ticks std::atomic<bool>:each second; t1 window=2(bytes=4) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 32mb actions, in 323.28mln ticks -- actions cost is 9.63449 ticks std::atomic<bool>:each second; t2 window=2(bytes=4) shift=1 (bytes 2) subelems=1 (bytes 2) -- done 32mb actions, in 323.665mln ticks -- actions cost is 9.64595 ticks std::atomic<bool>:nonintersected cacheline;main thread window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 12.4965mln ticks -- actions cost is 11.9176 ticks std::atomic<bool>:nonintersected cacheline; t1 window=64(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 2) -- done 1mb actions, in 15.51mln ticks -- actions cost is 14.7915 ticks std::atomic<bool>:nonintersected cacheline; t2 window=64(bytes=128) shift=32 (bytes 64) subelems=1 (bytes 2) -- done 1mb actions, in 16.4261mln ticks -- actions cost is 15.6652 ticks init std::unique_ptr<std::mutex> - sizeof 16 - alignof 8 - pool size is 8mb elems - it is 128 mbs capacity std::unique_ptr<std::mutex>:each second;main thread window=2(bytes=32) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 4mb actions, in 96.4893mln ticks -- actions cost is 23.0049 ticks std::unique_ptr<std::mutex>:each second; t2 window=2(bytes=32) shift=1 (bytes 16) subelems=1 (bytes 16) -- done 4mb actions, in 115.36mln ticks -- actions cost is 27.5039 ticks std::unique_ptr<std::mutex>:each second; t1 window=2(bytes=32) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 4mb actions, in 117.244mln ticks -- actions cost is 27.9531 ticks std::unique_ptr<std::mutex>:nonintersected cacheline;main thread window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 26.279mln ticks -- actions cost is 25.0616 ticks std::unique_ptr<std::mutex>:nonintersected cacheline t2 window=8(bytes=128) shift=4 (bytes 64) subelems=1 (bytes 16) -- done 1mb actions, in 31.1812mln ticks -- actions cost is 29.7367 ticks std::unique_ptr<std::mutex>:nonintersected cacheline t1 window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 37.26mln ticks -- actions cost is 35.5339 ticks std::unique_ptr<std::mutex>:nonintersected cacheline different sides iter;main thread window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 28.1461mln ticks -- actions cost is 26.8422 ticks std::unique_ptr<std::mutex>:nonintersected cacheline different sides iter; t1 window=8(bytes=128) shift=0 (bytes 0) subelems=1 (bytes 16) -- done 1mb actions, in 31.1156mln ticks -- actions cost is 29.6742 ticks std::unique_ptr<std::mutex>:nonintersected cacheline different sides iter; t2 window=8(bytes=128) shift=4 (bytes 64) subelems=1 (bytes 16) -- done 1mb actions, in 39.1968mln ticks -- actions cost is 37.381 ticks init std::mutex - sizeof 48 - alignof 8 - pool size is 2.66667mb elems - it is 128 mbs capacity std::mutex:each second;main thread window=2(bytes=96) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 1.33333mb actions, in 32.5716mln ticks -- actions cost is 23.297 ticks std::mutex:t2 window=2(bytes=96) shift=1 (bytes 48) subelems=1 (bytes 48) -- done 1.33333mb actions, in 33.074mln ticks -- actions cost is 23.6564 ticks std::mutex:t1 window=2(bytes=96) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 1.33333mb actions, in 33.075mln ticks -- actions cost is 23.6571 ticks std::mutex:nonintersected cacheline;main thread window=4(bytes=192) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 0.666667mb actions, in 15.6728mln ticks -- actions cost is 22.4201 ticks std::mutex:nonintersected cacheline t1 window=4(bytes=192) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 0.666667mb actions, in 16.1759mln ticks -- actions cost is 23.1399 ticks std::mutex:nonintersected cacheline t2 window=4(bytes=192) shift=2 (bytes 96) subelems=1 (bytes 48) -- done 0.666666mb actions, in 22.536mln ticks -- actions cost is 32.2381 ticks std::mutex:nonintersected cacheline different sides iter;main thread window=4(bytes=192) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 0.666667mb actions, in 18.2865mln ticks -- actions cost is 26.1591 ticks std::mutex:nonintersected cacheline different sides iter; t2 window=4(bytes=192) shift=2 (bytes 96) subelems=1 (bytes 48) -- done 0.666666mb actions, in 15.8295mln ticks -- actions cost is 22.6443 ticks std::mutex:nonintersected cacheline different sides iter; t1 window=4(bytes=192) shift=0 (bytes 0) subelems=1 (bytes 48) -- done 0.666667mb actions, in 19.2192mln ticks -- actions cost is 27.4933 ticks init noLock - sizeof 1 - alignof 1 - pool size is 128mb elems - it is 128 mbs capacity noLock:each second;main thread window=2(bytes=2) shift=0 (bytes 0) subelems=1 (bytes 1) -- done 64mb actions, in 96.8275mln ticks -- actions cost is 1.44284 ticks noLock:t2 window=2(bytes=2) shift=1 (bytes 1) subelems=1 (bytes 1) -- done 64mb actions, in 98.1151mln ticks -- actions cost is 1.46203 ticks noLock:t1 window=2(bytes=2) shift=0 (bytes 0) subelems=1 (bytes 1) -- done 64mb actions, in 98.4507mln ticks -- actions cost is 1.46703 ticks noLock:nonintersected cacheline; main thread window=64(bytes=64) shift=0 (bytes 0) subelems=1 (bytes 1) -- done 2mb actions, in 13.657mln ticks -- actions cost is 6.51215 ticks noLock:nonintersected cacheline t2 window=64(bytes=64) shift=1 (bytes 1) subelems=1 (bytes 1) -- done 2mb actions, in 14.3405mln ticks -- actions cost is 6.83807 ticks noLock:nonintersected cacheline t1 window=64(bytes=64) shift=0 (bytes 0) subelems=1 (bytes 1) -- done 2mb actions, in 14.3567mln ticks -- actions cost is 6.84582 ticks noLock:32 in each 64; main thread window=64(bytes=64) shift=0 (bytes 0) subelems=32 (bytes 32) -- done 64mb actions, in 37.5734mln ticks -- actions cost is 0.559888 ticks noLock:t2 window=64(bytes=64) shift=32 (bytes 32) subelems=32 (bytes 32) -- done 64mb actions, in 37.2388mln ticks -- actions cost is 0.554902 ticks noLock:t1 window=64(bytes=64) shift=0 (bytes 0) subelems=32 (bytes 32) -- done 64mb actions, in 38.756mln ticks -- actions cost is 0.577509 ticks noLock:64 in each 128; main thread window=128(bytes=128) shift=0 (bytes 0) subelems=64 (bytes 64) -- done 64.0001mb actions, in 39.5265mln ticks -- actions cost is 0.588991 ticks noLock:t1 window=128(bytes=128) shift=0 (bytes 0) subelems=64 (bytes 64) -- done 64.0001mb actions, in 40.3772mln ticks -- actions cost is 0.601667 ticks noLock:t2 window=128(bytes=128) shift=64 (bytes 64) subelems=64 (bytes 64) -- done 64mb actions, in 41.0366mln ticks -- actions cost is 0.611494 ticks
без блокировок TBasicElem. Если обрабатывать 32 байта из кеш линии, то в релизе 1 модификация стоит 0.2-0.6 тиков.
если же брать по 1 элементу с кеш линии то стоимость возрастает до 5-6 тиков. Причём примерно такой же результат получается если собираться без флагов оптимизации
Итого: берём базовую стоимость обработки в 6 тиков, во всех остальных случаях можно считать что это входит в стоимость полезной работы, и будем её вычитать для вычисления стоимости накладных расходов
std::atomic<bool>- 2 байтовые элементы, при random-read (режим не взаимодействующих кешлиний) - получаем 12 тиков в 1 потоке, и ~14 тиков на двух потоках. То есть можно говорить об удвоении стомости.Стоимость при обработке множества элементов в рамках одной кеш-линии составила 8.8 тиков, и по 9.6 тиков в случае формального контеншена тредов. (Я ожидал увидеть тут большую просадку, возможно эффективно треды смогли перейти в почти непересекающийся режим - один из потоков обогнал второй и далее они не имели контеншена на каждом мелком элементе)
Итого: в удачном случае горячий атомик флаг даёт накладные в 2-4 тика. Стоит помнить об опасностях горячих циклов - можно получить ситуацию когда тред съедает всё ядро пытаясь захватить лок, и даже не даёт владельцу лока выполнить свою работу. В целом в серверных приложениях имхо стоит с осторожностью юзать горячие проверки - как правило есть работа на других тредах, которую они могут сделать пока текущий будет ждать.
Фьютекс (
std::atomic<bool> wait + notify), лошадка ради которой эта статья родилась. Показывает стоимость в 54-59 тиков (~50 тиков накладных - для простоты). При условии что 1 тред без попутчика выдаёт 20 тиков (14 накладных). Звучит слабо.
Тут я подумал, что ядро может орудовать не кеш линиями а чем-то другим (страницами?), и возможно при работе примерно в одном месте двух потоков, и решил добавить флаг разнонаправленного вычисления.
И действительно, при по настающему не конкурирующем вычислении (за что конкуренция - не очевидно) - получаем те же 20 тиков (14 накладных), что и на одном треде.
Итого: фьютекс вроде в 3 раза дороже горячего цикла (хотя по факту мы говорим "о +2 двух стоимостях cache-miss"), но зато даёт защиту от ситуаций действительного контеншена - лишние треды не будут есть время ядер и уйдут спать.
std::atomic<bool> ptr wait + notify- обарачиваем флаг в unique_ptr, теперь наши объекты занимают по 16 байт (из за выравнивания в 8). В основном прямом проходе 22 тика (те же в районе 15 тиков накладных) на 1 треде, и 60-70 на двух тредах (скажем что это 60 накладных)в разнонаправленном вычислении получаем 24-29 тиков (20 накладных)
Итого: обернуть фьютекс в поинтер даёт ещё 1 базовый кост (видимо это примерно стоимость кеш мисса). Такова стомость, если хочется хранить векторочки из ваших объектов
Наконец подтягиваются олды.
std::mutex- 48 байт размера нашего пейлоуда (40 сам мьютекс, и до 8 из-за alignment вырос размер 1-байтового пейлоуда). Да, позикс-мьютексы по памяти конечно прямо тяжеленькие. В случае работы с не пересекающимися кеш линиями наш результат - 20-30 тиков (~25 накладных). Прямо неплохой результат, причём тут нет нигде просадок до 50+ тиков в любых вариантах - очень стабильноstd::unique_ptr<std::mutex>- теже 16 байт в векторе (40 на куче тут отложим в сторону), что и в случае с фьютексом за указателем. Стоимость скачет от 25 до 35. (запишем ~30 накладных), и тоже нет особых выбросов в плохую сторону. Тут ещё надо заметить что второй поток часто подороже, так как его элемент часто занимает 2 разных кеш-линии.
Итого: мьютекс себя показывают нормально, 1-2 базовых коста доп накладных, но зато стабильность. Хоть и надо заплатить памятью.
Общие результаты: Если рассматривать сценарий гранулярных локов (куча небольших объектов, каждый защиён своим локом), то
мьютекс за указателем - неплохой безопасный дефолт, с которого не зазорно начать
если начинает поддавливать память (и ненужен movable дефолтный конструктор) - то стоит рассмотреть вариант фьютекса - будет оптимизация памяти и саксес-случаев. Но лучше это делать опираясь на замеры, если окажется что ваш случай имеет немало contention, то можно получить и проигрыш
горячие циклы могут ещё немного помочь, об опасностях уже говорил.
Код из листингов, опции сборки выложены тут
