《Go 语言并发之道》读后感 – 第一章
前言
人生路漫漫,总有一本书帮助你在某条道路上打通任督二脉,《Go 语言并发之道》就是我作为一个 Gopher 道路上的一本打通任督二脉的书。说说我和它的偶遇,在一次 B 站云原生社区一次分享会上,众多大佬同推荐,并决定一起去读《Kubernetes 源码刨析》一书。我听到后心潮澎湃,冲到当当准备下单买下一本《Kubernetes 源码刨析》,但是发现竟然要邮费,那么我凑个单吧,在众多推荐中突然看见《Go 语言并发之道》,它作为树叶映衬着花朵次日便到了我楼下的快递柜。万万没想到,我对树叶的喜爱,远超花朵。
性能瓶颈
在 1965 年,戈丁·摩尔写了一篇三页的论文,成就了后期人们耳熟能详的摩尔定律。看到 Intel 如同牙膏一样的挤单核的频率,我们就可想物理性能极限的天花板或许已经到来了,所以开始推出多核 CPU ,多核多线程,以 Intel i9 为例已经是 8核16线程。
再以物理空间的举例,还记得5年前,我在一家传统行业龙头公司做桌面运维,在师父的指引下几乎将分公司所有的笔记本电脑拆解一同,炎炎夏日清理积灰。我就发现镶嵌在主板上的 CPU 是一个方方正正的放款。然而现在的 CPU 已经变成一个长方形躺在我们电脑主板上了。从形状的变化也可以看出 CPU 性能已经达到极限。
并发之苦
众所周知,并发代码是很难正确构建的。它通常需要完成几个迭代才能让它按预期的方式工作,即使这样,在某些时间点(更高的磁盘利用率,更多的用户登录到系统等)到达之前,Bug 在代码中存在数年的事情也不少见,以至于以前未发现的 Bug 在后面先露出来。
在书中提到了以下几种问题,在完成并发代码时常常遇见:
竞争条件
当两个或多个操作必须按正确的顺序执行,而程序并未保证这个顺序,就会发生竞争。例如:多个线程,进程同时修改一块内存空间,需要想办法确保修改的线后顺序,或正确性。
// 一个例子
var data int
go func(){
data++
}()
if data == 0{
fmt.Printf("The values is %v \n",data)
}
上面的代码有三种输出结果:
- 不打印任何东西
- 打印 “The values is 0″
- 打印 ”The values is 1″
你会发现上面的代码执行顺序乱了,这个需要亲自做实验,多执行几遍。你可以用一个 for{}
试一下。
为了解决以上的问题,我们可以让程序在执行过程中暂停几秒,试着等待看程序是否会恢复正常。
// 一个例子
var data int
go func(){
data++
}()
// 暂停 3s
time.Sleep(3 * time.Second)
if data == 0{
fmt.Printf("The values is %v \n",data)
}
但是在实际生产,生活中我们程序所需要的执行时间是不固定的,有可能当前网速快,请求就变快;有肯能服务器磁盘有坏道,写盘卡住很长时间;较大的 JSON 数据在序列化与反序列化上花费了过多时间。当这个时候你怎么确定 time.Sleep()
时间呢?
原子性
当某些东西被认为是原子的,或具有原子性的时候,这就以为者它运行的环境中,它是不可分割的或不可中断的。
第一件非常重要的事情就是 “上下文”。你的程序,操作系统,硬件,都存在上下文。操作的原子性可以根据当前定义的范围而改变。
书中举了一个非常有趣的例子,我们大家应该都玩过游戏,游戏的外挂就是修改了游戏程序的内存中的上下文从而加强了你的角色。这对于游戏开发者来说,他们的程序没有问题,健康良好的运行,但是外挂修改了游戏程序在操作系统环境中的上下文。
不可分割(indivisible)和不可中断(uninterruptible)这些术语在你锁定义的上下文中,原子的东西将被完整运行,例如:
i++
但是以上原子操作又可以拆分成三步:
- 检索 i 的值
- 增加 i 的值
- 存储 i 的值
我的理解,针对于我们所写代码的操作,和想要出现的结果,需要原子性。但是再对一个函数细分,它可能就不是原子性的。
内存访问同步
假设有这样一个数据竞争:两个并发进程视图访问相同的内存区域,他们访问内存的方式不是原子的。就会出现竞争。这里需要提出一个新的名词,叫临界区(critical section)。举个例子:
var data int
go func(){ data++ }()
if data == 0{
fmt.Println(data)
}else{
fmt.Println(data)
}
例子中有三个临界区:
- goroutine 正在使数据变量递增
- if 语句,它检查数据的值是否为 0
- fmt.Println() 语句,在检索并打印变量的值
为了保证内存访问操作的正确性,我们通常的方式是通过 sync
包在临界区加锁,好了现在我们知道加锁可以保证内存访问同步。那么问题来了:
- 我的临界区是否是频繁进入和退出?
- 我的临界区应该有多大?
死锁,活锁和饥饿
死锁:
死锁是所有的并发进程彼此等待的。在这种情况下,没有外界干预,程序将无法恢复。死锁例子,我这里就偷个懒不写了,相信刚接触 channel 的小伙伴一定被 deadlock
困扰了很久,在尘封的记忆中找出那段代码回一下吧。
出现死锁有几个必要条件。1971年,Edgar Coffman 的论文给出指导意见,Coffman 条件如下:
-相互排他,并发进程同时拥有资源的独占权。
- 等待条件,并发进程必须同时拥有一个资源,并等待额外的资源
- 没有抢占,并发进程拥有的资源只能被该进程释放,即可满足这个条件
- 循环等待,并发进程P1 必须等待一系列其他的并发进程 P2,这些并发进程同时也等待 P1 ,这样便满足了这个最终条件。
活锁:
活锁是正在主动执行并发操作的程序,但是这些操作无法向前推进程序的状态。我的理解就是各退一步,然后再退,这就是活锁。
书中用两个人从走廊的两头通过走廊是互退一步举例,我们生活中还有类似例子。例如:你骑自行车按照交通规则靠右行驶,迎面来一个二杆子没有遵守交通规则,这样的错车径历谁都经历过,很有可能就撞一起了。
饥饿:
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源。举个例子:《海贼王》近期路飞被凯多囚禁了,去工地板砖,但是他是一个贪婪的工人,把所有的砖都搬完了,获得了大量的饭票,其他工人没有饭票就得饿肚子。当然路飞还是会分享食物给其他工友,但是计算机中的程序可不会这么智能。
在日常的开发过程中,我们需要找到一个平衡点,同步访问内存是昂贵的,所以将我们的锁扩展到临界区之外是有利的。另一方面,这样做我们就得冒着饿死其他并发进程的风险。
还有来自外部的饥饿,例如:CPU,内存,文件句柄,数据库链接等,任何必须共享的资源都是有可能产生饥饿的原因。
确定并发安全
最后,我们来谈谈开发并发代码的最困难的地方,即所有其他问的根源——人。每一行代码后面至少有一个人。
注释,首次别这么严重的强调了一次,特别是在并发代码中,作者希望每一个负责并发的团队,或人,把每一个并发函数,接口(类),注释清楚。
- 谁负责并发?
- 如何利用并发原语解决这个问题?
- 谁负责同步?
如果没有足够的注释,调用方,复查代码的人可能需要非常多的时间才能够正确的使用已完成的并发代码,当这些人遇见这种情况,他可能选择重构。反复造轮子,你就会发现 TMD 重复代码怎么这么多!
面对复杂的简单性
这也许是我选择 Go 语言作为我的主语言的原因,作为一个从 Python 到 Go 的运维开发工程师,写 Go 代码的时候无数次回想起 Python 操作列表,字典的便捷,而且在写代码的时候是如此优雅,就想我们在说话写文章一样,然而开心是有代价的。写时简单,部署难,是我对 Python 程序的总结。Go 的代码看起来虽然丑,写起来也觉得丑,但是写时难,部署易,这对于运维来说,so happy!
并发方面,Python 线程池,进程池,需要各导入不同的包才可使用,协程不在官方库内,此时苦瓜脸。然而 Go 从原语级别解决这个问题,启动 goroutine 只需 go 即可,多个协程间的通信,我们创建 channel 即可
没有用过其他的语言,比如:Java,C++, Rust 等,我也不好做比较。
再次声明,我并没有诋毁 Python ,作为一个运维,没有 Python 这个世界是不完整 。:)