go程序设计语言第九章-并发控制
某个函数如果在并发情况下仍然可以正确的工作,则说明它是并发安全的。
同样,如果某个类型的所有方法和操作都是并发安全的,则该类型就是并发安全的。
我们避免并发访问大多数的类型,无论是将变量局限在一个goroutine内(避免多个goroutine访问同一变量),
还是使用互斥条件维持更高级别的不变性,都是为了这个目的。
包级别的导出函数一般都是并发安全的,由于package级的变量没法被限制在单一的gorouine中,
因此修改这些变量时“必须”使用互斥条件。
一个函数在并发调用时没法工作的原因太多了,比如死锁(deadlock)、活锁(livelock)和饿死(resource starvation)。我们没有空去讨论所有的问题,这里我们只聚焦在竞争条件上。
竞争条件指的是程序在多个goroutine交叉执行操作时,没有给出正确的结果。
竞争条件是很恶劣的一种场景,因为这种问题会一直潜伏在你的程序里,
然后在非常少见的时候蹦出来,或许只是会在很大的负载时才会发生,
又或许是会在使用了某一个编译器、某一种平台或者某一种架构的时候才会出现。
这些使得竞争条件带来的问题非常难以复现而且难以分析诊断。
一个包级别的变量:
// Package bank implements a bank with only one account.
package bank
var balance int
func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }
由于Deposit操作是非原子性的,并发执行时就会产生问题。
这个程序包含了一个特定的竞争条件,叫作数据竞争。无论任何时候,
只要有两个goroutine并发访问同一变量,且至少其中的一个是写操作的时候就会发生数据竞争。
根据数据竞争的定义,可以有三种方式来避免数据竞争:
package bank
var deposits = make(chan int)
var balances = make(chan int)
func Deposit(amount int) {
deposits <- amount
}
func Balance() int {
return <- balances
}
func teller() {
var balance int
for {
select {
// 每调用一次Deposit(),balance值就增加
// 外部调用Deposit()不阻塞
case amount := <- deposits:
balance += amount
// 每次调用Balance(),取走一个值后,新的值才可写入
// 外部调用Balance()可能阻塞
case balances <- balance:
}
}
}
// 此包一旦调用,就后台执行teller()函数
func init() {
go teller()
}
使用sync.Mutex保护临界区:
var (
mu sync.Mutex
balance int
)
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
balance += amount
}
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
通常使用defer来释放锁,即使临界区代码出现panic,defer语句也会执行。
go中的互斥锁是不可重入锁,即获取之后就没法再次获取,Lock之后不能再Lock。
go语言提供了“多读单写”锁即允许多个读操作同时进行,但同一时刻只允许一个写操作,
sync.RWMutex:
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // readers lock
defer mu.RUnlock()
return balance
}
RLock只能在临界区共享变量没有任何写入操作时可用。一般来说,我们不应该假设逻辑上的只读函数/方法也不会去更新某一些变量。比如一个方法功能是访问一个变量,但它也有可能会同时去给一个内部的计数器+1(译注:可能是记录这个方法的访问次数啥的),或者去更新缓存——使即时的调用能够更快。如果有疑惑的话,请使用互斥锁。
RWMutex只有当获得锁的大部分goroutine都是读操作,而锁在竞争条件下,也就是说,goroutine们必须等待才能获取到锁的时候,RWMutex才是最能带来好处的。RWMutex需要更复杂的内部记录,所以会让它比一般的无竞争锁的mutex慢一些。
内存模型: 在什么条件下,goroutine在读取一个变量的值的时候,能够看到其他goroutine对这个变量进行的写的结果。
happens-before是一个术语,并不仅仅是Go语言才有的。简单的说,通常的定义如下:
假设A和B表示一个多线程的程序执行的两个操作。如果A happens-before B,那么A操作对内存的影响 将对执行B的线程(且执行B之前)可见。
刚接触这个术语的人总是容易误解,这里必须澄清的是,happens-before并不是指时序关系,并不是说A happens-before B就表示操作A在操作B之前发生。它就是一个术语,就像光年不是时间单位一样。具体地说:
一个goroutine观察另一个goroutine的语句执行,不能保证观察到的结果就是其原来的顺序。
如
var a string
var done bool
func setup() {
a = "hello world"
done = true
}
// 没有happen before的保证
func main() {
go setup()
for !done {
}
print(a)
}
在main中,即使观察到done为true,最后打印的a也可能为空。
为保证操作的顺序性,Go 内存模型中很重要的一个概念:happens-before。
在一个 goroutine 内部,程序的执行顺序和它们的代码指定的顺序是一样的。
即使编译器或者 CPU 重排了读写顺序,从行为上来看,也和代码指定的顺序一样。
init函数
应用程序的初始化是在一个goroutine内部的。
如果包 p 导入了包 q,那么,q 的 init 函数的执行一定 happens before p 的任何初始化代码。
main 函数一定在导入的包的 init 函数之后执行。
包级别的变量在同一个文件中是按照声明顺序逐个初始化的,除非初始化它的时候依赖其它的变量。同一个包下的多个文件,会按照文件名的排列顺序进行初始化。这个顺序被定义在Go 语言规范中,而不是 Go 的内存模型规范中。
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
hello()函数内容,a的赋值happen before go语句,go语句又happen before f()函数执行,因此f()执行的打印,一定是“hello world"
这是goroutine启动的保证,退出时无保证,必须使用mutex或channel。
var a string
var bufch = make(chan struct{}, 10)
func f() {
a = "hello world"
bufch <- struct{}{} // send happen before receive
}
// 有happen before保证
// buffered chan, 第n个send一定happen before 第n个receive
func g() {
go f()
<- bufch // receive
fmt.Println(a)
}
(2) close 一个 Channel 的调用,肯定 happens before 从关闭的 Channel 中读取出一个零值。
就说,取出零值一定发生在close之后。
因此可以将6行的send操作换做close,也能保证同样的执行顺序。
(3) 对于unbuffered channel, 从此channel中读取数据的调用一定happen before 往此channel中发送数据的操作。
这也是《GO程序设计语言》里提到的,When a value is sent on an unbuffered channel, the receipt of the value
happens before the reawakening of the sending goroutine.
(当一个值被发送给unbuffered chan, 这个值的接收一定happen before发送goroutine的再次唤醒)
即receive happend before send
// 有happen before保证
// unbuffered chan,其receive一定happen before其send
func f3() {
a = "hello world"
<-unbufchan // receive happen before send
}
func main() {
go f3()
unbufchan <- struct{}{} // send
fmt.Println(a)
}
(4) 如果 Channel 的容量是 m(m>0),那么,第 n 个 receive 一定 happens before 第 n+m 个 send 的完成。
这里给出了更广泛的针对 buffered channel 的保证。利用这个规则,我们可以实现信号量(Semaphore)的并发原语。Channel 的容量相当于可用的资源,发送一条数据相当于请求信号量,接收一条数据相当于释放信号。
Go的runtime和工具链为我们装备了一个复杂但好用的动态分析工具,竞争检查器(the race detector)。
只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器创建一个你的应用的“修改”版或者一个附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下所有的同步事件,比如go语句,channel操作,以及对(*sync.Mutex).Lock,(*sync.WaitGroup).Wait等等的调用。(完整的同步事件集合是在The Go Memory Model文档中有说明,该文档是和语言文档放在一起的。译注:https://golang.org/ref/mem )
竞争检查器会报告所有的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件;并不能证明之后不会发生数据竞争。所以为了使结果尽量正确,请保证你的测试并发地覆盖到了你的包。
实现一个并发的缓存cache,允许多个goroutne同时访问,无值时设置值,有值时直接获取:
func httpGetBody(url string, done chan struct{}) (interface{}, error) {
select {
case <-done:
return nil, nil
default:
}
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func main() {
incomingURLs := []string{"http://aa.html", "http://bb.html"}
m := memo.NewMemo(httpGetBody)
var wg sync.WaitGroup
for _, url := range incomingURLs {
wg.Add(1)
go func(url string) {
defer wg.Done()
start := time.Now()
value, err := m.Get(url)
if err != nil {
log.Print(err)
}
fmt.Printf("%s, %s, %d bytes\n", url, time.Since(start), len(value.([]byte)))
}(url)
}
wg.Wait()
}
第一版, 使用互斥锁:
package memo
import "sync"
type Func func(key string) (interface{}, error)
type result struct {
value interface{}
err error
}
type Memo struct {
f Func
mu sync.Mutex
cache map[string]result
}
func NewMemo(f Func) *Memo {
return &Memo{f: f, cache: make(map[string]result)}
}
// Get 非并发安全
func (memo *Memo) Get(key string) (interface{}, error) {
// 整个函数内加锁,会导致效率低
memo.mu.Lock()
res, ok := memo.cache[key]
if !ok {
res.value, res.err = memo.f(key)
memo.cache[key] = res
}
memo.mu.Unlock()
return res.value, res.err
}
// Get 非并发安全
func (memo *Memo) Get2(key string) (interface{}, error) {
// 加锁分为两部
// 1 查找时加锁
memo.mu.Lock()
res, ok := memo.cache[key]
memo.mu.Unlock()
// 2 更新阶段再次加锁
// 在这两个操作的中间阶段,没有锁,其他goroutine可以随意使用cache
// 带来的问题是:两个goroutine同时拿到没有值的key,都去更新值
// 然后后执行的那个会覆盖先执行的那个
// 而本意是同样的key只执行一次
if !ok {
memo.mu.Lock()
res.value, res.err = memo.f(key)
memo.cache[key] = res
memo.mu.Unlock()
}
memo.mu.Unlock()
return res.value, res.err
}
Get()加锁在整个函数内,则实际没有了并发的效果;Get2()加锁分为两块,
但可能存在同一个key被两个goroutine同时访问时都进行f()执行的情况。
第二版,使用锁+channel:
package memo2
import "sync"
type Func func(key string) (interface{}, error)
type result struct {
value interface{}
err error
}
type entry struct {
res result
ready chan struct{}
}
// Memo 里不直接保存result,而是保存entry,其中包含result和一个用于通信的chan
type Memo struct {
f Func
mu sync.Mutex
cache map[string]*entry
}
func NewMemo(f Func) *Memo {
return &Memo{f: f, cache: make(map[string]*entry)}
}
// Get 这个函数利用了close happen before receive机制
// 实现了一个并发、不重复、无阻塞的cache
func (memo *Memo) Get(key string) (value interface{}, err error) {
// 还是查找key时加锁
memo.mu.Lock()
e := memo.cache[key]
if e == nil {
e = &entry{ready: make(chan struct{})}
memo.cache[key] = e
// 赋值完成后就可以解锁了
memo.mu.Unlock()
e.res.value, e.res.err = memo.f(key)
} else {
memo.mu.Unlock()
}
// 这里等e.ready通知
// 如果一个goroutine直接走到这,有可能另一个goroutine正在进行f()这个慢函数操作
// 则利用chan的close通知机制等待其操作完成
<-e.ready
return e.res.value, e.res.err
}
第三版,使用Monitor线程,将cache控制在一个goroutine内操作, 使用CSP的模型:
package memo3
type Func func(key string) (interface{}, error)
type result struct {
value interface{}
err error
}
type entry struct {
res result
ready chan struct{}
}
type request struct {
key string
response chan <- result
}
type Memo struct {
requests chan request
}
func NewMemo(f Func) *Memo {
memo := &Memo{requests: make(chan request)}
go memo.server(f)
return memo
}
// Get 每次发送一个request到memo的队列,由Monitor线程执行
// 执行结束后从response获取结果
func (memo *Memo) Get(key string) (interface{}, error) {
response := make(chan result)
memo.requests <- request{key, response}
res := <- response
return res.value, res.err
}
func (memo *Memo) Close() {
close(memo.requests)
}
func (memo *Memo) server(f Func) {
cache := make(map[string]*entry)
// 一直在遍历requests
for req := range memo.requests {
e := cache[req.key]
if e == nil {
e = &entry{ready: make(chan struct{})}
cache[req.key] = e
// 新起一个goroutine执行这个耗时操作
go e.call(f, req.key)
}
// 也是新起一个goroutine防止阻塞
go e.deliver(req.response)
}
}
func (e *entry) call(f Func, key string) {
e.res.value, e.res.err = f(key)
close(e.ready)
}
func (e *entry) deliver(response chan<- result) {
<-e.ready
response <- e.res
}
练习题: 扩展Func类型和Get方法,支持调用方提供一个可选的done channel,使其具备通过该channel来取消整个操作的能力(§8.9)。
一个被取消了的Func的调用结果不应该被缓存。
package memo2_2
import "sync"
type Func func(key string, done chan struct{}) (interface{}, error)
type result struct {
value interface{}
err error
}
type entry struct {
res result
ready chan struct{}
}
// Memo 里不直接保存result,而是保存entry,其中包含result和一个用于通信的chan
type Memo struct {
f Func
mu sync.Mutex
cache map[string]*entry
}
func NewMemo(f Func) *Memo {
return &Memo{f: f, cache: make(map[string]*entry)}
}
// Get 这个函数利用了close happen before receive机制
// 实现了一个并发、不重复、无阻塞的cache
func (memo *Memo) Get(key string, done chan struct{}) (value interface{}, err error) {
// 捕获到done则直接返回函数
// 一般不会出现函数刚执行就done结束,正常是正执行f()却取消了操作
select {
case <-done:
return nil, nil
default:
}
// 还是查找key时加锁
memo.mu.Lock()
e := memo.cache[key]
if e == nil {
e = &entry{ready: make(chan struct{})}
memo.cache[key] = e
// 赋值完成后就可以解锁了
memo.mu.Unlock()
e.res.value, e.res.err = memo.f(key, done) // 这是一个耗时操作
close(e.ready)
} else {
memo.mu.Unlock()
}
// 等待操作同样key的goroutine执行结束,可能是它自己,也可能是其他goroutine
<-e.ready
select {
case <-done:
// 结果不缓存, 有可能刚f()执行完又捕获了done(则有结果),也可能在f执行过程中捕获了done(则无结果)
// 但都需要重新清空cache
memo.mu.Lock()
delete(memo.cache, key)
memo.mu.Unlock()
return nil, nil
default:
return e.res.value, e.res.err
}
}
同时main goutine变为:
func httpGetBody2(url string, done chan struct{}) (interface{}, error) {
select {
case <-done:
return nil, nil
default:
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
// 不赞成使用Cancel字段?使用Context代替?
req.Cancel = done
//cli := http.Client{
//}
cli := http.DefaultClient
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func main() {
incomingURLs := []string{"http://aa.html", "http://bb.html"}
m := memo2_2.NewMemo(httpGetBody2)
var wg sync.WaitGroup
done := make(chan struct{})
for _, url := range incomingURLs {
wg.Add(1)
go func(url string) {
defer wg.Done()
start := time.Now()
value, err := m.Get(url, done)
if err != nil {
log.Print(err)
}
fmt.Printf("%s, %s, %d bytes\n", url, time.Since(start), len(value.([]byte)))
}(url)
}
wg.Wait()
}
每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量.
这个固定大小的栈同时很大又很小, 对于简单的goroutine,不需要这么大;而对于深度递归,又显得小了。
一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。一个goroutine的栈,和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量,
但是和OS线程不太一样的是,一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。
而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,
OS线程会被操作系统内核调度, 由硬件执行中断,调用一个叫作scheduler的内核函数。
此函数挂起当前执行的线程并将它的寄存器内容保存到内存中,从线程列表中挑选下一个要执行的线程,然后从内存中恢复该线程的寄存器信息,
恢复执行该线程的现场并开始执行线程。
因为操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另一个线程的到寄存器,
然后更新调度器的数据结构。
这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期。
Go的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如m:n调度,因为其会在n个操作系统线程上多工(调度)m个goroutine。
Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的goroutine(译注:按程序独立)(就是说,调度器只关心go程序,不像
操作系统那样关注所有的程序)。
和操作系统调度不同,Go调度不采用硬件定时机制,而是被具体的Go language constructs进行调用。
例如,当一个goroutine调用time.Sleep()函数或者阻塞在一个channel或Mutex锁处,调度器将此goroutine sleep然后执行另外的goroutine
直到时机到了再去唤醒它。
因为这种调度方式不需要进入内核的上下文,所以重新调度一个goroutine比调度一个线程代价要低得多。
Go的调度器使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码。
其默认的值是运行机器上的CPU的核心数,所以在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码。
(GOMAXPROCS是前面说的m:n调度中的n)。
在休眠中的或者在通信中被阻塞的goroutine是不需要一个对应的线程来做调度的。在I/O中或系统调用中或调用非Go语言函数时,是需要一个对应的操作系统线程的,但是GOMAXPROCS并不需要将这几种情况计算在内。
在大多数支持多线程的操作系统和程序语言中,当前的线程都有一个独特的身份(id),并且这个身份信息可以以一个普通值的形式被很容易地获取到,典型的可以是一个integer或者指针值。这种情况下我们做一个抽象化的thread-local storage(线程本地存储,多线程编程中不希望其它线程访问的内容)就很容易,只需要以线程的id作为key的一个map就可以解决问题,每一个线程以其id就能从中获取到值,且和其它线程互不冲突。
goroutine没有可以被程序员获取到的身份(id)的概念。这一点是设计上故意而为之,由于thread-local storage总是会被滥用。比如说,一个web server是用一种支持tls的语言实现的,而非常普遍的是很多函数会去寻找HTTP请求的信息,这代表它们就是去其存储层(这个存储层有可能是tls)查找的。这就像是那些过分依赖全局变量的程序一样,会导致一种非健康的“距离外行为”,在这种行为下,一个函数的行为可能并不仅由自己的参数所决定,而是由其所运行在的线程所决定。因此,如果线程本身的身份会改变——比如一些worker线程之类的——那么函数的行为就会变得神秘莫测。
Go鼓励更为简单的模式,这种模式下参数(译注:外部显式参数和内部显式参数。tls 中的内容算是"外部"隐式参数)对函数的影响都是显式的。这样不仅使程序变得更易读,而且会让我们自由地向一些给定的函数分配子任务时不用担心其身份信息影响行为。