上一篇文章《Go语言高并发系列一:基础理论》介绍了进程、线程、协程各个级别的并发模式和GPM调度器,我们知道了Go语言并发的底层思想。
这篇文章我们来更深入的学习Go语言的并发相关内容,相关知识点会比较多,所以本文会稍稍较长。
channel
什么是channel?
channel在goroutine之间架起管道,实现goroutine间的同步和通信。
因为Go语言的并发模型是CSP,提倡通过通信来共享内存,而不是通过共享内存实现通信。可以说channel就是goroutine间的通信机制。
channel的一些特性:
- channel是goroutine-safe的,可以多个goroutine同时访问。
- channel可以在goroutine间传递数据
- channel总是遵循先进先出的原则,保证收发数据的顺序。
- channel可以导致goroutine的阻塞
简单说下channel结构:
type hchan struct {
// chan 里元素数量
qcount uint
// chan 底层循环数组的长度
dataqsiz uint
// 指向底层循环数组的指针
// 只针对有缓冲的 channel
buf unsafe.Pointer
// chan 中元素大小
elemsize uint16
// chan 是否被关闭的标志
closed uint32
// chan 中元素类型
elemtype *_type // element type
// 已发送元素在循环数组中的索引
sendx uint // send index
// 已接收元素在循环数组中的索引
recvx uint // receive index
// 等待接收的 goroutine 队列
recvq waitq // list of recv waiters
// 等待发送的 goroutine 队列
sendq waitq // list of send waiters
// 保护 hchan 中所有字段
lock mutex
}
我们可以看到channel底层也是通过mutex来保证操作这些结构的并发安全。
创建一个channel
// 格式
var 变量 chan 元素类型
// 声明
var a chan int
var b chan string
var c chan byte
var d chan []string
var e chan []int
// 无缓冲
f := make(chan []int)
// 带缓冲
g := make(chan []int, 10)
两种channel
channel分为两种:无缓冲,带缓冲,。
不带缓冲的可以看做”同步模式“,带缓冲的可以看做”异步模式“。
无缓冲channel
无缓冲的channel里是不能存储数据的,数据的发送和接收必须同步进行,否则就会进入阻塞状态,直到数据被接收。
实验代码如下:
func produce(a chan<- int) {
for i := 0; i < 3; i++ {
a <- i
fmt.Println("produce data: ", i)
}
}
func consume(b <-chan int) {
for true {
val := <-b
fmt.Println("consume data: ", val)
<-time.After(time.Second)
}
}
func main() {
ch := make(chan int)
go produce(ch)
go consume(ch)
<-time.After(3 * time.Second)
}
输出结果:
consume data: 0
produce data: 0
consume data: 1
produce data: 1
consume data: 2
produce data: 2
我们可以看到produce和consume是按顺序依次进行的,上一条数据被接收了,才能继续发送下一条数据。
带缓冲channel
带缓冲的channel里能存储1个或多条数据。
它 不要求数据的发送和接收必须同步进行。只有在channel没有可用可用缓冲区容纳新数据时,发送方才会阻塞。只有在没有数据可接收时,接收方才会阻塞。
我们只需要改动一行上面无缓冲channel的实验代码即可:
...
ch := make(chan int, 3)
...
输出结果:
produce data: 0
produce data: 1
produce data: 2
consume data: 0
consume data: 1
consume data: 2
我们可以看到produce程序不需要等待,直接把0、1、2写入了channel。
单向channel
有时候,我们有一些特殊的需求,要限制一个channel只能接收或者只能发送,我们称这种为单向channel。 声明方式如下:
send := make(chan<- int) //只能发送
receive := make(<-chan int) //只能接收
肯定有心细的同学已经发现了,我们上面的实验代码里procuce和consume的参数,就是定义的单向channel。
...
func produce(a chan<- int) {
...
func consume(b <-chan int) {
...
select
假如我们有多个channel需要等待。比如我们启动5个goroutine从网上下载内容,并发把结果发送到5个channel里,哪个先下好,就先处理哪个channel的数据。
如果我们依次去尝试获取channel的数据,在第一个就会被阻塞,无法同时查看后面4个的结果。这个时候就可以用select同时监听。格式如下:
select {
case <-ch1:
...
case data := <-ch2:
...
case ch3 <- data: //等待data发送到ch3
...
default:
默认操作
}
N个channel中,任意一个channel有数据产生,select都可以监听到,并执行相应的操作。当未监听到数据时,执行default,如果没有定义default,就会进入阻塞状态。
持续接收数据
我们有两种方式持续从channel接收数据,如果channel没有数据就阻塞等待。
range
示例代码如下:
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}()
for val := range ch {
fmt.Println(val)
}
for循环
示例代码如下:
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}()
for {
val, ok := <-ch
if ok == false {
break
}
fmt.Println("get val: ", val)
}
需要注意的是,这两种遍历方式,发送者没有关闭channel或者在range关闭,都会导致死锁(deadlock)。
关闭
channel可以关闭。关闭方式:
close(ch)
判断channel是否关闭:
v, ok := <-ch // 如果 ok==false,则channel已关闭
关闭nil channel会导致panic。
写入已经关闭的 channel 会导致panic。
读取已经关闭的 channel 会读到零值。
注意事项总结
使用channel过程中,有一些细节如果没有注意到的话,会导致panic或者死锁。
下面总结了几点注意事项:
- 读、写、关闭未初始化的channel,会导致死锁或panic
var ch chan int
<-ch //读取未初始化的channel
ch <- 1 //写入未初始化的channel
close(ch) //关闭未初始化的channel
- 无缓冲channel,只有读 或者 只有写。会导致死锁
ch := make(chan int)
ch <- 4
ch := make(chan int)
<- ch
- channel未正确的关闭,会导致死锁
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
//close(ch) 此处不关闭channel
}()
for val := range ch {
fmt.Println(val)
}
- 未正确判断channel关闭状态,可能导致死循环
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}()
for {
val := <-ch //正确方式:val,ok := <-ch 。判断ok==true时,break
fmt.Println("get val: ", val)
}
sync并发控制
sync包为我们提供了并发编程控制需要的一些功能。下面讲解一些经常用到的功能。
sync.waitGroup
当我们有一组goroutine需要执行,在主goroutine需要等待它们全部执行完成时,可以使用
waitGroup来进行控制。
示例代码:
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
// 计数加 1
wg.Add(1)
go func(i int) {
// 计数减 1
defer wg.Done()
time.Sleep(time.Duration(i) * time.Second)
fmt.Println(fmt.Sprintf("goroutine%d 结束", i))
}(i)
}
// 等待执行结束
wg.Wait()
fmt.Println("所有 goroutine 执行结束")
sync.Mutex
sync.Mutex:互斥锁 。一个互斥锁只能同时被一个goroutine锁定,其他goroutine将阻塞直到互斥锁被解锁,然后再重新争抢互斥锁。
什么时候会用到互斥锁呢?
当发生资源竞争的时候。比如同一内存块被多个goroutine同时访问,就可能造成无法预知的后果,需要用锁来进行控制。
下面举个累加求和的例子来感受一下:
var (
sum = 0
wg sync.WaitGroup
)
func add() {
defer wg.Done()
for i := 0; i < 1000; i++ {
sum += 1
}
}
func main() {
wg.Add(3)
go add()
go add()
go add()
wg.Wait()
fmt.Println(sum)
}
输出结果:
2069
3个goroutine,每个加1000,最终结果应该是3000。为什么结果这么奇怪呢?
因为这里的sum对3个goroutine来说就是公共资源,它们同时对它进行加操作,造成了不可预知的后果。
下面我们来加上互斥锁,看看效果:
var (
sum = 0
wg sync.WaitGroup
m sync.Mutex
)
func add() {
defer wg.Done()
m.Lock()
defer m.Unlock()
for i := 0; i < 1000; i++ {
sum += 1
}
}
func main() {
wg.Add(3)
go add()
go add()
go add()
wg.Wait()
fmt.Println(sum)
}
输出结果:
3000
sync.RWMutex
sync.RWMutex:读写锁。
读写锁相对于互斥锁最大的区别是:可以分别对读、写加锁。
一般用在读多写少的情况下,它的特点如下:
- 同时只有一个goroutine获得写锁
- 同时有任意多个goroutine获得读锁
- 同时只能存在读锁或写锁(读锁与写锁互斥)
示例代码:
var m sync.RWMutex
func main() {
for i := 0; i < 3; i++ {
go read(1)
}
for j := 0; j < 3; j++ {
go write(2)
}
for m := 0; m < 3; m++ {
go read(3)
}
time.Sleep(10 * time.Second)
}
func read(i int) {
m.RLock()
defer m.RUnlock()
fmt.Println("读: ", i)
time.Sleep(1 * time.Second)
fmt.Println("读结束")
}
func write(i int) {
m.Lock()
defer m.Unlock()
fmt.Println("写: ", i)
time.Sleep(1 * time.Second)
fmt.Println("写结束")
}
输出结果:
读: 1
读: 1
读: 3
读结束
读结束
读结束
写: 2
写结束
读: 1
读: 3
读: 3
读结束
读结束
读结束
写: 2
写结束
写: 2
写结束
sync.Once
在实际工作中,我们会遇到让代码只执行一次的场景,比如说创建单例、只加载一次资源等。
此时我们可以用sync.Once来确保,即使在高并发的情况下,代码也只执行了一次。
var once sync.Once
func main() {
once.Do(func() {
fmt.Println("do once")
})
}
sync.Map
Go语言中的 map 在并发情况下,只读是安全的,并发读写是不安全的。在并发场景下,我们可以使用并发读写安全的sync.Map。
var mm sync.Map
func main() {
mm.Store("a", 1)
v, ok := mm.Load("a")
fmt.Println(v, ok)
mm.Delete("a")
mm.LoadOrStore("b", 2)
//遍历,返回一个bool值,当返回false时,遍历立刻结束
mm.Range(func(key, value interface{}) bool {
return true
})
}
sync.Cond
sync.Cond是用来发号施令,协调goroutine运行的工具。相比于sync.Mutex,它可以同时阻塞一组goroutine,直到sync.Cond主动通知它们运行。
举个栗子:
当一个goroutine正在读取数据,其他goroutine必须等待这个oroutine接收完数据,才能读取到正确的数据。这时如果使用互斥锁,只能有一个goroutine可以等待,并读取到数据,没办法通知其他oroutine也读取数据。
那有哪些办法呢?
- 用一个全局变量标识第一个goroutine是否接收数据完毕,剩下的goroutine反复检查该变量的值,直到读取到数据。
- 创建多个channel, 每个oroutine阻塞在一个channel上,接收数据的goroutine在数据接收完毕后,挨个通知。
我们用sync.Cond就可以解决这个问题,sync.Cond有三个方法:
- Wait,会自动释放锁c.L,然后阻塞当前goroutine。直到被广播或信号唤醒,然后重新给c.L加锁,并且继续执行 Wait 后面的代码。
- Signal,唤醒一个等待中的goroutine。
- Broadcast,唤醒所有等待中的goroutine。
示例代码:
func main() {
cond := sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 2; i++ {
go func(ii int) {
defer wg.Done()
fmt.Println(fmt.Sprintf("等待消息 :%d", ii))
cond.L.Lock()
defer cond.L.Unlock()
cond.Wait()
fmt.Println(fmt.Sprintf("收到消息 :%d", ii))
}(i)
}
go func() {
defer wg.Done()
<-time.After(2 * time.Second)
fmt.Println("数据读取完毕,请其他成员开始运行")
cond.Broadcast()
}()
wg.Wait()
}
输出结果:
等待消息 :0
等待消息 :1
数据读取完毕,请其他成员开始运行
收到消息 :1
收到消息 :0
总结
本文主要讲了channel和sync包相关的知识点。
channel在goroutine之间架起管道,实现goroutine间的同步和通信。
sync包提供了丰富的并发控制工具。
下一篇文章我们来学习context相关知识。