Недавно "орлиный глаз" (то есть я) заметил, что в std::atomic у методов есть wait/notify методы.
Я думал, что std::atomic это сугубо user-space примитивы синхронизации, но согласно cppref (да и быстрым тестам) - оказалось, что wait стопает поток. Как указано в cppref - реализация (при наличии поддержки в платформе) предполагает использование futex-ов.
Futex - это специальный набор системных вызовов, которые, упрощённо, работают поверх хеш-таблицы с ключами в виде адресов памяти, а хранить могут очередь тредов, ждущих события.

Таким образом, можно реализовать критическую секцию (мьютекс), по такому алгоритму

  • используем atomic-flag, чтобы указать - взята блокировка или нет

  • если уже взята - уходим в wait события по адресу самого этого флага

  • при release делаем notify

В удачном случае (когда контеншена на самом деле нет) - нужно делать только 1 системный вызов (notify), что лучше чем posix-мьютексы, требующие двух системных вызовов (и на взятие лока и на release).

В общем, захотелось мне замерить какой вариант сколько стоит.

Замеряемые варианты

  1. TBasicElem - pyaload без блокировки

struct TBasicElem {
    static constexpr std::string_view Name = "noLock";
    uint8_t Data;

    void Lock() {}
    void UnLock() {}
};
  1. 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();}
};
  1. 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();}
};
  1. 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);}
};
  1. 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();
    }
};
  1. 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
  1. без блокировок TBasicElem. Если обрабатывать 32 байта из кеш линии, то в релизе 1 модификация стоит 0.2-0.6 тиков.

  2. если же брать по 1 элементу с кеш линии то стоимость возрастает до 5-6 тиков. Причём примерно такой же результат получается если собираться без флагов оптимизации

Итого: берём базовую стоимость обработки в 6 тиков, во всех остальных случаях можно считать что это входит в стоимость полезной работы, и будем её вычитать для вычисления стоимости накладных расходов

  1. std::atomic<bool> - 2 байтовые элементы, при random-read (режим не взаимодействующих кешлиний) - получаем 12 тиков в 1 потоке, и ~14 тиков на двух потоках. То есть можно говорить об удвоении стомости.

  2. Стоимость при обработке множества элементов в рамках одной кеш-линии составила 8.8 тиков, и по 9.6 тиков в случае формального контеншена тредов. (Я ожидал увидеть тут большую просадку, возможно эффективно треды смогли перейти в почти непересекающийся режим - один из потоков обогнал второй и далее они не имели контеншена на каждом мелком элементе)

Итого: в удачном случае горячий атомик флаг даёт накладные в 2-4 тика. Стоит помнить об опасностях горячих циклов - можно получить ситуацию когда тред съедает всё ядро пытаясь захватить лок, и даже не даёт владельцу лока выполнить свою работу. В целом в серверных приложениях имхо стоит с осторожностью юзать горячие проверки - как правило есть работа на других тредах, которую они могут сделать пока текущий будет ждать.

  1. Фьютекс (std::atomic<bool> wait + notify), лошадка ради которой эта статья родилась. Показывает стоимость в 54-59 тиков (~50 тиков накладных - для простоты). При условии что 1 тред без попутчика выдаёт 20 тиков (14 накладных). Звучит слабо.

Тут я подумал, что ядро может орудовать не кеш линиями а чем-то другим (страницами?), и возможно при работе примерно в одном месте двух потоков, и решил добавить флаг разнонаправленного вычисления.

  1. И действительно, при по настающему не конкурирующем вычислении (за что конкуренция - не очевидно) - получаем те же 20 тиков (14 накладных), что и на одном треде.

Итого: фьютекс вроде в 3 раза дороже горячего цикла (хотя по факту мы говорим "о +2 двух стоимостях cache-miss"), но зато даёт защиту от ситуаций действительного контеншена - лишние треды не будут есть время ядер и уйдут спать.

  1. std::atomic<bool> ptr wait + notify - обарачиваем флаг в unique_ptr, теперь наши объекты занимают по 16 байт (из за выравнивания в 8). В основном прямом проходе 22 тика (те же в районе 15 тиков накладных) на 1 треде, и 60-70 на двух тредах (скажем что это 60 накладных)

  2. в разнонаправленном вычислении получаем 24-29 тиков (20 накладных)

Итого: обернуть фьютекс в поинтер даёт ещё 1 базовый кост (видимо это примерно стоимость кеш мисса). Такова стомость, если хочется хранить векторочки из ваших объектов

  1. Наконец подтягиваются олды. std::mutex - 48 байт размера нашего пейлоуда (40 сам мьютекс, и до 8 из-за alignment вырос размер 1-байтового пейлоуда). Да, позикс-мьютексы по памяти конечно прямо тяжеленькие. В случае работы с не пересекающимися кеш линиями наш результат - 20-30 тиков (~25 накладных). Прямо неплохой результат, причём тут нет нигде просадок до 50+ тиков в любых вариантах - очень стабильно

  2. std::unique_ptr<std::mutex> - теже 16 байт в векторе (40 на куче тут отложим в сторону), что и в случае с фьютексом за указателем. Стоимость скачет от 25 до 35. (запишем ~30 накладных), и тоже нет особых выбросов в плохую сторону. Тут ещё надо заметить что второй поток часто подороже, так как его элемент часто занимает 2 разных кеш-линии.

Итого: мьютекс себя показывают нормально, 1-2 базовых коста доп накладных, но зато стабильность. Хоть и надо заплатить памятью.

Общие результаты: Если рассматривать сценарий гранулярных локов (куча небольших объектов, каждый защиён своим локом), то

  1. мьютекс за указателем - неплохой безопасный дефолт, с которого не зазорно начать

  2. если начинает поддавливать память (и ненужен movable дефолтный конструктор) - то стоит рассмотреть вариант фьютекса - будет оптимизация памяти и саксес-случаев. Но лучше это делать опираясь на замеры, если окажется что ваш случай имеет немало contention, то можно получить и проигрыш

  3. горячие циклы могут ещё немного помочь, об опасностях уже говорил.

Код из листингов, опции сборки выложены тут