为什么std :: mutex比std :: atomic更快?

时间:2015-04-09 08:42:16

标签: c++ mutex atomic

我想在std::vector中以多线程模式放置对象。所以我决定比较两种方法:一种使用std::atomic,另一种使用std::mutex。我看到第二种方法比第一种方法更快。为什么?

我使用GCC 4.8.1,在我的机器上(8个线程),我发现第一个解决方案需要391502微秒,第二个解决方案需要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;
}

2 个答案:

答案 0 :(得分:26)

我不认为您的问题可以通过 only 来解决,标准互斥体与平台相关,因为它们可以。但是,有一点需要提及。

互联网不是慢。你可能已经看过一些文章,比较它们与自定义旋转锁和其他“轻量级”东西的性能,但这不是正确的方法 - 这些是不可互换的。

旋转锁相当快,当它们被锁定(获取)相对较短的时间 - 获取它们非常便宜,但其他线程,也试图锁定,是活跃的整个这个时间(不断循环运行)。

自定义旋转锁可以这种方式实现:

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上,我们有两个这样的原语 - Critical Section,它们在每个进程的基础上工作,而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)。如果互斥锁仍然被锁定,则会调用pause instruction is used(如果可用)或Sleep(0),这基本上会导致上下文切换,并允许调度程序为另一个被阻塞的线程提供执行某些有用操作的机会。然后,调用Sleep(1)来执行实际(短暂)睡眠。非常好!

另外,这句话:

  

螺旋锁的目的是忙于等待

并非完全正确。 spinlock的目的是作为一个快速,易于实现的锁原语 - 但它仍然需要正确编写,并考虑到某些可能的情况。例如,Intel says(关于Boost使用_mm_pause()作为一种在lock()内屈服的方法):

  

在自旋等待循环中,暂停内在提高了速度   代码检测到锁的释放并特别提供   显着的性能提升。

所以,实现就像 void lock() { while(m_flag.test_and_set(std::memory_order_acquire)); } 可能不如看起来那么好。

答案 1 :(得分:0)

还有一个与您的问题相关的重要问题。高效的自旋锁永远不会在涉及存储(例如 exchangetest_and_set)的操作上“自旋”。在典型的现代架构中,这些操作生成的指令要求具有锁定内存位置的缓存线处于独占状态,这非常耗时(尤其是当多个线程同时旋转时)。始终在加载/只读时自旋,并仅在此操作有可能成功时才尝试获取锁。

例如,这里有一篇很好的相关文章:Correctly implementing a spinlock in C++