关于cpu:从CPU的视角看-多线程代码为什么那么难写

27次阅读

共计 3107 个字符,预计需要花费 8 分钟才能阅读完成。

  当咱们提到多线程、并发的时候,咱们就会回想起各种诡异的 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 关键字?

正文完
 0