Go 语言上手-并发编程| 青训营笔记

144 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

Go 语言上手-并发编程

1. 并发与并行

并发:同一时间段内执行多个任务

image.png

并行:同一时刻执行多个任务

image.png

Go 语言中的并发编程可以用两种手段实现,基于 CSP 并发模型的 Goroutine 和 Channel 以及基于传统共享内存模型的 Lock 和 WaitGroup,本文为 CSP 并发模型的学习笔记

2. CSP 并发编程模型

Do not communicate by sharing memory; instead, share memory by communicating

不要通过共享内存进行通信,通过通信共享内存

CSP 全称是 “Communicating Sequential Processes”,这也是 Tony Hoare 在 1978 年发表在 ACM 的一篇论文。论文里指出一门编程语言应该重视 input 和 output 的原语,尤其是并发编程的代码。

CSP 具有以下特点

  • CSP 定义了输入输出语句用于 process 间的通信
  • process 需要输入驱动(如果输入为false就不执行了),并产生输出,供其他 process 消费

我第一次了解到 CSP 并发编程模型是在写 2021 MIT6.S081 Lab: Xv6 and Unix utilities 的 primes 实验时接触到的,它要求基于 CSP 并发编程模型思想,利用管道实现一个并发素数筛。swtch.com/~rsc/thread…

所以如果你如果不能快速理解 CSP,那么你可以类比一下使用管道实现进程间通信,不过相比传统并发模型,我们把通信放在了首位。

实践证明,如果把 process 间的通信看成最重要的,那么并发编程会简单很多。请试着回忆以下传统的基于共享内存并发模型时的苦痛吧

2.1 协程 Goroutine

Goroutine 可以看作是 Go 语言中的用户级、轻量级线程(或者说是协程)的实现并且 Go 在语言层面就已经为我们实现了运行时的调度机制,可以让程序员更加专注于业务逻辑

Goroutine 和线程的区别

  • 内存资源: 创建线程固定的栈内存,goroutine 的栈内存是根据需求动态变化的。

并且 goroutine 的栈内存通常为 KB 级别,而线程的栈内存通常为 MB 级别,这让我们可以创建许多的 goroutine 来提高并发,同时节省内存资源

  • 创建和销毀:

线程由操作系统管理,内核级,创建销毁开销大

goroutine 由 Go runtime 管理,用户级,创建销毁开销小

  • 切换

相比线程切换需要保存一大堆寄存器, goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。

  • Goroutine 没有 ID 号

在大多数支持多线程的操作系统和程序语言中,线程都会有一个 ID,但是 goroutine 没有。这样的设计是故意为之的,这有效避免了 thread-local storage 被滥用,参数对函数的影响都是显式的。真能使程序更易读,也不用担心其身份信息会影响行为

Goroutine 和线程的联系

  • 一个线程对应用户态多个goroutine
  • go 程序可以同时使用多个操作系统线程
  • goroutine和线程是多对多的关系,即m:n
  • Go runtime 里的 Go scheduler 负责把 goroutine 调度到 thread 上执行。 OS scheduler 负责把 thread 调度到 CPU core 上执行

Goroutine 编程实践 我们可以使用以下方式来创建一个 goroutine

go 函数名(参数)

接下来看一个斐波那契函数的经典例子(来自 Go 语言圣经)

package main

import (
   "fmt"
   "time"
)

func main() {
   go spinner(100 * time.Millisecond)    //go 函数名(参数)来创建一个 goroutine
   const n = 45
   fibN := fib(n)
   fmt.Printf("\rFibonacci(%d) = %d\n\n", n, fibN)
}

func spinner(delay time.Duration) {    //打印标识符表明程序还在运行
   for {
      for _, r := range `-|/` {
         fmt.Println("\r%c", r)
         time.Sleep(delay)
      }
   }
}

func fib(x int) int {
   if x < 2 {
      return x
   }
   return fib(x-1) + fib(x-2)
}

image.png

main goroutine 和 spinner goroutine 独立同时运行,主函数返回时所有 goroutine 被直接打断程序退出。

image.png

除了从主函数推出或直接终止程序之外,没有其他编程方法能让一个 goroutine 来打断另一个的执行,但是下面我们可以看到利用 goroutine之间的通信来让一个 goroutinue 请求其他的 goroutine, 并且被请求的 goroutine 自行结束

2.2 通道 Channel

通道 Channel 是用来传递数据的一个数据结构, 是 goroutine 之间的通信机制

基础操作

我们可以使用内置的 make 函数创建一个 channel

ch := make(chan int)  //unbuffered channel
ch := make(chan int,0)  //unbuffered channel
ch := make(chan int,8)  //buffered channel with capacity 3

我们使用 <- 运算符来进行一个 channel 的接受和发送操作

ch <- x //向 channel 发送
x = <-ch //从 channel 接收
<-ch  //不使用接收结果也合法

同时我们也可以利用 close 来关闭 channel,关闭后再对这个 channel 进行的操作都会导致 panic 异常

close(ch)

不带缓存的 Channels

一个基于无缓存 Channels 的发送操作将导致发送者 goroutine 阻塞,直到另一个 goroutine 从相同的 Channel 上接收数据。

同样的。如果接收操作先发生,那么接收者 goroutine 也将阻塞,直到有另一个 goroutine 对同一个 Channel 发送数据。

利用这一特点,我们可以通过不带缓存的 Channels 实现两个 goroutine 的同步!!!

func main() {
    conn, err := net.Dial("tcp", "localhost:8000")
    if err != nil {
        log.Fatal(err)
    }
    done := make(chan struct{}) //unbuffered channel
    go func() {  //后台 goroutine
        io.Copy(os.Stdout, coon)
        log.Println("done")
        done <- struct{}{}  //发送信息给 main goroutine
    }()
    mustCopy(conn, os.Stdin)
    conn.Close()
    <-done  //阻塞在这了,等待后台 goroutine 发送信息
}

串联的 Channels(Pipeline) 我们可以用 Channels 将多个 goroutine 串联起来,一个 Channel 的输出作为下一个 Channel 的输入,这就是管道 pipeline,并且 Go 语言的循环可以直接在一个 Channel 上迭代

下面的例子用来生成 100 个数字的平方数

image.png

func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; x < 100; x++ {
            naturals <- x
        }
        close(naturals)
    }()

    // Squarer
    go func() {
        for x := range naturals {
            squares <- x * x
        }
        close(squares)
    }()

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

单方向的 Channels

有时候我们只需要 Channels 做单向的接收或发送,为了表明这个意图和防止滥用,Go 语言提供了单方向的 Channels,这样就可以在编译器对操作意图进行限制

//普通 channel,变量名表明了意图,但是还是容易出错
func counter(out chan int)
func squarer(out, in chan int)
func printer(in chan int)

//单方向 channel
out chan<- int  //表示只 发送 int
in <-chan int  //表示只 接收 int

如果作为函数参数传递,如果形参为单方向 Channels,实参为普通的 Channels,那么普通的 Channels 会被隐式转化为 单方向的,反之不行。

func counter(out chan<- int) {//表示只 发送 int
    for x := 0; x < 100; x++ {
        out <- x
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for v := range in {
        out <- v * v
    }
    close(out)
}

func printer(in <-chan int) {//表示只 接收 int
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    naturals := make(chan int)
    squares := make(chan int)
    go counter(naturals)
    go squarer(squares, naturals)
    printer(squares)
}

带缓存的 Channels

带缓存的 Channels 内部持有一个元素队列,队列的最大容量 make 函数创建传入的第二个参数

  • 如果内部缓存队列满的,那么对这个 Channel 的发送操作将阻塞,直到另一个 goroutine 接收导致队列非满;否则无阻塞地向这个 Channel 发送
  • 如果内部缓存队列空的,这个 Channel 的接收操作将阻塞,直到另一个 goroutine 发送导致队列非空

一定程度上,缓存队列解耦了接收和发送的 goroutine,队列非空/非满,双方就不必同步等待对方完成操作

参考资料

Go 语言圣经:books.studygolang.com/gopl-zh/ch8…

Go 语言之并发:zhuanlan.zhihu.com/p/445112151

Go语言基础之并发:www.liwenzhou.com/posts/Go/14…

什么是CSP:golang.design/go-question…

goroutine 和线程的区别:golang.design/go-question…