Go语言的并发编程 | 青训营笔记

301 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

首先是面试中也经常问的一个知识点:并发和并行的区别

并发:多线程程序在一个核的cpu上运行

并行:多线程程序在多个核的cpu上运行

Go 天生支持并发编程,可以充分发挥多核优势,高效运行。

Goroutine 协程

Go 语言主要向提供协程来实现并发。

协程:用户态,轻量级线程,栈MB级别。

线程:内核态,线程跑多个协程,栈KB级别。

这里的用户态是指协程是用户级别的概念,和操作系统内核无关,也就是说操作系统内核是感知不到协程的存在的。对协程的创建,撤销,同步与通信等功能都无需内核的支持。

而内核态就好理解了,内核态的线程是在操作系统内核的支持下运行的,操作系统内核能对线程进行调度。

这里的用户态和内核态的区别是,操作系统内核是否对并发任务的切换有所感知,是否能够干涉线程的切换。

两种状态其实各有好处,具体体现在程序中的特性也看语言的具体实现。

Go 语言可以使用 go 关键字来启动一个协程。

 func main() {
     for i := 0; i < 10; i++ {
         go func(n int) {
             fmt.Printf("%d ", n)
         }(i)
         
     }
     // 9 4 1 0 6 7 8 2 5 3
     // 0 2 1 5 9 3 6 7 4 8
     // 等待三秒,等协程执行完
     time.Sleep(3 * time.Second)
 }

Chan 通道

Go 语言有自己特有的并发模型:CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存来通信。

当然Go语言也保留了通过共享内存来通信的通信机制(通过加锁)。

Go 提供了通道(channel)来进行协程间的通信。

无缓冲通道也叫同步通道,顾名思义没有缓冲区。发送者执行到发送操作的时候,如果没有接收者接收,将会一直堵塞,直到有接收者来接收,接收者执行到接收操作的时候亦然。

有缓冲通道的内部持有一个元素队列。队列的最大容量是在调用 make() 函数创建通道时候时通过第二个参数指定的。

向有缓冲通道的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素。如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个 Goroutine 执行接收操作而释放了新的队列空间。相反,如果有缓冲通道是空的,接收操作将阻塞直到有另一个 Goroutine 执行发送操作而向队列插入元素。

通道(chan)使用Go语言内置的 make() 函数来申请内存。

 c1 := make(chan int) // 无缓冲区的通道
 c2 := make(chan int, 2) // 缓冲区为2

有缓存的通道其实也是一个比较经典的消费者&生产者模型。

 func CalSquare() {
     src := make(chan int)
     dest := make(chan int, 3)
     go func() {
         defer close(src)
         for i := 0; i < 10; i++ {
             src <- i
         }
     }()
     go func() {
         defer close(dest)
         for i := range src {
             dest <- i * i
         }
     }()
     for i := range dest {
         //复杂操作
         print(i, " ")
     }
     // 0 1 4 9 16 25 36 49 64 81 --- PASS: TestCalSquare (0.00s)
 }

这里使用了 Go 原生提供的单元测试进行了简单运行,后文出现的代码同理。

 func TestCalSquare(t *testing.T) {
     CalSquare()
 }

可以看到输出的结果是有序的,也就是说这里的并发安全是有保障的。

在实际开发中应该避免对共享内存做一些非并发的读写操作。

锁 Lock

Go 语言提供的通过共享内存实现通信的方式。

 var (
     x    int64
     lock sync.Mutex
 )
 ​
 func addWithLock() {
     for i := 0; i < 2000; i++ {
         lock.Lock()
         x += 1
         lock.Unlock()
     }
 }
 func addWithoutLock() {
     for i := 0; i < 2000; i++ {
         x += 1
     }
 }
 ​
 func Add() {
     x = 0
     for i := 0; i < 5; i++ {
         go addWithoutLock()
     }
     time.Sleep(time.Second)
     println("WithoutLock:", x)
     x = 0
     for i := 0; i < 5; i++ {
         go addWithLock()
     }
     time.Sleep(time.Second)
     println("WithLock:", x)
 }

第一行是不加锁的代码的运行结果,这个结果是有问题的。第二行的才是正确结果。

 WithoutLock: 8470
 WithLock: 10000

在存在多个 Goroutine 同时操作临界区的资源的时候,需要加锁来保证程序执行的正确性。

线程同步 WaitGroup

之前的例子都是使用 time.Sleep(time.Second) 来实现的主协程阻塞,保证子协程执行完毕,这样的方式不够优雅也不够稳定。

Go 语言提供了 sync.WaitGroup 来实现当前协程的阻塞。

sync.WaitGroup 的机制其实有点像引用计数法,在计数器变为 0 之后才会放开主协程的阻塞。

 func ManyGoWait() {
     var wg sync.WaitGroup
     wg.Add(5)
     for i := 0; i < 5; i++ {
         go func(j int) {
             defer wg.Done()
             hello(j)
         }(i)
     }
     wg.Wait()
 }

执行结果,可以看到所有的协程都执行完毕了。

 hello goroutine : 4
 hello goroutine : 0
 hello goroutine : 1
 hello goroutine : 3
 hello goroutine : 2