首页 > 其他分享 >从CPU的视角看 多线程代码为什么那么难写!

从CPU的视角看 多线程代码为什么那么难写!

时间:2023-04-30 22:32:01浏览次数:36  
标签:难写 Cache 线程 内存 多线程 数据 CPU

  当我们提到多线程、并发的时候,我们就会回想起各种诡异的bug,比如各种线程安全问题甚至是应用崩溃,而且这些诡异的bug还很难复现。我们不禁发出了灵魂拷问 “为什么代码测试环境运行好好的,一上线就不行了?”。 为了解决线程安全的问题,我们的先辈们在编程语言中引入了各种各样新名词,就拿我们熟悉的Java为例,不仅java语言自带了synchronized、volatile、wait、notify… ,jdk中各种工具包也是层出不穷,就比如单一个Lock,就可以有很多种实现,甚至很多人都谈锁色变。

  为什么会出现这种情况,我们得先从CPU和主存(RAM)的关系说起。 上个世纪80年代,PC机兴起的时候,CPU的运算速度只有不到1MHz。放现在你桌上的计算器都可以吊打了它了。那时候就是因为CPU运算慢,它对数据存取速度的要求也不那么高,顶多也就1微秒(1000ns)取一次数据,一次访存100ns对CPU来说也算不上什么。 然而这么多年过去了,CPU一直在沿着摩尔定律的道路一路狂奔,而内存访问延迟的速度却一直止步不前。(当然存储也有非常大的发展,但主要体现在容量方面,而访问延时自诞生初就没什么变化)。

  我们来对比下CPU和内存过去几十年之间的发展速率: 在这里插入图片描述

  可以看出,在过去40年里, CPU的运算速度增量了上千倍,而内存的访问延时却没有太大的变化。 我们就拿当今最先进CPU和内存举例,目前商用的CPU主频基本都是3GHz左右的(其实十多年前基本上就这个水平了),算下来CPU每做一次运算仅需0.3ns(纳秒)。而当前最先进的内存,访问延迟是100ns左右的,中间相差300倍。如果把CPU比作一个打工人的话,那么他的工作状态就会是干一天活然后休一年,这休息的一年里等着内存里的数据过来(真是令人羡慕啊)。

  其实CPU的设计者早就意识到了这点,如果CPU真是干1休300的话,未免也太不高效了。在说具体解决方案前,我这里先额外说下内存,很多人会好奇为什么主存(RAM)的访问速度一直上不来? 这个准确来说其实只是DRAM内存的速度上不了。存储芯片的实现方式有两种,分别是DRAM和SRAM,SRAM的速度其实也一直尽可能跟着CPU在跑的。那为什么不用SRAM来制造内存?这个也很简单,就是因为它存储密度低而且巨贵(相对于DRAM),所以出于成本考量现在内存条都是采用DRAM的技术制造的。

  SRAM容量小成本高,但速度快,DRAM容量大成本低,但速度慢。这俩能不能搭配使用,取长补短?结论是肯定的,在计算机科学里有个”局部性原理“,这个原理是计算机科学领域所有优化的基石。我这里就单从数据访问的局部性来说,某个位置的数据被访问,那么相邻于这个位置的数据更容易被访问。那么利用这点,我们是不是可以把当前最可能被用到的小部分数据存储在SRAM里,而其他的部分继续保留在DRAM中,用很小的一块SRAM来当DRAM的缓存,基于这个思路,于是CPU芯片里就有了Cache,CPU的设计者们觉得一层缓存不够,那就给缓存再加一层缓存,于是大家就看到现在的CPU里有了所谓的什么L1 Cache、L2 Cache, L3 Cache。

  存储示意图如下,真实CPU如右图(Intel I7某型号实物图): 在这里插入图片描述

  多级缓存的出现,极大程度解决了主存访问速度和CPU运算速度的矛盾,但这种设计也带来了一个新的问题。CPU运算时不直接和主存做数据交互,而是和L1 Cache交互,L1 cache 又是和L2 Cache交互…… 那么一定意味着同一份数据被缓存了多份,各层存储之间的数据一致性如何保证? 如果是单线程还好,毕竟查询同一时间只会在一个核心上运行。但当多线程需要操作同一份数据时,数据一致性的问题就凸显出来了,如下图,我们举个例子。
在这里插入图片描述
  在上图中3个CPU核心各自的Cache分别持有了不同的a0值(先忽略E和I标记),实际上只有Cache0里才持有正确的数值。这时候,如果CPU1或者CPU2需要拿着Cache中a0值去执行某些操作,那结果可想而知。如果想保证程序在多线程环境下正确运行,就首先得保证Cache里的数据能在"恰当"的时间失效,并且有效的数据也能被及时回写到主存里。

  然而CPU是不知道当前时刻下哪些数据该失效、哪些该回写、哪些又是可以接着使用的。这个时候其实CPU的设计者也很犯难,如果数据频繁失效,CPU每次获取必须从主存里获取数据,CPU实际运算能力将回到几十年前的水平。如果一直不给不失效,就会出现数据不一致导致的问题。于是CPU的设计者不干了:”这个问题我处理不了,我给你们提供一些可以保证数据一致性的汇编指令,你们自己去处理”。 于是大家就在intel、arm的开发手册上看到了像xchg、lock、flush……之类的汇编指令,C/C++语言和操作系统的开发者将这些封装成了volatile、atomic……以及各种系统调用,JVM和JDK的开发者又把这些封装了我在文首说的那一堆关键词。 于是CPU的设计者为了提升性能导致数据一致性的问题,最终还是推给了上层开发者自己去解决。

  作为上层的开发者们(比如我们)就得判断,在多线程环境下那些数据操作必须是原子操作的,这个时候必须使用Unsafe.compareAndSwap()来操作。还有那些数据是不能被CPU Cache缓存的,这个时候就得加volatile关键词。极端情况下,你可以所有的操作搞成原子操作、所有的变量都声明成volatile,虽然这样的确可以保证线程安全,但也会因为主存访问延时的问题,显著降低代码运行的速度。这个时候局部性原理又发挥出其神奇的价值,在实际情况下,绝大多数场景都是线程安全的,我们只需要保证某些关键操作的线程安全性即可。举个简单的例子,我们在任务向多线程分发的时候,只需要保证一个任务同时只被分发给一个线程即可,而不需要保证整个任务执行的过程都是完全线程安全的。

  作为Java开发者,Java和JDK的开发者们已经帮我们在很多场景下封装好了这些工具,比如我们就拿ReentrantLock实现一个多线程计数器的例子来看。 在这里插入图片描述

  其中increment() 本身不是一个线程安全的方法,如果多个线程并发去调用,仍然会出现count值增长不准确的问题。但在lock的加持下,我们能保证increment()方法同时只能有一个线程在执行。想象下,如果我们把上述代码中的counter()方法换成一些更复杂的方法,而完全不需要在方法中去考虑线程安全的问题,这不就实现了仅在关键操作上保证准确性就能保证全局的线程安全吗!而当我们去深究lock的实现时,就会发现它底层也只是在tryAcquire中使用CAS设置了state值。 在这里插入图片描述
  在多线程编程中,加锁或加同步其实是最简单的,但是在什么时候什么地方加锁却是一件非常复杂的事情。你需要考虑锁的粒度的问题,粒度太大可能影响性能,粒度过小可能导致线程安全的问题。还需要考虑到加锁顺序的问题,加锁顺序不当可能会导致死锁。还要考虑数据同步的问题,同步的数据越多,CPU Cache带来的性能提升也就越少……

  从上面CPU的发展变化我们可以看到,现代CPU的本质其实也是一个分布式系统,很多时候仍需要编程者手动去解决数据不一致性的问题。当然随着编程语言的发展,这些底层相关的东西也逐渐对普通程序员变得更透明化,我们是不是可以预想,未来是不是会有一门高性能、并且完全不需要程序员关注数据一致性的编程语言出现? 

  最后上面计数器代码给大家留一个思考题: 代码中的counter变量声明是否需要加volatile关键字?

标签:难写,Cache,线程,内存,多线程,数据,CPU
From: https://blog.51cto.com/xindoo/6238340

相关文章

  • Android开发多线程断点续传下载器
    111Android开发多线程断点续传下载器分类: Android 2011-10-0123:14931人阅读 评论(8) 收藏 举报使用多线程断点续传下载器在下载的时候多个线程并发可以占用服务器端更多资源,从而加快下载速度,在下载过程中记录每个线程已拷贝数据的数......
  • 获得android手机的CPU核心数
    //CPU个数privateintgetNumCores(){//PrivateClasstodisplayonlyCPUdevicesinthedirectorylistingclassCpuFilterimplementsFileFilter{@Overridepublicbooleanaccept(Filepathname){//Checkiffilenameis&......
  • HPL:多进程CPU性能测试
    使用HPL进行CPU性能测试CPU型号为:AMDRyzen75800H,本次实验使用两台Linux虚拟机测试,每个虚拟机分配一个CPU。理论浮点峰值=CPU主频×CPU核数×CPU每周期执行浮点运算的次数本机CPU的基准频率为3.2GHZ,加速频率为4.4GHZghz。CPU有8核16线程(一个核虚拟成2个CPU),CPU单个周期浮点计算......
  • Pytorch2 如何通过算子融合和 CPU/GPU 代码生成加速深度学习
    动动发财的小手,点个赞吧!PyTorch中用于图形捕获、中间表示、运算符融合以及优化的C++和GPU代码生成的深度学习编译器技术入门计算机编程是神奇的。我们用人类可读的语言编写代码,就像变魔术一样,它通过硅晶体管转化为电流,使它们像开关一样工作,并允许它们实现复杂的逻辑——这......
  • 实验2 多线程
    创建一个线程#include<stdio.h>#include<unistd.h>#include<pthread.h>#include<sys/types.h>void*threadFunc(void*arg){printf("InNEWthreaad\n");}intmain(){ pthread_ttid;//createthreadfunctionpthread_create(......
  • Java多线程之---用 CountDownLatch 说明 AQS 的实现原理
    本文基于jdk1.8。CountDownLatch的使用前面的文章中说到了volatile以及用volatile来实现自旋锁,例如java.util.concurrent.atomic包下的工具类。但是volatile的使用场景毕竟有限,很多的情况下并不是适用,这个时候就需要synchronized或者各种锁实现了。今天就来说一下几......
  • C# 多线程
    首先要关注电脑配置是否是多核多CPU的。因为一个CPU在同一时刻只能运行一个线程,但是多个CPU在同一时刻就可以运行多个线程。 多线程的优点:1、可以同时完成多个任务;2、可以使程序的响应速度更快;3、可以让占用大量处理时间的任务或当前没有进行处理的任务定期将处理时间让给......
  • 多线程读写文件
    参考:实践1-2:多线程读写文件-l.w.x-博客园(cnblogs.com)得到的结论是,可以多线程读写,但是会有多种情况:多线程同时读同一个文件,在这种情况下并不会造成冲突多线程同时写同一个文件,会造成写数据丢失多线程同时对同一个文件进行写和读,会造成脏读解决办法是加锁,同时......
  • 《Effective C#》系列之(六)——提高多线程的性能
    一、综述《EffectiveC#》中提高多线程性能的方法主要有以下几点:避免锁竞争:锁的使用会导致线程阻塞,从而影响程序的性能。为了避免锁竞争,可以采用无锁编程技术,如CAS(Compare-And-Swap),Interlocked等。使用ThreadPool:ThreadPool是.NETFramework提供的一个线程池,它可以......
  • 聊聊开关和CPU之间故事
    目录开关电报和继电器门电路材料学的发展继电器与哈佛Mark1号真空管与巨人一号晶体管与IBM608计算机2大特性:计算能力和记忆能力作者:小牛呼噜噜|https://xiaoniuhululu.github.io大家好,我是呼噜噜,在先前的一篇文章计算机中数值和字符串怎么用二进制表示?中,我们知道了计算机为......