后端go笔记3: goroutine, channel, 并发安全锁 | 青训营

53 阅读5分钟

记录了goroutine, channel, 和并发安全锁的有关知识点

参考材料:

GOROUTINE

单进程/单核:单一执行流程,时间成本大

多线程/多进程操作系统:解决cpu阻塞问题,但切换成本大(高消耗调度cpu,占用内存)

线程分为用户空间和内核空间 -> 切换成绑定在一起的用户线程(co-routine协程)和内核线程(thread):

  • 用户空间线程:由应用自己管理,操作系统不直接感知他们的存在;管理和切换在用户空间中进行,而操作系统只负责为应用程序分配资源
  • 内核空间线程:由操作系统内核(内核线程调度器)管理;可以在多个处理器上并行执行

利用多核和协程调度器开启多个协程,协程调度器的效率起关键作用

goroutine就是co-routine,但缩减了内存和增加了调度灵活性

老go调度器:

  • 每次创建、销毁、调度goroutine都需要线程获取(保护协程) -> 锁竞争
  • 每次goroutine执行完、放回后都会转移到协程队伍最后 -> 延迟和额外的系统负载
  • 频繁的系统调用 -> 线程阻塞和取消阻塞增加运行成本

因此新增processor处理器

  • 每个都带一个goroutine的本地队列
  • 如果想运行goroutine,要先获取processor
  • 程序的max同时运行的goroutine数量 = num of processor

GMP(goroutine, m线程, processor)模型(from刘丹冰):

Screenshot 2023-08-09 at 23.20.31.png

注意,全局队列也是存放等待运行的goroutine,如果本地队列没地儿放了再放在全局里

调度器设计策略

  1. 复用线程
  2. 利用并行
  3. 抢占
  4. 全局G队列

复用线程

work stealing机制:解决processor本地队列goroutine分配不均的问题

以下图为例,P2现在是空闲的,而P1有多个goroutine等待解决 -> P2会从P1的队列中抢一个goroutine来运行,提高效率

Screenshot 2023-08-09 at 23.25.03.png

hand off机制: 通过创建新的thread,转移goroutine队列来解决P阻塞问题

以上图为例,假如P1运行G1时阻塞了,程序会唤醒一个新的thread并转移P1的本地队列到这个thread上,继续程序的执行;假如G1还执行,那G1可能会加入其他队列,如果不执行,G1和M1可能会睡眠/销毁

利用并行

通过GOMAXPROCS限定P的个数 = CPU核数/2

抢占

在goroutine和cpu绑定运行过程中,假如有多个goroutine等待与cpu绑定 -> 每个goroutine最多和cpu绑定10ms -> 过了10ms,无论有没有运行完都要主动释放cpu的绑定,下一个等待运行的goroutine就会抢占cpu

全局G队列

  • 基于work stealing机制的补充:假如P1已经运行到最后一个本地队列中的goroutine了,P2就可以从全局队列中抢一个来运行
  • 有锁的保护,需要加锁解锁 -> 效率稍慢

创建代码

通过go关键词来触发goroutine

如果main停止,子goroutine也会停止

goroutine不一定是一个func,也可以是存在于main中的匿名func:

func main(){
    go func(){
        defer fmt.Println("end.")
        func(){
            defere fmt.Println("before end.")
        }()
        fmt.Println("before before end.")
    }()
}

注意:

  • goroutine的具体参数在最后}()中标明,因此在以上代码parameter为空的同时具体parameter也是空的
  • goroutine通过runtime.Goexit()停止运行,单单return的话不太够处理嵌套func的停止
  • goroutine之间不通过channel无法互相通信,所以在协程过程中光靠goroutine return回值不行

channel

基本信息

  • 负责在goroutine中间传递信息,还有同步两个goroutine进度的功能(无论谁先执行阻塞,为了成功沟通都要等待另一个给channel信息/接收信息)
  • 通过make(chan Type)或者 make (chan Type, capacity)来初始化,其中 capacity是缓冲区域的大小
  • 默认双向channel
  • 单向的channel: <-chan string (只读取chan信息)和chan <- string (只往chan写信息)
channel <- value //传输信息给chan
<- channel //接收并丢弃信息
fmt.Printf(<-channel) //打印出channel的信息
msg1 := <- channel //接受信息并将信息作为初始值
msg2, ok := <- channel //初始化msg2但ok可以检查信息是否为空

并发实践的阅读材料里有更多channel之间沟通的例子,其中用到了:

  • struct 加入bool wait来保证两个channel同时输出值
  • c <- <-input 合并管道, func fanIn 是一个合并input管道的func

无缓冲的channel交换流程:

  1. 两个goroutine到达channel,两个都未开始执行程序
  2. 负责传递的goroutine连接上通道,但直到信息传递完成前它都是被锁住的
  3. 负责接收的goroutine连接上通道,也是被锁住
  4. 交换完成,锁被释放

有缓冲的channel:

  • 缓冲就像一个仓库,传递的goroutine往里面传递值的时候不会强制要求接收的goroutine同步,因此不会互相阻塞
  • 但不会互相阻塞 != 不阻塞,如果channel缓冲区满了/空了一样会阻塞;直到新值存放进去/被取出来才会
  • 跟slice一样,len()cap()可以得出channel里现有的储存量和总容量
  • 可以用time.Sleep()来给传递的goroutine足够时间存放数据

关闭channel

  • close(channel)可以关闭channel
  • data, ok := <- channel中的okfalse的话就说明channel已关闭
  • 只有当不需要channel发送数据/想结构循环时才关闭
  • 关闭后无法再发送数据(会触发panic错误),但可以继续接受数据
  • 如果不关闭channel但永远阻塞 -> deadlock error
  • nilchannel的收发都会被阻塞

channel和range

  • 主要适用于for loop:for data := range c{ == for data, ok := <- c; ok{
  • range只会在c有数据的情况下才会继续运行,因此ok就失去了必要性

channel和select

select block: 可以监控多个channel的状态; 有点点点像switch

  • 在任何一个case能跑之前block
  • 如果多个cases同时满足,随机run一个
  • 如果没有可以run的,就run default
  • 每个case只有1个通信操作,either 接收 or 发送
select {
case <- chan1: //哪个条件先能触发,就运行哪个
case chan2 <- 1:
default: //
}

ex. Google搜索模拟(参考阅读帖最后一栏)

并发安全锁

多个goroutine同时操作一块内存会造成数据竞争

之前提到的channel可以解决这个问题,以下多介绍两种possible soln

sync包的Mutex互斥锁

保证只有一个goroutine可以访问共享资源,随机下一个等待的goroutine

var (
    x int64
    lock sync.Mutex
)
func AddWithLock(){
    for i := 0; i <1000; i++{
        lock.Lock() //强制锁住区域,不让其他goroutine进来
        x+=1
        lock.Unlock()
    }
}
func main(){
    for i := 0; i < 5; i++{
        go AddWithLock()
    }
    time.Sleep(2*time.Second)
}

这样就可以保证数据不会泄漏/因为竞争出现隐形bug

sync包的WaitGroup

有点像goroutine里调度器的全局队列 (感觉单一个安全锁不太能保证程序在跑完所有goroutine之后才output,所以结合wg会好点)

WaitGroup 常用函数用途
wg.Add(int a)添加a个等待的任务
wg.Done()减少计数器的值,表示已完成1个任务
wg.Wait()阻塞当前goroutine直到计数器归零
func main(){
    var wg sync.WaitGroup
    wg.Add(5) 
    for i := 0; i < 5; i++{ //5个并发任务
        go func(j int) {
            defer wg.Done() //输出完str后wg计数器就会--
            fmt.Println("wg:",j)
        }(i)
    }
    wg.Wait()//保证wg 0-4都会被输出完
}