12、Go并发编程
12、Go 并发编程
goroutine 启动与调度
什么是 goroutine?
goroutine 是 Go 语言中的轻量级用户态线程,由 Go 运行时管理。启动一个 goroutine 只需使用 go 关键字,语法简单,开销极小(初始栈仅约 2KB),可以在一个程序中轻松创建成千上万个 goroutine。
package main
import (
"fmt"
"time"
)
func printNum(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("goroutine %d: %d\n", id, i)
time.Sleep(time.Millisecond * 100)
}
}
func main() {
// 启动 10 个 goroutine
for i := 0; i < 10; i++ {
go printNum(i)
}
// 主 goroutine 等待一会儿,否则可能看不到输出
time.Sleep(time.Second)
fmt.Println("done")
}
注意:主 goroutine 退出会导致程序结束,所有 goroutine 会被强制终止,所以这里用 time.Sleep 等待(实际开发中应使用 sync.WaitGroup 或 channel 同步)。
GOMAXPROCS
runtime.GOMAXPROCS(n) 设置可以同时执行用户级 Go 代码的操作系统线程数(逻辑处理器 P 的数量)。默认值等于机器的 CPU 核心数。
- 影响并发度:增大 n 可以提高并行度,但不能超过物理核心数。
- 可以在程序运行时动态调整(但一般不频繁变动)。
import "runtime"
func main() {
fmt.Println("Default GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 获取当前值
runtime.GOMAXPROCS(2) // 设置为 2
}
GOMAXPROCS 不限制创建 goroutine 的数量,只限制真正并行执行的 goroutine 数量。当某个 goroutine 进入系统调用或阻塞时,P 可以切换执行其他 goroutine。
Gosched
runtime.Gosched() 让出当前 goroutine 的执行权,将其放回队列,并让调度器重新调度。当前 goroutine 未来仍会被再次调度执行。
常用于:
- 避免某个 goroutine 长时间霸占 P。
- 调试或协作式调度场景。
go func() {
for i := 0; i < 10; i++ {
fmt.Println("goroutine1:", i)
runtime.Gosched() // 让出 CPU,给其他 goroutine 执行机会
}
}()
注意:Gosched 不会挂起 goroutine,只是暂停并立即重新参与调度,相当于一次自愿的上下文切换。
Goexit
runtime.Goexit() 立即终止调用它的 goroutine,但会保证该 goroutine 中所有已注册的 defer 语句被执行(这不同于直接调用 os.Exit)。
- 终止后 goroutine 不会恢复,也不会触发 panic。
- 如果在主 goroutine 调用
Goexit,会等待其他 goroutine 结束后才让程序崩溃(因为主 goroutine 退出但其他未结束)——通常不会主动在主 goroutine 中使用。
func worker() {
defer fmt.Println("cleanup done")
fmt.Println("working...")
runtime.Goexit()
fmt.Println("never printed")
}
输出:
working...
cleanup done
重要区分:
return只是退出函数(若在 goroutine 顶层则退出 goroutine);os.Exit立即退出进程,不执行任何 defer;Goexit退出 goroutine 但执行 defer。
goroutine vs OS 线程
| 特性 | goroutine | OS 线程 |
|---|---|---|
| 管理者 | Go 运行时 | 操作系统内核 |
| 栈大小 | 初始约 2KB,可动态伸缩 | 通常固定 1MB 或更多 |
| 上下文切换 | 用户态切换,开销极小 | 内核态切换,开销较大 |
| 数量 | 可以轻松创建数十万个 | 通常几百到几千个 |
| 调度 | Go 调度器 (G-P-M 模型) | 操作系统调度器 |
goroutine 不是线程,而是运行在线程之上的协作式任务。Go 运行时会将多个 goroutine 映射到少量的 OS 线程上(默认最大线程数为 GOMAXPROCS),通过抢占式调度实现并发。
goroutine 调度示意图(GMP 模型)
GMP 的三个角色
- G(Goroutine):轻量级用户态线程,包含执行栈、程序计数器、状态等信息。
- M(Machine):操作系统线程(OS Thread),真正执行计算的资源,由内核管理。
- P(Processor):逻辑处理器,是调度的上下文,维护一个本地 goroutine 队列。P 的数量由
runtime.GOMAXPROCS(n)决定,默认等于 CPU 核心数。
关系:每个 P 必须绑定一个 M 才能执行 G。P 就像“执照”,M 必须获取 P 才能执行 Go 代码。
调度示意图(ASCII 艺术)

调度流程详解
- 创建 Goroutine
使用go func()创建新的 G,它会被优先放入当前 P 的本地运行队列(若本地队列已满,则放入全局队列)。 - M 绑定 P 执行
每个 P 需要分配一个 M 来运行其队列中的 G。M 会不断从 P 的本地队列中弹出 G 并执行,这就是典型的“循环调度”(不需要时钟中断,是协作式 + 抢占式结合)。 - 系统调用阻塞
当 G 执行同步系统调用(如文件 I/O)时,M 会陷入内核阻塞。此时,运行时会解绑该 M 的 P,并将 P 交给另一个空闲的 M(或新建一个 M),让 P 继续执行其他 G。这样就避免了因某个线程阻塞而浪费 CPU。 - 网络 I/O 等异步阻塞
对于网络 I/O(使用netpoller),G 会被挂起,M 不会被阻塞。当 I/O 就绪后,G 会被重新放回 P 的队列。 - 工作窃取(Work Stealing)
当一个 P 的本地队列为空时,它会尝试:- 从全局运行队列中获取 G。
- 从其他 P 的本地队列中“偷”一半的 G(平衡负载)。
这保证了所有 CPU 核心都能被充分利用。
- 抢占调度
如果一个 G 执行时间过长(超过 10ms),运行时会向其发送抢占信号(基于信号的抢占,Go 1.14+),G 会在合适的时机(如函数调用)让出执行权,防止单一 G 独占 P。
GOMAXPROCS 的影响
GOMAXPROCS 设定的是 P 的数量,也就是同时执行 Go 代码的操作系统线程数。
- 即使有成千上万个 goroutine,也只有
GOMAXPROCS个可以真正并行。 - 增大该值可以利用多核并行,但要避免无谓的上下文切换。
- 在容器环境或 I/O 密集型应用中,适当调整
GOMAXPROCS可以优化性能。
| 组件 | 作用 | 特点 |
|---|---|---|
| G | 函数执行任务 | 轻量栈(初始 2KB),数量不受限 |
| M | 操作系统线程 | 重量级,通常等于或大于 P 数量 |
| P | 逻辑处理器,持有 G 队列 | 数量由 GOMAXPROCS 决定,是并发度的关键 |
| 本地队列 | P 私有的 G 队列 | 无锁访问,高效 |
| 全局队列 | 共享的 G 队列 | 访问需要加锁,作为溢出和窃取源 |
channel
channel 是 Go 中 goroutine 之间通信的管道,遵循 CSP(Communicating Sequential Processes)模型。核心思想:不要通过共享内存来通信,而要通过通信来共享内存。
声明与创建
// 声明未初始化的 channel(nil)
var ch chan int
// 创建无缓冲 channel
ch = make(chan int)
// 创建有缓冲 channel,容量为 3
ch = make(chan int, 3)
无缓冲 channel(同步通信)
无缓冲 channel 的发送和接收必须同时准备好,否则会阻塞:
- 发送操作
ch <- value会阻塞直到有另一个 goroutine 执行接收。 - 接收操作
<-ch会阻塞直到有另一个 goroutine 发送数据。
示例:goroutine 通过 channel 发送数据给 main
func main() {
ch := make(chan string)
go func() {
time.Sleep(time.Second)
ch <- "hello from goroutine" // 发送后阻塞,直到 main 接收
}()
msg := <-ch // 阻塞等待接收
fmt.Println(msg)
}
有缓冲 channel(异步通信)
有缓冲 channel 有一个容量缓冲区:
- 发送方只在缓冲区满时阻塞。
- 接收方只在缓冲区空时阻塞。
func main() {
ch := make(chan int, 2)
ch <- 1 // 不阻塞,缓冲区有空位
ch <- 2 // 不阻塞
// ch <- 3 // 会阻塞,因为缓冲区已满(容量为2)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
}
阻塞行为对比:
- 无缓冲:每一次发送都需要对应的接收,否则阻塞,相当于同步传递。
- 有缓冲:在容量未耗尽时发送可不阻塞,接收同理,相当于异步队列。
关闭 channel
使用 close(ch) 关闭 channel,接收方可以检测到 channel 是否关闭:
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
}
遍历 channel 常用 for range,直到 channel 关闭并读完所有数据。
单向通道(只读/只写)
默认创建的 channel 是双向的(可读可写),但在函数参数或返回值中,可以将双向通道转换为单向通道,用以约束操作方向,增强代码安全性和可读性。
| 类型 | 写法 | 含义 |
|---|---|---|
| 双向通道 | chan T |
可发送和接收 |
| 只读通道 | <-chan T |
只能从中接收数据 |
| 只写通道 | chan<- T |
只能向其中发送数据 |
- 示例:限制通道方向
// 生产者返回一个只读通道,外部只能读
func producer() <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}()
return ch // 双向通道自动转换为只读
}
// 消费者接收只读通道
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}
func main() {
ch := producer()
consumer(ch)
}
producer内部创建双向通道,返回时隐式转为<-chan int,防止调用方误发数据。consumer的参数声明为只读,明确语义。- 双向通道可以隐式转换为单向通道,但反过来不行(无法从单向转回双向)。
- 试图从只写通道读取或向只读通道发送会导致编译错误。
select 多路复用
需要同时等待多个 channel 操作时,select 语句可以监听多个 case,哪个先就绪就执行哪个。如果多个同时就绪,随机选择一个。
基本使用
select {
case msg1 := <-ch1:
fmt.Println("from ch1:", msg1)
case ch2 <- 42:
fmt.Println("sent to ch2")
default:
fmt.Println("no channel ready")
}
default 分支可避免阻塞(非阻塞操作)。
超时控制
使用 time.After 返回一个在指定时间后发送一个值的 channel,配合 select 实现超时:
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "result"
}()
select {
case res := <-ch:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("timeout!")
}
}
由于 sleep 2 秒,而超时设置的 1 秒,最终输出 timeout!。
sync.WaitGroup、sync.Mutex
sync.WaitGroup:等待一组 goroutine 完成
WaitGroup 用于等待一组并发任务结束,类似倒计时计数器。
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 计数加 1
go func(id int) {
defer wg.Done() // 完成时计数减 1
fmt.Printf("worker %d\n", id)
}(i)
}
wg.Wait() // 阻塞直到计数为 0
fmt.Println("all done")
}
sync.Mutex:互斥锁保护共享数据
多个 goroutine 同时读写共享变量会引发数据竞争(data race)。可以使用 sync.Mutex 保护临界区。
并发计数器示例:
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var wg sync.WaitGroup
counter := Counter{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Inc()
}()
}
wg.Wait()
fmt.Println(counter.Value()) // 1000
}
运行 race 检测:
go run -race main.go
如果没有竞争,不会输出警告。若忘记加锁,检测器会报告数据竞争。
sync.Once、sync.RWMutex
sync.Once:确保函数只执行一次
sync.Once 实现单例模式、延迟初始化等场景,即使多个 goroutine 同时调用 Do,内部函数也只会执行一次。
单例模式示例:
type Singleton struct {
data string
}
var instance *Singleton
var once sync.Once
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{data: "initialized"}
fmt.Println("Singleton created")
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s := GetInstance()
fmt.Printf("%p\n", s)
}()
}
wg.Wait()
}
无论多少个 goroutine 调用 GetInstance,"Singleton created" 只会打印一次,所有 goroutine 拿到的实例相同。
Once 使用场景:数据库连接池初始化、加载配置文件、注册驱动等。
sync.RWMutex:读写锁
适用于读多写少的场景,允许多个 goroutine 同时读,写时独占。
type SafeMap struct {
mu sync.RWMutex
data map[string]string
}
func (m *SafeMap) Get(key string) string {
m.mu.RLock() // 读锁,允许多个读并发
defer m.mu.RUnlock()
return m.data[key]
}
func (m *SafeMap) Set(key, value string) {
m.mu.Lock() // 写锁,独占
defer m.mu.Unlock()
m.data[key] = value
}
数据竞争
多个 goroutine 并发访问同一块内存,且至少有一个是写操作,没有同步保护,就会发生数据竞争。结果不可预测。
锁保护
- 故意制造数据竞争
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 没有锁,竞争!
}()
}
wg.Wait()
fmt.Println(counter) // 结果小于 1000,每次可能不同
}
- 使用
go run -race检测
运行:
go run -race race.go
会输出类似:
==================
WARNING: DATA RACE
Read at 0x00c00001a0b8 by goroutine 8:
main.main.func1()
/path/race.go:15 +0x4a
Previous write at 0x00c00001a0b8 by goroutine 7:
main.main.func1()
/path/race.go:15 +0x5e
==================
报告指明了哪两个 goroutine 在访问同一个内存地址,以及是哪一行代码。
- 用锁修复:将计数器修改用 sync.Mutex 保护,再次运行 go run -race,不再有警告。
阅读 race 报告技巧:关注 WARNING: DATA RACE 后面的两个访问操作,看它们是否确实存在冲突(读-写或写-写),以及各自对应的 goroutine 和源码位置。
Mutex 作为匿名字段必须使用指针接收者
如果在一个结构体中嵌入 sync.Mutex 作为匿名字段,并且在定义方法时使用了值接收者,那么每次调用该方法时都会复制整个结构体,包括内部的 Mutex。这会导致每个副本拥有独立的锁状态,锁机制完全失效,无法保护共享资源。
- 错误示例(值接收者——锁失效)
type SafeCounter struct {
sync.Mutex
count int
}
// 值接收者:每次调用都会复制 SafeCounter,锁被复制,各副本独立
func (c SafeCounter) Inc() {
c.Lock()
c.count++ // 操作的是副本的字段
c.Unlock() // 解锁的也是副本的锁
// 外面的原始 SafeCounter 的锁根本没有被持有!
}
并发调用 Inc 时,多个 goroutine 各自获得一个副本,它们之间没有互斥,对 count 的读写产生数据竞争。
- 正确做法:指针接收者
func (c *SafeCounter) Inc() {
c.Lock()
c.count++
c.Unlock()
}
使用指针接收者,方法操作的是原始结构体,只有一个锁实例,互斥有效。
原则:只要结构体包含
sync.Mutex或类似不可复制的同步原语,其方法必须使用指针接收者。可以通过go vet的copylocks检测器发现这类问题。
原子操作(sync/atomic)
原子操作提供 无锁 的并发安全保证,适用于简单的整数操作(增加、交换、比较并交换等),性能远高于 Mutex,因为不需要上下文切换和内核参与。
- 使用
atomic实现无锁计数器
import "sync/atomic"
func main() {
var counter int64 = 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&counter)) // 1000
}
其他常用函数:
atomic.AddInt64(&val, delta)加一个值atomic.LoadInt64(&val)原子读取atomic.StoreInt64(&val, new)原子写入atomic.CompareAndSwapInt64(&val, old, new)CAS 操作
Mutex vs atomic 性能对比
粗略基准测试(1000 个 goroutine 各增加 10000 次):
Mutex耗时约 50ms(涉及内核调度)atomic耗时约 5ms(纯用户态指令)
选用建议:
- 对单一整数的简单操作,优先使用
atomic。 - 保护一段代码块(多条语句)或复杂结构,使用
Mutex。
生产者-消费者模式
生产者-消费者是经典的并发模型:一个或多个 goroutine 负责产生数据(生产者),一个或多个 goroutine 负责处理数据(消费者),它们之间通过 channel 传递数据,实现解耦和流量控制。
- 实现:单生产者,多消费者
func producer(ch chan<- int, count int) {
for i := 0; i < count; i++ {
ch <- i
fmt.Printf("Produced: %d\n", i)
}
close(ch) // 生产完毕,关闭通道
}
func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for v := range ch {
fmt.Printf("Consumer %d consumed: %d\n", id, v)
time.Sleep(time.Millisecond * 10) // 模拟处理
}
}
func main() {
const numConsumers = 3
ch := make(chan int, 5) // 带缓冲,减少阻塞
var wg sync.WaitGroup
// 启动消费者
for i := 0; i < numConsumers; i++ {
wg.Add(1)
go consumer(i, ch, &wg)
}
// 启动生产者
go producer(ch, 20)
wg.Wait() // 等待所有消费者完成
fmt.Println("All done")
}
- 生产者生产完毕后调用
close(ch)。 - 消费者使用
for range,通道关闭后自动退出循环。 - 使用
sync.WaitGroup等待所有消费者退出后主函数返回。
Worker 池
固定数量 Worker 池
创建一个固定数量的 worker goroutine,它们从一个共享的任务 channel 中获取任务并执行。可以控制并发度,避免无限制创建 goroutine 耗尽资源。
- 实现
type Task struct {
ID int
// 可携带其他数据
}
func worker(id int, tasks <-chan Task, wg *sync.WaitGroup) {
defer wg.Done()
for task := range tasks {
fmt.Printf("Worker %d processing task %d\n", id, task.ID)
time.Sleep(time.Millisecond * 100) // 模拟耗时任务
}
}
func main() {
const numWorkers = 4
tasks := make(chan Task, 10)
var wg sync.WaitGroup
// 启动 worker
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, tasks, &wg)
}
// 分发任务
for i := 0; i < 20; i++ {
tasks <- Task{ID: i}
}
close(tasks) // 不再有新任务,关闭通道
wg.Wait() // 等待所有 worker 退出
fmt.Println("All tasks completed")
}
- 使用 有缓冲 channel 作为任务队列,避免 worker 频繁阻塞。
- 任务分发完毕后 关闭 channel,worker 使用
range自动退出。 sync.WaitGroup确保主 goroutine 等待所有 worker 完成。
动态添加任务与优雅关闭
- 支持在运行时动态添加任务,无需等到所有任务都加入后才关闭 channel。
- 支持优雅关闭:通知 worker 停止接收新任务,并等待当前任务执行完毕。
- 可复用 Worker 池
type WorkerPool struct {
tasks chan func() // 任务函数
wg sync.WaitGroup
quit chan struct{} // 通知 worker 退出
quitOnce sync.Once // 保证只关闭一次 quit
}
func NewWorkerPool(numWorkers int) *WorkerPool {
p := &WorkerPool{
tasks: make(chan func()),
quit: make(chan struct{}),
}
p.wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go p.worker()
}
return p
}
func (p *WorkerPool) worker() {
defer p.wg.Done()
for {
select {
case task, ok := <-p.tasks:
if !ok {
return // 任务通道关闭
}
task()
case <-p.quit:
return // 收到退出信号
}
}
}
func (p *WorkerPool) Submit(task func()) {
p.tasks <- task
}
// 优雅关闭:不再接受新任务,等待已有任务完成
func (p *WorkerPool) Shutdown() {
close(p.tasks) // 关闭任务通道,worker 会处理完剩余任务
p.wg.Wait()
}
// 立即停止:发送 quit 信号,worker 立刻退出
func (p *WorkerPool) Stop() {
p.quitOnce.Do(func() {
close(p.quit)
})
p.wg.Wait()
}
func main() {
pool := NewWorkerPool(3)
// 动态提交任务
for i := 0; i < 10; i++ {
id := i
pool.Submit(func() {
fmt.Printf("Processing %d\n", id)
time.Sleep(time.Second)
})
}
// 优雅关闭:等待所有已提交的任务执行完毕
pool.Shutdown()
fmt.Println("All tasks finished")
}
- quit channel:用于立即终止 worker,适用于需要紧急停止的场景。
- 关闭 tasks channel:使 worker 在处理完剩余任务后自动退出。
- 复用性:通过函数闭包传递任务,无需定义具体 Task 结构体,适用于各种任务。
并发实战:简易并发爬虫
爬取多个 URL,使用并发提高效率,但限制最大并发数(例如同时只有 5 个请求)。
- 实现方案:使用带缓冲 channel 作为信号量
func crawl(url string) {
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
defer resp.Body.Close()
fmt.Printf("%s - %d\n", url, resp.StatusCode)
}
func main() {
urls := []string{
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
// ... 更多
}
const maxConcurrency = 5
sem := make(chan struct{}, maxConcurrency) // 信号量
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
sem <- struct{}{} // 获取许可证
defer func() { <-sem }() // 释放许可证
crawl(u)
}(url)
}
wg.Wait()
fmt.Println("Crawl finished")
}
为每个爬取操作加入 context 超时:
func crawlWithTimeout(url string, timeout time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
// ...
}
浙公网安备 33010602011771号