我自己做了一个小测试,发现了一个有趣的数据,先将结论写下来:
- 单线程的性能,可能超过100核(甚至近万核)的且没有使用lock的多线程的性能
- 就算多线程采用无锁并发数据结构,它也可能远远低于单线程的执行速度
再看测试代码
单线程,先加mutex锁,然后执行一个长时间计算(完整源代码链接如此)
std::lock_guard<std::mutex> lock(g_mutex);
int sum = ;
for (int i = ; i < times; ++i) {
++sum;
}
多线程,采用无锁结构(CAS -- 以及我对CAS的一个理解 ),我们只用一个线程跑,理论上,如果有多核下,每多一倍线程,性能会翻倍
std::atomic<int> sum();
for (int i = ; i < times; ++i) {
int expected = sum;
while(sum.compare_exchange_weak(expected, expected+1, std::memory_order_relaxed));
}
执行结果
然后在我的Mac笔记本上(基于Ubuntu系统)跑的结果(执行时间:纳秒ns or nano second,越少越好,可以认为性能和时间成线性反比)
g++编译选项 | 单线程(ns) | 多线程无锁CAS (ns) | 性能倍数比 (单线程 vs 多线程CAS) |
---|---|---|---|
-O0 | 2071273 | 42319216 | 20 |
-O2 | 1994 | 17901105 | 8977 |
我的分析
我的cost经验数据
首先,我一般用下面的数据(不保证完全准确,但数量级一般吻合)去理解计算机的cost(latency时延)
- 如果是寄存器CPU register, latency 远低于 0.1 纳秒nano second(ns)
- 如果是L1 cache, latency 一般低于1 纳秒nano second(ns)
- 如果是主内存main memory, latency 一般在 100 纳秒nano seconds(ns)上下
- 如果用到 atomic primitive(比如:是上面代码中的std::atomic<int>), 如果只是读 read, latency一般是几个纳秒nano seconds(ns)
- 如果用到 atomic primitive, 如果有写 write, latency 一般是 20 纳秒nano seconds(ns)
- CAS 内部使用到了 atomic primitive for write, 所以,latency of CAS 是 around 纳秒20 nano seconds(ns)
- 当使用 mutex, 如果没有发生线程竞争race, i.e. 我们用的是 futex, latency 是 20 纳秒nano seconds(ns). 你可以猜测, futex 是基于 atomic primitive
- 当使用 mutex, 如果发生线程竞争race, i.e., 线程切换thread context switch 会发生, 平均的消耗 (overhead or latency) 是几个微秒 micro seconds(us) 。记住:1 微秒(us) = 1000 纳秒(ns). 如果操作系统再次唤醒并调度休眠的线程回来,整个时间可能是几百个微秒(us)
再用经验数据对实验结果做分析
将上面这些经验数据,用于上面的代码,就可以得到如下的分析:
对于mutex加锁,然后执行计算的单线程代码,除了计算外,它只有一次很小的消耗,如果没有线程竞争,它只涉及一个futex,就是一个20ns。同时,访问的内存变量(代码里的sum)次需要从主内存读到L1 cache,后面就可以一直在L1 cache,然后计算完毕,后再写回主内存。
对于lock-free(CAS)多线程,每次计算都要用到CAS,就是每次循环,都有一个附加的几十纳秒的消耗(实际平均消耗要小于20ns,因为多次使用同一atomic primitive)。
编译选项的差别
至于编译选项O0和O2的差别,在于编译器的优化。在O2下,编译器可以聪明地用下面的逻辑代码去优化
sum = 0;
store sum to register;
for one million loop:
do register++;
store register to sum;
这时,程序甚至不用读L1 cache里的sum变量,而是直接在CPU寄存器里进行计算,这时,每次计算的变量访问时延将远小于0.1 ns。
这也就解释了,为什么在编译器O2的优化下,单线程的计算性能,居然达到了无锁CAS单线程的9000倍,换言之,无锁多线程需要9000个线程(同时需要CPU有9000个core的支持),才能达到单核单线程的性能。
在我们编写多线程代码中,不管是用无锁CAS数据结构,还是有锁mutex,如果代码不妥当,编译器很难对保护区(critical section)里的代码做优的优化,同时,这些锁或者无锁CAS,都有一定的消耗。
为什么单线程的Redis够快
这也从某种程度解释了Redis为什么这么快。因为在Redis的数据结构里,大部分数据,都是在单线程下完成的,这样,编译器和CPU都可以为此无障碍的进行充分的优化。
而且,多线程代码中,很容易出现不同步的bug,而且异常难易捕捉(因为不是必然出现,很可能在特定情况下,才发生线程数据同步的问题)。
所以,我挺佩服两个产品的:
一个是Redis,十来年来坚守单线程的架构(Redis用了多线程,但主逻辑是单线程,包括新的6.0版本将network io进行多线程化,也是主线程单线程)
一个是Python,创作者的观点是:如果因为采用多线程并发的一些方案,导致一些语言特性对比单线程出现性能下降的话,那么,他不会采用,即继续保留GIL。
BunnyRedis必须用多线程,但如何向Redis学习?
在我开发BunnyRedis中,我不得不用到多线程,因为涉及两个点逃不过:
- 和Kafka的通信
- 用RocksDB访问磁盘
但是,我力求线程模型简单,在Redis主线程和上面必须用到的其他多个线程之间,用很少的数据结构进行交互,而且各个线程的主要数据尽量让各个线程独自享用(ownership)。
这让我在BunnyRedis里改写Redis代码时,安全信心十足,因为Redis内部的大部分的数据结构,如Hash, List, ZipList,SDS string,都是在单线程下执行,我不仅不担心它们的线程安全,我还觉得它们够快。
参考引用:elephant_eye_c_plusplus/lock_free_vs_thread_lock.md at master · szstonelee/elephant_eye_c_plusplus