Golang是一门静态的,强类型的,高并发的语言,而本书会聚焦在高并发的方面。第一章则从并发的基本内容讲起,大致包括几个方面:
- 什么是并发?
- 并发为什么困难?
- 并发为什么值得研究?
摩尔定律的内容是众所周知的,大概就是集成电路上的晶体管的数量每两年就会翻一番。
这个定律的起因在于,人类迫切地需要提升单位空间的运算能力,因此穷极一切地去努力提升运算能力。而提升运算能力这一需求又源于当今世界上的存储能力和数据产生量的提高。
但是摩尔定律所预估的速度正在逐步放缓,除非在一段时间以内人类的基础技术能有质的改变。而在这个条件之下应运而生的产物便是:多核处理器。
以多个核心来处理来处理和解决问题,效率相比起以往肯定是提高了的,尤其在问题可以由各个独立的程序构成而不互相影响的情况下更能有所体现,但是Gene Amdhl提出了一条定律:
并行计算的收益的限制取决于有多少程序必须以顺序的方式编写。
也就是说并行计算提升的效率取决于能够独立执行的程序。
云计算,现在的时代的热词。它的思想在时代思潮中根深蒂固,云计算隐含了一种新的规模化的方法,用于应用程序部署和水平扩展。云计算意味着可以访问大量的资源池,它们被自动调配到适当的机器中以满足工作负载需求。
云计算带来的直接后果是开发者获得了大量的运算能力,可以用来解决庞大的问题。
但是云计算的实现并非动动嘴皮子就可以完成的,需要在机器实例之间通信,聚合和存储结果等等,但是最最最困难的莫过于如何设计并行计算的模型
接下来将从设计并行计算模型的角度来分析为什么并发很难。
当两个或者多个操作必须按正确的顺序执行,而程序并未保证这个顺序,就会发生竞争条件。
也就是说,如果你希望程序的执行顺序为A,但是不恰当的引入并发会导致执行顺序的改变,从而影响程序的准确性。
同时不正确的导入并发会给程序带来巨大的不稳定性。
很多程序员解决不确定性的方式是加入耗时操作:比如休眠(time.Sleep())。但是事实上,错误的代码和错误的逻辑势必会导致错误的结果,加入休眠仅仅使得到的结果在概率上更加接近逻辑的准确性,但是永远不会变成真正的逻辑正确。
竞争条件可能存在在一段被认为是正确的代码中而不被发现,这种代码只是看上去在用正确的方式来执行,但是事实上只是执行的顺序是正确的这件事情本身的概率比较大而已,最终早晚有可能会出现一些意料之外的结果。
原子性是指:当某些东西被认为是原子时,或者具有原子性的时候。这意味着它在运行的环境中,它是不可分割或者不可中断的。
原子性的判定的一个很重要的条件就是程序的上下文。在一个上下文中可能是原子,在另一个程序中可能就不是一个原子。
代码的原子性具有一些性质:原子操作的结合并不会产生更大的原子操作。使一个操作成为原子操作应当取决于它的上下文。
如果某个东西是具有原子性的,则它在并发环境中是安全的。
因此在并发中,确认代码的哪些部分需要原子化,以及在什么程度和级别上原子化。
在程序中需要独占访问共享资源的部分中,对其对于内存的访问做同步控制。
在接下来的讨论之前,先提及一个概念:临界区( Critical Section)。
程序中需要独占访问共享资源的部分,称为临界区。
临界区是一个代码片段,这个代码片段的特点是独占了对于共享资源的访问。
现在假设两个并发进程试图访问相同的内存区域,它们访问内存的方式不是原子的(即可以被中断的)。
那么为了保证一个临界区内的代码能够按照预期的来执行,也就是访问内存的过程不被中断(因为这会给程序带来更大的不稳定性),我们就必须保证一个临界区内对于内存的访问的稳定性。
既然无法改变读写内存的原子性,我们就使用锁(Lock)来解决内存访问同步的问题(这实际上很常见,从数据库原理中我们也经常见到,锁在计算机系统和网络资源的访问控制上也很常见)。
给每个临界区加锁,这就可以了吗?
频繁的加锁是程序运行的效率变低了,但是在我们的眼中这显然要比程序的逻辑是错误的要好的多。
在考虑临界区的对于内存访问的控制的问题的时候我们需要考虑两个问题:
在程序的上下文中解决这两个问题是一种艺术,并且增加了内存访问同步的难度。
竞争条件,原子性和内存访问同步的讨论主要的意义在于维持你程序的正确性:并非只是在当前的测试和运行中能够拿到正确的结果,而是代码在逻辑上就是正确的。这样就可以保证程序的输出永远是正确的。这非常的重要。
但是在之前的讨论中留下了一些问题: 我们虽然使用了锁来保证了内存访问的同步,但是锁的引入也造成了效率的下降,以致于我们不得不去思考我们的临界区应该如何分布和控制。
不正当的使用锁会导致你的程序完全停止运行!
这一类问题就是:死锁、活锁和饥饿
死锁在gopher的世界中永远是常见的,从字面意思来看也十分的清晰:锁死掉了,也就是永远都不会打开了。
死锁程序是所有并发进程彼此等待的程序,在这种情况下,如果没有外界的干预,这个程序将永远无法回复。
如何构成一个死锁呢?其实非常的简单。这本书提供了一个最简单的例子:有两个goroutine对两个资源AB先后进行访问,但是goroutineA先访问A后访问B,goroutineB先访问B后访问A。而在两次访问之间有耗时操作。
究竟会导致什么后果呢?
Deadlocks这次让我们的程序直接宕掉了。
死锁真的不好啊,虽然golang会自动帮我们检测死锁的发生,但是这毕竟是不够的。我们需要自己去检测程序中发生死锁的可能性。万幸的是,早在1971年一位名叫Edgar Coffman的人就在论文中列举了产生死锁的条件:现在被成为Coffman条件。
- 相互排斥
- 并发进程同时拥有对于资源的独占权
- 等待条件
- 并发进程必须同时拥有一个资源,并且等待额外的资源
- 没有抢占
- 并发进程拥有的资源只能被该进程释放,即可满足这个条件
- 循环等待
- 一个并发进程必须等待一系列其他并发进程,这些并发进程同时也在等待该进程,这样就满足了最终的条件
谦让是中国人自古以来的优良传统。
先讲一个故事:
你在马路上骑车,然后对面也有人骑车过来,你们刚好正对。于是你们双方以同样的反应速度发挥中国人的优良传统:谦让。然后你们同时左拐右拐…终于你们撞上了…
这就是活锁。
活锁是正在主动执行并发操作的程序,但是这些操作无法向前推进程序的状态。
产生活锁的常见的原因便是:两个或者两个以上的并发进程试图在没有协调的情况下防止死锁。
这里的饥饿是指并发进程对于内存的访问控制权的饥饿,也就是一些并发进程会比另外一些进程更加的横行霸道。
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源。
这个贪婪的,横行霸道的并发进程不公平的组织一个或者多个并发进程,导致自己的工作效率高但是其他并发进程的工作效率被排挤。
一个稍微有一点良心的并发进程,在结束对于一个资源的原子性的操作后,会选择释放锁。
但是一个没有良心的并发进程,它会自始至终以尽可能长的时间来霸占锁。
这是多么的多么的…emmm霸道啊?
如何确定并发的安全呢?
在协同工作的时代,大家使用的代码很可能不出自自己的手,这意味着为了确定并发安全,代码的开发者需要加上注释!
不然的话,一个新手面对一段代码:
一旦这些问题没有一个确定的答案,很显然有可能出现一些奇奇怪怪的后果。没有人希望这样。
所以:写注释!
这一节大概就是狠狠地吹了一顿golang。