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

107 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第2天。今天主要学习了Go语言的并发编程依赖管理测试并利用Gin框架实现了一个简单的web服务端。该文章将在课程基础上进一步探索Go语言的并发编程。

1. Go的协程Goroutine

一般线程分为内核态用户态,我们平时所说的线程即为内核态线程,其具有以下两个缺点:

  • 调度开销大:操作系统创建和切换线程都需要进入内核,导致CPU有很大一部分时间被用于线程的调度;
  • 内存占用高:内核创建操作系统线程时会为其分配一个较大的栈内存,一般为MB级别。 所谓用户态线程既协程是一种基于线程之上,但比线程更加轻量的存在。Go使用协程(Goroutine)主要有以下三个原因:
  • 协程切换不需要进入到内核态,因此所需的时间较小;
  • 一个协程只需2k的内存;
  • 协程的调度发生在内核态,没有线程的开销,当某一线程阻塞时,线程上的其他协程会被调度到别的线程上运行,因此更加灵活。

1.1 Go的调度模型GPM模型

GPM模型包括3个组件:

  • G(Goroutine):既协程,本质上是一个可被暂停并恢复的函数,也就是代码中使用go关键字运行的函数;
  • M(Machine):既线程,M是标准库runtime中的一个结构体,每创建一个M便会同时创建一个线程并于该M进行绑定;
  • P(Processor):介于G和M之间的一个组件,其绑定一个缓冲队列和一个线程M,其数量一般在程序运行后不会改变,可由用户直接配置; GPM三者的关系如下所示: 图片1.png 当程序启动一个协程既运行到go func(){...}()时,该模型会进行以下操作:
  1. 创建一个G并将其放入一个P的本地队列,若所有P的本地队列均满时,将其放入全局队列;
  2. M从与之关联的P的本地队列中获取一个可执行的G,若关联的P的本地队列为空,则尝试从全局队列中获取一批G放入P的本地队列,若全局队列仍未空,则从其他P的本地队列中偷取一半的G放入关联的P的本地队列;
  3. M获取到可执行的G后运行G,如果G顺利执行完那么销毁该G并回到步骤2继续获取下一个G,如果G在执行过程中发生系统调用或阻塞,则创建一个新的M'或从协程池中取一个M'来接管当前的P,新的M'回到步骤2获取新的G',旧的M会继续执行G直到G系统调用结束进入步骤4;
  4. 如果旧的M能获取到P,则把其正在处理的G放入到P的队列中,继续执行,否则将G放入到全局队列中,M进入休眠态放回协程池;
  5. 当M获取不到G时,进入休眠态; 值得注意的是上述过程中M不会被销毁,其数量会在程序运行的过程中不断增加,为防止线程数无限制的增加,在go程序启动时,会设置M的最大数量,默认为10000。 个人认为在高并发的场景下,GPM的数量关系为
GMP G \geq M \geq P

1.2 GPM模型的一些参数配置

主要有两个可设置的参数:P的个数和M的最大数量,可通过设置环境变量的方式或使用runtime包中的函数进行设置。 P的个数:

$GOMAXPROCS //配置环境变量
runtime.GOMAXPROCS() //使用标准库函数

M的最大数量:

import runtime/debug
debug.SetMaxThreads()

两个参数的配置一定程度上影响了程序的并发能力,可根据物理机器的性能和应用场景来设计。

2. Channel

Go语言建议使用通信的方式来实现协程之间的数据共享,Channel是Go语言提供的一个基本数据类型,为引用类型,本质上是一个带锁的队列;包括带缓冲无缓冲两种,声明方式如下:

ch := make(chan int)//无缓冲channel
ch := make(chan int, 10)//带缓冲channel

channel有发送接收数据、遍历、关闭等基本操作

a := <- ch //发送数据,被变量a接收
<- ch //发送数据,不接受
ch <- a //接收变量a的数据
for a := range ch {...} //遍历channel,也可理解为不断向ch中接收数据直到channel关闭
close(ch) //关闭channel,之后如果再给ch发送数据会panic,从ch中读数据会读取到0值除非ch还有缓冲的数据没读出来

关于channel的阻塞:

  • 无缓冲的channel在单方面接收数据或发送数据时均会阻塞,直到有协程对其发送数据或接收数据,一般用于两个协程的同步
func worker(done chan bool) {
    time.Sleep(time.Second)
    // 通知任务已完成
    done <- true
}
func main() {
    done := make(chan bool, 1)
    go worker(done)
    // 等待任务完成
    <-done
}
  • 有缓冲的channel在发送数据时当缓冲区满后才会阻塞,在接收数据时当缓冲区为0时会阻塞(前提是channel没关闭),带缓冲的channel可以看作是一个生产-消费模型 select语句:结构类似于switch case语句,case后接channel的发送或接收语句,select会执行没被阻塞的case代码块,如果case均被阻塞,则会执行default代码块,若无default则阻塞在select语句,配合time.Sleep() time.After()Timer可以实现超时处理的操作。
import "time"
import "fmt"
func main() {
    c1 := make(chan int, 1)
    timer := time.NewTimer(time.Second * 2)
    go func() {
        //函数体
        c1 <- 0
    }()
    select {
    case <-c1:
        //在规定时间内完成
    case <-timer.C:
        //超时处理
    }
}

3. 互斥锁和原子操作

Go语言同样支持通过临界区来实现共享内存,在sync里提供了互斥锁sync.Mutex和读写互斥锁sync.RWMutex,可通过调用Mutex.Lock()、Mutex.Unlock()进行加锁和释放锁,RWMutex稍微复杂点,可以选择加读写锁或加读锁RLock()、RUnlock()、Lock()、UnLock(),根据临界区资源的读写频率选择加锁的方式来提高性能。除此之外,Go还提供了更高效的原子操作方式来实现对单个变量的并发安全的续写操作,在sync/atomic包中实现了对int32、float32等基本数据类型的Add、Compare、Swap、Load、Store等操作。