Go 并发
goroutine
介绍
goroutine 是由Go运行时(runtime)负责调度的、轻量的用户级线程。可以实现更高的并发性能。
基本概念:
- 极致轻量:初始栈仅 2KB(动态可伸缩),会根据需要自动扩容(最大可达 GB 级别),(OS 线程栈通常是固定的 MB 级别)。
- 用户态调度 :由 Go 运行时的 G-M-P 调度器管理,而非直接由操作系统调度,调度开销远低于 OS 线程,能更高效地利用 CPU 资源
- 简单易用: 由go关键字创建,goroutine 执行完毕后,其占用的资源会被运行时自动回收,无需手动管理;
- 安全通信 :推荐通过 channel 实现 goroutine 间的安全通信,也可通过共享内存 + 同步原语实现,但需手动处理并发安全问题。
使用示例
go
package main
import (
"fmt"
"time"
)
var eggCount = 0
func cook(name string) {
for i := 0; i < 1000; i++ {
tmp := eggCount
tmp++
eggCount = tmp
}
fmt.Printf("厨师%s操作完成\n", name)
}
func main() {
// 启动两个goroutine(两个厨师)同时操作共享数据
go cook("A")
go cook("B")
// 等待goroutine执行完成
time.Sleep(1 * time.Second)
// 预期结果是 2000,但实际运行会得到小于2000的随机数(比如1897、1956等)
fmt.Printf("最终鸡蛋数:%d\n", eggCount)
}
分析:
- eggCount 是共享内存数据,所有 goroutine 都能访问
- cook 函数里的 temp := eggCount → temp++ → eggCount = temp 是三步操作,不是 "一次性完成" 的
- 两个 goroutine 执行时,会互相打断对方的操作,导致最终结果错误
解决:进行加锁操作 ,保证证共享数据的原子性访问(要么不执行,要么执行完,不被打断)
优化后代码
package main
import (
"fmt"
"sync"
"time"
)
var eggCount = 0
// 定义一个互斥锁,保护eggCount的访问
var mu sync.Mutex
func cook(name string) {
for i := 0; i < 1000; i++ {
// 加锁:同一时间只有一个goroutine能进入这个代码块
mu.Lock()
// 临界区:操作共享数据的代码
tmp := eggCount
tmp++
eggCount = tmp
// 解锁:释放锁,让其他goroutine可以进入
mu.Unlock()
}
fmt.Printf("厨师%s操作完成\n", name)
}
func main() {
// 启动两个goroutine(两个厨师)同时操作共享数据
go cook("A")
go cook("B")
// 等待goroutine执行完成
time.Sleep(1 * time.Second)
// 预期结果是 2000,但实际运行会得到小于2000的随机数(比如1897、1956等)
fmt.Printf("最终鸡蛋数:%d\n", eggCount)
}
由于一个应用内部启动的所有 goroutine 共享进程空间的资源 ,多个 goroutine 同时读写同一块内存,会导致操作步骤被打断,结果不符合预期,出现数据竞争(Race Condition)。
只要多个 goroutine 操作同一块可写的内存,就必须考虑同步;要么加锁 ,要么用 channel 传递数据。
goroutine 间的通信 - channel
Channel 是 Go 实现 CSP 并发模型的核心载体 ,本质是并发安全的 FIFO 通信管道 ,用于 goroutine 间传递数据 而非共享内存,其内部通过锁机制和原子操作保证并发安全 ,保证每个写入的数据单元只会被一个 goroutine 完整读取。
核心定义与创建
Channel 是类型化的通信管道,需指定传递的数据类型,分为无缓冲 和带缓冲两种:
go
// 1. 声明(未初始化的channel为nil,操作会永久阻塞)
var ch chan int // nil channel
// 2. 初始化
ch1 := make(chan int) // 无缓冲channel(同步通信,缓冲区大小=0)
ch2 := make(chan int, 5) // 带缓冲channel(异步通信,缓冲区大小=5)
channel 方向:函数参数中声明只读(<-chan T )/ 只写(chan<- T),增强代码可读性和安全性
核心特性(并发安全的底层保障)
- 原子性操作:channel 的发送(ch <- v)和接收(v := <-ch)操作是原子的,不会出现 "数据被拆分读取 / 写入" 的情况。
- 内部锁机制:底层通过互斥锁保证同一时间只有一个 goroutine 能完成发送 / 接收操作,天然避免数据竞争(如多个 goroutine 读取同一个 channel 时,每个数据块仅被一个 goroutine 取走)
- FIFO 队列 :数据按照先进先出的顺序被处理
关键使用规则
无缓冲 channel:严格同步通信
- 特性:发送方和接收方必须同时就绪,一方未就绪则另一方阻塞;
- 禁忌:禁止在单个 goroutine 中执行发送 + 接收操作,会直接导致死锁
- 正确用法:至少两个 goroutine 配合 ,实现严格同步
- 原理:主要依赖原子操作 实现发送 / 接收的配对,无需互斥锁
go
// 正确示例:生产者+消费者goroutine配对
func main() {
ch := make(chan int)
// 生产者goroutine
go func() {
ch <- 1 // 等待消费者就绪后发送
}()
// 消费者goroutine(主goroutine)
fmt.Println(<-ch) // 等待生产者就绪后接收
}
带缓冲 channel:弹性异步通信
- 特性:缓冲区未装满时,发送方无需等待接收方;缓冲区未空时,接收方无需等待发送方;
- 阻塞规则:
✅ 缓冲区满 → 发送操作阻塞,直到消费者取走数据腾出空间;
✅ 缓冲区空 → 接收操作阻塞,直到生产者发送数据; - 适用场景:生产 / 消费速度不匹配(如任务队列、IO 异步处理),需合理设置缓冲大小(过小易频繁阻塞,过大浪费内存)。
- 原理:因涉及缓冲区(环形队列)的读写,底层会用到互斥锁(mutex) 保护缓冲区的状态(如长度、头尾指针),同时结合原子操作保证数据安全
Channel 关闭:生产者唯一责任
- 核心原则:生产者负责关闭 channel(生产者明确生产结束时机),消费者仅读取,禁止关闭(否则 panic);
- 禁止操作:
❌ 关闭已关闭的 channel → panic;
❌ 向已关闭的 channel 发送数据 → panic
go
// 生产者关闭channel,消费者通过range/ok判断结束
func producer(ch chan<- int) {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch) // 生产完成,关闭channel
}
func consumer(ch <-chan int) {
// 方式1:range遍历(自动感知channel关闭)
for v := range ch {
fmt.Println("接收:", v)
}
// 方式2:ok判断(v为数据,ok=false表示channel关闭且无数据)
for {
v, ok := <-ch
if !ok {
break
}
fmt.Println("接收:", v)
}
}
多 goroutine 读取同一 channel(无数据竞争)
channel 天然支持多消费者并发读取,内部锁保证数据仅被一个 goroutine 取走,无需额外同步
go
package main
import "fmt"
type add struct {
a int
b int
}
func worker(sum add, results chan<- int) {
results <- sum.a + sum.b
}
func main() {
num := add{1, 2}
results := make(chan int, 1)
go worker(num, results)
// channel 是一个引用类型,打印的是channel的内存地址
fmt.Println(results)
// 从 channel 中读取实际的计算结果
sum := <-results
fmt.Println(sum)
}
0xc00001c1c0
3
先发送所有数据,关闭channel,启动一个 worker 处理所有数据
go
package main
import (
"fmt"
)
type Animal struct {
A int
B int
}
func worker(animal <-chan Animal, results chan<- int) {
for a := range animal {
sum := a.A + a.B
results <- sum
}
}
func main() {
const jobNums = 10
animal1 := make(chan Animal, jobNums)
results := make(chan int, jobNums)
// 先发送所有数据
for i := 0; i < jobNums; i++ {
animal1 <- Animal{A: 3 * i, B: 6 * i}
}
// 发送完数据后关闭 channel
close(animal1)
// 启动一个 worker 处理所有数据
go worker(animal1, results)
// 接收所有结果
for i := 0; i < jobNums; i++ {
fmt.Println("result", <-results)
}
}
多 worker 并发处理
go
package main
import (
"fmt"
"sync"
)
type Animal struct {
A int
B int
}
func worker(id int, animal <-chan Animal, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done() // goroutine结束就登记-1
//worker 通过 for range 循环监听任务到来
for a := range animal {
sum := a.A + a.B
fmt.Printf("Worker %d processed: A=%d, B=%d, sum=%d\n", id, a.A, a.B, sum)
results <- sum
}
}
func main() {
const jobNums = 10
animal1 := make(chan Animal, jobNums)
results := make(chan int, jobNums)
var wg sync.WaitGroup
// 先启动 worker
//用少量 worker 处理大量任务,避免频繁创建/销毁 goroutine
numWorkers := 3
for i := 0; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, animal1, results, &wg)
}
// 发送所有数据
for i := 0; i < jobNums; i++ {
animal1 <- Animal{A: 3 * i, B: 6 * i}
}
// 关闭channel
close(animal1)
// 启动一个 goroutine 来关闭 results channel
go func() {
wg.Wait() // 等待所有登记的goroutine都结束
close(results)
}()
for i := 0; i < jobNums; i++ {
fmt.Println("result", <-results)
}
}
ps :
执行顺序 很重要:通常先启动消费者 (worker),再生产数据
Worker 数量 ≠ 任务数量:worker 是并发处理器,一个 worker 可以处理多个任务
Channel 关闭时机:关闭 channel 是给 worker 的"任务完成"信号
CSP
CSP 一种基于通信 的并发模型
核心思想:不是通过共享内存来实现并发协作,而是通过独立的并发实体(进程/协程)之间的通信 来传递数据,协调行为。
不要通过共享内存通信,要通过通信共享内存
核心要素:
- goroutine :对应 CSP 中的 "并发实体"(轻量级协程,替代了 CSP 中的进程 / 线程)
- channel :对应 CSP 中的 "通信管道",是 goroutine 之间唯一的通信方式,数据通过 channel 传递,而非直接共享。
go
package main
import (
"fmt"
"sync"
)
func produce(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 10; i++ {
ch <- i
fmt.Println("生产:", i)
}
close(ch)
}
func consuse(ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Println("消费:", num)
}
}
func main() {
// 无缓冲区,保证生产一个,消费一个
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go produce(ch, &wg)
go consuse(ch, &wg)
wg.Wait()
fmt.Println("结束")
}
生产者和消费者模型
- 生产者和消费者是两个独立的 goroutine(CSP 并发实体)
- 数据(数字 1-10)不是通过共享变量传递,而是通过 channel(通信管道);
- 无缓冲 channel 保证了 "生产一个、消费一个" 的同步,无需手动加锁,体现 CSP 思想
Go 中 channel 的缓冲和无缓冲,在 CSP 模型中分别对应什么场景?
无缓冲 channel :对应 CSP 的 "同步通信 "------ 发送方和接收方必须同时就绪,一方未就绪则另一方阻塞,适合需要严格同步的场景(比如上面的生产者 - 消费者 ,确保生产和消费一一对应);
有缓冲 channel :对应 CSP 的 "异步通信 "------ 发送方可以先把数据放入缓冲区,无需等待接收方立即接收,适合 "生产速度略快于消费速度" 的场景(比如任务队列,生产者快速提交任务,消费者慢慢处理)
使用场景限制
适合场景 : goroutine之间需要传递数据,协调执行顺序的场景(比如生产者 - 消费者、任务分发、流水线处理 )
不适用场景:
- 高频读写的共享状态 (比如计数器):用 channel 传递数据的开销略高于互斥锁,此时用 sync/atomic 原子操作或 sync.Mutex 更高效
- 简单的同步等待(比如等待多个 goroutine 完成):用 sync.WaitGroup 比 channel 更简洁
不关闭channel影响
不关闭,不一定会直接导致程序报错,但是可能会引发资源泄露 和程序逻辑异常。
Go 运行时会在channel被垃圾回收的时候自动清理其资源,关闭channel的核心目的是向接收方传递"数据发送完毕"信号 ,而不是释放资源。
不关闭 channel 的问题,本质是接收方无法感知 "数据已全部发送",进而导致逻辑异常或资源泄漏,而非 channel 本身的内存泄漏。
场景 1. 接收方用 for range 读取 channel
for range ch 会持续读取 channel 中的数据,直到 channel 被关闭且缓冲区为空 。如果不关闭 channel,接收方会一直阻塞在读取操作上,导致 goroutine 永久阻塞(泄漏)
go
package main
import (
"fmt"
"time"
)
func producer(ch chan int) {
// 生产者发送3个数据后退出,但不关闭channel
for i := 1; i <= 3; i++ {
ch <- i
}
// 未执行 close(ch)
}
func consumer(ch chan int) {
// for range 会一直等待读取,直到channel被关闭
for v := range ch {
fmt.Println("收到数据:", v)
}
fmt.Println("消费结束") // 这行永远不会执行
}
func main() {
ch := make(chan int, 3)
go producer(ch)
go consumer(ch)
// 主goroutine等待5秒后退出
time.Sleep(5 * time.Second)
fmt.Println("主程序退出,但consumer goroutine仍阻塞")
}
场景 2.接收方用 "带 ok 的读取"(v, ok := <-ch)但无退出逻辑
如果接收方通过 v, ok := <-ch 读取,但没有额外的退出条件,不关闭 channel 会导致接收方陷入 "无效等待"。
go
func consumer(ch chan int) {
for {
v, ok := <-ch
if !ok { // 只有channel关闭且缓冲区空,ok才为false
fmt.Println("通道关闭,退出消费")
break
}
fmt.Println("收到:", v)
}
}
场景 3.大量创建未关闭的 channel(隐性资源泄漏)
如果程序循环创建 channel,且每个 channel 都有 goroutine 持有引用(即使没有数据传递),不关闭 channel 会导致
- channel 本身占用的内存(缓冲区、内部锁等)无法被 GC 回收;
- 持有 channel 引用的 goroutine 永久阻塞,资源泄漏
调度器核心原理
Goroutine 调度器是 Go 运行时(runtime)层面的组件,负责把大量轻量级的 goroutine 高效映射到操作系统的线程 (M,Machine)上执行,核心目标是最大化利用 CPU 核心 ,同时最小化 goroutine 切换开销
核心设计:GMP模型
多线程多处理器模型
核心是将用户态的 goroutine(G)映射到内核态的线程(M)上执行,通过 P 作为中间层解耦,最大化 CPU 利用率。
| 角色 | 全称 | 含义 |
|---|---|---|
| G | Goroutine | 轻量级协程,包含执行栈、程序计数器、状态等,是调度的基本单位(用户态),而且 G 对象是可以重用的 |
| M | Machine | 操作系统线程(内核态),是真正执行代码的 "载体",对应 1:1 的系统线程 |
| P | Processor | 处理器(调度器核心),是 G 和 M 之间的 "桥梁",代表 "执行上下文",P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态 |
M和P进行有效绑定后,进入一个调度循环 :
从P的本地运行队列以及全局队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M。
sysmon (Go 1.14+)
介绍
Go 1.14 之前是协作式调度,如果 goroutine 死循环、长时间纯计算,会霸占 P 导致其他 goroutine 饿死。
Go 1.14+ 引入了基于 sysmon(系统监控)的抢占式调度 ,防止某个 goroutine 死循环、长时间计算,霸占 P 不让其他 goroutine 运行,导致程序卡顿、饿死。
sysmon 是 runtime 里一个后台常驻的系统监控 M,不绑定 P,专门干脏活
- 检查网络、锁、timer 是否过期
- 检查每个 P 上正在运行的 G 跑了多久
- 发现某个 G 跑太久 → 发信号抢占
抢占式调度(Preemptive Scheduling)
当 sysmon 发现:
这个 G 在 P 上连续运行 ≥ 10ms
它会:
- 给当前绑定这个 P 的 M 发送一个 SIGURG 信号
- M 收到信号,会强制暂停当前 G
- 保存 G 的栈、寄存器、程序计数器
- 把 G 放回 P 的本地队列
- P 立刻换一个新的 G 执行
所有 G 都能被抢占吗?
不是,运行在 g0 栈 (调度器自身代码 )、系统调用、锁保护的临界区 不能被抢占。
只有运行在用户栈、普通代码的 G 才能被抢占。
抢占会不会影响性能?
几乎不会。
- 抢占开销极小
- 10ms 一次,频率很低
- 换来的是整个程序不卡死、调度公平收益远大于开销。