前言
Golang天生为高并发、分布式场景设计,是云原生领域的首选语言,Docker、Kubernetes、Istio、ArgoCD等核心基础设施均基于Golang构建。
- Go通过Goroutine提供轻量级并发执行单位
- 通过GMP调度模型实现Goutine高效调度到OS线程执行
- 基于CSP通信模型实现Channel,保障了Goroutine之间的高效通信
这一系列原生设计共同组成Golang并发模型,大幅降低了并发编程的复杂度,开发者基于Golang内置的并发能力,能够快速构建高性能、高并发的分布式系统。
一、同步/并发/并行概念
同步
2个/多个独立运行个体之间的执行顺序相互依赖,要么A的执行依赖B的执行结果,要么B的执行依赖A的执行结果。
并发
同一时间段内+交替执行多个任务,并发描述的是1个时间段。
并行
同一时刻+同时执行多个任务,不同的线程被不同的CPU执行,并行描述的是1个时间点。需要依赖多核心CPU实现。
小结
由于Python的全局解释器锁(GIL)导致多线程最终被调度到同1个CPU上。称为伪并发
在硬件设备满足多核CPU的前提下,GPM-Goroutine调度框架可以把多个Goroutines同时调度到不同的CPU上执行,称为真并发/并行。
二、Goroutine
Goroutine本质是协程。
在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务。
同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。
那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?
我们原来的实现使用线程实现并发的方案流程是:
- 程序----》os线程池-----》os调度线程----->cpu
在Golang中
- 程序---》goroutine------》go's runtime调度goroutines--------》线程池-------》os线程接口-----》os调度线程----->cpu
1.使用Goroutine
Goroutine有1个特性,一旦main函数结束,所有Goroutines也会全部消失。
因为mian函数结束相当于进程(资源单位)结束了,皮之不存毛将焉附?
package main
import "fmt"
func hello() {
fmt.Println("hello")
}
//程序启动之后会主动创建1个main goroutine
func main() {
go hello() //开启1个独立的goroutine
fmt.Println("main")
//main函数结束之后由main函数启动的goroutine也全部结束
}
2.控制Goroutine的执行顺序
sync.WaitGroup保证多个goroutine执行顺序
package main import ( "fmt" "math/rand" "sync" "time" ) //waitGroup协调gortines顺序 var wg sync.WaitGroup func f() { //在go中生成随机数字(要加seed种子) rand.Seed(time.Now().UnixNano()) for i := 0; i < 5; i++ { n1 := rand.Intn(11) fmt.Println(n1) } } func f1(i int) { // goroutine结束就登记-1 defer wg.Done() //开启1个goroutine:睡300毫秒 time.Sleep(time.Millisecond * time.Duration(rand.Intn(300))) fmt.Printf("goroutine%d\n",i) } func main() { for i := 0; i < 10; i++ { // 启动一个goroutine就登记+1 wg.Add(1) go f1(i) } //如何等待10个goroutines全部完成,main函数再结束。 wg.Wait()//wg.Wait()等待计数器减为0 }
4.内核线程和Goroutines的关系
CPU执行的最小单位是OS线程,所有的goroutine最终都需要被runtime调度映射到真正的OS线程上,被CPU执行。
1个OS线程对应用户态N个Goroutine。
1个GO程序可以被Processor处理器使用多个OS线程。
Goroutines和OS线程是多对多的映射关系=M:N。
三、Goroutine调度模型
线程的出现是为替代进程阻塞,协程(Goroutine)的出现是为了替代线程去阻塞。
线程的调度执行是由OS自动完成的,但是协程(Goroutine)属于用户态程序,所以要想使用协程,需要在代码中实现复杂的协程调度与执行机制。
传统并发是由OS线程/进程实现的重量级并发,切换成本高且阻塞影响整个线程,而Go并发通过轻量Goroutine和内置的GMP调度模型实现高效并发。
GMP模型通过P解耦Goroutine(任务)和 M(线程),让调度器高效管理大量轻量级任务,实现高并发同时充分利用CPU资源。
Golang遇到IO阻塞时,阻塞的是协程(Goroutine)根本不是线程,所以Golang天然适合大并发。
1.调度模型概览图

G:Goroutine用户态定义的协程,任务逻辑单元。
P:Processor是Goroutine调度的桥梁,持有OS线程(M)的执行权,把G分配给M执行,同时保证调度灵活和高效。
M:Macheine的意思,M和内核线程是1对1绑定的,且绑定关系固定不变。
2.Goroutine状态
Goroutine和线程一样具有生命周期和状态,GMP就是对Go协程Goroutine的全生命周期管理。
| 状态(英文) | 中文常用叫法 | 含义 / 使用场景 |
|---|---|---|
| Gidle | 空闲 | 初始状态,尚未被调度执行,或者已经完成执行,可以被 GC 回收。 |
| Grunnable | 可运行 | goroutine 已准备好运行,放在本地 runq / global runq / runnext 中等待 P 调度。 |
| Grunning | 正在运行 | goroutine 正在 M 上执行,状态中 CPU 正在跑它的代码。 |
| Gsyscall | 系统调用中 | goroutine 正在执行阻塞系统调用(如文件 IO),不会占用 P;P 会去调度其他 goroutine。 |
| Gwaiting | 阻塞等待 | goroutine 被阻塞等待某些条件(channel recv/send、mutex、semaphore、timer 等),挂在对应等待队列里。 |
| Gdead | 已结束 | goroutine 执行完毕,生命周期结束,准备被回收。 |
| Gcopystack | 栈复制中 | goroutine 的栈正在增长或收缩,需要暂停执行并复制栈。 |
3.调度队列
Go运行时runtime会根据Goroutine的状态把不同Goroutine存放不到不通队列,便于统一调度。
|
状态
|
典型队列 / 位置
|
|---|---|
|
Grunnable
|
P.runq / runnext / global runq
|
|
Gwaiting
|
channel.recvq / channel.sendq / semaphore / timer 等同步等待队列
|
|
Grunning
|
绑定 M 正在执行
|
|
Gsyscall
|
M 的线程池外,P 可调度其他 G
|
|
Gidle / Gdead
|
空闲或已结束,不在队列里
|
runnext运行队列(VIP 插队位):每个P有一个单独插队槽位runnext,存放刚被唤醒或handoff的goroutine,保证它下一个立即执行。
本地运行队列:G创建之后会优先存放到本地队列,P也会优先调度本地运行队列中的G到M执行,减少对global queue的访问,减少全局锁竞争。本地队列长度固定256。
全局运行队列:LocalQueue本地满/空时的补充队列,P空闲时,会先尝试从其他P的本地队列steal一半goroutine,如果没抢到,再从全局队列获取goroutine,保证负载均衡,无固定长度。
netpoll阻塞队列:存放正在等待I/O阻塞的Goroutine。
4.调度优先级
1. runnext(hand off / 刚唤醒的VIP goroutine)
2. 本地 runq
3. 从其他 P 偷本地 runq(work stealing)
4. 全局 runq
5. netpoll / timer
5.插队调度
假设一个场景:
goroutine G1正在发送数据到 channel
goroutine G2正在channel recv 阻塞等待
// G2: 阻塞等待数据 go func() { fmt.Println("G2: 等待数据...") val := <-ch // 阻塞在 channel recv fmt.Println("G2: 收到数据:", val) }() time.Sleep(100 * time.Millisecond) // 确保 G2 先阻塞 // G1: 发送数据 go func() { fmt.Println("G1: 发送数据 42") ch <- 42 // 发送数据,唤醒 G2 fmt.Println("G1: 数据发送完成") }()
如果G1先执行,然后G2被唤醒
最简单的做法:把 G2放回本地runq
问题来了:
-
runq 是环形队列,可能已有很多 goroutine 排队
-
G2 明明正等待 G1 的完成才可以继续执行
-
如果排队在runq 后面,G2 可能被延迟执行,CPU cache 也没法复用刚刚的数据
解决方案:插队调度runnext
Go runtime 设计中,每个P都有1个单独的优先执行槽位 runnext。
当一个Goroutine被唤醒时,如果原P空闲放入原P的runnext对列,如果原来的P忙,Go runtime会通过handoff把被唤醒的Goroutine重新放到相对空闲P的本地runnext队列,保证它下一个立即执行。
下一次调度时优先执行它,优先级高于本地runq和全局 runq,从而降低调度延迟并提高执行效率。
6.WorkStealing和Handoff机制
handoff的存在,是为了确保唤醒的goroutine不被调度延迟;
无论Goroutine原来绑定P是否忙?都能在最短时间被执行;
| 行为 | 英文专属名称 | 中文常用叫法 | 目的 |
|---|---|---|---|
| Work Stealing | WorkStealing | 工作窃取机制 | 保证空闲的P能尽快找到可运行的goroutine,维持系统负载均衡,提高整体 CPU 利用率 |
| Hand Off | HandOff | 执行权交接/插队机制 | 保证刚刚被唤醒的goroutine能立刻插队执行,而不是再进入runqune,降低调度延迟,提高缓存命中率,优化高并发下的执行效率 |
7.Goroutine池避免Goroutine无限膨胀
通过GMP模型得知全局运行队列是没有固定长度的,所有一直开Goroutie会导致系统OOM。
在Golang中可以轻松启动多个goroutine,但物极必反,无论我们启动多少个goroutine最终干活的还是os线程。
Goroutine池可以限制Goroutine的数量。
1个8核的服务器可以同时启动16个线程,但是golang中启动了1000个goritine。
16个os线程划分1000个goroutine无疑是增加了go runtime调度频率。并没有加速程序执行速度。
Goroutine池在Java、Python等语言的并发编程场景中,通常以共享数据为核心出发点,先有需要多线程共享的数据集,再通过加锁(如 synchronized、Lock、threading.Lock 等)来约束线程的执行流程,以此保障数据安全;
在Golang中,则以规范共享数据使用流程为核心出发点,基于CSP(通信顺序进程)模型的Channel通道,让多个Goroutine之间通过Channel通信传递数据而非争抢共享数据,第一性原理从共享逻辑底层出发,根本上保障了数据通信的安全性;
二者的核心出发点截然不同:前者是保护共享数据不被争抢(被动防御),后者合理规划使用共享数据的流程(主动规避)。
在并发编程场景下,当多个并发执行单元(线程 / 协程 / Goroutine)启动后,执行单元之间往往存在数据共享需求;而由于执行单元的调度顺序不可控,对共享数据的非原子性、非顺序化操作,会直接引发竞态条件(Race Condition)问题。
在Java和Python中,通常通过「共享内存 + 手动加锁」的方式避免并发编程引发的竞态问题。
Golang与之不同,其核心设计理念是通过通信(Channel)实现Goroutines间的并发控制与数据交互,而非共享内存 + 手动锁;
package main import ( "fmt" "sync" ) var wg sync.WaitGroup func main() { wg.Add(2) //chnanel缓冲区长度为1 ch1 := make(chan int, 1) //G1读 go func() { defer wg.Done() fmt.Println("G1执行读操作") val := <-ch1 fmt.Println("G1读到值:", val) }() //G2写 go func() { defer wg.Done() fmt.Println("G2执行写操作") ch1 <- 10 }() wg.Wait() close(ch1) fmt.Println("全部结束") }
读操作:val := <-ch1
├─ 第一步:加 channel 锁(必须先拿锁,否则连查都查不了)
│ ├─ 查 sendq(写等待队列)→ 没人
│ ├─ 查缓冲区 → 空
│ └─ 把自己(G1)放进 recvq 排队
├─ 第二步:解锁(挂起前必须解锁!否则锁会被占死,其他G永远拿不到)
└─ 第三步:G1挂起,等被唤醒
写操作:ch1 <- 10 ├─ 第一步:加 channel 锁(必须先拿锁,否则连查都查不了) │ ├─ 查 recvq(读等待队列)→ 有人(G1在排队) │ ├─ 唤醒recvq队头的G1(标记G1为“就绪态”,等待调度执行) │ ├─ 【直接数据交接】将数据10传递给G1(缓冲区空,无需放入缓冲区) │ └─ 从recvq中移除G1(recvq清空) ├─ 第二步:解锁(释放锁,供其他G使用) └─ 第三步:G2无需挂起,继续执行后续代码
总结
读Goroutine是写Goroutine的唤醒者+读旧数据者
写Goroutine是读Goroutine的唤醒者+写新数据者
注意:缓冲区的数据是写Goritine被唤醒后主动补进去的,不是读Gorutine帮它放的,反之亦然。
| 操作类型 | 第一步(优先) | 第二步(次优先) | 第三步 | 第四步 |
|---|---|---|---|---|
| 写操作 | 先看 recvq(读等待队列)有读Groutine等待唤醒 | recvq 没人 → 看缓冲区 | 写Gorutine去环形队列里面写数据 | 读Goroutine被写Goroutune唤醒后开始读操作 |
| 读操作 | 先看 sendq(写等待队列)读写Groutine等待唤醒 | sendq 没人 → 看缓冲区 | 读Goroutine从环形队列里面读数据 | 写Goroutine被读Goroutune唤醒后开始写操作 |
1.Channel创建
每1个Channel都是1个具体类型的导管,叫作Channel的元素类型。
例如:1个有int类型元素的通道,写为
chan int
像map类型一样channel类型是1个通过make创建的引用类型。
当Channel作为参数传递到另1个函数时,复制的是channel引用,这样调用者和被调用者都会引用同1份数据结构。
和其他引用类型一样channel的零值为nil。
2.Channel比较运算
2个同类型的channel可以使用==符合进行比较运算。
var chan1 = make(chan struct{}, 1) var chan2 = make(chan struct{}, 1) func main() { fmt.Println(chan1 == chan2) }
但是只有2个同类型的channel都是同1个Channel的引用时,比较值=true。
var chan1 = make(chan struct{}, 1) var chan2 = make(chan struct{}, 1) //比较2个同类型的channel func isEqual(ch1, ch2 chan struct{}) (res bool) { res = ch1 == ch2 return } func main() { res1 := isEqual(chan2, chan1) fmt.Println(res1) //false //只有2个同类型的channel都是同1个Channel的引用时,比较值=true。 res2 := isEqual(chan1, chan1) fmt.Println(res2) //true res3 := isEqual(chan2, chan2) fmt.Println(res3) //true }
Channel也可以和nil进行比较。
var chan1 = make(chan struct{}, 1) func main() { fmt.Println(chan1 == nil) }
3.Channel操作
Golang让并发变得简单,但没有让并发变得安全。
Golang提供了Goroutine + Channel这套简洁优雅的GSP并发模型,极大降低了并发编程的门槛,几行代码就能写出高并发程序。但简单,不代表安全。
- 死锁
- Goroutine泄漏
- 内存泄漏
- for + select 空转死循环,导致 CPU 瞬间跑满
- 向已关闭Channel 发送数据导致 panic
- 重复关闭、关闭 nil channel 导致崩溃
| 操作/状态 | nil channel | 正常 channel | 已关闭 channel |
|---|---|---|---|
| 写 ch <- x | 永久阻塞 | 满则阻塞,不满立刻执行 | 直接 panic |
| 读 <-ch | 永久阻塞 | 空则阻塞,有数据立刻执行 | 永远就绪(读零值) |
| close(ch) | 直接 panic | 可关,仅 1 次 | 直接 panic |
1.两个主要操作
channel有2个主要操作:发送(send)和接收(receive)两者结合在一起统称为1次通信。
var chan1 = make(chan struct{}, 1) func main() { //发送 chan1 <- struct{}{} //接收 <-chan1 }
2.关闭操作
Channel关闭代表着该通道写入完成,变成永久只读状态。
所有监听这个Channel的Goroutines都会立刻解除阻塞,并读到零值和ok=false。
注意:Channel的Close操作不会只唤醒1个读Goroutine,而是广播退出。
- Channel关闭之后, 读取到的是通道元素类型的零值,不会引发异常。
- Channel关闭之后,写入会引发 panic: send on closed channel异常。
- Channel关闭之后,再次关闭channel会引发panic: close of closed channel异常。
close(chan1)
3.for range读操作
Channel关闭代表着该通道的写入完成
package main import ( "fmt" "golang.org/x/sys/windows" "time" ) var naturalNumberCh = make(chan int, 100) func write() { threadID := windows.GetCurrentThreadId() defer fmt.Printf("----write-%d结束\n", threadID) for i := 0; i <= 100; i++ { naturalNumberCh <- i } } func read() { threadID := windows.GetCurrentThreadId() defer fmt.Printf("----read-%d结束\n", threadID) for n := range naturalNumberCh { fmt.Printf("----read-%d读取到值%d\n", threadID, n) } } func main() { go write() go read() go read() go read() time.Sleep(10 * time.Second) fmt.Println("main关闭channel") //channel一旦关闭读取当前channel的3个read goroutie完成读取之后,立即结束阻塞,退出! close(naturalNumberCh) time.Sleep(10 * time.Second) }
Channel关闭后,使用for range读当前channel的全部Goroutines,完成读取---->结束阻塞----->退出for range循环。
- 在循环遍历channel时,如果channel已关闭,for循环正常遍历,正常退出!
- 在循环遍历channel时,如果channel未关闭,for range循环读完channel中的值之后还会继续读,for range循环不结束,导致当前Goroutine一直阻塞,无法正常退出,最终可能会造成死锁!
for range循环遍历channel引发的死锁问题
package main import ( "fmt" "sync" ) var wg sync.WaitGroup var naturalCh = make(chan int) var squareCh = make(chan int) func counter() { for i := 0; i < 100; i++ { naturalCh <- i } fmt.Println("counter协程结束") wg.Done() } func squarer() { for n := range naturalCh { squareCh <- n * n } //Channel不关闭,for range循环会一直读channel造成当前Goroutine一直阻塞,一直不结束 fmt.Println("squarer协程结束") wg.Done() } func printer() { for n := range squareCh { fmt.Println(n) } //Channel不关闭,for range循环会一直读channel造成当前Goroutine一直阻塞,一直不结束 fmt.Println("printer协程结束") wg.Done() } func main() { wg.Add(3) go counter() go squarer() go printer() fmt.Println("main协程结束") //wg一直等待squarer和sprinter结束,但是这2个Goroutine一直不结束!!! wg.Wait() }
4.for select循环监听多个channel
select语句同时检测多个channel的1次读/写操作是否阻塞or就绪?只要某1个读/写操作变为可执行(ready),就会执行对应的case。
select可以同时检测多个channel的1次读/写操作是否为就绪状态?
- 若无任何channel操作就绪:有 default 则执行 default,无 default 则阻塞,直到至少一个 channel 操作就绪;
- 若仅1个channel 操作就绪:执行该操作对应的 case 逻辑;
- 若多个 channel 操作同时就绪:随机选择其中一个就绪的 case 执行(其余就绪的 case 不会被执行)
for select避免空转循环
死循环不可怕,无法推进业务逻辑的无阻塞的死循环,会让CPU被100%占满,导致程序/服务器性能雪崩,而有合理阻塞的死循环则完全没问题。
select语句只能检测多个channel的1次读/写,需要配合死循环进行循环监听,循环监听需要有结束条件。
对已关闭的channel,进行读操作channel永远就绪,容易造成程序死循环,导致CPU跑满。
发送操作就绪状态
|
channel 状态 | 是否就绪(可立即发送) | 具体条件 |
|---|---|---|
| nil channel | ❌ 不就绪 | 永远阻塞,无就绪可能 |
| active(正常)无缓冲 | ✅ 就绪 | 有其他 goroutine 正在阻塞等待接收这个 channel 的数据(recvq 非空) |
| active(正常)有缓冲 | ✅ 就绪 | 缓冲区未满(len(ch) < cap(ch)),或有其他 goroutine 阻塞等待接收(recvq 非空) |
| closed channel | ❌ 不就绪(直接 panic) | 发送到已关闭 channel 会 panic,不属于 “就绪 / 不就绪” 范畴,是非法操作 |
接收操作就绪状态
| channel 状态 | 是否就绪(可立即接收) | 具体条件 |
|---|---|---|
| nil channel | ❌ 不就绪 | 永远阻塞,无就绪可能 |
| active(正常)无缓冲 | ✅ 就绪 | 有其他 goroutine 正在阻塞等待发送这个 channel 的数据(sendq 非空) |
| active(正常)有缓冲 | ✅ 就绪 | 缓冲区非空(len(ch) > 0),或有其他 goroutine 阻塞等待发送(sendq 非空) |
| closed channel | ✅ 永远就绪 | 无论有无数据都可立即接收:有数据则读数据,无数据则返回零值 + ok=false |
Fan-In消费多个Chanel中数据
package main import ( "fmt" "time" ) func main() { ch1 := make(chan int, 10) ch2 := make(chan int, 10) //生产者1 go func() { time.Sleep(1 * time.Second) for i := 1; i <= 10; i++ { ch1 <- i } close(ch1) }() //生产者2 go func() { time.Sleep(2 * time.Second) for i := 11; i <= 20; i++ { ch2 <- i } close(ch2) }() //Fan-In消费者-循环监听消费多个有缓冲区channel中数据 for ch1 != nil || ch2 != nil { select { case v, ok := <-ch1: //channel读到零值是数据读完的信号,将当前channel1赋值为nil,退出循环监听。 if !ok { ch1 = nil continue } fmt.Println("ch1:", v) case v, ok := <-ch2: //channe2读到零值是数据读完的信号,将当前channel2赋值为nil,退出循环监听。 if !ok { ch2 = nil continue } fmt.Println("ch2:", v) // 超时分支 case <-time.After(5 * time.Second): fmt.Println("消费超时,强制退出") ch1 = nil ch2 = nil } } fmt.Println("所有数据都消费完毕!") }
5.Channel使用注意
package main /* 1. 基础:单向发、单向收 写一个程序: 启动一个 goroutine,向 channel 发送 1,2,3,4,5 主 goroutine 从 channel 接收并打印 发送完后关闭 channel 要求:用无缓冲 channel。 */ func main() { ch1 := make(chan int) go func() { for i := 1; i <= 5; i++ { ch1 <- i } }() for i := 1; i <= 5; i++ { println(<-ch1) } } package main import ( "fmt" "sync" "time" ) /* 2. 缓冲 channel 与异步生产消费 生产者 goroutine:每秒发送一个数字(1~10)到缓冲大小为 3 的 channel 消费者 goroutine:从 channel 接收并打印 主 goroutine 等待所有任务完成 观察:缓冲满时生产者会阻塞,空时消费者阻塞 */ func main() { ch2 := make(chan int, 3) wg := sync.WaitGroup{} wg.Add(2) //生产Goroutine go func() { defer wg.Done() for i := 1; i <= 10; i++ { time.Sleep(1 * time.Second) ch2 <- int(i) } close(ch2) }() //消费Gorutine go func() { defer wg.Done() for i := range ch2 { fmt.Println(i) } }() wg.Wait() } package main import ( "fmt" "time" ) /* 3. 用 channel 实现 “等待协程结束”(不用 sync.WaitGroup) 启动 5 个 worker goroutine,每个 worker 随机 sleep 一小段时间后结束 每个 worker 结束时向一个 done channel 发一个信号 主 goroutine 只在所有 5 个 worker 都结束后才退出 */ func main() { doneCh := make(chan int) for i := 1; i <= 5; i++ { go func() { fmt.Println("Goroutine开启") time.Sleep(1 * time.Second) fmt.Println("Goroutine结束") doneCh <- 1 }() } for i := 1; i <= 5; i++ { <-doneCh } fmt.Println("done") } package main import ( "fmt" "sync" ) /* 4. 单向 channel 限制方向(安全写法) 定义两个函数: func producer(out chan<- int) 只写 channel func consumer(in <-chan int) 只读 channel 在 main 里创建普通双向 channel,传给这两个函数,实现生产消费。 */ var wg sync.WaitGroup // 只写 channel func producer(out chan<- int) { defer wg.Done() out <- 1 } // 只读chanel func consumer(in <-chan int) { defer wg.Done() fmt.Println(<-in) } func main() { ch4 := make(chan int) wg.Add(2) go producer(ch4) go consumer(ch4) wg.Wait() } /* * 5. for-range 遍历 channel + 关闭 启动一个 goroutine 发送 1~10,然后 close (ch) 主 goroutine 用 for v := range ch 接收 体会:range 会一直取,直到 channel 被关闭才退出循环 */ package main import "fmt" func main() { ch5 := make(chan int) go func() { defer close(ch5) for i := 1; i <= 10; i++ { ch5 <- i } }() for v := range ch5 { fmt.Println(v) } } /* 6. 经典:用 channel 实现 “协程顺序打印” 启动 3 个 goroutine A、B、C,按顺序打印: */ package main import ( "fmt" "sync" ) func main() { wg := sync.WaitGroup{} wg.Add(3) numberChan := make(chan int, 9) chA := make(chan int) chB := make(chan int) chC := make(chan int) //GoroitineA go func() { defer wg.Done() <-chA fmt.Printf("A:%d\n", <-numberChan) chB <- 1 }() //GoroitineB go func() { defer wg.Done() <-chB fmt.Printf("B:%d\n", <-numberChan) chC <- 1 }() //GoroitineC go func() { defer wg.Done() <-chC fmt.Printf("C:%d\n", <-numberChan) }() chA <- 1 for i := 1; i <= 9; i++ { numberChan <- i } wg.Wait() }
Channel的生产数量和消费数量必须严格对等,避免Goroutine死锁/阻塞;
Close(Channel)后生产端不能再发送数据,消费端的for range循环确立了边界,生产端读完数据循环正常退出,避免Goroutine泄漏。
| 操作/状态 | channel=nil | 正常channel | 已关闭的channel |
|---|---|---|---|
| 读 | 阻塞 | 成功或阻塞 | 读到零值 |
| 写 | 阻塞 | 成功或阻塞 | panic |
| 关闭 close(ch) | panic | 成功 | panic |
4.Channel分类
根据Channel容量,可以把Channel划分为有缓冲Channel和无缓冲channel。
- 有缓冲通道(BufferdChannel) :不能缓冲数据,容量=0
- 无缓冲通道:(unbufferdChannel):可以缓冲一定数量的数据,容量>0
根据Channel支持的读、写功能,可以把Channel划分为单向Channel和双向Channel。
- 单向channel:仅支持读或写,1种功能。
- 双向channel:同时支持读和写,2种功能。
1.无缓冲Channel
无缓冲channel也称汇合Channel和同步Channel:不能缓冲数据,容量=0;
var unbufferdCh1 = make(chan struct{}) //无缓冲channel var unbufferdCh2 = make(chan struct{},0) //无缓冲channel var bufferdCh = make(chan struct{}, 1) //容量=1的有缓冲channel
当1个goroutine1向无缓冲channelA发送数据时,goroutine1会进行阻塞状态,直到另1个goroutine2从读无缓冲channelA读取数据。
此时1次通信操作完成,goroutine1和goroutine2都同时处于运行状态。
此时1次通信操作完成,goroutine2和goroutine1都同时处于运行状态。
2个goroutine使用无缓冲通道通信会导致goroutine同步化,因此无缓冲通道也称为同步通道。
经典案例:
基于1个无缓冲channel特性,使用2个Goroutine交替打印奇偶数。
package main import ( "fmt" "sync" ) var wg sync.WaitGroup //无缓冲Channel var ch = make(chan struct{}) //Goroutine写 func workerW(ch chan struct{}) { for i := 1; i <= 10; i++ { fmt.Println("workerW开始", i) ch <- struct{}{} fmt.Println("workerW结束", i) } wg.Done() } //Goroutine读 func workerR(ch chan struct{}) { for i := 1; i <= 10; i++ { fmt.Println("workerR开始", i) <-ch fmt.Println("workerR结束", i) } wg.Done() } func main() { wg.Add(2) go workerW(ch) go workerR(ch) wg.Wait() } /* 执行结果:假设workerR Goroutine先开始执行 ----------------------------------------- 1. workerR开始 i=1, 然后workerR读无缓冲Channel进入阻塞,workerR阻塞,workerW执行 ---workerR读阻塞 ----------------------------------------- 2. workerW开始 i=1 然后workerW写入无缓冲Channel,结束workerR的阻塞 ---workerW写不阻塞 3. workerW结束 i=1 workerW继续执行 4. workerW开始 i=2,然后workerW写无缓冲Channel进入阻塞,workerR执行 ---workerW写阻塞 ----------------------------------------- 5. workerR结束 i=1 workerR执行 6. workerR开始 i=2,然后workerR读无缓冲Channel,结束workerW的阻塞 ---workerR读不阻塞 7. workerR结束 i=2 workerR继续执行 8. workerR开始 i=3,然后workerR读无缓冲Channel进入阻塞,workerR阻塞,workerW执行 ---workerR读阻塞 ----------------------------------------- 9. workerW结束 i=2 workerW继续执行 10.workerW开始 i=3 然后workerW写无缓冲Channel,结束workerR的阻塞 ---workerW写不阻塞 11.workerW结束 i=3 workerW继续执行 12.workerW开始 i=4,然后workerW写无缓冲Channel进入阻塞,workerR执行 ---workerW写阻塞 ----------------------------------------- 13.workerR结束 i=3 workerR继续执行 14.workerR开始 i=4 然后workerR读无缓冲Channel,结束workerW的阻塞 ---workerR读不阻塞 15.workerR结束 i=4 workerR执行 16.workerR开始 i=5,然后workerR读无缓冲Channel进入阻塞,workerR阻塞,workerW执行 ---workerR读阻塞 ----------------------------------------- */
无缓冲channel控制多个Goroutine的执行顺序
package main import ( "fmt" ) func main() { ch1 := make(chan bool) ch2 := make(chan bool) go func() { fmt.Println("step1") <-ch1 }() go func() { ch1 <- true fmt.Println("step2") ch2 <- true }() <-ch2 fmt.Println("step3") }
2.单向Channel
当1个Channel用作函数的形参时,它几乎被有意地限制不能发送或者不能接收。
限制函数操作Channel的权限可以避免Channel被误用。
Go提供了单向Channel类型,仅支持发送 or 读取1种操作。
五、Goroutine并发执行流程同步
sync包则可以实现多个Goroutine并发执行流程的安全控制;
两者分别从数据传递和执行流程,这两个密不可分的维度,支撑高并发编程的可靠性。
sync包中提供了Mutex(互斥锁)、once(一次性操作)、waigroup(主线程等待所有goroutine结束再推出)、RWMutex(读写相互斥锁)等功能,帮助我们实现执行流程的并发安全。
package main import ( "context" "fmt" "golang.org/x/sys/windows" "sync" ) var naturalNumberCh = make(chan int, 100) var wg sync.WaitGroup var donech = make(chan uint32, 3) func write() { threadID := windows.GetCurrentThreadId() defer func() { wg.Done() //记得channel写入完成关闭,否则for range循环读一直不结束! close(naturalNumberCh) fmt.Printf("----write-%d结束\n", threadID) }() for i := 0; i <= 100; i++ { naturalNumberCh <- i } } func read(ctx context.Context, once *sync.Once) { defer wg.Done() threadID := windows.GetCurrentThreadId() //循环监听 for { //监听多个channel select { //1.监听结束信号 case <-ctx.Done(): fmt.Printf("----read-goroutine-%d结束------\n", threadID) return //2.不结束即执行 default: //记得写完了关闭Channel,否则for range循环不结束! for n := range naturalNumberCh { fmt.Printf("----read-goroutine-%d读取到值%d\n", threadID, n) } once.Do(func() { donech <- threadID }) } } } func main() { defer fmt.Println("mian函数结束") ctx, cancel := context.WithCancel(context.Background()) readerCount := 3 writeCount := 1 //开1个写go程 for i := 0; i < writeCount; i++ { go write() } //开3个读go程执行结束后主动通知main函数,发请求结束的请求! for i := 0; i < readerCount; i++ { go read(ctx, &sync.Once{}) } gocount := readerCount + writeCount wg.Add(gocount) //mian函数收到了3个读goroutine发送的请求结束请求,调用cancel主动结束它们! for i := 0; i < readerCount; i++ { fmt.Printf("----read-goroutine-%d请求结束!\n", <-donech) } cancel() wg.Wait() }
1.goroutine资源争用现象
我们知道MySQL客户端用到的数据放在mysqld服务端的数据库中当多个客户端连接数据库时有事会需要加锁操作保证数据安全。
程序中用到变量数据在内存里,我开多个goroutine去同时对同1个全局变量进行修改,相当于多个MySQL的客户端同时对数据库同1条数据进行修改。
var wg sync.WaitGroup //定义1个全局变量 var number int64 //对全局变量进行+1操作 func add1() { for i := 0; i < 5000; i++ { //1.从内存中找到number变量对应的值 //2.进行+1操作 //3.把结果赋值给number写到内存 number++ } wg.Done() } func main() { wg.Add(2) go add1() go add1() //fmt.Println(number) wg.Wait() fmt.Println(number) //每次执行结果都不一致 }
2.sync.Mutex互斥锁
Mutex可以防止同1时刻,同1资源(全局变量)被多个goroutine操作。
互斥锁不区分是读、写操作,只要有1个goruitne拿到Mutax,其余的所有goroutines,无论是读还写,只能等待。
Mextex是使用struct实现的而在golang中struct属于value类型。
需要注意的是在使用sync.Mutex时如果把它当成参数传入到函数里面,mutax就会被copy生成2把不同的mutex。
var lock sync.Mutex lock.Lock() //加锁 lock.Lock() //加锁
1个公共资源被N个goroutines 操作引发的问题
package main
import (
"fmt"
"sync"
)
//锁
var x = 0
var wg sync.WaitGroup
//每次执行add增加5000
func add() {
defer wg.Done()
for i := 0; i < 5000; i++ {
x++
}
}
func main() {
wg.Add(2)
//开启2个goroutines同时对x+1
go add()
go add()
/*2个goroutines如果同1时刻都去获取公共变量x=50,
然后在独自的栈中对x+1改变了x都=51
就少+了1次,导致结果计算不准!
*/
wg.Wait()
fmt.Println(x)
}
3.使用互斥锁
package main
import (
"fmt"
"sync"
)
//锁
var x = 0
var wg sync.WaitGroup
/*
A Mutex must not be copied after first use.
使用互斥锁一定要确保该锁不是复制品(作为参数传递时一定要传指针)
*/
//互斥锁
var lock sync.Mutex
//每次执行add增加5000
func add() {
defer wg.Done()
for i := 0; i < 5000; i++ {
lock.Lock() //加锁
x++ //操作同1资源
lock.Unlock() //释放锁
}
}
func main() {
wg.Add(2)
//开启2个goroutines同时对x+1
go add()
go add()
wg.Wait()
fmt.Println(x)
}
4.RWMutex(读/写互斥锁)
使用数据库时我们大部分的场景都是读的频率高于写的频率,所以我们可以使用2个数据库,1个叫主库另1个叫从库,主库支持写操作,从库支持度操作,主从之间通过bin log同步数据。
如果现在数据在内存中放着也是读变量的频率远远高于修改变量的频率。我们可以使用RWmutex
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。
读写锁分为两种:
读锁:当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;
写锁:当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待;
var rwlock sync.RWMutex //读锁 rwlock.RLock() rwlock.RUnlock() //写锁 rwlock.Lock() rwlock.Unlock()
Rwmutex区分goroutine读、写操作,仅在写时资源被lock,读的goroutines等待。(读并发、写串行)。
应用场景:所以使用RWMutex之后,在读操作大于写操作次数的场景下并发执行效率会比Mutex更快。
如果读和写的操作差别不大,读写锁的优势就发挥不出来。
package main
import (
"fmt"
"sync"
"time"
)
var x = 0
var lock sync.Mutex
var rwlock sync.RWMutex
var wg sync.WaitGroup
//rwlock
func read() {
defer wg.Done()
//加普通互斥锁
// lock.Lock()
//加读锁
rwlock.RLock()
fmt.Println(x)
time.Sleep(time.Millisecond)
//释放普通互斥锁
// lock.Unlock()
//释放读锁
rwlock.RUnlock()
}
func write() {
defer wg.Done()
// lock.Lock()
//加写锁
rwlock.Lock()
x++
time.Sleep(10 * time.Millisecond)
// lock.Unlock()
//释放写锁
rwlock.Unlock()
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
go write()
wg.Add(1)
}
//读的次数一定要大于写的次数
for i := 0; i < 1000; i++ {
go read()
wg.Add(1)
}
wg.Wait()
fmt.Println(time.Now().Sub(start))
//Mutex:1.205s
//RWMutex 194ms
}
6.sync.WaitGroup
var wg sync.WaitGroup wg.Add(2) wg.Done(2) wg.Wait()
主goroutine结束之后,又它开启的其他goroutines会自动结束!!
如何做到让main goroutine等待它开启的goroutines结束之后,再结束呢?
main goroutine执行time.Sleep(duration)肯定是不合适,因为我们无法精确预测出 goroutines到底会执行多久?
| 方法名 | 功能 |
|---|---|
| (wg * WaitGroup) Add(delta int) | 计数器+delta |
| (wg *WaitGroup) Done() | 计数器-1 |
| (wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
var wg sync.WaitGroup
func hello() {
defer wg.Done()
fmt.Println("Hello Goroutine!")
}
func main() {
wg.Add(1)
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
wg.Wait()
}
7.sync.Once
如何确保某些操作在并发的场景下只执行1次,例如只加载一次配置文件、只执行1次close(channel)等。
func (o *Once) Do(f func()) {}
Onece的Do方法只能接受1个没有参数的函数作为它的参数, 如果要传递的func参数是有参数的func, 就需要搭配闭包来使用。

下面是借助sync.Once实现的并发安全的单例模式:
package singleton
import (
"sync"
)
type singleton struct {}
var instance *singleton
var once sync.Once
func GetInstance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
8.sync.Map
Golang内置的Map数据类型是非线程安全的Map;
sync包提供了1个线程安全的Map即sync.map。
sync.map内部使用2个map即read和dirty,实现读写分离的机制;
type Map struct { mu Mutex // 把read看成一个安全的只读快照表,实际对应的是readOnly, read atomic.Value // readOnly // dirty需要使用上面的mu加锁才能访问里面的元素, //dirty中包含所有在read字段中但未被expunged(删除)的元素, //重点包含最新的 KV 对,等时机成熟,dirty 会被转换为 read, 然后该字段会被置为空 dirty map[interface{}]*entry // misses是一个计数器,记录在从read中读取数据的时候,没有命中的次数, //每次从 read 中没找到回到 dirty 中查询都会导致 misses 自增一, //当misses > len(dirty) 时,就会触发dirty转换 misses int }
在读取时不需要加锁,在写入时则会进行细粒度的锁定,以保证数据的一致性和并发安全性;
var syncMap sync.Map //新增 syncMap.Store(key, n) //删除 syncMap.Delete(key) //改 syncMap.LoadOrStore(key) //遍历 syncMap.Range(walk)
golang中的map在并发情况下: 只读是线程安全的,但是写线程不安全,所以为了并发安全 & 高效,官方帮我们实现了另1个sync.map。
fatal error: concurrent map writes //go内置的map只能支持20个并发写!
package main
import (
"fmt"
"strconv"
"sync"
)
var m = make(map[string]int)
func get(key string) int {
return m[key]
}
func set(key string, value int) {
m[key] = value
}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
//设置1个值
set(key, n)
//获取1个值
fmt.Printf("k=:%v,v:=%v\n", key, get(key))
wg.Done()
}(i)
}
wg.Wait()
}
就支持20个并发也太少了!
Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。
同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。
package main
import (
"fmt"
"strconv"
"sync"
)
var syncMap sync.Map
var wg sync.WaitGroup
func walk(key, value interface{}) bool {
fmt.Println("即将删除Key =", key, "Value =", value)
syncMap.Delete(key)
return true
}
func main() {
for i := 0; i < 200; i++ {
//开启20个协程去syncMap并发写操作,也是可以顺利写进去的的!
key := strconv.Itoa(i)
wg.Add(1)
go func(n int) {
//设置key
syncMap.Store(key, n)
//通过key获取value
value, ok := syncMap.Load(key)
if !ok {
fmt.Println("没有该key", key)
}
fmt.Println(value)
wg.Done()
}(i)
}
//使用for 循环或者 for range 循环无法遍历所有syncMap只能使用syncMap.Range()
//不幸运的Go没有提供sync.Map的Length的方法,需要自己实现!!
syncMap.Range(walk)
wg.Wait()
}
六、并发场景数据原子操作保障
Go语言中,sync.Mutex互斥锁的底层实现依赖 sync/atomic包提供的原子操作来保障关键状态的安全修改;
原子操作能够确保并发场景下数据的读写操作不被其他线程/协程/Goroutine打断,是实现并发数据安全的核心底层机制。
原子操作概念
在程序中执行1行内容为a=a+1的代码时,CPU其实是分多个步骤去执行这1行代码的;
- 从内存中获取a变量原值
- 对a变量原值进行+1操作
- +1操作结果赋值给变量a
以上多个步骤处理,那么就意味着有中间状态(操作中、没操作完的状态);
而原子操作,它是一个不可分割的整体,没有中间状态,要么成功了、要么失败了。
原子操作需要通过给底层的CPU发送原子操作指令去实现。
在并发读写模式下,在变量级别使用原子操作的好处是在多Goroutine并发操作的同1个变量时
- 可以保证当前变量值一致性
- 比sync.Mutex锁的粒度更小,性能更快
原子操作函数
atimic包提供了一组原子操作函数,用于对值类型的变量进行原子操作;
| 方法 | 解释 |
|---|---|
| func LoadInt32(addr *int32) (val int32) func LoadInt64(addr *int64) (val int64) func LoadUint32(addr *uint32) (val uint32) func LoadUint64(addr *uint64) (val uint64) func LoadUintptr(addr *uintptr) (val uintptr) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer) |
读取操作 |
| func StoreInt32(addr *int32, val int32) func StoreInt64(addr *int64, val int64) func StoreUint32(addr *uint32, val uint32) func StoreUint64(addr *uint64, val uint64) func StoreUintptr(addr *uintptr, val uintptr) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer) |
写入操作 |
| func AddInt32(addr *int32, delta int32) (new int32) func AddInt64(addr *int64, delta int64) (new int64) func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) func AddUintptr(addr *uintptr, delta uintptr) (new uintptr) |
修改操作 |
| func SwapInt32(addr *int32, new int32) (old int32) func SwapInt64(addr *int64, new int64) (old int64) func SwapUint32(addr *uint32, new uint32) (old uint32) func SwapUint64(addr *uint64, new uint64) (old uint64) func SwapUintptr(addr *uintptr, new uintptr) (old uintptr) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer) |
交换操作 |
| func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) |
比较并交换操作 |
ackage main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type Counter interface {
Inc()
Load() int64
}
// 普通版
type CommonCounter struct {
counter int64
}
func (c CommonCounter) Inc() {
c.counter++
}
func (c CommonCounter) Load() int64 {
return c.counter
}
// 互斥锁版
type MutexCounter struct {
counter int64
lock sync.Mutex
}
func (m *MutexCounter) Inc() {
m.lock.Lock()
defer m.lock.Unlock()
m.counter++
}
func (m *MutexCounter) Load() int64 {
m.lock.Lock()
defer m.lock.Unlock()
return m.counter
}
// 原子操作版
type AtomicCounter struct {
counter int64
}
func (a *AtomicCounter) Inc() {
atomic.AddInt64(&a.counter, 1)
}
func (a *AtomicCounter) Load() int64 {
return atomic.LoadInt64(&a.counter)
}
func test(c Counter) {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
c.Inc()
wg.Done()
}()
}
wg.Wait()
end := time.Now()
fmt.Println(c.Load(), end.Sub(start))
}
func main() {
c1 := CommonCounter{} // 非并发安全
test(c1)
c2 := MutexCounter{} // 使用互斥锁实现并发安全
test(&c2)
c3 := AtomicCounter{} // 并发安全且比互斥锁效率更高
test(&c3)
}
浙公网安备 33010602011771号