之前囫囵吞枣地靠着有道词典把《Mastering GO》看了一遍,什么笔记都没记,回头一想好像什么也没记住,英语水平差也不太可能去二刷,现在看《Concurrency in GO》不能犯之前的错误,需要记点什么,不是翻译书籍,一方面是转化成自己的语言,其次是时间长了可以来复习,还可以给想学习的小伙伴一起看看,共同进步。
第一章主要介绍了并发的发展历史和并发编程的难点,最后介绍 GO 语言在这些困难之处做出的努力,自带并发和低延迟的垃圾回收机制、内存管理机制、丰富的同步原语和运行时系统可以兼顾性能和可维护性的同时,更容易写出正确的并发代码,降低程序员的心智负担,make your life easier。
摩尔定律,网络规模和我们所处的困境
介绍了计算机科学发展的一些历史和并发编程出现的必然性(多核处理器的诞生)。
被称为计算机第一定律的摩尔定律是指IC上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。广泛应用于众多领域,形容指数级的增长。
为什么并发编程很难?
并发代码很难编写,一般需要多次迭代才能按照预期的方式工作,bug 隐藏很深,甚至几年前的代码在某些场景下(磁盘使用率高、大量的用户登入到系统等)也会出现问题。大部分并发编程的问题可以归纳总结为下面几种。
数据竞争(data race)
多个并发线程(至少一个写操作)同时尝试访问同一块内存区域,并且操作的方式不是原子性的,读线程正在读取内存,而写线程还没有完成写入操作,这时会读取到不完整的数据。
数据竞争的原因
我们知道,一枚 CPU 核心同一时刻只能执行一条机器指令,指令因其不可分割性被称为原子操作,即:不能拆分成更小的操作。
注:希腊单词 atom (ἄτομος; atomos),表示不可切分。
不可分割性让原子操作天然具备线程安全:当一个线程写入共享数据操作具有原子性时,其他线程在写入操作完成之前无法读取数据;反之,当一个线程读取共享数据操作具有原子性时,一定能够读到某个时刻的完整数据,不会发生数据竞争。
坏消息是,程序中绝大多数的操作并不是原子操作,即使如 x = 1 这样简单的赋值语句,在硬件上也可能由多条机器指令组成,赋值语句本身并不是线程安全的。
举个例子,
package main
import (
"fmt"
"sync"
)
func main() {
var s int32
var wg sync.WaitGroup
for i := 0; i <= 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s++
}()
}
wg.Wait()
fmt.Println(s) // 9462,每次执行结果都不一样
}
数据竞争的解决办法
解决办法有多种,互斥锁和原子操作等等,代码如下:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var s int32
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&s, 1) // 原子操作
}()
}
wg.Wait()
fmt.Println(s) // always 10000
}
竞态条件(race condition)
多个线程按照不可预知的顺序执行操作,而实际的要求应该按照指定顺序执行,程序的运行结果产生(预料之外)的变化。
竞态条件的原因
就像操作系统能完全控掌控线程管理,按照调度算法启动、暂停、终止线程,而程序员无法控制线程执行时间或执行顺序。程序员也无法控制 goroutines 的执行顺序,甚至无法掌控 goroutines 启动需要多长时间。
举个例子:
1 var data int
2 go func() {
3 data++
4 }()
5 if data == 0 {
6 fmt.Printf("the value is %v.\n", data)
7 }
执行以上示例代码会有三种不同的可能,程序执行的结果完全无法预期。
- 什么都不输出,代表第 3 行在第 5 行之前执行
- the value is 0,代表第 5 行在第 3 行之前执行,并且第 6 行也在第 3 行之前执行
- the value is 1,代表第 5 行在第 3 行之前执行,但第 6 行在第 3 行之后执行
这类问题经常发生在当一个线程执行 "check-then-act"(检查如果 value 等于 X 然后执行一些逻辑),然而另外一个线程在 “check” 和 “act” 之间对 X 进行一些操作的时候。比如:
if (x == 5) // The "Check"
{
y = x * 2; // The "Act"
// If another thread changed x in between "if (x == 5)" and "y = x * 2" above,
// y will not be equal to 10.
}
这时候 y 的结果完全无法预料。
竞态条件的解决办法
为了防止竞态条件的产生,可以将这一系列操作作为一个critical section(临界区)。
// 临界区加锁
if (x == 5)
{
y = x * 2; // Now, nothing can change x until the lock is released.
// Therefore y = 10
}
// 临界区释放锁
Go 语言提供了检测竞态的工具,go run -race main.go 或者 go build -race main.go
原子性
当讨论原子性的时候,意味着在当前上下文,该操作是不可分割或不可中断的。
首先一个很重要的概念“上下文”。有些操作在一个上下文中是原子性的,不代表在其他上下文中也是。比如一个操作在你的进程上下文中是原子性的,在操作系统的上下文中就可能不是。操作系统上下文中的原子操作在机器上下文中就可能不是。总之,一个操作的原子性与否,会随着你当前定义的上下文或作用域改变而改变。考虑原子性时,通常要做的第一件事就是明确定义上下文或作用域,该操作才有原子性可言。
接下来看看“不可分割”或“不可中断”,这些意味着在你定义的上下文中,原子性的事物将整体发生,而在该上下文中不会同时发生任何事情。
看个简单的例子:
i ++
看上去像原子操作,但是实际上它包含多个操作:
- 获取 i 的值
- i 的值加 1
- 保存 i 的值
每个单独的操作是原子性,但是组合在一起也许就不是了,这取决于你定义的上下文。如果你的上下文是一个没有多进程的程序,i ++ 就是原子的。如果你的上下文是一个没有将 i 变量暴露给其他 goroutines 的 goroutine(局部变量),i ++ 也是原子的。
内存同步访问
确保同一时刻仅有一枚线程在使用资源。将代码的特定部分作上标记,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱。这个特定的部分就是critical section(临界区)的概念。
将前面的例子做一点点修改:
var data int
go func() { data++}()
if data == 0 {
fmt.Println("the value is 0.")
} else {
fmt.Printf("the value is %v.\n", data)
}
以上代码存在 3 个 临界区:
- data 变量加 1 的 goroutine
- if 语句,检查 data 的值是否等于 0
- fmt.Printf 语句,获取 data 的值用于输出
Go 语言存在很多方式保护程序中的临界区,下面列举一个比较常规的互斥锁方式。
var memoryAccess sync.Mutex // 1
var value int
go func() {
memoryAccess.Lock() // 2
value++
memoryAccess.Unlock() // 3
}()
memoryAccess.Lock() // 4
if value == 0 {
fmt.Printf("the value is %v.\n", value)
} else {
fmt.Printf("the value is %v.\n", value)
}
memoryAccess.Unlock() // 5
- 声明一个互斥锁变量
- 为 goroutine 中的临界区加锁,直到释放锁之前,goroutine 将独占访问 value 变量的内存
- 释放 goroutine 中的临界区锁,代表 goroutine 已完成对应内存的操作
- 为 if 语句的临界区加锁,确保 if 语句 和 fmt.Printf 语句 可以独占访问 value 变量的内存,没有其他线程可以修改它,程序按照预期执行。
- 释放 if 语句的临界区的锁,代表操作完成
通过给临界区加锁释放锁实现了对数据的独占访问,但是这需要高度依赖开发者加锁和释放锁,即使在一些大型开源项目中只加锁不释放锁的 bug 也普遍存在,使用的时候需要倍加注意。
内存同步访问也会带来性能问题,激烈的锁竞争会影响程序性能,在确保程序正常的前提下,锁的粒度(临界区)越小越好。
注意一下上面的方式虽然解决了数据竞争(data race),但是没有解决竞态条件(race condition),因为多个线程的执行顺序还是不确定的。
除了上述的问题之外,并发编程还有很多问题需要考虑,如死锁、活锁、饥饿等,处理不好也会导致程序出现问题。
死锁
死锁程序是一种所有并发进程都在等待的程序。 在这种状态下,程序在没有外部干预的情况下永远不会恢复。
惯例举例:
type value struct {
mu sync.Mutex
value int
}
var wg sync.WaitGroup
printSum := func(v1, v2 *value) {
defer wg.Done()
v1.mu.Lock() // 1
defer v1.mu.Unlock() // 2
time.Sleep(2 * time.Second) // 3
v2.mu.Lock()
defer v2.mu.Unlock()
fmt.Printf("sum=%v\n", v1.value+v2.value)
}
var a, b value
wg.Add(2)
go printSum(&a, &b) // goroutine X
go printSum(&b, &a) // goroutine Y
wg.Wait()
- 加锁进入临界区
- 使用 defer 语句在 return 之前执行释放锁退出临界区
- 使用 sleep 模拟一个需要执行一段时间的任务(触发死锁)
上述代码的执行结果:
fatal error: all goroutines are asleep - deadlock!
原因分析如下:
框代表函数,水平线对这些函数的调用,垂直线代表函数的生命周期。
mian 函数调用 go printSum(&a, &b) 开启 goroutine X,立马又调用 go printSum(&b, &a) 开启 goroutine Y,X 中 给 a 加锁,执行 sleep 之后尝试给 b 加锁,a 锁未释放,Y 中先给 b 加锁,执行 sleep 之后尝试给 a 加锁,b 锁也未释放,这就造成 X, Y 都持有对方需要的资源,并尝试获取对方的资源,永久地等待下去。
科夫曼条件
1971年,埃德加科夫曼在一篇论文中列举了这些条件。这些条件现在称为科夫曼条件,是帮助检测,防止和纠正死锁的技术基础。
A deadlock situation on a resource can arise if and only if all of the following conditions hold simultaneously in a system:
Mutual exclusion: At least one resource must be held in a non-shareable mode. Otherwise, the processes would not be prevented from using the resource when necessary. Only one process can use the resource at any given instant of time.
Hold and wait or resource holding: a process is currently holding at least one resource and requesting additional resources which are being held by other processes.
No preemption: a resource can be released only voluntarily by the process holding it.
Circular wait: each process must be waiting for a resource which is being held by another process, which in turn is waiting for the first process to release the resource. In general, there is a set of waiting processes, P = {P1, P2, …, PN}, such that P1 is waiting for a resource held by P2, P2 is waiting for a resource held by P3 and so on until PN is waiting for a resource held by P1.
These four conditions are known as the Coffman conditions from their first description in a 1971 article by Edward G. Coffman, Jr.
- 互斥条件:至少一个资源必须以不可共享的模式持有。 否则,在必要时不会阻止进程使用资源。 在任何给定时刻,只有一个进程可以使用该资源。
- 保持和等待条件:一个进程当前至少持有一个资源,并请求其他进程持有的额外资源。
- 无抢占条件:无法强制从进程中获取资源。资源只能由持有它的进程自愿释放。
- 循环等待条件:一个进程正在等待第二个进程持有的资源而第二个进程正在等待第三个进程的情况……等等,最后一个进程正在等待第一个进程。从而形成一个循环等待。
同时满足上面 4 个条件就会触发死锁。分析一下上面的例子是如何满足柯夫曼条件的:
- printSum 函数确实需要对 a 和 b 的独占权限,因此它满足此条件。
- goroutine X 持有资源 a 并且尝试请求资源 b
- goroutines 都无法被抢占
- goroutine X 等待 goroutine Y 持有的资源 b,goroutine Y 等待 goroutine X 持有的资源 a,形成了循环等待。
死锁预防
我们已经了解到,如果所有四个科夫曼条件都成立,则会发生死锁,因此阻止其中的一个或多个可以防止死锁。
- 删除互斥:所有资源必须是可共享的,这意味着一次可以有多个进程获取资源。这种方法几乎是不可能的。
- 删除保持和等待条件:可以通过要求进程在启动之前(或在开始一组特定操作之前)请求它们将需要的所有资源来防止满足保持和等待条件。 另一种方法是要求进程只有在没有资源时才请求资源; 首先,他们必须先释放所有当前持有的资源,然后才能从头开始请求他们需要的所有资源。也不太实际。
- 抢占资源: 抢占“锁定”资源通常意味着回滚,应该避免,因为它的开销非常大。 允许抢占的算法包括无锁和无等待算法以及乐观并发控制。 如果一个进程持有一些资源并请求一些不能立即分配给它的其他资源,则可以通过释放该进程当前持有的所有资源来消除这种情况。
- 避免循环等待条件:如果资源在层次结构中维护,并且进程可以按优先级递增的顺序保存资源,则可以避免这种情况。这避免了循环等待。另一种方法是强制每个进程规则使用一个资源,进程可以在释放当前所拥有的资源后请求资源。这避免了循环等待。
活锁
活锁类似于死锁,不同之处在于活锁中涉及的进程的状态不断地相互改变,没有进展。
概念很抽象,想象一个这样的场景,你需要越过一个人穿过走廊,他移动到一边让你通过,但你也这样做了。你移动到另一边,不巧他也这样做了,这种情况永久持续下去就是活锁。
好像还是很难想象跟编程有什么关系,死锁是加不上就死等,活锁是加不上就放开已获得的资源重试。这里应用一个知乎上的高赞回答。并发问题中活锁(live lock)到底指的是什么?
饥饿
饥饿是并发进程无法获得执行工作所需的所有资源的任何情况。 活锁是饥饿的一种极端场景,所有的并发进程都是平等的,都没有完成自己的工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程不公平地阻止一个或多个并发进程尽可能有效地完成工作,甚至根本无法完成工作(饿死)。 解决饥饿问题通常使用“公平”策略,比如 GMP 模型中为了防止全局队列的饥饿,每 61 次调度就会优先从全局队列中获取 G。还有 sync 包中 Mutex 的实现也考虑了饥饿模式,当一个 G 尝试获取锁的时间超过 1 ms 就会转成饥饿模式,在锁竞争的时候会直接分配给饥饿的 G 去运行。请记住,饥饿还可以产生于CPU,内存,文件句柄和数据库连接等,任何必须共享的资源都可能产生饥饿。
水平有限,有不对的地方欢迎提出修改意见。
参考资料
了解「多线程」技术
DBMS 中的死锁
死锁 维基百科
《Concurrency in GO》作者: Katherine Cox-Buday