高性能多线程编程中,频繁使用互斥锁会影响程序性能。某些情况下用自旋锁替代互斥锁也许是优化性能的好手段。
下文将简单的分析自旋锁和互斥锁各自的优势
无竞争的互斥锁
在常见的linux和glibc版本中,无竞争使用互斥锁并不会带来额外的系统调用。因为Futex技术,使得在线程能直接获取到锁得情况下无需使用系统调用,直接利用CPU原子指令进行加锁或解锁,故互斥锁在竞争小得场合并不会因为频繁进行系统调用而损耗性能。
#include <iostream>
#include <chrono>
#include <mutex>
#include <list>
#include <cstdint>
static constexpr size_t loop_max = 10000000;
static std::list<int> g_list;
template <typename LockType>
void test_lock(const char *lock_name)
{
auto start = std::chrono::steady_clock::now();
LockType locker;
for (size_t i = 0; i < loop_max; i++)
{
std::lock_guard<LockType> lock_guard(locker);
g_list.emplace_back(i);
g_list.pop_back();
}
std::int64_t ms = std::chrono::duration_cast<std::chrono::microseconds>
(std::chrono::steady_clock::now() - start).count();
std::cout << "lock_type " << lock_name << "\n";
std::cout << "elapsed " << ms / 1000000.0 << "\n\n";
}
int main()
{
test_lock<std::mutex>("std::mutex");
return 0;
}
对以上代码进行编译测试,由于仅一个线程,故使用互斥锁不会出现竞争和等待,也不会进行系统调用
我们可以用strace命令进行确认,该程序未使用与锁相关的系统调用
有竞争的互斥锁
假设存在两个线程以以下顺序执行代码
Thread1 | Thread2 |
---|---|
申请锁 | |
得到锁 | |
申请锁 | |
释放锁 | |
得到锁 | |
释放锁 |
- Thread1申请锁时无需使用系统调用,用户态加锁
- Thread1得到锁
- Thread2申请锁时在用户态中发现该锁已经被申请,在锁对象中登记自己需要等待该锁,使用系统调用通知内核等待解锁
- Thread1释放锁时发现该锁对象有其他线程需要使用,解锁后使用系统调用通知内核拉起等待的线程
- Thread2得到锁
- Thread2释放锁,这时该锁不被任何一个线程等待,故无需进行系统调用
结论:linux下互斥锁的加锁和解锁尽可能在用户态完成,如果涉及到等待和唤醒操作,才会进行系统调用
应用层自旋锁实现
C++11提供了原子能力,此处可直接使用std::atomic_flag实现自旋锁。
注意:由于用户态不能关闭调度,使临界区内任务尽快做完。故系统可能会在临界区调度到其他线程,最坏的情况是其他线程不停的尝试获取这个锁,但因为获取到这个锁的线程还在等待下一次调度,导致白白浪费宝贵的CPU时间,故我们可以在lock()函数中插入一条std::this_thread::yield()
防止这种情况。
#include <atomic>
#include <thread>
// 自旋锁的实现
class SpinLock {
public:
SpinLock() = default;
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock) = delete;
void lock() { // acquire spin lock
while (flag.test_and_set(std::memory_order_acquire)) {
std::this_thread::yield();
}
}
void unlock() { // release spin lock
flag.clear(std::memory_order_release);
}
private:
atomic_flag flag;
};
无竞争下互斥锁和自旋锁性能测试
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>
#include <atomic>
#include <list>
#include <cstdint>
// 自旋锁的实现
class SpinLock {
public:
SpinLock() = default;
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock) = delete;
void lock() { // acquire spin lock
while (flag.test_and_set(std::memory_order_acquire)) {
std::this_thread::yield();
}
}
void unlock() { // release spin lock
flag.clear(std::memory_order_release);
}
private:
atomic_flag flag;
};
static constexpr size_t loop_max = 10000000;
static std::list<int> g_list;
template <typename LockType>
void test_lock(const char *lock_name)
{
auto start = std::chrono::steady_clock::now();
LockType locker;
for (size_t i = 0; i < loop_max; i++)
{
std::lock_guard<LockType> lock_guard(locker);
g_list.emplace_back(i);
g_list.pop_back();
}
std::int64_t ms = std::chrono::duration_cast<std::chrono::microseconds>
(std::chrono::steady_clock::now() - start).count();
std::cout << "lock_type " << lock_name << "\n";
std::cout << "elapsed " << ms / 1000000.0 << "\n\n";
}
int main()
{
test_lock<std::mutex>("std::mutex");
test_lock<SpinLock>("SpinLock");
return 0;
}
运行输出
lock_type std::mutex
elapsed 0.322497
lock_type SpinLock
elapsed 0.251232
结论:虽然在无竞争情况下,互斥锁和自旋锁都不会进行系统调用,但自旋锁的实现更简单,性能更高
有竞争下互斥锁和自旋锁性能测试
#include <iostream>
#include <list>
#include <vector>
#include <chrono>
#include <thread>
#include <mutex>
#include <atomic>
#include <cstdint>
// 自旋锁的实现
class SpinLock {
public:
SpinLock() = default;
SpinLock(const SpinLock&) = delete;
SpinLock& operator=(const SpinLock) = delete;
void lock() { // acquire spin lock
while (flag.test_and_set(std::memory_order_acquire)) {
std::this_thread::yield();
}
}
void unlock() { // release spin lock
flag.clear(std::memory_order_release);
}
private:
atomic_flag flag;
};
static std::list<int> g_list;
static constexpr size_t loop_max = 10000000;
template <typename LockType>
void test_thread(size_t id, LockType &lock)
{
for (size_t i = 0; i < loop_max; i++)
{
std::lock_guard<LockType> lock_guard(lock);
g_list.push_back(id);
g_list.pop_back();
}
}
template <typename LockType>
void begin_test(size_t thread_number, const char *lock_name)
{
std::vector<std::thread> threads;
LockType lock;
auto start = std::chrono::steady_clock::now();
for (size_t i = 0; i < thread_number; i++)
{
threads.emplace_back([i, &lock](){
test_thread(i, lock);
});
}
for (auto &t : threads)
{
t.join();
}
std::int64_t ms = std::chrono::duration_cast<std::chrono::microseconds>
(std::chrono::steady_clock::now() - start).count();
std::cout << "lock_type " << lock_name << "\n";
std::cout << "elapsed " << ms / 1000000.0 << "\n\n";
}
int main()
{
size_t nproc = std::thread::hardware_concurrency();
std::cout << "nproc: " << nproc << "\n\n";
for (size_t n = 1; n <= 2*nproc; n++)
{
std::cout << "=============== " << n << " ===============\n";
begin_test<std::mutex>(n, "std::mutex");
begin_test<SpinLock>(n, "SpinLock");
}
return 0;
}
运行输出
nproc: 4
=============== 1 ===============
lock_type std::mutex
elapsed 0.032644
lock_type SpinLock
elapsed 0.021863
=============== 2 ===============
lock_type std::mutex
elapsed 0.221694
lock_type SpinLock
elapsed 0.053253
=============== 3 ===============
lock_type std::mutex
elapsed 0.242243
lock_type SpinLock
elapsed 0.078929
=============== 4 ===============
lock_type std::mutex
elapsed 0.242026
lock_type SpinLock
elapsed 0.12438
=============== 5 ===============
lock_type std::mutex
elapsed 0.38374
lock_type SpinLock
elapsed 0.154654
=============== 6 ===============
lock_type std::mutex
elapsed 0.446244
lock_type SpinLock
elapsed 0.182802
=============== 7 ===============
lock_type std::mutex
elapsed 0.395281
lock_type SpinLock
elapsed 0.212078
=============== 8 ===============
lock_type std::mutex
elapsed 0.37593
lock_type SpinLock
elapsed 0.264617
结论:自旋锁性能好于互斥锁
为何自旋锁性能好于互斥锁
很多人说互斥锁会频繁进行系统调用,我认为这个说法是不正确的。
在竞争小的情况下,自旋锁和互斥锁均不会过多的进行系统调用。
而竞争大的情况下,自旋锁需要调用sched_yield,互斥锁需要调用futex。
互斥锁更慢的原因时其本身实现更复杂,除了要在用户态进行原子操作外,还要维护一个等待队列,记录哪些线程需要申请锁,以便于实现让权等待。但如果临界区内的任务非常轻量,甚至轻量到比加锁解锁的开销还小的情况下,再去维护这个队列的话,从开销上来看就不那么划算,反而直接使用自旋锁这种简单的机制能获得更高的效率。
怎么选择使用互斥锁还是自旋锁
通常情况下推荐使用互斥锁,在不追求极致性能的情况下,使用互斥锁往往就足够了。
在追求极致性能情况下,若临界区内任务很轻,能够很迅速的完成。在这种情况下可以考虑使用自旋锁,若不满足这个条件,使用自旋锁只会降低程序性能。
评论:
不调yield最大的问题在于用户态没法禁止调度,如果持有锁的线程在临界区内被操作系统剥夺CPU使用,调度给一个尝试拿锁的线程。那尝试拿锁的线程就只能不停的空转,直到耗尽本次调度的整个时间片。
我把yield去掉以后确实自旋锁慢了好几倍,但是我不理解的是,我只开了三个线程,我是8核心16线程的电脑,按理说这三个线程都不应该被切掉啊
很棒的测试,把我一直想做的测试做了。其实我一直在纠结自旋锁里到底要不要使用yield,如果不用,那就是期望自旋锁在空转的一个时间片内获取到锁。加了yield,就是期望在几个时间片内获取到锁,性价比才高,这就要求临界区负担小,而且竞争自旋锁的线程不能太多。如果临界区负担大,或者竞争线程比较多,还不如直接用mutex,把线程放到阻塞队列,等待唤醒,还不影响cpu轮询效率。真是太纠结了,平常使用的时候还是倾向于加了yield的自旋锁,因为任务大多执行很快,没必要使用mutex阻塞线程等待唤醒