记录了goroutine, channel, 和并发安全锁的有关知识点
参考材料:
- 青训营课件,刘丹冰课件(补充)
- 阅读帖:go并发实践(juejin.cn/post/699313…
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刘丹冰):
注意,全局队列也是存放等待运行的goroutine,如果本地队列没地儿放了再放在全局里
调度器设计策略
- 复用线程
- 利用并行
- 抢占
- 全局G队列
复用线程
work stealing机制:解决processor本地队列goroutine分配不均的问题
以下图为例,P2现在是空闲的,而P1有多个goroutine等待解决 -> P2会从P1的队列中抢一个goroutine来运行,提高效率
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合并管道, funcfanIn是一个合并input管道的func
无缓冲的channel交换流程:
- 两个goroutine到达channel,两个都未开始执行程序
- 负责传递的goroutine连接上通道,但直到信息传递完成前它都是被锁住的
- 负责接收的goroutine连接上通道,也是被锁住
- 交换完成,锁被释放
有缓冲的channel:
- 缓冲就像一个仓库,传递的goroutine往里面传递值的时候不会强制要求接收的goroutine同步,因此不会互相阻塞
- 但不会互相阻塞 != 不阻塞,如果channel缓冲区满了/空了一样会阻塞;直到新值存放进去/被取出来才会
- 跟slice一样,
len()和cap()可以得出channel里现有的储存量和总容量 - 可以用
time.Sleep()来给传递的goroutine足够时间存放数据
关闭channel
close(channel)可以关闭channeldata, ok := <- channel中的ok是false的话就说明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都会被输出完
}