Golang 协程(Goroutine)

一、基本概念

Go 协程(Goroutine)是 Go 语言并发模型的核心,是一种由运行时管理的轻量级线程。

进程、线程和协程的关系:

  • 线程可以理解为轻量级的进程
  • 协程可以理解为轻量级的线程

二、核心特性

1. 资源占用极低

  • 创建成本低
    相比线程,协程的创建和销毁成本更低,能支持大规模并发。

  • 动态栈
    栈空间按需扩展,避免固定栈导致的资源浪费。

2. 调度机制高效

  • GMP 模型
    下文详述其具体实现。

  • 工作窃取(Work-Stealing)
    当 P 的本地队列为空时,会从其他 P 窃取 50% 的 G,实现负载均衡。

  • 抢占式调度
    Go 1.14+ 引入基于信号的异步抢占,避免单个 G 长时间占用 CPU。

三、创建与通信

1. 启动协程

在调用函数前加 go 关键字即可启动协程:

go task() // 启动协程执行 task 函数

(1)主协程的工作

封装 main() 函数的 goroutine 称为主协程,其他为子协程。主协程的工作流程:

  • 首先设定每个协程可申请的栈空间最大尺寸(不同系统尺寸不同,超过会引发栈溢出恐慌,导致程序终止)。
  • 然后进行初始化工作:
    • 创建特殊的 defer 语句,用于主协程退出时的善后处理(应对主协程非正常结束)。
    • 启动后台内存垃圾清扫协程,并设置 GC 可用标识。
    • 执行 main 包中的 init() 函数。
    • 执行 main() 函数(执行完后检查是否引发运行时恐慌并处理)。
  • 最后,主协程结束自己及当前进程。

(2)协程规则

  • 新 goroutine 启动时,调用立即返回,Go 不等待其执行结束,忽略返回值后立即执行下一行代码。
  • 主协程退出会终止所有子协程。

(3)示例

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

// 循环打印消息
func show(msg string) {
	for i := 0; i < 5; i++ {
		fmt.Printf("msg: %v\n", msg)
		time.Sleep(time.Millisecond * 100)
	}
}

// 测试协程基本运行
func test01() {
	go show("golang1") // 启动子协程
	show("golang2")    // 主协程执行
	fmt.Println("end...")
}

// 发送 HTTP 请求并打印响应体长度
func responseSize(url string) {
	fmt.Println("step1: ", url)
	response, err := http.Get(url)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("step2: ", url)
	defer response.Body.Close()

	fmt.Println("step3: ", url)
	body, err := ioutil.ReadAll(response.Body)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("step4: ", len(body))
}

// 测试多协程并发请求
func test02() {
	go responseSize("https://www.baidu.com")
	go responseSize("https://www.hldx.com")
	go responseSize("https://jd.com")
	time.Sleep(time.Second * 10) // 等待子协程执行完成
}

func main() {
	// test01()
	test02()
}

2. 同步控制

(1)sync.WaitGroup

用于等待一组协程完成,避免主协程提前退出:

	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			fmt.Println(id)
			wg.Done()
		}(i)
	}

	wg.Wait()
}

(2)Context 取消

通过上下文传递终止信号,优雅结束协程(用于需要主动终止协程的场景)。

3. 协程间通信

(1)通道(Channel)

用于协程间数据传递,分为无缓冲和带缓冲两种:

  • 无缓冲通道:同步阻塞,发送和接收操作需同时就绪。
  • 带缓冲通道:异步非阻塞,仅当缓冲区满(发送)或空(接收)时才阻塞。

(2)Select 多路复用

监听多个通道,响应最先就绪的操作,常用于处理多通道数据。

4. 协程时间轴分析

image-1766157524799

  • 可通过 time.Sleep 控制程序执行过程,观察协程运行顺序。

四、GMP 模型

GMP 是 Go 的并发调度模型,实现了 M:N 线程模型(M 个用户线程(协程)运行在 N 个内核线程上)。

1. 线程模型对比

(1)N:1 模型

N 个用户线程运行在 1 个内核线程中。

  • 优点:用户线程上下文切换快。
  • 缺点:无法充分利用 CPU 多核算力。

(2)1:1 模型

每个用户线程对应 1 个内核线程。

  • 优点:充分利用 CPU 算力。
  • 缺点:线程上下文切换慢。

(3)M:N 模型(Go 实现)

  • 优点:兼顾 CPU 算力利用和协程上下文切换效率。
  • 缺点:调度算法较复杂。

2. 核心组件

  • G(Goroutine)

Go 协程实例,存储执行状态(如程序计数器、栈信息等)。

  • M(Machine)

操作系统线程,绑定 CPU 执行具体任务。

  • P(Processor)

逻辑处理器,管理本地队列(LRQ)并调度 G 到 M 运行。其数量由 GOMAXPROCS 控制,默认等于 CPU 核数。

3. 调度器模型

image-1766157539546

  • 每个操作系统线程 M 持有一个逻辑处理器 P。
  • 每个 P 有一个本地队列(LRQ),还有一个全局队列(由多个 P 共享),队列中维护待执行的协程 G。
  • 执行时,P 会将队列中的 G 调度到 M 中执行。

4. 调度策略

(1)队列轮转

  • P 周期性将 G 调度到 M 执行,一段时间后保存上下文,将 G 放到队列尾部,再取新 G 调度。
  • P 会周期性查看全局队列,调度其中的 G 到 M 执行。

(2)系统调用(阻塞处理)

  • Go 提供 M 缓存池。
  • 当 G0 即将进入系统调用时,M0 释放 P,空闲的 M1(来自缓存池或新建)获取 P 继续执行队列中剩余 G。
  • G0 结束系统调用后:
    • 若有空闲 P,M0 获取 P 继续执行 G0。
    • 若无空闲 P,G0 放入全局队列等待调度,M0 进入缓存池沉睡。

(3)工作量窃取

  • 新创建的 G 优先放入当前 P 的本地队列。
  • 本地队列满时,转移 50% 的 G 到全局队列;本地队列为空时,先检查全局队列,若为空则从其他 P 偷取协程。

(4)抢占式调度

  • 信号式抢占(Go 1.14+):监控线程 sysmon 每 10ms 检测一次,强制抢占运行超 10ms 的 G。
  • 目的:避免单个协程长时间占用 CPU,影响其他协程调度。