✎说明: 本文章收录于我的博客专栏读Go语言并发之道
前言
本文档是读《Go语言并发之道》一书之后的总结,按照章节进行记录。
任何地方有误,请读者不吝赐教。ℬℯℓℓℯℜ life to you
目录
1. Goroutine
每个go程序中都至少有一个goroutine,即main goroutine。
先总结:goroutine的使用成本非常低,包括其创创建和上下文切换成本,据作者测试,操作系统线程的上下文切换需要1.467
微秒, 而goroutine的切换只有0.225微秒,速度提升达85 %(书中是92 %,没明白怎么计算出来的)。
我们可以安心的在程序中使用goroutine,但仍然需要确保系统最初的设计是正确的。
1.1 goroutine是独一无二的,既不是OS线程,也不是绿色线程,而是一个更高级别的抽象,称为协程。协程是一种非抢占式的简单并发原语(函数、闭包或方法)。
goroutine的独特之处在于它们与go的runtime的深度集成:go的runtime会观察它们的行为,并在它们阻塞时自动挂起它们,不被阻塞时恢复它们。
runtime和goroutine的逻辑之间是一种优雅的伙伴关系,因此可以被认为是一种特殊类型的协程。
Go的主机托管机制(goroutine调度器)是一个名为M: N调度器的实现,它将M个绿色线程映射到N个OS线程。
Go语言遵循一个称为fork - join的并发模型。fork指的是在程序中的任意一点,它可以将执行的子分支与其父节点同时运行;join指的是在将来某个时候,这些并发的执行分支将会合并在一起。看一个例子:
这段代码中的go sayHello() 就是一个fork点,而wg.Wait()就是一个join点;想像一下,没有使用wg创建join连接点,那我们就无法保证sayHello的运行,这段代码会没有机会运行sayHello就退出了,主协程和子协程之间没有同步,后续会讲sync.WaitGroup的使用和原理。
1.2 goroutine的另一个好处就是轻,一个新创建的goroutine被赋予了几千字节,大部分情况都是够用的,当它不运行时,Go语言runtime会自动增长(缩小)存储堆栈的内存,允许更多的goroutine存在于内存中。
作者通过以下代码测算goroutine的内存消耗(最小):
代码创建了1W个协程,最后计算出每个协程的内存消耗,结果是2.817KB。那么创建一百万个协程的最小内存消耗就是:
2.8KB * 1e6 / 1024 ≈ 2734MB ≈ 3GB,轻量显而易见。
当goroutine数量足够多的时候,我们可能会担心它们的切换会成为性能的瓶颈!
其实不然!我们知道OS线程的上下文切换是昂贵的,因为切换前必须保存当前线程的寄存器值,指令,内存页相关的资源,以便下次能够恢复现场。而软件中的上下文切换相对来说廉价的多,在一个软件定义的调度器下,runtime可以更有选择性的保存数据用于检索,持久化,以及何时需要持久化。
作者对linux操作系统下的线程上下文切换和goroutine之间切换的性能做了benchmark,结果已在前面开头的总结中说明,大部分的场景下,我们无需担心goroutine上下文切换对我们程序带来的性能损耗。
2. sync包
它包含对低级别内存访问同步最有用的并发原语。
3. sync.WaitGroup
这个东西通过之前的示例已经演示过如何使用了,比较简单。 当我们只希望所有协程都能被执行,而不关心协程的执行结果时,就使用它。 它是一个协程计数器,Wait()方法会阻塞,直到计数器为0。
参考下面的例子:
4. sync.Mutex / sync.RWMutex
互斥锁和读写锁,就是内存同步访问的一种实现方式。
Mutex提供一种让程序独占共享资源的安全的方式,锁完全互斥,同一时刻只能有一个并发进程持有锁,释放前加锁会导致死锁。 RWMutex提供更灵活的共享资源的访问方式,包含读锁+写锁,允许加多个读锁或者一个写锁,适用于读多写少的场景。 示例参考这里: golang 中 sync.Mutex 和 sync.RWMutex
5. sync.Cond
先看代码:
输出:
代码说明:声明一个长度为10的队列,开启一个循环,每次往队列中push一个元素,
并启动一个goroutine,它的内容是延迟1s从队列中取出一个元素,操作完后向cond对象发送一个信号。每次循环都会判断当队列长度等于2时,就阻塞在这,只有接收到一个cond信号后再向下执行。
Cond类型的官方解释比较晦涩:一个goroutine的集合点,等待或发布一个事件。
使用场景:当goroutine执行到某个位置时,需要接收到一个外部(其他goroutine)
信号才能向下执行,否则就阻塞。这里的信号可以理解为满足某种条件。你可能马上想到用chan来解决这个问题,但是当有多个goroutine需要接收信号呢,在每个goroutine中启用一个死循环不停判断条件是否满足吗?这始终不是一个上得了台面的方案。而Cond就提供了一个优雅的解决方案。
三个关键方法:
Wait() 用来阻塞goroutine,直到接收到唤醒信号。
Signal() 用来向所有使用Wait()方法最久的那个goroutine发送唤醒信号
Broadcast() 用来向所有使用Wait()方法的goroutine发送唤醒信号
※ 需要注意的是这部分代码:
c.L.Lock()
for condition=true {
c.Wait()
}
c.L.UnLock()
当条件为真,进入for循环时,这块区域并没有一直持有锁,在进入Wait()方法时,UnLock方法就会调用,然后阻塞,退出Wait()方法时,Lock方法又会调用。否则你会奇怪为什么前面的实例代码中,主协程内Wait时(上一行代码是Lock),发送信号的goroutine还能继续执行Lock,而不出现死锁。
来看一个使用Broadcast()的案例: 假设现在要安放十个炸弹,所有炸弹都由同一个引信引爆,要求在3s后引爆这十个炸弹。 代码如下:
6. sync.Once
这个类型比较简单,用的也比较多;主要用来在调度在你的程序中只需执行一次的函数。
代码如下:
var i int
f1: = func(){
i + +
}
f2: = func(){
i - -
}
var once sync.Once
once.Do(f1)
f.Println(i) // 1
once.Do(f2)
f.Println(i) // 1
它只有一个Do方法,原理:在第一次传入时,将它内部的状态器改为done,后续的调用判断状态为done时将被忽略。
7. sync.Pool
它是Pool模式的并发安全实现。
Pool模式,是一种创建和提供可供使用的固定数量实例或Pool实例的方法。通常用于约束创建昂贵的场景(如数据库连接),以便于只创建固定数量的实例,防止服务崩溃;当然,它也适用于数量不确定的场景。
Go中的Pool的主要接口是它的Get方法,调用时首先检查池中是否有可用的实例,有就直接返出去,没有再使用其New方法创建新实例返出去。当我们的任务完成时,记得将实例放回Pool中,以供其他并发进程使用。
简单的示例:
func main() {
myPool := &sync.Pool{
New: func () interface{} { // 定义创建实例的方法
fmt.Println("Creating new instance. ")
return struct{}{}
},
}
myPool.Get() // 从池中获取一个实例(这个时候没有,会创建一个)
instance := myPool.Get() // 从池中获取一个实例(这个时候没有,会创建一个)
myPool.Put(instance) // 放回一个实例
myPool.Get() // 再获取一个实例
/* 刚才做了两次资源的创建,最后一次Get获取了第二次放回去的实例 */
}
输出: Creating new instance. Creating new instance.
这里使用Pool模式而不使用需要即创建方式的原因很简单,就是资源复用、节省实例创建时间开销。
还有一种常见的情况,用Pool来预先缓存一定数量的实例,这个时候,我们主要的目的就不是节省内存了,而是提前加载来节省消费者的时间成本。这种情况在编写高吞吐网络服务器时十分常见,服务器需要快速响应请求。 在书上,这里是有一个使用Pool模式的server例子的,但我觉得你明白了上一段话就不需要看这个示例了,你也去可以参考原书。
Pool小结:
i. Pool的Get方法是并发安全的
ii. 从Get中收到一个实例时,不需要对该对象的状态作任何假设
iii. 用完实例后,一定使用Pool的Put方法将实例放回池中(推荐使用defer)
8. Channel
chan是一种CSP模型派生的同步原语之一,它们可以用来同步内存访问。但最好用于goroutine之间传递信息(你可以用来干sync.Cond类型干的事情)。 本文档不是go教程,所以不会讲解chan的基本使用,只会讲与并发相关的我们会用到或需要注意的地方。
i. 使用关闭的chan来通知多个goroutine 我们应当了解channel在关闭时仍然可以读取,代码如下:
myChan := make(chan, int)
close(myChan) // 可以运行的代码
Value, ok := <- myChan
fmt.Println(Value, ok)
输出:0, false
而且,关闭的channel可以被无限次读取,我们可以利用这个特性来给所有监听同一个chan的goroutine发送信号,
代码如下:
begin := make(chan interface{})
var wg sync.WaitGroup
for i:=0;i<5;i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<- begin
fmt.Printf("%v has begun\n", i)
}(i)
}
time.Sleep(1*time.Second)
fmt.Println("Unblocking goroutines...")
close(begin) // 通知所有goroutine
wg.Wait()
我们之前使用的sync.Cond类型解决这种问题,不过看起来chan的实现更简洁可读。但是在较复杂的场景中,Cond类型会比channel更易用、高效。这就需要开发者对两种方法特性的熟练掌握,才能做到物尽其用。
在这里,书中提到了channel应该如何更好的在并发程序中使用。 设计者应该尽量保持channel所有权的范围足够小,才能保证并发程序足够稳定、健壮。 这是由于channel在不同状态下的操作结构是不一样的,可能是阻塞/输出/编译错误/panic,看看作者对channel的总结图:
如果将使用channel的goroutine角色区分清楚,就不会有异常的风险; 一般情况下,我们将使用channel的goroutine角色分为拥有者、消费者, 拥有者负责channel的创建,写入,关闭,消费者负责读取,角色分工明显; 如果需要分离一个生产者角色出来,那最好把关闭的责任也交给它。
9. select
select语句是将channel绑定在一起的黏合剂。它可以帮助安全的将channel与诸如取消、超时、等待和默认值之类的概念结合在一起。 来看一个例子:
c1 := make(chan int)
c2 := make(chan string)
go func() {
fmt.Println("wait 2s...")
time.Sleep(2*time.Second)
c1 <- 1
c2 <- "hi"
}()
for {
select {
case k := <- c1:
fmt.Println("get from c1:",k)
case k := <- c2:
fmt.Println("get from c2:",k)
case <- time.After(1*time.Second):
fmt.Println("waiting...")
}
}
输出:
这是一个常见的select的使用模式,在一个死循环中不断的定时的监听多个channel上的数据流动,channel上一有数据就执行对应逻辑,如果没有数据,就会执行time.After方法,这也是一个channel,就像一个定时器一样,创建一个即时管道,在你指定的时间流逝后输出当前时间,当然我们不需要它的输出,只利用其管道特性。
需要注意的一个地方: Select{} 一个空的select语句,会永远阻塞,它会造成死锁。
10. GOMAXPROCS控制的简介
runtime包中,有个函数叫GOMAXPROCS。
这个名称具有误导性,一般会认为它与逻辑处理器的数量有关,但实际上这个函数控制的是OS线程的数量将承载的所谓的“工作队列”(译文原话,感觉有点问题),关于其更多信息和工作原理将在第六章说明。 在Go v1.5之前,这个函数总是被设置为1,但是你会在大多数Go语言程序中找到这段代码:
runtime.GOMAXPROCS(runtime.NumCPU())
即将其设置为计算机逻辑CPU数量,因为大部分开发者都希望它们的程序能够充分利用机器上的所有CPU核心,所以在随后的Go版本中,它被自动设置为主机上逻辑CPU的数量。
博主注: 在Go v1.12 中,对这个函数的描述是:
// GOMAXPROCS sets the maximum number of CPUs that can be executing
// simultaneously and returns the previous setting. If n < 1, it does not
// change the current setting.
// The number of logical CPUs on the local machine can be queried with NumCPU.
// This call will go away when the scheduler improves.
介于写文章时只读到这里,还不能确定译文所说的“控制的OS线程的数量”是什么意思,但我想既然官方都这么描述,暂时把它按字面意思理解也不会有错,也就是设置程序利用CPU的最大核心数(不超过硬件限制)。