掘金 后端 ( ) • 2022-11-25 21:29

Golang 并发&同步的详细原理和使用技巧

Golang 并发概要说明

并发模型

Golang 的并发模型属于一种很典型的 CSP(communicating sequential processes) 并发模型,其核心是不要通过共享内存来通信,而应该通过通信来共享内存。具体实现,就是通过 goroutine 来实现并发,然后并发的 goroutine 之间通过 Channel 来进行通信; 为此,Golang 的并发也有两个明显特点:

  1. Golang 非常善于并发,可以很简单的来实现(goroutine),直接一个 go 语句基就搞定,通过提高并发可以显著提高处理效率。
  2. Golang 的 goroutine 并发协程之间一般是采用通信的方式来共享变量或者逻辑交互,而这其中就可以结合 channel 来让并发协程之间进行共享数据,而 channel 就是各个 goroutine 之间通信的管道。

go 关键字实现并发

Golang 并发是通过 goroutine 协程来实现的,通过 go 关键字可以非常简单的启动一个协程;通过 go 关键字启动协程之后, 主进程并不会等待协程的执行,而是继续执行直至结束。因此,如果父子进程之间要有控制关系的话,就需要同步机制来保证。

Golang 并发&同步的设计和实现

为何需要同步控制

Go 既然天然支持并发,并且可以很简单的实现并发编程,那么这些并发的协程之间,如果同时访问访问内存中的同一个数据,在没有同步的机制下,那么同一个数据的访问一定会出现错乱,因此,在并发的场景,一定要通过同步机制才能确保同一内存数据的正确访问。

并发和 context

当需要进行多批次的计算任务同步,或者需要一对多的协作流程的时候;通过 Context 的关联关系(go 的 context 被设计为包含了父子关系),我们就可以控制子协程的生命周期,而其他的同步方式是无法控制其生命周期的,只能是被动阻塞等待完成或者结束。context 控制子协程的生命周期,是通过 context 的 context.WithTimeout 机制来实现的,这个是一般系统中或者底层各种框架、库的普适用法。

context 对并发做一些控制包括 Context Done 取消、截止时间取消 context.WithDeadline、超时取消 context.WithTimeout 等。

一个简单的 context.WithTimeout 示例如下:

package main

import (
"fmt"
"sync"
"time"

"golang.org/x/net/context"
)

var (
wg sync.WaitGroup
)

func work(ctx context.Context) error {
defer wg.Done()

for i := 0; i < 1000; i++ {
select {
case <-time.After(2 * time.Second):
fmt.Println("Do work ", i)

// we received the signal of cancelation in this channel
case <-ctx.Done():
fmt.Println("Cancel the context ", i)
return ctx.Err()
}
}
return nil
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()

fmt.Println("Hey, I'm going to do some work")

wg.Add(1)
go work(ctx)
wg.Wait()

fmt.Println("Finished. I'm done")
}

并发 和 sync(锁)

sync mutex 包括互斥锁和读写锁

sync.Mutex 互斥锁

互斥锁是传统的并发程序对共享资源进行访问控制的主要手段,它由标准库代码包 sync 中的 Mutex 结构体类型代表。sync.Mutex 类型(确切地说,是*sync.Mutex类型)只有两个公开方法:Lock 和 Unlock。Lock 被用于锁定当前的互斥量,而 UnLock 则被用来对当前的互斥量进行解锁。

sync.Mutex 互斥锁使用的最佳实践

  • 推荐的使用姿势是把对同一个互斥锁的成对的锁定和解锁操作放在同一个层次的代码块中。

  • 锁定的范围不要太长,锁定的时间尽可能的简短。

  • 如果有合适的 struct 结构体的封装的话,可以把 sync.Mutex 变量当做 struct 的一个成员变量,这样可以在该 struct 的多个方法中都可以使用

sync.RWMutex 读写锁

**sync.RWMutex 读写锁即是针对于读写操作的互斥锁,它与普通的互斥锁最大的不同就是,它可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则与互斥锁有所不同。在读写锁管辖的范围内,它允许任意多个读操作的同时进行;但是在同一时刻,它只允许有一个写操作在进行。并且,在某一个写操作被进行的过程中,读操作的进行也是不被允许的。**也就是说,读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。但是,多个读操作之间却不存在互斥关系。它的优势在于可以多个协程同时读数据,这样在高并发并且读多写少的场景下,可以大大提高我们的访问性能。

sync.RWMutex 读写锁使用的最佳实践

  • 与互斥锁类似,sync.RWMutex 类型的零值就已经是立即可用的读写锁了

  • 对已被写锁定的读写锁再次进行写锁定,会造成当前 goroutine 的阻塞,直到该读写锁被写解锁。 如果有多个 goroutine 因此而被阻塞,那么当对应的写解锁被进行之时只会使其中一个 goroutine 解锁恢复阻塞,正常运行。

  • 对一个已被写锁定的读写锁进行读锁定,也会阻塞相应的 goroutine。但不同的是,一旦该读写锁被写解锁,那么所有因进行读锁定而被阻塞的 goroutine 的都会恢复阻塞并正常运行

  • 对一个已被读锁定的读写锁进行写锁定操作,那么这个写锁定操作将会等待直至所有施加于该读写锁之上的读锁定都被清除。同样的,在有多个写锁定操作为此而等待的情况下,相应的读锁定的全部清除只能让其中的某一个写锁定操作获得进行的机会。

并发和 Channel

channel 作为 Go 并发模型的核心思想:不要通过共享内存来通信,而应该通过通信来共享内存,那么在 Go 里面,当然也可以很方便通过 channel 来实现协程的并发和同步了,并且 channel 本身还可以支持有缓冲和无缓冲的,通过 channel + timeout 实现并发协程之间的同步也是常见的一种使用姿势。

具体的可以参考我的《Golang Channel 详细原理和使用技巧》 一文中的详细说明。

并发 和 sync(sync.WaitGroup)

虽然 context 可以通过父子关系来控制子协程的生命周期,但是,如果我们的并发协程之间要等待其他协程执行完毕,那么 context 就无法做到了,这个时候就需要 sync.WaitGroup 同步了。

WaitGroup 的介绍

WaitGroup 用来实现 go 协程之间的同步,用来保证多个协程同步执行并等待所有协程执行结束,通过 WaitGroup 机制就可以不用使用 sleep 一个固定时间来进行等待了。

WaitGroup 内部有一个计数器,最初从0开始计数,它总共对我们提供了三个方法:Add(delta int), Done(), Wait()。

  • Add: 添加或者减少等待 goroutine 的数量,Add 的调用一定是要在 goroutines 之外进行的。 如果没有这样做,会引入一个数据竞争条件,因为我们没有对goroutine 做任何调度顺序上的保证。
  • Done: 相当于Add(-1),把计数器减 1
  • Wait: 执行阻塞,直到所有的 WaitGroup 数量变成 0,然后就会恢复正常。

WaitGroup vs Channel

请允许我打个小广告:这篇文章首发在我微信公众号【后端系统和架构】中,点击这里可以去往公众号查看原文链接,如果对你有帮助,欢迎前往关注,更加方便快捷的接收最新优质文章

Go 里面可以很方便通过 channel 实现协程的并发和同步,因为 channel 本身可以支持有缓冲和无缓冲的,然后可以实现同步和阻塞,通过 channel + timeout 实现并发协程之间的同步也是常见的一种使用姿势。

那么 WaitGroup 和 channel 相比,他们有啥区别呢,如下:

  • sync.WaitGroup 比 Channel 会更快一些,而且比 Channel 更容易理解。

  • sync.WaitGroup 的操作函数较多,不像 Channel 就是读写。

  • sync.WaitGroup 的重点在于同步,等待多个并发的 Go 协程同步执行完成。

  • Channel 可以很容易的在多个协程之间进行通信是同步通信的一个更高级的抽象,在实际应用比较容易使用,重点在于通信。

  • Channel 也是 GO 并发模型的核心:不要通过共享内存来通信,而应该通过通信来共享内存

同时,sync.WaitGroup 和 Channel 还可以可以结合使用,具体点击这里查看

WaitGroup 的使用示例

假如,我们需要创建多个 goroutine 去并发执行业务逻辑,然后一定要等这些并发全部完成后才继续接下来的程序逻辑执行,这个是一个非常场景的业务场景,大多数业务场景都需要这样。一个简单示例如下:

package main

import (
    "fmt"
    "sync"
)

var waitgroup sync.WaitGroup
func Afunction(shownum int) {
    fmt.Println(shownum)
    waitgroup.Done() //任务完成,将任务队列中的任务数量-1,其实.Done就是.Add(-1)
}

func main() {
    for i := 0; i < 10; i++ {
        waitgroup.Add(1) //每创建一个goroutine,就把任务队列中任务的数量+1, 一定要在 go 之外调用
        go Afunction(i)
    }
    
    waitgroup.Wait() //.Wait()这里会发生阻塞,直到队列中所有的任务结束就会解除阻塞
}

WaitGroup 的问题

WaitGroup 可以实现并发同步的控制,最大优点是可以通过 Wait() 阻塞直到并发协程中的所有任务都执行完才解除阻塞,但是依然有它的一些问题,包括:

  • WaitGroup 不能保证多个并发 goroutine 之间的执行次序

  • WaitGroup 无法指定固定的 goroutine 数目

  • 如果错误的调用 Done 方法, 会导致 waitGroup 内部计数值出现负数的情况,这样

  • 无法返回并发协程的错误信息,更加不能控制,如果并发协程中,有一个协程出错就不再等其他 goroutine 了,避免浪费资源。

并发 和 errgroup

官方原生的 errgroup

因为 WaitGroup 无法帮助我们返回所需要的错误信息,或者并发协程中只要一个 goroutine 出错我们就不再等其他 goroutine 了,因此,在实际项目应用中,还是会有一些不便,因此 errgroup 就做了这个事情。

官方原生的 errgroup 包 就是对 sync.waitGroup 的进一步封装,并且同时通过 Context 来控制超时,然后把 error 相关的处理逻辑给我们实现好了,使用 Go() 函数返回的第一个错误来停止所有协程,使用 errgroup 的时候尤其需要小心踩坑闭包问题。具体的使用示例,可以直接查看 官方原生的 errgroup 包 中的例子。

errgroup 包提供的 Group 结构体如下:

type Group struct {
    // context 的 cancel 方法
cancel func()

    // 复用 WaitGroup,可以同步等待多个并发的协程执行完毕
wg sync.WaitGroup

// 单例模式,用来保证只会接受一次错误
errOnce sync.Once

    // 保存第一个返回的错误
err     error
}

需要注意的是, errgroup 这里有两个问题:

  1. 没有对并发协程做 recover 的处理,那么就可能会导致使用者的不当从而触发 panic 而无法捕捉堆栈信息,或者直接导致程序退出。
  2. 无法控制并发量

其他扩展的 errgroup

由于原生的 errgroup 有上面两个问题,因此很多公司内部,或者社区,都在原生的基础上做了一些扩展封装,用来解决原生 errgroup 的问题,比如 kratos 的 errgroup,在原生的 errgroup 之上,做了进一步的扩展,改进了没有 recover 问题和并发数量问题,err 中包含了panic 发生时的详细堆栈信息,使得 errgroup 机制更加健壮。

最后