【读Go语言并发之道】第3章 Go语言并发组件

✎说明: 本文章收录于我的博客专栏读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的最大核心数(不超过硬件限制)。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
本书作者带你一步一步深入这些方法。你将理解 Go语言为何选定这些并发模型,这些模型又会带来什么问题,以及你如何组合利用这些模型中的原语去解决问题。学习那些让你在独立且自信的编写与实现任何规模并发系统时所需要用到的技巧和工具。 理解Go语言如何解决并发难以编写正确这一根本问题。 学习并发与并行的关键性区别。 深入到Go语言的内存同步原语。 利用这些模式中的原语编写可维护的并发代码。 将模式组合成为一系列的实践,使你能够编写大规模的分布式系统。 学习 goroutine 背后的复杂性,以及Go语言的运行时如何将所有东西连接在一起。 作者简介 · · · · · · Katherine Cox-Buday是一名计算机科学家,目前工作于 Simple online banking。她的业余爱好包括软件工程、创作、Go 语言(igo、baduk、weiquei) 以及音乐,这些都是她长期的追求,并且有着不同层面的贡献。 目录 · · · · · · 前言 1 第1 并发概述 9 摩尔定律,Web Scale和我们所陷入的混乱 10 为什么并发很难? 12 竞争条件 13 原子性 15 内存访问同步 17 死锁、活锁和饥饿 20 确定并发安全 28 面对复杂性的简单性 31 第2 对你的代码建模:通信顺序进程 33 并发与并行的区别 33 什么是CSP 37 如何帮助你 40 Go语言并发哲学 43 第3 Go语言并发组件 47 goroutine 47 sync包 58 WaitGroup 58 互斥锁和写锁 60 cond 64 once 69 池 71 channel 76 select 语句 92 GOMAXPROCS控制 97 小结 98 第4 Go语言并发模式 99 约束 99 for-select循环103 防止goroutine泄漏 104 or-channel 109 错误处理112 pipeline 116 构建pipeline的最佳实践 120 一些便利的生成器 126 扇入,扇出 132 or-done-channel 137 tee-channel 139 桥接channel模式 140 队列排队143 context包 151 小结 168 第5 大规模并发 169 异常传递169 超时和取消 178 心跳 184 复制请求197 速率限制199 治愈异常的goroutine 215 小结 222 第6 goroutine和Go语言运行时 223 工作窃取223 窃取任务还是续体 231 向开发人员展示所有这些信息 240 尾声 240 附录A 241

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值