一、Goroutine:轻量级并发单元
-
基本概念
-
Goroutine是Go的轻量级线程,由Go运行时(Runtime)管理,而非操作系统线程。
-
启动成本极低(初始栈仅2KB,可动态扩展),可轻松创建数百万个Goroutine。
-
-
与OS线程的区别:
-
内存占用:OS线程栈通常MB级,Goroutine栈KB级。
-
调度方式:OS线程由内核调度(上下文切换成本高),Goroutine由Go运行时调度(用户态调度,无系统调用)。
-
协作式调度:Goroutine主动让出CPU(如通过channel、time.Sleep等),避免抢占式调度的复杂性。
-
二、GMP模型
Go的调度器采用M:N 模型,将M个Goroutine映射到N个OS线程上,核心组件如下:
- G(Goroutine):保存执行栈、状态等信息。
- M(Machine):对应一个OS线程,负责执行G代码。由操作系统管理。
- P(Processor):代表了
M
所需的上下文环境,也是处理用户级代码逻辑的处理器,维护本地Goroutine队列(Local Queue),一个P绑定一个M。它负责衔接M和G的调度上下文,将等待执行的G与M对接。
但为何go的并发如此高效呢?那么就要从它调度的策略去了解了
调度策略
调度流程
- 创建一个G对象,加入到本地队列或者全局队列
- 如果有空闲的P,则创建一个M
- M会启动一个底层线程,循环执行能找到的G任务
- G任务的执行顺序是先从本地队列找,本地没有则从全局队列找(一次性转移(全局G个数/P个数)个,再去其它P中找(一次性转移一半)。
- G任务执行是按照队列顺序(即调用go的顺序)执行的。
调度层级结构
三级队列体系:
-
P的本地队列(Local Queue):每个P维护一个最多256个G的队列(无锁环形队列),优先处理本地任务。
-
全局队列(Global Queue):所有P共享的队列,当本地队列满时,新G会进入全局队列(需要加锁访问)。
-
等待队列(Channel Wait Queue):当G因Channel操作阻塞时,会被挂起到对应的发送/接收队列。
G的分配策略
-
新G的分配:
- 新创建的G(如go func())优先放入当前P的本地队列。
- 若本地队列已满,则将本地队列中前一半的G(共128个)移动到全局队列,腾出空间存放新G。
-
唤醒的G:
- 因Channel或time.Sleep阻塞后被唤醒的G,会重新放入原P的本地队列(避免迁移成本)。
Work Stealing(任务窃取)
目的:
-
触发条件:
-
当P的本地队列为空时,P会尝试从以下位置窃取G:
-
全局队列:定期检查全局队列(约每61次调度一次),避免全局队列饥饿。
-
其他P的本地队列:随机选择一个P,从其本地队列尾部窃取一半的G(减少竞争)。
-
-
系统调用处理
-
阻塞型系统调用(如文件I/O):
-
当G发起阻塞系统调用时,M会与当前P解绑。
-
P被释放后,可能被其他空闲的M获取(或创建新M),继续执行其他G。
-
系统调用完成后,M尝试为G寻找可用的P:
-
成功:将G放入P的本地队列。
-
失败:将G放入全局队列,M进入休眠状态。
-
-
-
非阻塞型系统调用(如网络I/O通过netpoller):
- 通过异步I/O机制(如epoll/kqueue)挂起G,M继续执行其他G,无需释放P。
自旋线程(Spinning Thread)
什么是自旋状态
指当一个goroutine尝试获取一个已经被其他goroutine持有的锁时,它会进入一种持续检查锁状态的状态,而不是立即进入阻塞状态。这种机制被称为自旋锁(Spinlock)
-
工作原理:
- 当一个goroutine请求自旋锁时,如果锁已被其他goroutine占用,那么当前goroutine会不断检查锁的状态,直到锁被释放,而非立即休眠。
-
优点:
- 自旋线程数量上限为GOMAXPROCS(P的数量),避免CPU空转浪费。
- 自旋锁避免了阻塞和上下文切换,从而减少了系统开销
- 在锁持有时间非常短的情况下,自旋锁可以提高效率
-
缺点:
- 如果锁被持有的时间较长,goroutine会不断检查锁的状态,这会浪费CPU资源
-
适用场景:
- 快速响应新任务(如Channel消息到达或定时器触发),减少任务延迟
抢占式调度(Preemption)
该策略并非go诞生就采取的,在go1.14之前是采取协作式调度,但是协作式调度有一个缺陷——Goroutine需要主动让出CPU(如通过channel、time.Sleep或系统调用),否则会独占线程导致其他Goroutine“饿死”。
协作式调度缺陷
Goroutine必须主动触发调度点(如函数调用、channel操作)才能让出CPU。
若一个Goroutine执行无函数调用的死循环,其他Goroutine将被无限阻塞
func main() {
go func() {
for { /* 无函数调用、无channel操作、无系统调用 */ } // 阻塞其他Goroutine
}()
time.Sleep(time.Second)
fmt.Println("永远不会执行") // 在Go 1.13及之前无法输出
}
为了解决这个问题,go在1.14后通过基于信号的异步抢占实现了更公平的调度。
目标则是:
-
强制长时间运行的Goroutine让出CPU,避免“饿死”其他任务。
-
支持高并发下的公平性,提升程序响应速度。
抢占式调度原理
通过异步信号和栈扩张检查两种机制实现:
基于信号的异步抢占(Go 1.14+)
- 核心流程
-
监控线程sysmon检测长时间运行的G:
-
sysmon是Go运行时(Runtime)的后台线程,无需绑定P。
-
每隔约10ms检查所有运行中的Goroutine。
-
若某个G在同一P上运行超过10ms,标记其为“可抢占”。
-
-
-
发送抢占信号SIGURG:
-
向目标G所在的OS线程(M)发送SIGURG信号。
-
SIGURG是用户态信号,默认被Go运行时保留,不影响应用程序逻辑。
-
-
信号处理函数触发调度:
-
OS线程收到SIGURG后,中断当前执行的G,跳转到Go的信号处理函数。
-
保存当前G的上下文(寄存器、栈指针等),并修改其PC(Program Counter)为异步抢占函数。
-
-
强制让出CPU:
-
异步抢占函数调用调度逻辑,将当前G的状态从Grunning改为Grunnable。
-
将该G放回全局队列或原P的本地队列,等待下次调度。
-
-
技术细节
-
sysmon内部循环:
- 记录所有P的G任务计数schedtick,schedtick会在每执行一个G任务后递增。
- 如果检查到 schedtick一直没有递增,说明P一直在执行同一个G任务,如果超过一定的时间(10ms),在G任务的栈信息里面加一个标记。
- G任务在执行的时候,如果遇到非内联函数调用,就会检查一次标记,然后中断自己,把自己加到队列末尾,执行下一个G。
- 如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用,会一直执行G任务,直到goroutine自己结束;如果goroutine是死循环,并且GOMAXPROCS=1,阻塞。
-
信号选择:使用SIGURG而非其他信号(如SIGSEGV),因为:
-
该信号通常不被应用程序使用,避免冲突。
-
Linux/BSD等系统支持实时信号(Real-time Signal),适合异步处理。
-
-
栈扩张检查(Stack Expansion Preemption)
-
原理
-
当Goroutine执行函数调用时,Go会检查栈空间是否需要扩展。
-
在栈扩展的路径中,插入抢占检查逻辑,若发现G需要被抢占,则主动让出CPU。
-
-
流程
-
函数调用触发栈检查(如调用新函数、局部变量分配大量栈空间)。
-
检查当前G是否被标记为“可抢占”。
-
若可抢占,调用调度器
gopark
挂起当前G。
-
局限性
-
非完全抢占:
-
无法抢占所有计算密集型代码(如无函数调用的循环)。
-
需开发者避免编写长时间独占CPU的代码。
-
-
信号处理依赖OS支持:
-
Windows系统在Go 1.14-1.19中不支持异步抢占,需等待OS线程自然让出(如通过time.Sleep)。
-
Go 1.20后Windows通过
SuspendThread
实现类似机制。
-
-
调试干扰:
- 抢占可能影响调试器(如
GDB
)对程序的控制流跟踪。
- 抢占可能影响调试器(如
三、Channel:通信与同步
channel一个类型管道,通过它可以在goroutine之间发送和接收消息。它是Golang在语言层面提供的goroutine间的通信方式。Golang并发的核心哲学是不要通过共享内存进行通信。
分类
-
无缓冲通道:发送方和接收方必须同时就绪,否则被加入等待队列。
-
缓冲通道:缓冲区满时发送方阻塞,空时接收方阻塞。
通道实例 := make(chan 数据类型) // 不带缓冲的chan
通道实例 := make(chan 数据类型,数量) // 带缓冲的chan
细节
无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:
func main() {
ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
}
上面这段代码能够通过编译,但是执行的时候会出现以下错误:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
.../src/main.go:8 +0x54
为什么会出现deadlock
错误呢?
因为我们使用ch := make(chan int)
创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。简单来说就是无缓冲的通道必须有接收才能发送。
func recv(c chan int) {
ret := <-c
fmt.Println("接收成功", ret)
}
func main() {
ch := make(chan int)
go recv(ch) // 启用goroutine从通道接收值
ch <- 10
fmt.Println("发送成功")
}
无缓冲通道上的发送操作会阻塞,直到另一个goroutine
在该通道上执行接收操作,这时值才能发送成功,两个goroutine
将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine
在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine
同步化。因此,无缓冲通道也被称为同步通道
。
死锁
该死锁是channel自己的报错,不是说go不允许死锁出现。
需要有读写两个协程共同工作时,才能避免死锁出现。当只有一个协程进行写或读的操作,都会触发死锁。
即使有读写两个协程,如果其中一个执行完,另一个还没有,此时没有关闭管道的话,也是会报死锁错误,如以下代码:
var wg sync.WaitGroup
func main() {
pipline := make(chan int)
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
pipline <- i
}
}()
go func() {
defer wg.Done()
for v := range pipline {
fmt.Println("v:", v)
}
}()
wg.Wait()
}
解决方法很简单,只要在发送完数据后,手动关闭信道,告诉 range 信道已经关闭,无需等待就行。
func main() {
pipline := make(chan int)
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
pipline <- i
}
close(pipline) // 关闭chan,循环chan的协程就可以退出循环了,否则因为chan的sendq为空陷入deadlock
}()
go func() {
defer wg.Done()
for v := range pipline {
fmt.Println("v:", v)
}
}()
wg.Wait()
}
基本操作
ch := make(chan 数据类型)
// 向通道发送数据
ch <- 值
// 从通道接收数据
<- ch
示例展示:
func main() {
// 案例1
ch := make(chan int, 10)
fmt.Println(len(ch), cap(ch))
ch <- 1
ch <- 2
ch <- 3
fmt.Println(<-ch) // FIFO
fmt.Println(<-ch)
fmt.Println(<-ch)
// 案例2
ch2 := make(chan interface{}, 3)
ch2 <- 100
ch2 <- "hello"
ch2 <- Stu{"yuan", 22}
fmt.Println(<-ch2)
fmt.Println(<-ch2)
fmt.Println(<-ch2)
管道的关闭与循环
当向通道中发送完数据时,我们可以通过close
函数来关闭通道。但为什么需要关闭通道呢?
原来是不close掉channel是会发生死锁的,原因是遍历一个未关闭的 channel 时,遍历操作会一直尝试从 channel 中读取数据。如果 channel 中没有数据且未关闭,range 操作会阻塞,等待新的数据写入。而由于channel没有写入的协程且没关闭,会一直阻塞形成死锁。
以下是示例展示:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 10)
ch <- 1
ch <- 2
ch <- 3
// 方式1
go func() {
time.Sleep(time.Second * 10)
ch <- 4
}()
for v := range ch {
fmt.Println(v, len(ch))
// 读取完所有值后,ch的sendq中没有groutine
if len(ch) == 0 { // 如果现有数据量为0,跳出循环
break
}
}
close(ch)
for i := range ch {
fmt.Println(i)
}
}
如何判断通道是否关闭
我们可以在读取的时候使用多重返回值的方式,如果返回值是 false 则表示 ch 已经被关闭:
x, ok := <-ch
chan结构细节
通道的结构hchan,源码在src/runtime/chan.go下:
type hchan struct {
qcount uint // total data in the queue 当前队列里还剩余元素个数
dataqsiz uint // size of the circular queue 环形队列长度,即缓冲区的大小,即make(chan T,N) 中的N
buf unsafe.Pointer // points to an array of dataqsiz elements 环形队列指针
elemsize uint16 //每个元素的大小
closed uint32 //标识当前通道是否处于关闭状态,创建通道后,该字段设置0,即打开通道;通道调用close将其设置为1,通道关闭
elemtype *_type // element type 元素类型,用于数据传递过程中的赋值
sendx uint // send index 环形缓冲区的状态字段,它只是缓冲区的当前索引-支持数组,它可以从中发送数据
recvx uint // receive index 环形缓冲区的状态字段,它只是缓冲区当前索引-支持数组,它可以从中接受数据
recvq waitq // list of recv waiters 等待读消息的goroutine队列
sendq waitq // list of send waiters 等待写消息的goroutine队列
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex //互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
}
// sudog 代表goroutine
type waitq struct {
first *sudog
last *sudog
}