【问题标题】:Why is std::mutex faster than std::atomic?为什么 std::mutex 比 std::atomic 快?
【发布时间】:2015-04-09 08:42:15
【问题描述】:

我想以多线程模式将对象放入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;
}

【问题讨论】:

  • 1.请发布您的编译器、编译选项和测量结果。 2. 在测量后对结果数据做一些可观察的事情,否则足够好的优化器可以将代码删除为死代码。
  • 在使用 Visual Studio 2013 的 32 位发布版本中,我得到 0、46800 和 64 位一致地给我 0、62400,所以看起来原子要么超级快,要么测试工具不是t 真的有效。如果您正在使用它,您还应该知道,在 Visual Studio 2013 及以下版本中,high_resolution_clocksystem_clock 没有任何不同。 stackoverflow.com/q/16299029/920069
  • 无论如何,这段代码都被严重破坏了。 memory_order_relaxed 的原子操作不是同步操作。
  • 我更新了我的代码。现在,当我使用四个线程时,第一个解决方案比第二个解决方案更快(25-30%)。但是如果我增加线程数(20-25%),第一个解决方案比第二个解决方案慢。
  • 谁在乎。代码仍然被破坏。你认为你能得出什么结论?破码更快?破码更慢?这些有什么用?

标签: c++ mutex atomic


【解决方案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 上,我们有两个这样的原语 - Critical Section,在每个进程的基础上工作,Mutex,没有这样的限制。

锁定互斥体(或临界区)的成本要高得多,但操作系统能够真正让其他等待线程“休眠”,从而提高性能并帮助任务调度程序进行有效的资源管理。

我为什么要写这个?因为现代互斥锁通常是所谓的“混合互斥锁”。当此类互斥锁被锁定时,它的行为就像普通的自旋锁一样 - 其他等待线程执行一定数量的“自旋”,然后锁定重度互斥锁以防止浪费资源。

在您的情况下,互斥锁在每次循环迭代中被锁定以执行此指令:

second_result[counter] = omp_get_thread_num();

它看起来很快,所以“真正的”互斥锁可能永远不会被锁定。这意味着,在这种情况下,您的“互斥锁”可以与基于原子的解决方案一样快(因为它本身就是基于原子的解决方案)。

另外,在第一个解决方案中,您使用了某种类似自旋锁的行为,但我不确定这种行为在多线程环境中是否可以预测。我很确定,“锁定”应该具有 acquire 语义,而解锁是 release 操作。 Relaxed 对于这个用例来说,内存排序可能太弱了。


我将代码编辑得更紧凑、更正确。它使用std::atomic_flag,这是唯一保证无锁的类型(与std::atomic&lt;&gt; 特化不同)(即使std::atomic&lt;bool&gt; 不提供)。

另外,请参阅下面关于“不屈服”的评论:这是特定情况和要求的问题。自旋锁是多线程编程中非常重要的一部分,通常可以通过稍微修改其行为来提高其性能。例如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) 来执行实际(短)睡眠。很不错!

另外,这个声明:

自旋锁的目的是忙于等待

并不完全正确。自旋锁的目的是作为一种快速、易于实现的锁原语——但它仍然需要正确编写,并考虑到某些可能的场景。例如,Intel says(关于 Boost 使用 _mm_pause() 作为在 lock() 内部产生的一种方法):

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

所以,像这样的实现 void lock() { while(m_flag.test_and_set(std::memory_order_acquire)); } 可能没有看起来那么好。

【讨论】:

  • 那不是自旋锁。自旋锁的目的是忙于等待并且明确地不让步。
  • 您应该为此使用预先存在的std::atomic_flag 类。 This is how a "proper" spin-lock should look.
  • @Kaiserludi 这可能不是是真的。我更新了答案以解决您的评论。 @bit2shift 也一样 - your 自旋锁的实现可能并非在每种情况下都是“正确的”。例如,Boost 在lock() 中使用了非常好的自定义收益策略来优化其自旋锁实现的性能。关于std::atomic_lock - 我已经更新了代码。它确实是唯一保证无锁的类型,因此在编写自定义自旋锁时是自然的选择。
  • @bit2shift 抱歉,自旋锁不应该是这样的。在需要高速缓存行的独占状态的操作上进行旋转是非常低效的。例如,在此处讨论:en.wikipedia.org/wiki/Spinlock#Significant_optimizations 或此处:rigtorp.se/spinlock
【解决方案2】:

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

例如,一篇不错的相关文章在这里:Correctly implementing a spinlock in C++

【讨论】:

    猜你喜欢
    • 2022-09-28
    • 1970-01-01
    • 2017-03-28
    • 2020-09-21
    • 1970-01-01
    • 2016-09-07
    • 2015-01-07
    • 2015-04-18
    • 2021-12-27
    相关资源
    最近更新 更多