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

88 阅读4分钟

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

Go 语言通过协程(Goroutine)实现并发。协程是与 “进程” 和 “线程“ 类似的概念,但相比后两者,协程要更加的轻量,线程的栈是 MB 级别的,而协程的栈是 KB 级别的,协程在执行和调度上的资源消耗都比线程小得多。

一个简单的例子

在 Go 中使用协程非常简单,只需要在被调用的函数前加上 go 关键字,例如:

go fun(a, b, c)

这条简单的语句创建了一个协程负责执行 fun() 函数。更具体一点的例子,假如我们需要创建 5 个协程,分别打印数字 0 ~ 4:

func printNum(num int) {
    fmt.Println(num)
}
​
func main() {
    for i := 0; i < 5; i++ {
        go printNum(i)
    }
    // 让main()函数等待协程运行结束(简单粗暴)
    time.Sleep(time.Second)
}

这里用 Sleep() 函数强制让 main() 协程休眠 1 秒,它的作用是等待子协程的运行,这是因为 main() 协程执行到这里就没事情做了,接下来就会退出程序,导致子协程还没来得及运行就被终止,看不到输出结果,所以这里简单粗暴地让 main() 休眠了 1 秒给子协程充足的时间执行并退出。

运行输出:

4
0
1
3
2

可以发现输出是无序的,这是因为 5 个协程是并发运行的。

既然协程指定的是函数,那么自然的也可以指定匿名函数,这种写法是相当常见的:

func main() {
    for i := 0; i < 5; i++ {
        go func(num int) {
            fmt.Println(num)
        }(i)
    }
    time.Sleep(time.Second)
}

实现并发同步

在前面的例子中,为了让主协程等待子协程运行结束,我们简单粗暴地让主协程等待了 1 秒,实际开发中我们很难去精确估计子协程运行需要多长时间,更优雅的方法应该是让子协程在结束之前通知并唤醒主协程。

在 Go 中可以使用 sync.WaitGroup 实现并发任务的同步,它一共有三个方法:

  • Add(delta int):计数器 +delta
  • Done():计数器 -1
  • Wait():阻塞直到计数器归零

sync.WaitGroup 实际上维护了一个计数器,记录了当前还需要等待多少个协程执行结束,当调用 Wait() 时协程会进入休眠状态,直到计数器归零才会被唤醒。

func main() {
    var wg sync.WaitGroup
    wg.Add(5) // 需要等待5个子协程,因此计数器+5
    for i := 0; i < 5; i++ {
        go func(num int) {
            defer wg.Done() // 每个协程运行结束时,让计数器-1
            go func(num int) {
                fmt.Println(num)
            }(i)
        }(i)
    }
    // 主协程进入阻塞,等待wg计数器归零
    wg.Wait()
}

协程间通信

共享内存

协程间通信的方式之一是直接共享内存,简单来说就是多个协程共享一些变量,这种通信方式需要注意并发安全,在访问临界资源前可能需要上锁。

func main() {
    var (
        x int = 0
        wg sync.WaitGroup
        lock sync.Mutex
    )
    wg.Add(5)
    for i := 0; i < 5; i++{
        go func(n int) {
            defer wg.Done()
            for i := 0; i < n; i++ {
                lock.Lock() // 可以试着注释掉lock这两行,看看结果
                x++
                lock.Unlock()
            }
        }(1000)
    }
    wg.Wait()
    fmt.Println(x)
}

使用通道

协程间通信的方式另一种方式是使用通道(channel),通过使用通道间接地共享内存,这也是Go所提倡的方式。

声明一个名为 ch 的传递 int 类型变量的通道:

var ch chan int

声明后的通道还需要使用 make() 函数初始化才能使用,例如:

ch1 := make(chan, int) //无缓冲通道
ch2 := make(chan, int, 3) //缓冲区大小为3的通道

缓冲区大小即通道最多存放多少个元素,当缓冲区大小为 3 时,通道最多存放 3 个元素。

  • 当通道填满时,继续向通道传入数据则协程会被阻塞,直到通道中有元素被取走从而腾出空间。
  • 当通道为空时,试图从通道读取的协程会被阻塞,直到传入新元素或通道被关闭。

无缓冲通道会使协程同步化,因此又称为同步通道。

通过操作符 <- 用来收发通道中的数据:

channel <- x    // 将x发送到通道中
x := <- channel // 从通道中取出一个值并赋值给x

举个例子:

func main() {
    chan1 := make(chan int) // 无缓冲通道
    chan2 := make(chan int, 5) // 有缓冲通道,缓冲大小为5
    // 第一个协程负责生产数字
    go func() {
        defer close(chan1)
        for i := 1; i <= 10; i++ {
            chan1 <- i
        }
    }()
    // 第二个协程负责将第一个协程生产的数字取平方
    // 另外,通道也可以使用range读取
    go func() {
        defer close(chan2)
        for i := range chan1 {
            chan2 <- i * i
        }
    }()
    // 主协程负责打印第二个协程生产的平方数
    for i := range chan2 {
        fmt.Println(i)
    }
    // 协程的通道可以理解为函数的返回值
}

简单来说,协程中的通道就类似于函数的返回值,利用通道我们可以让原本孤立运行的协程间进行数据交换。