Go语言并发基础——goroutine和channel的简单使用 | 豆包MarsCode AI刷题

114 阅读10分钟

前言

在我的上一篇文章网络与部署课后实践作业——UDP socket感知ACK丢包重传 | 豆包MarsCode AI刷题中,为了提高服务端处理客户端传来信息的并发能力,使用了go关键字来将读到127.0.0.1:8000地址信息后的处理和回发消息部分封装在goroutine中。

但是实际上当时我对Go语言的goroutine并没有很深入的了解,仅仅是凭借着为数不多的操作系统课程实验中,使用C++进行多线程并发编程的经验进行胡乱摸索,走一步看一步地进行尝试。也正是因为这样使得在一开始的探索过程中出现了不少诸如变量不同步之类并发常见bug。

所以借着这篇笔记的机会,对Go语言最显著的并发主体goroutine和相应的通信机制channel进行简单的使用。

goroutine

基础介绍

goroutine是Go语言实现并发逻辑的主体,又叫go程。goroutine是一种轻量级线程,或者说是协程。在操作系统的课程学习中,我们知道:

  • 进程是资源调度的基本单位,进程间的资源相互独立,一般情况下难以沟通
  • 线程是CPU调度的基本单位,可以视为轻量级进程,在进程内部实现了一定程度的资源共享

而协程则可以理解为更进一步轻量化、节省资源的线程,其调度执行权由CPU切换到程序自身。正因为Go语言自带这种协程并发机制,使得其在处理高并发高性能要求的场景具有天然的优势。

简单使用

goroutine的开启方式非常简单,只需要在包装好的协程函数前使用go关键字就可以在运行是开启一个goroutine。这个函数可以是具名函数,也可以是匿名函数。

go ThisFunction(parameters) // 可以

go func(){ // 同样可以

        // 一些具体逻辑

}()

需要注意的是,程序的入口main函数在启动时也是作为一个goroutine来运行的,在我们自定义新的goroutine时,它会与main函数所在的goroutine并行执行。

阻塞等待

当然,这就带来了一些执行顺序方面的疑惑,多个goroutine到底哪个先执行哪个后执行呢?事实上,执行顺序是我们无法预测的,这就会带来一些麻烦。例如下面的示例程序中,我们启动了一个goroutine并计划在控制台打印一些信息。

package main

import "fmt"

func main() {

    go func() {
       fmt.Println("This is new goroutine")
    }()

    fmt.Println("Main goroutine")

}

我们实际上看到的程序输出却是这样的,控制台只打印出了“Main goroutine”一句就程序就直接结束了运行,而没有办法打印出新启动的goroutine中的信息。

image.png

在C++中,我们可以通过在main函数中使用pthread_exit()的方法来指定主线程退出不影响子线程继续运行那么Go语言这里有什么方法呢?实际上,Go语言的WaitGroup机制可以解决这个问题。

使用sync包的WaitGroup类型定义一个变量wg,使用WaitGroup.Add(delta int)方法来手动指定有几个新建的goroutine。如果要新增goroutine,delta参数可以设置为正整数,反之设置为负整数可以减少管理中的goroutine数量。

在goroutine中,当其任务完成的时候,使用WaitGroup.Done()方法来告诉goroutine管理器说这个goroutine的任务完成了,整个进程的goroutine数量要减少。实际上相当于WaitGroup.Add(-1)

而在main函数中,我们需要等待子程序goroutine的完成再执行其他逻辑(或者只是简单地防止main函数过早退出),可以使用WaitGroup.Wait()方法来阻塞持续等待wg的goroutine管理值为0。

通过这种阻塞等待的方法,将程序修改如下:

package main

import (
    "fmt"
    "sync"
)

func main() {

    var wg sync.WaitGroup

    wg.Add(1)

    go func() {
       fmt.Println("This is new goroutine")
       wg.Done()
    }()
    wg.Wait()
    fmt.Println("Main goroutine")

}

程序执行效果正如我们预期的那样,main函数中等待子goroutine执行完成再执行自己的打印语句。

image.png

变量加锁

让我们先来看这样一段程序,按照我们的想法,在两个goroutine中分别对result进行十万次自增操作,最后在main函数中应该打印出result的值为200000。

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup

var result int = 0

func main() {
    wg.Add(2)
    go CalculateNum()
    go CalculateNum()
    wg.Wait()

    fmt.Println(result)
}

func CalculateNum() {
    for i := 1; i <= 100000; i++ {
       result++
    }
    wg.Done()
}

但是实际上我们并不能得到200000的结果,相反,每次运行我们都会得到一个不同的值,但都离200000相差较远。这是因为两个goroutine对共享的全局变量的操作互相干扰,导致一部分自增操作没能成功完成。

image.png

在C++中,我们采用pthread_mutex_t的方式定义互斥锁信号量,用pthread_mutex_lock()pthread_mutex_unlock()来处理加锁和解锁的逻辑,防止共享变量出现不可预知的误操作。

而在Go语言中,我们加解互斥锁的方式非常类似。可以定义sync.Mutex类型的变量lock,通过对lock执行加锁lock.Lock()和解锁lock.Unlock()来控制何时可以对共享变量操作。需要注意的是,C++中是通过传信号量指针的方式来加解锁,Go语言中直接将加解锁实现成了信号量自有的方法,一定程度上降低了繁琐的逻辑。

通过这种方法,我们对刚才的程序改写如下:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var lock sync.Mutex // 新增信号量
var result int = 0

func main() {
    wg.Add(2)
    go CalculateNum()
    go CalculateNum()
    wg.Wait()

    fmt.Println(result)
}

func CalculateNum() {
    for i := 1; i <= 100000; i++ {
       lock.Lock() // 实际上加锁与解锁的过程可以写到for循环外面,也是一样的效果
       result++
       lock.Unlock()
    }
    wg.Done()
}

当信号量加锁的时候,其他任何地方试图对同一个信号量加锁的语句都会被暂时阻塞,直到信号量被解锁的地方,其他加锁语句(以及更后面的逻辑)才能继续运行。这样就保证了result变量一次只能被同一个自增逻辑所访问,杜绝了不同goroutine之间共享变量操作冲突的问题。

为了避免对信号量的迷惑操作,加锁与解锁应当是成对出现的,否则一个goroutine中加的锁仍然可能被另外一个goroutine中单独的解锁语句所解除,这样反而造成了信号量的操作混乱。

image.png

channel

基础介绍

当然我们使用并发编程时,实际上像我前一篇文章那样只需要开启goroutine处理并发请求的情况在实际业务逻辑中并不足够。各个goroutine之间往往有必要进行一定的通信,才能保证并发操作的正确实现。

channel(管道)就是Go语言中为我们提供的通信机制,它可以简单方便的被同个进程中的goroutine共享访问,作为通信的管道而存在。

简单使用

声明

channel使用make语句声明,格式为make(chan type[, size]),其中size是可选参数。根据size的有无,channel被分为无缓冲channel和有缓冲channel。

无缓冲channel

无缓冲channel是要结合goroutine来使用的,因为其没有缓冲区,在放入的同时必须在另一端取出,否则会被持续阻塞。

来看示例程序,这里我们定义了一个无缓冲的channel叫做ch,先试图向里面存一个int类型数据123456,在下面试图取出这个数据并打印出来。

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    // go func() {
    ch <- 123456
    // }()
    // fmt.Println("test")
    fmt.Println(<-ch)
}

运行发现报错,提示发生死锁。这是由于在向ch送进数据的时候,由于无缓冲,程序阻塞等待另一边取出,但是实际上只有一个main函数所在的goroutine,没人能取出来,所以会一直阻塞下去,被编译器判定为死锁。

image.png

实际上在上面的程序中,我们可以将注释的三行删掉,也就是将送数据的部分包在goroutine中。再次运行发现正常了。程序在main函数goroutine中线运行到第二个输出语句,随后阻塞等待新goroutine中向ch传来数据才能打印出来。

image.png

有缓冲channel

有缓冲区的channel就不必必须跟goroutine绑定,而是可以自己独立使用。此时channel类似于一个队列,先送进去的数据,在取的时候也会先出。需要注意的是,向已满的channel中继续传输数据,或者向已空的channel中继续取数据,都会引发死锁错误,原理与无缓冲channel一致。

可读与可写channel

实际上默认通过make方法创建的channel是既支持读方法又支持写方法的双端channel,但是实际上在业务当中,我们往往希望对程序操作channel的权限进行一定的设置,这就有了可读与可写channel的用武之地。

以下是两行用于声明可读与可写channel的代码,对channel权限的设置需要建立在已有channel的基础上。实际上只是一个限制操作权限的别名,实际上的channel还是原来的一个。

ch := make(chan int, 5)
var readChannel <-chan int = ch // 可读channel
var writeChannel chan<- int =ch // 可写channel

可以看出可读/可写channel的声明方法实际上和读/写channel的操作语句非常相似。如果向可读channel中传输数据,就会引发错误。从可写channel读取数据也是同理。

image.png

close

可以使用close()方法来关闭一个channel,channel在被关闭之后不能被继续写入,但是其中已有的数据还可以被继续正常读出。这与close()方法的参数有关。

image.png

可以看到方法接受的参数是一个写入类型的channel(前面已经介绍过),相当于只是禁止了channel的写入而没有禁止读取操作。

channel一旦被关闭之后就不能再被常规方法打开了(或者说Go语言不支持原生的方式实现这种操作),但是可以通过channel指针操作结构成员的方法来强制打开,不过那就属于有些黑魔法操作了,这里不进行介绍。

select case结构

select case语句以读取channel作为case条件时,如果有多个分支语句满足可读取的条件,就会随机选取一个进行读取。例如下面的程序:

package main

import "fmt"

func main() {
    ch1 := make(chan int, 5)
    ch2 := make(chan int, 5)
    ch3 := make(chan int, 5)
    ch1 <- 1
    ch2 <- 2
    ch3 <- 3
    var x int
    select {
    case x = <-ch1:
       fmt.Println(x)
    case x = <-ch2:
       fmt.Println(x)
    case x = <-ch3:
       fmt.Println(x)
    default:
       fmt.Println("没有读取到数据")
    }
    // 证明不会同时读取出来
    if x == 1 {
       y := <-ch2
       fmt.Println(y)
    }

}

程序运行会随机地从可读取的case中选出一个进行读取,每次运行结果都会不同,if特判一下证明只会读取一个可行的case而不是全部读取了但是只执行一句里面的内容。

image.png

如果将上面代码中三行写入数据的语句注释掉,下面的select case读取分支也不会出现错误,而是会直接执行default分支。

image.png

小结

本文通过对两大并发机制进行简单的介绍和使用,实现了对于Go语言并发编程的初步入门学习。对上一篇文章中只是用了,但是没有用多明白的goroutine协程启动,以及额外的协程间通信机制channel进行基本的了解,可以为更深入学习铺垫基础。