web-dev-qa-db-ja.com

std :: mutexがstd :: atomicより速いのはなぜですか?

マルチスレッドモードでstd::vectorにオブジェクトを配置したい。そこで、2つのアプローチを比較することにしました。1つはstd::atomicを使用し、もう1つはstd::mutexを使用します。 2番目のアプローチは最初のアプローチより速いことがわかります。どうして?

私はGCC 4.8.1を使用していますが、私のマシン(8スレッド)では、最初のソリューションには391502マイクロ秒が必要で、2番目のソリューションには175689マイクロ秒が必要です。

#include <vector>
#include <omp.h>
#include <atomic>
#include <mutex>
#include <iostream>
#include <chrono>

int main(int argc, char* argv[]) {
    const size_t size = 1000000;
    std::vector<int> first_result(size);
    std::vector<int> second_result(size);
    std::atomic<bool> sync(false);

    {
        auto start_time = std::chrono::high_resolution_clock::now();
        #pragma omp parallel for schedule(static, 1)
        for (int counter = 0; counter < size; counter++) {
            while(sync.exchange(true)) {
                std::this_thread::yield();
            };
            first_result[counter] = counter;
            sync.store(false) ;
        }
        auto end_time = std::chrono::high_resolution_clock::now();
        std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end_time - start_time).count() << std::endl;
    }

    {
        auto start_time = std::chrono::high_resolution_clock::now();
        std::mutex mutex; 
        #pragma omp parallel for schedule(static, 1)
        for (int counter = 0; counter < size; counter++) {
            std::unique_lock<std::mutex> lock(mutex);       
            second_result[counter] = counter;
        }
        auto end_time = std::chrono::high_resolution_clock::now();
        std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end_time - start_time).count() << std::endl;
    }

    return 0;
}
14

標準のミューテックスのみを参照して質問に答えることはできないと思います。ミューテックスは、可能な限りプラットフォームに依存しています。ただし、注意すべき点が1つあります。

ミューテックスは遅くありません。カスタムスピンロックやその他の「軽量」なものとパフォーマンスを比較するいくつかの記事を見たことがあるかもしれませんが、それは適切なアプローチではありません-これらは互換性がありません。

スピンロックは、比較的短時間でロック(取得)されると、かなり高速になります。取得は非常に安価ですが、他のスレッドでは、ロックしようとしていて、この間ずっとアクティブです(ループで常に実行されています)。

カスタムスピンロックは次の方法で実装できます。

_class SpinLock
{
private:
    std::atomic_flag _lockFlag;

public:
    SpinLock()
    : _lockFlag {ATOMIC_FLAG_INIT}
    { }

    void lock()
    {
        while(_lockFlag.test_and_set(std::memory_order_acquire))
        { }
    }

    bool try_lock()
    {
        return !_lockFlag.test_and_set(std::memory_order_acquire);
    }

    void unlock()
    {
        _lockFlag.clear();
    }
};
_

Mutexはプリミティブであり、はるかに複雑です。特に、Windowsでは、このようなプリミティブが2つあります。プロセスごとに機能する Critical Section と、そのような制限がない Mutex です。

ミューテックス(またはクリティカルセクション)のロックははるかにコストがかかりますが、OSには他の待機中のスレッドを「スリープ」状態にする機能があり、パフォーマンスが向上し、効率的なリソース管理でタスクスケジューラが役立ちます。

なぜ私はこれを書くのですか?最新のミューテックスは、いわゆる「ハイブリッドミューテックス」と呼ばれることが多いためです。このようなミューテックスがロックされると、通常のスピンロックのように動作します。他の待機中のスレッドはいくつかの「スピン」を実行し、リソースを浪費しないように重いミューテックスがロックされます。

あなたの場合、この命令を実行するために、ループの反復ごとにmutexがロックされます。

_second_result[counter] = omp_get_thread_num();
_

高速なように見えるため、「実際の」ミューテックスはロックされない場合があります。つまり、この場合、「ミューテックス」は原子ベースのソリューションと同じくらい高速になる可能性があります(それ自体が原子ベースのソリューションになるため)。

また、最初のソリューションでは、スピンロックのような動作を使用しましたが、この動作がマルチスレッド環境で予測可能かどうかはわかりません。 「ロック」にはacquireセマンティクスが必要ですが、ロック解除はreleaseオペレーションです。 Relaxedメモリの順序付けは、この使用例には弱すぎる可能性があります。


私はコードをよりコンパクトで正しいものに編集しました。 _std::atomic_flag_ を使用します。これは、(_std::atomic<>_特殊化とは異なり)ロックフリーであることが保証されている唯一のタイプです(_std::atomic<bool>_でも、それ)。

また、以下の「譲れない」についてのコメントを参照してください。これは特定のケースと要件の問題です。スピンロックはマルチスレッドプログラミングの非常に重要な部分であり、その動作はわずかに変更することでパフォーマンスを向上させることができます。たとえば、Boostライブラリはspinlock::lock()を次のように実装します。

_void lock()
{
    for( unsigned k = 0; !try_lock(); ++k )
    {
        boost::detail::yield( k );
    }
}
_

ソース: boost/smart_ptr/detail/spinlock_std_atomic.hpp

ここで、detail::yield()は(Win32バージョン)です。

_inline void yield( unsigned k )
{
    if( k < 4 )
    {
    }
#if defined( BOOST_SMT_PAUSE )
    else if( k < 16 )
    {
        BOOST_SMT_PAUSE
    }
#endif
#if !BOOST_PLAT_WINDOWS_RUNTIME
    else if( k < 32 )
    {
        Sleep( 0 );
    }
    else
    {
        Sleep( 1 );
    }
#else
    else
    {
        // Sleep isn't supported on the Windows Runtime.
        std::this_thread::yield();
    }
#endif
}
_

[ソース: http://www.boost.org/doc/libs/1_66_0/boost/smart_ptr/detail/yield_k.hpp]

まず、スレッドは一定の回数(この場合は4回)スピンします。 mutexがまだロックされている場合、 pause命令が使用されます (使用可能な場合)またはSleep(0)が呼び出されます。これにより、基本的にコンテキストの切り替えが発生し、スケジューラが別のブロックをブロックできるようになります役に立つ何かをする機会をスレッドします。次に、Sleep(1)が呼び出され、実際の(短い)スリープが実行されます。非常に素晴らしい!

また、このステートメント:

スピンロックの目的は待っている忙しいです

完全に真実ではありません。スピンロックの目的は、高速で実装が容易なロックプリミティブとして機能することですが、特定の可能なシナリオを念頭に置いて、適切に記述する必要があります。たとえば、 Intelは_mm_pause()内での生成方法としてのBoostのlock()の使用について):

スピンウェイトループでは、pauseコンパイラ組み込み関数により、コードがロックの解放を検出する速度が向上し、特にパフォーマンスが大幅に向上します。

したがって、void lock() { while(m_flag.test_and_set(std::memory_order_acquire)); }のような実装は、見た目ほど良くない場合があります。

32
Mateusz Grzejek