绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
单线程就比多线程性能差吗?不一定
2022-04-15 20:22:16

先看结论

我自己做了一个小测试,发现了一个有趣的数据,先将结论写下来:

  1. 单线程的性能,可能超过100核(甚至近万核)的且没有使用lock的多线程的性能
  2. 就算多线程采用无锁并发数据结构,它也可能远远低于单线程的执行速度

再看测试代码

单线程,先加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)
-O020712734231921620
-O21994179011058977

我的分析

我的cost经验数据

首先,我一般用下面的数据(不保证完全准确,但数量级一般吻合)去理解计算机的cost(latency时延)

  1. 如果是寄存器CPU register, latency 远低于 0.1 纳秒nano second(ns)
  2. 如果是L1 cache, latency 一般低于1 纳秒nano second(ns)
  3. 如果是主内存main memory, latency 一般在 100 纳秒nano seconds(ns)上下
  4. 如果用到 atomic primitive(比如:是上面代码中的std::atomic<int>), 如果只是读 read, latency一般是几个纳秒nano seconds(ns)
  5. 如果用到 atomic primitive, 如果有写 write, latency 一般是 20 纳秒nano seconds(ns)
  6. CAS 内部使用到了 atomic primitive for write, 所以,latency of CAS 是 around 纳秒20 nano seconds(ns)
  7. 当使用 mutex, 如果没有发生线程竞争race, i.e. 我们用的是 futex, latency 是 20 纳秒nano seconds(ns). 你可以猜测, futex 是基于 atomic primitive
  8. 当使用 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中,我不得不用到多线程,因为涉及两个点逃不过:

  1. 和Kafka的通信
  2. 用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


分享好友

分享这个小栈给你的朋友们,一起进步吧。

分布式思考和实践
创建时间:2022-04-14 14:15:30
关于分布式在数据库领域的应用的一些思考以及一些程序应用的开发
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

栈主、嘉宾

查看更多
  • szstonelee
    栈主

小栈成员

查看更多
  • miemieMIA
  • LCR_
  • jinchuan
  • MrSun
戳我,来吐槽~