<br>java
> 硬件工程师为均衡 CPU 与 缓存之间的速度差别,特地加的 CPU 缓存,居然在多核的场景下阴差阳错的成为了并发可见性问题的万恶之源!(本文过长,若是不是特别无聊,看到这里就能够了)程序员
还记得那些年,你写的那些多线程 BUG 吗?明明只想获得个 1 + 1 = 2 的预期,结果他有时候获得 1,有时候获得 3,但恰恰有时候他也会返回正确的 2。明明在本地运行的好好的,一上线一堆诡异的 BUG。你一遍一遍的检查代码,一行一行 debug,结果无功而返。<br> <br>变量为什么忽然变异?代码为什么乱序运行?条件为什么形同虚设?欢迎收看今天的《走进科学》之半夜。。。哦,不对,欢迎阅读今天的《并发那些事》之可见性问题的万恶之源。就像上面说的,咱们在写并发程序时,常常会出现超出咱们认识与直觉的问题,而按咱们的以往的经验,很难去察觉到他的问题所在。而又由于咱们不了解他发生的诱因,即便咱们按照书上的方案解决了,可是下次仍是会出现。因此本文的主旨并非解决问题的术,而是解决问题的道。一块儿来探究多线程问题的根源。<br> <br>首先揭开谜底,大多数并发问题的发生都是这三个问题致使的,可见性问题、原子性问题、有序性问题。那么又是什么致使这三个问题的出现呢?本文将一步步解析可见性问题出现的缘由。<br>浏览器
众所周知,电脑由不少的部件组成。其中最最最重要的有三个,它们分别是 CPU 、内存、IO(硬盘)。通常来讲它们三个的性能高低直接影响到了电脑的总体的性能优劣。<br> <br>可是从它们诞生之初,就有一个核心矛盾,即便过了几十年后的如今,科技的飞速发展依旧没能解决。那么是什么矛盾呢?<br> <br>在说矛盾以前,先说我个同事,他是个电竞高手,英雄联盟、王者荣耀什么的意识特别历害。每次看比赛的时候那种指点江山、挥斥方遒的英姿闪闪发光。可是呢,一上手打游戏,一顿操做猛如虎,一看战绩0杠5,刚开始咱们觉得他是个青铜,可是呢,不少时候游戏的真的就像他说的那样,他的预判,他的操做其实都至关的风骚。一直很疑惑,直到咱们得出了一个结论,其实他的确是一个王者,由于他满脑子都是骚操做,可是呢?他的双手跟不上他风骚的大脑。<br> <br>问题就在这里,核心矛盾就是速度的差别。CPU 就像是那位同事的大脑,很强很风骚,可是奈何 IO 就像那双跟不上节奏的手,限制了发挥。并且它们之间的速度差别要远远超出咱们的想像,CPU 就比如是火箭,那么内存就是三轮车,IO 可能就是马路旁一只不起眼的小蜗牛。缓存
既然有了这个问题,那就要想办法解决,首先这个问题出在硬件层,因此首当其冲的硬件工做师想了不少方式试图去解决。通过内存跟 IO 硬件工程师的不懈努力,这两个组件的速度都获得了大幅提高。可是呢?CPU 的工程师也没闲着,甚至英特尔的 CEO--高登·摩尔还宣布了一个以本身姓名定义的摩尔定律。其内容大体以下:<br>服务器
> 集成电路上可容纳的晶体管数目,约每18个月便会增长一倍微信
<br>能够简单的理解,CPU 每 18 个月性能就能翻一倍。这就让内存跟 IO 的硬件工程师很绝望了,不怕别人比你聪明,就怕比你聪明的人还比你努力。这仍是怎么玩?<br> <br><br> <br>固然,独木不成林,CPU 工程师也意识到了这个问题,我再怎么独领风骚,以1V5。没有用呀?打的正嗨,一回头,家被推了。我下了一部电影,双击打开,CPU 飞速运行,IO 在缓慢加载。我 CPU 运行到冒烟也没用呀,IO 制约了。结果就是电影变成了 PPT,一秒一停。这样下去你们都没得玩。眼看其它队友带不动,CPU 工程师想出了一个办法,我在 CPU 里面划一块出来作为缓存,这个缓存介于 CPU 与 内存之间,跟咱们经常使用的缓存功能差很少,为了均衡 CPU 与内存之间的速度差,在执行的时候会把数据先从 IO 加载到 内存,再把内存中的数据加载到 CPU 的缓存之中。将经常使用或者将用的数据缓存在 CPU 中后,CPU 每次处理时就不用总是等内存了,这极大的提升了CPU 的利用率。<br> <br>到这里,硬件工程师圆满的完成了任务,下面轮到了咱们软件工程师登场了。<br> <br>虽说加了缓存以后,CPU 的利用率成倍上升,从当初的运行 5 分钟,加载 2 小时。变成了,运行 2 分钟,加载 1 小时,可是体验仍是不好。还拿电影举例,看电影的时候不光有画面,还得有声音呀,你运行是快了,可是先放视频,再放声音。就像是先看一部默片,再听一遍广播,这种音画分离的观感没比 PPT 强多少。<br> <br>后来在软硬工程师的天才努力后,发明了一种神奇的东西--线程。说线程以前咱们先说一下进程,这个东西但是咱们能看到的东西,比始你启动的浏览器,好比你正在使用的微信,这些软件启动后,在操做系统中都是一个进程。而线程呢?它能够简单理解成是一个进程的子集,也就是说进程实际上是一堆线程组成。并且操做系统一般会把全部硬件资源,包括内存以内的全分配给进程,进程就像一个包工头同样再分配给底下的线程。可是惟独有同样资源,操做系统是直接分配给线程的,那就是 CPU 资源。<br> <br>这样的设置实际上是有深意的。可能有人以为,分给进程也能够呀,可是进程要比线程重的多,切换的开销过大,得不尝试。就像是你想打开一个新的网页,是打开一个新浏览器快呢?仍是打开一个新的 Tab 页快呢?总之有了线程以后,咱们就有了一个很酷炫的操做--线程切换。他能带来什么呢?接着说电影的事,咱们其实仍是先播视频再放声音。可是与上面不一样的是,咱们是先放一会视频,再放一会声音。只要单次播放的够短,两种操做之间的切换够快,就会让人感受其实视频与声音是同时播的错觉。而轻量的线程以及提供的切换能力给这种操做提供了可能。<br> <br>至此,问题在无数硬件与软件工程师的努力下,获得了比较完美的解决。<br>多线程
事情到了这里,本该皆大欢喜、功德圆满。结果英特尔又出来搞事,但其实他此次也是被逼无奈。<br> <br>还记得咱们上面说的以英特尔 CEO--高登·摩尔命名的摩尔定律吗?这个定律其实并非根据严谨的科学研究得出来的,而是经过英特尔的过往表现推导出的这个结论。按理说这是极不符合科学规律的,就像我遇到的每一个程序员都背个电脑包,可是我在大街上不能随便看到一个背着电脑包的人就说他是程序员。可是英特尔就是这么 NB,他在的大街上全是程序员。英特尔就这样维护着这个定律每 18 个月把 CPU 的性能翻一倍,持续了每多年。<br> <br><br> <br>直到第四任 CEO 的时候,摩尔定律忽然不灵了,上图就是时任英特尔 CEO--克瑞格·贝瑞特。在一次技术大会上,向与会者下跪。为一再延期直至最终失败放弃的 4GHz 主频奔 4 处理器致歉。<br> <br>到此,摩尔定律终结,CPU 的发展进入了瓶颈。直到有一天一个脑门闪光的硬件工程师敲响了克瑞格·贝瑞特办公室的大门。"老板你不用跪了,我有个办法能够把 CPU 性能提升一倍"。架构
一句话让克瑞格老泪纵横,那一天,回想起了,受那些家伙支配的恐怖……被囚禁在鸟笼中的屈辱……并发
克瑞格激动的问道:"什么方案?"分布式
硬件工程师:"很简单呀,咱们只要把如今两个的 CPU 装到一个大号的 CPU 里面,那么他的性能就是两个 CPU 的性能呀!我可真是一个小机灵鬼呢"
作了一生 CPU 的克瑞格,气的差点进了 ICU。"我老克就算跪一生,也不会作这种傻事"。
上图为英特尔发布的 28 核 CPU。嗯?<br> <br><br> <br>固然上面其实有些戏谑的成分,可是 CPU 的发展结果也的确是往更多的核心数去发展。从单核到双核再 6 核、8核不停的增加核心数,CPU 的性能也的确跟着增加。这其实跟咱们软件工程师经常使用的分布式架构同样,当单机的性能达到了瓶颈,不可能再经过纵向的增长服务器的性能提升系统负载,只能经过把单机系统,拆成多个分布式服务来进行横向的扩展。<br> <br>经过增长 CPU 的核心数,硬件工程师看似圆满的完成时代交给他的任务。结果一口大锅甩在了我们软件工程师的头上。<br> <br>来,咱们回顾一下,上面咱们说 CPU、内存、IO 他们有一个核心矛盾,这个矛盾就是速度的差别。并且这个差别仍然没有解决。可是咱们变相的解决了。解决方案是什么?硬件工程师在 CPU 的核内心划了一块地方作为缓存,经过这个缓存均衡他们之间的差别。而软件工程师呢,为了最大的提升 CPU 的利用率,搞了一个叫线程的东西,经过多线程之间的切换圆满解决问题。<br> <br>嗯,这个方案很完美,没有问题。可是,前提是运行在单核的 CPU 下。<br> <br>刚才咱们说了 CPU 的核心,会有一块地方缓存从内存里加载的数据,这样就不用每次从内存里加载了,提升了效率。可是呢,单核有一个缓存,多核就会出现多个缓存,再加上咱们多线程的运行,会出现什么状况呢?下面咱们以真实代码为例子:<br>
public class TestCount { private int count = 0; public static void main(String[] args) throws InterruptedException { TestCount testCount = new TestCount(); Thread threadOne = new Thread(() -> testCount.add()); Thread threadTwo = new Thread(() -> testCount.add()); threadOne.start(); threadTwo.start(); threadOne.join(); threadTwo.join(); System.out.println(testCount.count); } public void add() { for (int i = 0; i < 100000; i++) { count++; } } }
<br>代码很简单,两个线程都调用一个 add 方法,而这个 add 方法的操做是循环 10 w 次,每次都把这两个线程共享的 count 变量加 1 。按照咱们的直觉来讲,count 开始是 0,每一个线程加 10 w,总共两个线程,因此 10 w * 2 = 20 w。<br> <br>但是呢?结果并非咱们想的那样,我运行的结果是:113595。并且每次运行的结果都不同,你能够试试。结果基本上都在 10w ~ 20w 之间,并且无限趋向于 10w。<br> <br>这是什么鬼?还记得前面说的 CPU 缓存吗?没错,他就是这只鬼。为了便于说明问题,我画了几张图。<br> <br><br>上图是在单核的状况下,首先这个 count 会被加载到内存中。这时他是初始值 0。而后如图所示,第 1 步他被加载到了 CPU 的缓存中,CPU 处理器把他从缓存中取出来,而后进行 add 操做,加完以后再放入缓存中,缓存再把 count 写入内存中,最终咱们就获得告终果。可见单核状况下,由于共享缓存与内存,没有任何问题,咱们接着看多核的状况下。<br> <br>
<br>如上是多核场景下的运算过程,具体步骤以下:<br>
看到问题了吗?能够理解缓存中的 count 是内存中的 count 的一份拷贝。在缓存中修改时并不会变动内存中的值,而是过一段时间后刷新回内存,而线程1把计算了一半的值,刷新进内存后,线程2把这个新值加载到了 CPU2中,而后计算。与些同时 CPU 1完成了计算,并把值刷新进了内存,CPU2仍在计算,由于他不知道 CPU1把值改变了,计算完了,把本身计算的值也刷新进了内存中,这样就把刚刚 CPU1 忙乎半天的结果覆盖了。<br> <br>出现这个问题的根本缘由就是,CPU 1与 CPU 2各自的操做对于双方不可见。在这种状况下,运行期间其实总共有 3 个 count 变量,一个是内存中的 count,一个是 CPU1中的 count拷贝,最后一个是 CPU2中的 count 拷贝。<br>
硬件工程师为均衡 CPU 与 缓存之间的速度差别,而特地加的 CPU 缓存,居然在多核的场景下阴差阳错的成为了并发问题中可见性的根源!<br>
本文是《并发那些事》的第三篇,前两篇以下:
<br> <br> <br> <br> <br> <br> <br> <br> <br> <br> <br>