【问题标题】:Implement a high performance mutex similar to Qt's one实现一个类似于 Qt 的高性能互斥锁
【发布时间】:2015-05-25 10:49:37
【问题描述】:

我有一个多线程科学应用程序,其中多个计算线程(每个内核一个)必须将它们的结果存储在一个公共缓冲区中。这需要互斥机制。

工作线程只花费一小部分时间写入缓冲区,因此互斥锁大部分时间都处于解锁状态,并且锁定很有可能立即成功,而无需等待另一个线程解锁。

目前,我已经使用 Qt 的 QMutex 来完成这项任务,并且效果很好:互斥锁的开销可以忽略不计。

但是,我只需要将它移植到 c++11/STL。使用 std::mutex 时,性能下降 66%,线程大部分时间都在锁定互斥锁。

在另一个问题之后,我认为 Qt 使用基于简单原子标志的快速锁定机制,针对互斥锁尚未锁定的情况进行了优化。并在并发锁定发生时回退到系统互斥锁。

我想在 STL 中实现它。有没有一种基于 std::atomic 和 std::mutex 的简单方法?我已经深入研究了 Qt 的代码,但它对我的使用来说似乎过于复杂(我不需要锁超时、pimpl、占用空间小等...)。

编辑:我尝试了自旋锁,但效果不佳,因为:

定期(每隔几秒),另一个线程锁定互斥体并刷新缓冲区。这需要一些时间,因此此时所有工作线程都会被阻塞。自旋锁使调度变得忙碌,导致刷新比使用适当的互斥锁慢 10-100 倍。这是不可接受的

编辑:我已经尝试过了,但它不起作用(锁定所有线程)

class Mutex
{
public:
    Mutex() : lockCounter(0) { }

    void lock()
    {
        if(lockCounter.fetch_add(1, std::memory_order_acquire)>0)
        {
            std::unique_lock<std::mutex> lock(internalMutex);
            cv.wait(lock);
        }
    }

    void unlock();
    {
        if(lockCounter.fetch_sub(1, std::memory_order_release)>1)
        {
            cv.notify_one();
        }
    }


private:
    std::atomic<int> lockCounter;
    std::mutex internalMutex;
    std::condition_variable cv;
};

谢谢!

编辑:最终解决方案

MikeMB 的快速互斥体运行良好。

作为最终解决方案,我做了:

  • 使用带有 try_lock 的简单自旋锁
  • 当线程尝试锁定失败时,它们不会等待,而是填充一个队列(不与其他线程共享)并继续
  • 当线程获得锁时,它会使用当前结果更新缓冲区,但也会使用存储在队列中的结果(它处理自己的队列)
  • 缓冲区刷新效率更高:阻塞部分仅交换两个指针。

【问题讨论】:

  • 嗯.. 最好的锁定是不锁定。请问,'buffer'的结构是什么,每个'result'有多大,完成后对buffer做了什么?嫌疑人XY..?
  • 您是否尝试将性能与原始 pthread 互斥锁进行比较?
  • 你试过自旋锁吗?
  • @MartinJames :缓冲区是一个浮点数组,并且值被累积(添加)。大小太大,每个线程有一个缓冲区。如果有原子浮动我会很高兴:)
  • @galinette:不幸的是,没有——你只能在条件变量、原子和互斥体之上构建一个(我认为这是一个严重的缺陷)

标签: c++ qt c++11 stl mutex


【解决方案1】:

一般建议

正如在某些 cmets 中提到的,我首先要看看您是否可以重新构建程序设计以降低互斥锁实现对您的性能的重要性。
此外,由于标准 c++ 中的多线程支持非常新且有点幼稚,因此您有时只需要依靠平台特定的机制,例如linux 系统上的 futex 或 Windows 上的关键部分或 Qt 等非标准库。
话虽如此,我可以想到两种可能会加速您的程序的实现方法:

自旋锁
如果访问冲突很少发生,并且互斥锁只保留很短的时间(当然应该努力实现两件事),那么只使用自旋锁可能是最有效的,因为它不需要任何系统完全调用并且实现起来很简单(取自cppreference):

class SpinLock {
    std::atomic_flag locked ;
public:
    void lock() {
        while (locked.test_and_set(std::memory_order_acquire)) { 
             std::this_thread::yield(); //<- this is not in the source but might improve performance. 
        }
    }
    void unlock() {
        locked.clear(std::memory_order_release);
    }
};

当然,缺点是等待线程不会保持休眠并占用处理时间。

检查锁定

这本质上就是您演示的想法:您首先快速检查,是否确实需要基于原子交换操作的锁定,并且只有在不可避免时才使用重 std::mutex

struct FastMux {
    //Status of the fast mutex
    std::atomic<bool> locked;
    //helper mutex and vc on which threads can wait in case of collision
    std::mutex mux;
    std::condition_variable cv;
    //the maximum number of threads that might be waiting on the cv (conservative estimation)
    std::atomic<int> cntr; 

    FastMux():locked(false), cntr(0){}

    void lock() {
        if (locked.exchange(true)) {
            cntr++;
            {
                std::unique_lock<std::mutex> ul(mux);
                cv.wait(ul, [&]{return !locked.exchange(true); });
            }
            cntr--;
        }
    }
    void unlock() {
        locked = false;
        if (cntr > 0){
            std::lock_guard<std::mutex> ul(mux);
            cv.notify_one();
        }
    }
};

注意std::mutex 没有锁定在lock()unlock() 之间,但它仅用于处理条件变量。如果互斥体上存在高度拥塞,这会导致更多的锁定/解锁调用。

您的实现的问题是,cv.notify_one(); 可能会在 if(lockCounter.fetch_add(1, std::memory_order_acquire)&gt;0)cv.wait(lock); 之间调用,因此您的线程可能永远不会唤醒。

不过,我没有与您提议的实施的固定版本进行任何性能比较,因此您只需要查看最适合您的版本即可。

【讨论】:

  • 我已经尝试过自旋锁。它会导致另一个问题,因为问题稍微复杂一些:周期性地,一个额外的线程锁定互斥体,并刷新缓冲区,这需要一些时间。这会导致所有工作线程进入自旋锁,并且它们不会释放调度,因此刷新很慢。
  • @MikeMB 关于编辑,cv.wait/cv.notify 旨在完全防止这种情况发生。 cv.notify 会增加某种计数器,而 cv.wait 会在等待之前检查出来……否则条件变量几乎完全没用。
  • @sbabbi:你有这方面的资料吗?我认为您混淆了信号量和条件变量。
  • @MikeMB :感谢您的回答,它有效。性能不如 Qt 互斥锁,可能是因为一次冲突可能会产生许多锁。我最终实现了自旋锁,通过交换缓冲区将刷新时间减少到最小,并且作为最终优化,线程不锁定自旋锁,而是执行 try_lock,如果失败,它们会回退到每个线程队列,它们稍后会清空他们成功获得了锁。
【解决方案2】:

不是每个定义的真正答案,但根据具体任务,无锁队列可能有助于完全摆脱互斥锁。如果您有多个生产者和一个消费者(甚至多个消费者),这将有助于设计。链接:

更新到 cmets:

队列大小/溢出:

  • 可以通过以下方式避免队列溢出:i) 使队列足够大或 ii) 让生产者线程等待队列满后推送数据。
  • 另一种选择是使用多个消费者和多个队列并实现并行缩减,但这取决于数据的处理方式。

消费者线程:

  • 队列可以使用std::condition_variable 并使消费者线程等待直到有数据。
  • 另一种选择是使用计时器定期检查(轮询)队列是否为非空,一旦非空,线程可以连续获取数据并返回等待模式。李>

【讨论】:

  • 我已经考虑过这种方法。我还没有尝试过,因为我担心队列溢出问题。另外,如何避免消费者线程中的自旋锁等待一些传入数据?这将添加一个几乎无用的全速工作线程。
  • 这意味着调用 std::condition_variable::notify_one 非常频繁。如果开销是可以接受的,这是可以接受的,要进行测试轮询可能会导致溢出问题,因为代码必须以非常不同的生产率运行,并且可能在大量内核上运行
  • 可能最好使用某种层次结构。然后可以避免溢出,因为负载是分布的。另一方面,根据数据类型,简单地使队列足够大并在生产者端使用full 标志可能不会有什么坏处。但是,这也需要确保队列一旦停止就会被清空一定数量,以避免重复阻塞。
  • 我会考虑这个解决方案。我不喜欢在专用线程中“消耗”,因为在某些情况下,它可能会成为瓶颈(如果太多线程产生的速度太快)。所以线程应该填满一个队列,但有时会主动将它消耗到缓冲区。这可能是一个优雅但有些复杂的解决方案。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-03-22
  • 1970-01-01
  • 2010-12-01
  • 1970-01-01
  • 2017-07-31
相关资源
最近更新 更多