Go语言之并发与通道
进程和线程:
- 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位
- 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位
- 一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。
并发和并行:
- 多线程程序在一个核的cpu上运行,就是并发
- 多线程程序在多个核的cpu上运行,就是并行,并发包括并行,在并发基础上并行
协程和线程:
- 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的
- 线程:一个线程上可以跑多个协程,协程是轻量级的线程
并发不是并行:并发主要由切换时间片来实现”同时”运行,并行则是直接利用多核实现多线程的运行,go可以设置使用核数,以发挥多核计算机的能力
主动关闭goroutine的两种方式:
- channel
- context
一、Goroutine:
在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?
Go语言的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU,Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴,可以将goroutine理解为其他变成语言中的线程
Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。
语法格式:go 函数名(参数列表)
一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数
1、启动单个goroutine
下面例子中,没有使用goroutine,如图:

执行结果如下:

接下来在调用hello函数前面加上关键字go,启动一个goroutine执行这个函数,如图:


从上图可以看出,hello函数内容没有打印,因为在程序启动时,Go程序就会为main()函数创建一个默认的goroutine,当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束
使用go 关键字创建goroutine 时, 被调用函数的返回值会被忽略,如果需要在goroutine中返回数据,需要使用通道(channel)特性,通过通道将数据返回
所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep了,如图:

执行结果如下:

go用来指定睡眠时间的函数为time.Sleep,默认是1s,如果想睡眠5s钟,要写成 time.Sleep(5 * time.Second)
除了使用time.Sleep之外,也可以使用WaitGroup,还可以 使用通道(channel)的特性,通过通道把数据从goroutine 中作为返回值传出,可参考通道章节中的通道接收数据的多种方法中的第三中,接收数据但是忽略数据的例子
注:实际生产环境中不推荐使用time.Sleep(时间等待如果时间设置不合理也会出问题),推荐使用WaitGroup或者chaneel等特性
2、启动多个goroutine
使用go 关键字, 将running()函数并发执行,每隔一秒打印一次计数器,而main 的goroutine 则等待用户输入,两个行为可以同时进行,如图:

下图中的英文字母就是命令行输入的内容,和循环打印的内容可以同时进行,如图:

说明:这个例子中,Go 程序在启动时,运行时(runtime)会默认为main()函数创建一个goroutine,在main()函数的goroutine 中执行到go running 语句时,归属于running()函数的goroutine被创建, running()函数开始在自己的goroutine 中执行,此时, main() 继续执行,两个goroutine通过Go 程序的调度机制同时运作
goroutine间使用channel 通信
3、使用匿名函数创建goroutine
go 关键字后也可以为匿名函数或闭包启动goroutine
使用匿名函数或闭包创建goroutine 时,除了将函数定义部分写在go 的后面之外,还需要加上匿名函数的调用参数,格式如下:
go func(参数列表){
函数体
}(调用参数列表)
#如果匿名函数没有参数,那就不需要调用参数列表
下面例子是通过匿名函数方式创建goroutine,如图:

运行结果与上面的例子中结果一样
注意:上图中如果不加后面的命令行输入字段,那么匿名函数的中的内容不会打印出来,因为main()函数结束后,内部所有的goroutine都会一起结束,加上命令行输入,那么main()函数一直在运行,因此func()创建的goroutine也是一直运行的
总结:可以将goroutine理解将让两个函数同时运行,如上图,使用go后,其实是让main默认的goroutine和go func()开启的goroutine同时可以运行,如果不加go,那么func()会一直运行下去
二、goroutine调度
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程
- G:很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息
- P:管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针、堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务
- M:(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个goroutine最终是要放到M上执行的
P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时回收旧的M
P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数
1、runtime包
1.1 runtime.GOMAXPROCS: 调整并发的运行性能
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码,默认值是机器上的CPU核心数
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
下面例子中,定义了两个任务,但是只有一个逻辑核心,此时就是做完一个任务再做另一个任务,如图:


将逻辑核心修改为2或者更多,此时两个任务并行执行,如图:


除了显示的设置cpu的逻辑核心,还可以直接通过runtime.NumCPU()查询cpu数量,并使用e.GOMAXPROCS()设置,如图:


使用 runtime.NumCPU() 自动查询cpu数量,最大效率的利用cpu
GO 在GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行
1.2 runtime.Gosched() : 让出cpu时间片
这个函数的作用是让当前goroutine让出CPU,好让其它的goroutine获得执行的机会。同时,当前的goroutine也会在未来的某个时间点继续运行。

执行结果如下:

1.3 runtime.Goexit():退出当前协程
这个函数的作用是终止当前运行的goruntine,如图:


从运行结果可以看出,defer语句会被正常执行,因为runtime.Goexit函数在终止调用它的Goroutine的运行之前会先执行该Groution中还没有执行的defer语句
注意: goroutine是不能直接接收函数返回值的,res := go hello() 是不成立的,因此如果要接收返回值,需要使用通道
三、通道(channel)
通道可以理解为在多个goroutine间通信的管道
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine 中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法会造成性能问题
Go 语言的并发模型是CSP(Communicating Sequential Processes)提倡使用通信的方法代替共享内存,这里通信的方法就是使用通道( channel),如图:

1、通道的特性:
- Go 语言中的通道( channel )是一种特殊的类型。在任何时候,同时只能有一个goroutine访问通道进行发送和获取数据。goroutine 间通过通道就可以通信,channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
- channel是一种队列一样的结构,使用队列可以避免goroutine争抢数据,提高执行效率,并且总是遵循先入先出( First In First Out ) 的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型
2、通道(channel) 类型:
channel是一种类型,一种引用类型。声明通道类型的格式如下:
var 变量 chan 通道类型
#通道类型:通道内的数据类型
#通道变量:保存通道的变量
例如:
var ch1 chan int // 声明一个传递整型的通道
var ch2 chan bool // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道
channel类型的空值是nil ,声明后需要配合make 后才能使用
3、创建通道
通道是引用类型, 声明通道后需要使用make 进行初始化才能使用, 格式如下:
通道实例 := make(chan 数据类型)
- 数据类型: 通道内传输的元素类型
- 通道实例: 通过make 创建的通道句柄。
例如:
ch1 := make(chan int) //创建一个整数类型的通道
ch2 := make(chan interface{}) //创建一个空接口类型的通道,可存放任意格式
ch3 := make(chan *abcd) //创建指针类型的通道,可以存在指针类型的值,可配置结构体使用
4、使用通道发送数据
通道有发送(send)、接收(receive)和关闭(close)三种操作,创建后,就可以使用通道进行发送和接收操作
通道的发送使用特殊的操作符“<-”,将数据通过通道发送的格式为:
通道变量 <- 值 //表示将值发送到通道中
例如:
ch := make(chan interface{}) //创建一个空接口类型的通道,空接口可接收任意类型的值
ch <- 0 //将0放入通道中,也可以理解为将0发送到通道中
ch <- "hello" //将字符串hello放入通道中,将字符串发送到通道中
5、使用通道接收数据
通道接收同样使用 “<-” 操作符,通道接收有如下特性:
- 通道的收发操作在不同的两个goroutine 间进行,由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个goroutine 中进行
- 接收将持续阻塞直到发送方发送数据,如果接收方接收时, 通道中没有发送方发送数据, 接收方也会发生阻塞,直到发送方发送数据为止
- 每次接收一个元素,通道一次只能接收一个数据元素
通道的数据接收一共有以下4 种写法:
阻塞接收数据:
阻塞模式接收数据时,将接收变量作为 “<-” 操作符的左值,格式如下:
data := <- ch //从ch中接收值并赋值给变量data
非阻塞接收数据:
使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:
data , ok := <- ch //如果未能接收到数据,data为通道类型的零值,ok表示是否接收到数据
非阻塞的通道接收方法可能造成高的CPU 占用,因此使用非常少,如果需要实现接收超时检测, 可以配合select和计时器channel 进行
接收任意数据, 忽略接收的数据:
阻塞接收数据后, 忽略从通道返回的数据,格式如下:
<- ch
执行该语句时将会发生阻塞, 直到接收到数据, 但接收到的数据会被忽略,这个方式实际上只是通过通道在goroutine间阻塞收发实现并发同步
下面的例子表示开启一个并发匿名函数,但是由于main()函数的默认goroutine执行完就退出了,因此go func的结果也不会执行,如图:

运行结果如下,只打印了main()函数中的内容,和goroutine开头的例子一样,如图:

现在我们通过通道的方式来实现匿名函数的goroutine并发运行,如图:

用了通道后,匿名goroutine和main()的默认goroutine 可以实现并发运行(不在使用time.Sleep也可以啦),如图:

注:上图中的go func()表示一个goroutine,main()为另一个goroutine,通道的发送和接收需要在两个不同的goroutine中进行
循环接收:
通道的数据接收可以借用for range 语句进行多个元素的接收操作,格式如下:
for data := range ch {
}
通道ch 是可以进行遍历的,遍历的结果就是接收到的数据,数据类型就是通道的数据类型,在发送完数据后需要关闭通道,否则循环遍历将会报死锁错误
下面例子表示通过循环遍历通道中的数据,如图:


- 第15行:close()一定要放在func()函数内部,for循环外,当for循环执行完成后关闭通道,如果close放在func()之外,那么go func()和main自带的goroutine同时运行,可能出现还没写数据通道就关闭的情况,执行会出现死锁
注意:第15行在循环发送数据通道结束后需要关闭通道,因为数据已经都发送到通道中,没有数据继续发送了,但是外层main的goroutine还在等待遍历通道中的数据,这就造成了阻塞,最后报出死锁的错误,如图:

上面的例子创建的都是无缓冲通道(发送数据的时候必须同步有接收者,否则就会阻塞导致死锁),使用无缓冲通道往里面装入数据时,装入方将被阻塞, 直到另外通道在另外一个goroutine 中被取出,同样,如果通道中没有放入任何数据, 接收方试图从通道中获取数据时,同样也是阻塞。发送和接收的操作是同步完成的。
下面例子将goroutine和channel放在一起,展示用法,也是无缓冲通道,如图:

- 第21行:go hello(ch),创建goroutine,将通道发送过去,一定要放在for循环的前面,因为发送数据和接收数据是同步进行的,如果放在for循环后面,for循环执行发送数据后,没有接收方,会出现死锁错误
- 第25行:100这个数据可以自定义,此行代码的功能就是给接收方提供判断条件,在for循环发送完数据后执行
- 第12行:根据发送方发送过来的数据100进行判断,如果等于100,说明发送方的数据已经发送完毕,自己也接收完毕,可以停止接收,以免造成阻塞死锁
6、单向通道—通道中的单行道
Go 的通道可以在声明时约束其操作方向,如只发送或是只接收。这种被约束方向的通道被称做单向通道
只能发送的通道类型为chan <- ,只能接收的通道类型为< -chan , 格式如下:
var 通道实例 chan<- 元素类型 //只能发送通道
var 通道实例 <-chan 元素类型 //只能接收通道
例如:
ch := make(chan<- int) //只能发送数据的通道
ch := make(<-chan int) //只能接收数据的通道
下面例子中,创建一个只能发送的通道类型,如果接收就会出现错误,如图:

错误提示只能发送,如图:

下面例子中,创建一个只能接收数据的通道,如果向其内部发送数据就会提示错误,如图:

错误提示只能接收数据,不能发送数据,如图:

一个不能填充数据(发送)只能读取的通道是毫无意义的
7、带缓冲的通道
在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程, 并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞(没有数据但是还在接收)
创建带缓冲通道:
通道实例 := make(chan 通道类型,缓冲大小)
- 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型
- 缓冲大小:决定通道最多可以保存的元素数量
- 通道实例: 被创建出的通道实例
下面例子表示创建带有5个元素缓冲大小的整数类型通道,发送数据前后打印缓冲区大小,如图:


上面例子中只有数据发送到通道,并没有接收者,发送者也不会发生阻塞,因为有缓冲区
带缓冲通道在下面列举的情况下依然会发生阻塞:
- 带缓冲通道被填满时, 尝试再次发送数据时发生阻塞
- 带缓冲通道为空时,尝试接收数据时发生阻塞
为什么Go语言对通道要限制长度而不提供无限长度的通道?
通道(channel)是在两个goroutine 间通信的桥梁。使用goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。
8、通道的多路复用—-同时处理接收和发送多个通道的数据(select语句)
多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术,例如:电话,网线,光纤都属于多路复用,支持多人同时收发数据
Go 语言中提供了select关键字,可以同时响应多个通道的操作
select 就是用来监听和 channel 有关的 IO 操作,当 IO 操作发生时,触发相应的动作。select 只能应用于 channel 的操作,既可以用于 channel 的数据接收,也可以用于 channel 的数据发送。如果 select 的多个分支都满足条件,则会随机的选取其中一个满足条件的分支执行,语法格式如下:
select {
case <- chan1:
// 如果 chan1 成功读到数据,则执行该 case 处理语句
case chan2 <- 2:
// 如果成功向 chan2 写入数据,则执行该 case 处理语句
default:
// 如果上面都没有成功,则进入default处理流程
}
通道收发语句,如下:
| 操作 | 语句示例 |
| 接收任意数据 | case <- ch: |
| 接收变量 | case d := <-ch : |
| 发送数据 | case ch <- 100: |
服务器开发中会使用RPC ( Remote Procedure Call ,远程过程调用)简化进程间通信的过程。RPC 能有效地封装通信过程,让远程的数据收发通信过程看起来就像本地的函数调用一样
(1)、下面例子使用通道代理Socket实现RPC的过程,服务器和客户端在两个goroutine 中运行,如图:

- 第11行:使用select开始做多路复用
- 第12行和第14行的通道会同时开启,如果第12行的通道先返回,则执行第13行的逻辑,如果第14行的通道先返回则执行第15行的逻辑,表示请求超时,抛出错误
- time.After为time包提供的函数After(),此函数返回一个通道,在指定时间后(time.Second),通过通道返回当前时间

(2)、下面例子中,向通道中发送了数据,因此第一个case满足条件,将会执行,如图:


(3)、下面例子将ch <- 100注释,此时第二个case满足条件,如图:


(4)、上述条件都不满足,执行default语句,如图:


(5)、如果有多个case满足条件,则随机执行一个,如图:


(6)、如果select中没有任何参数,此时表示阻塞,如图:

上图中为一个定时任务程序,每隔5s运行一次,通过select{}阻塞让程序定时执行,运行结果如下:

注:select{}中为空此时为阻塞,等待下一轮定时任务执行,如果要遍历通道中的内容,需要将遍历语句放在select{}前面,如果放在后面,那么select{}阻塞了,就永远无法执行后面的代码,因此要放在前面
9、关闭通道后继续使用通道
通道是一个引用对象,和map类似 , map 在没有任何外部引用时, Go 程序在运行时(runtime) 会自动对内存进行垃圾回收( Garbage Collection , G C ),通道也可以被垃圾回收,但是通道也可以被主动关闭
使用close()来关闭一个通道:
close(ch)
关闭的通道依然可以被访问,访问被关闭的通道将会发生一些问题,被关闭的通道不会被置为nil
对己经关闭的通道进行发送,将会触发岩机,如图:

关闭通道后,再次向通道中写入数据,将会触发宕机,上述代码执行结果如下:

从己关闭的通道接收数据时将不会发生阻塞,如图:


- 第6行:定义保存3个元素的缓冲通道,类型我整型
- 第7-第8行:给通道发送2个数据
- 第9行:关闭通道
- 第10行:通过for循环遍历通道,cap()函数可以获取一个对象的容量
- 第11行:从通道中获取值
从运行结果可以看出,通道中可容纳3个元素,实际只存放了2个,通道关闭后依然可以正常获取通道中的值,但是遍历到第3个的时候,结果为0,0表示通道的默认值,因为,当通道关闭后,及时没有数据,在获取值也不会发生阻塞,取的值为默认值
注意:关闭已经关闭的channel也会引发panic
四、同步一一保证并发环境下数据访问的正确性(并发安全)
并发安全:在并发执行的环境中,共享数据可以被正确执行,不会发生紊乱,此时即为并发安全,否则并发不安全
Go 程序可以使用通道进行多个goroutine 间的数据交换,但这仅仅是数据同步中的一种方法。通道内部的实现依然使用了各种锁,因此优雅代码的代价是性能。在某些轻量级的场合,原子访问( atomic 包) 、互斥锁( sync .Mutex )以及等待组( sync.WaitGroup )能最大程度满足需求
1、竞态检测一一检测代码在并发环境下可能出现的问题
当多线程并发运行的程序竞争访问和修改同一块资源时,会发生竞态问题(并发不安全)
(1)、下面例子并发执行add()函数,对同一个全局变量操作,如图:

- WaitGroup:等待组,在文章最后面会讲用途是什么
- go add():并发执行这两个函数,如果当前系统cpu核心为1个,那么看不出并发效果,还是一个一个执行,如果是多个cpu,就可以看出效果
预期的结果是10000,但实际有时候不符合预期,如下(在2颗cpu机器上执行):

上述例子中两个go add并发执行,都去修改全局变量x,就会造成竞态问题,导致结果不符合预期,也可以理解为并发不安全,结果紊乱,通过命令go run -face可以查看到静态问题,如下:

2、原子操作(atomic)
(1)、下面通过原子操作(atomic)解决上述的静态问题,AddInt64()函数强制同一时刻只能有一个gorountie运行并完成这个加法操作。如图:

再次执行go run -race bing.go将不会出现竞态问题,如图:

注:对变量进行增减操作,虽然可以使用互斥锁(sync. Mutex)解决竞态问题,但是对性能消耗较大。在这种情况下,推荐使用原子操作( atomic )进行变量操作
3、互斥锁(sync. Mutex)一一保证同时只有一个goroutine可以访问共享资源
除了原子操作,互斥锁是一种常用的控制共享资源访问的方法(sync.Mutex),它能够保证同时只有一个goroutine可以访问共享资源,Go语言中使用sync包的Mutex类型来实现互斥锁
(1)、通过互斥锁对上面例子中变量进行加锁和解锁,实现并发安全,如图:

运行结果符合预期,如下:

注意:使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的
(2)、下面例子中通过互斥锁对变量进行加锁和解锁的操作,如图:


- 第18行:对互斥量进行加锁,一旦发生加锁,如果另一个goroutine尝试继续加锁就会发生阻塞,直到这个countGuard被解锁,通过这种方式可以保证对变量的操作为原子操作(原子操作即是进行过程中不能被中断的操作)
(3)、给goroutine加互斥锁,main先拿到锁的将会先执行,如图:


4、读写互斥锁(sync.RWMutex)–在读比写多的环境下比互斥锁更高效
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型
如图:

- 第14行:将sync.Mutex互斥锁改为sync.RWMutex读写互斥锁
- 第18行:获取count的过程是一个读取count数据的过程,适用于读写互斥锁,将countGuard.Lock()改为countGuard.RLock(),将读写互斥锁标记为读状态,如果此时另外一个goroutine 并发访问了countGuard , 同时也调用了countGuard.RLock(),并不会发生阻塞
- 第19行:使用读模式解锁
- 第23行:加写锁
- 第25行:解写锁
读取一个资源不涉及资源修改的时候是没有必要加锁的,是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来
读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待
注:在使用RLock()和RUnlock()、Lock()和Unlock()等函数时,它们必须成对出现
5、等待组(sync.WaitGroup)—保证在并发环境中完成指定数量的任务
除了可以使用通道( channel )和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步
等待组有下面几个方法可用,如下:
| 方法名 | 功能 |
| (wg *WaitGroup) Add( delta int) | 等待组的计数器+ 1 |
| (wg *WaitGroup) Done() | 等待组的计数器- 1 |
| (wg *WaitGroup) Wait() | 当等待组计数器不等于0时阻塞直到变0 |
等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了N 个并发任务进行工作时,就将等待组的计数器值增加N。每个任务完成时,这个值减1,同时,在另外一个goroutine 中等待这个等待组的计数器值为0 时, 表示所有任务己经完成
(1)、下面例子中阻塞main自带的goroutine,直到两个go add执行完毕,如图:

上图可以理解为:在main函数的goroutine之前之前先等待go add()这两个协程执行,因此设置wg.Add()为2,每次执行完一个写成后,wg.Done减去1,wg.Wait()继续阻塞直到协程数量为0,此时认为已经执行完毕再执行main函数中的goroutine,如果不使用WaitGroup,那么main所在goroutine会在其余的goroutine之前结束前先执行完,会得到不符合要求的结果,甚至提前结束进程
注意:sync.WaitGroup是一个结构体,如果它作为参数传递的时候要传递指针,不能使用time.Sleep来替代WaitGroup,因为time.Sleep是在赌时间,如果时间设置的不够,会导致结果超出预期,在正式代码里,永远不要用 Sleep 来同步 goroutine 的结束


