上一篇主要学习了基础语法和进阶的一些语法 【GO语言开发实践】一 GO 语法快速上手,这一篇主要学习一下Go的核心并发,深入理解下Go一直以来比较有优势的点:协程。还是使用「对比总结+带完整注释对照代码」模式学习完,主要学习内容:Goroutine协程、sync.WaitGroup等待组、sync.Mutex互斥锁、Channel通道、并发实战


GO 协程的设计思想

以下内容整理自与DeepSeek多轮对话:

1. 什么是 Goroutine:Go 的轻量级协程

Goroutine 是 Go 语言中实现并发的核心单元,是一种用户态的轻量级协程。与传统操作系统线程相比,它的创建成本极低(初始栈仅 2KB 且可动态伸缩),由 Go 运行时自行调度,而不是由操作系统内核管理。

一个典型的 goroutine 创建只需在函数前加上 go 关键字:

go myFunction()

这行代码会创建一个独立的、并发执行的“任务”,它会在某个可用的操作系统线程上被调度执行。


2. Goroutine 底层是如何实现的:GMP 调度模型

Go 的调度器实现了著名的 GMP 模型,完全在用户态运行,不依赖操作系统内核的线程调度。

2.1 GMP 中的三个核心角色

  • G(Goroutine)
    代表一个 goroutine,包含栈、指令指针、寄存器等信息。每个 G 有独立的状态(如就绪、运行、等待等)。

  • M(Machine)
    代表一个操作系统线程(内核线程)。M 负责真正执行 G 的代码。M 的数量由 Go 运行时控制,通常等于 CPU 核心数(GOMAXPROCS),但在阻塞系统调用时会临时增加。

  • P(Processor)
    代表一个逻辑处理器,是 M 的“调度助理”。每个 P 维护一个本地 goroutine 任务队列。P 的数量默认等于 GOMAXPROCS。M 必须绑定一个 P 才能执行 G。

2.2 调度流程的时间线图(GMP 视角)

下面用 Mermaid 序列图展示一个典型的调度流程,包含创建、执行、阻塞、唤醒、结束等阶段:

操作系统 内核线程 M 逻辑处理器 P Go 运行时 主 Goroutine 操作系统 内核线程 M 逻辑处理器 P Go 运行时 主 Goroutine 队列中有 G1, G2... 继续执行其他 G,不阻塞线程 alt [G 执行正常代码] [G 遇到 channel 读写或锁] [G 进行阻塞系统调用(如文件读)] alt [G 执行完成] loop [调度循环] go func() 创建 G 将 G 放入 P 的本地队列 从队列中取出 G 切换到 G 的上下文并执行 执行 G 的业务逻辑 G 进入 _Gwaiting 将 G 挂起,从队列中取下一个 G 陷入内核,M 阻塞 系统调用完成,M 恢复 将原 G 重新加入队列 G 进入 _Gdead,回收资源

该图清晰展示了:同一个 M 可以在不同 G 之间快速切换,而切换过程完全在用户态完成(除了真正的阻塞系统调用)。


3. Goroutine 的生命周期与核心状态

Goroutine 拥有与线程类似的状态机,定义在 runtime/runtime2.go 中。

3.1 Goroutine 核心状态说明

状态 含义 典型转换
_Grunnable 就绪,等待被调度执行 新建的 G 或从等待中唤醒的 G 进入此状态
_Grunning 正在某个 M 上执行 调度器从队列中取出 G 后运行
_Gwaiting 等待某条件(如 channel、锁、time.Sleep) 执行阻塞操作时进入;条件满足后转为 _Grunnable
_Gsyscall 正在执行阻塞的系统调用 发起系统调用时进入;完成后转为 _Grunnable
_Gdead 已结束 执行完毕或从未被使用

对于网络 I/O,Go 使用非阻塞 socket + netpoller(epoll/kqueue),当数据未就绪时,goroutine 被挂起,线程不会进入内核阻塞态,而是去执行其他 goroutine。这里并没有“线程进入内核态等待”,只有 epoll 等待系统调用会短暂进入内核,但线程本身不阻塞。

对于真正的阻塞系统调用(如文件 I/O、time.Sleep),线程会进入内核阻塞态,此时 Go 会临时新建线程来替代

3.2 Goroutine 状态切换图

go func() 创建

调度器选中并执行

时间片用完 / 抢占 / 主动让出

等待 channel、锁、sleep

发起阻塞系统调用

等待条件满足(如 channel 收到数据)

系统调用完成

函数返回,执行完成

资源回收(或放入空闲池复用)

_Grunnable

_Grunning

_Gwaiting

_Gsyscall

_Gdead

调度器使用协作式+抢占式混合调度:goroutine 会主动让出(如 channel 操作),但长时间运行的 G 也会被异步抢占(Go 1.14 起),防止“饿死”其他 G。

4. 协程的本质:操作系统看不到的执行单元

一个关键的本质问题:操作系统在物理/内核层面根本没有“协程”这个概念。协程完全是用户态的运行时(语言、库、或虚拟机)自己模拟出来的轻量级执行流

4.1 操作系统只知道内核线程

操作系统(Linux、Windows 等)的最小调度单元是内核线程(或进程)。每个线程拥有独立的寄存器上下文、栈,切换必须通过系统调用陷入内核,由内核调度器完成。

4.2 协程是如何“活”起来的?

协程只存在于用户态,其核心机制是:

  • 保存/恢复寄存器:每个协程维护一组寄存器快照(PC、SP、通用寄存器等)。
  • 用户态切换:从一个协程切换到另一个时,运行时执行一段汇编代码,将当前寄存器保存到旧协程的上下文中,再从新协程的上下文中恢复寄存器,然后跳转到新协程的代码继续执行。
  • 完全不需要操作系统参与,就像一次函数调用 + 切换栈。

因此,一个内核线程可以在用户态时分复用成千上万个协程。不是把线程“拆碎”,而是让同一个线程不断换活干。

4.3 临时增多的内核线程是怎么回事?

虽然 Go 通常保持内核线程数 ≈ GOMAXPROCS,但当 goroutine 执行阻塞的系统调用(如读文件)时,该线程会陷入内核阻塞,Go 运行时会临时新建一个内核线程来接管被释放的 P,继续执行其他 G。系统调用返回后,临时线程会被回收或休眠。

这些临时线程的阻塞和唤醒机制与 Java 传统线程完全一样(都是内核线程)。区别在于:

  • Java 中每个任务往往独占一个线程 → 阻塞成本高,且数量受限。
  • Go 中临时线程极少出现(只发生在真正阻塞的系统调用),且不影响用户创建百万级 goroutine 的能力。

4.4 为什么操作系统不直接支持协程?

这是一个非常深刻的底层问题。答案的核心是:操作系统的最小调度单元是内核线程,协程完全是用户态模拟出来的概念,操作系统“看不到”也“不需要”看到协程。

4.4.1 操作系统的设计边界

操作系统的核心职责是管理物理资源:CPU 时间、内存、磁盘、网络等。为了公平且安全地分配 CPU 时间,内核必须能够强制抢占执行中的程序。这个抢占的单位就是内核线程(或进程)。内核线程拥有:

  • 独立的硬件上下文(寄存器、栈指针、PC)
  • 内核栈和用户栈
  • 属于某个进程,享有独立的地址空间

操作系统提供了线程的创建、销毁、切换、同步等系统调用,但它不关心用户态程序如何在单个线程内实现“多任务”。这就像高速公路管理者只关心车道和车辆(线程),不关心一辆车里坐了多少人(协程)。

4.4.2 如果操作系统支持协程会怎样?

理论上,内核也可以提供类似 clone(CLONE_VM | CLONE_SYSCALL) 的轻量级执行单元,称为“用户线程”或“协程”。但实际上,这样做会带来几个严重问题:

  1. 调度复杂性爆炸 内核既要调度线程,又要调度协程,调度器复杂度骤增。而且协程的切换成本极低,如果每次切换都要陷入内核,这个优势就荡然无存了。所以协程必须在用户态完成切换才快。

  2. 阻塞语义混乱 如果内核知道协程的存在,那么当一个协程做阻塞 I/O 时,内核应该阻塞整个线程还是只阻塞这个协程?如果只阻塞协程而让同线程的其他协程继续跑,那就需要内核支持可增长的栈用户态上下文保存等,这基本等于把整个 Go 运行时的功能搬到内核里,违背了“内核保持精简”的设计哲学。

  3. 可移植性问题 不同的操作系统对协程的支持程度不同(比如 Windows 的纤程 Fibers 一直半死不活,Linux 的 ucontext 已被标记为废弃)。如果语言依赖内核提供的协程,就会失去跨平台一致性。

4.4.3 正确的关系

操作系统提供内核线程;用户态运行时(如 Go、Java 虚拟线程、Rust tokio)在内核线程之上模拟出协程/虚拟线程。

协程的调度在用户态完成,只有涉及真实的 I/O 阻塞或时间片耗尽时,才需要内核介入(而且介入的粒度是线程,不是协程)。


5. Go Goroutine vs. Redis 单线程 AIO 模型

Go 的 goroutine 是否像 Redis 的单线程事件循环?答案是:底层机制有本质不同,但在“用少量线程支撑大量并发任务”这个目标上殊途同归。

5.1 Redis 模型:单线程 + 非阻塞 I/O + 事件循环

  • 单线程:整个 Redis 服务器只有一个主线程处理所有网络请求。
  • 非阻塞 I/O:socket 设置为非阻塞,使用 epoll(Linux)/ kqueue(BSD)同时监听大量文件描述符。
  • 事件循环:主线程循环调用 epoll_wait,一旦有事件就绪,依次执行对应的命令处理函数(如 getset)。
  • 禁止阻塞:任何阻塞操作(如 sleep、磁盘 I/O)都会卡住整个 Redis。

5.2 Go Goroutine 模型:多线程 + 阻塞式编程 + 用户态调度

  • 多线程:默认有 GOMAXPROCS 个内核线程(M),每个线程都可以执行 goroutine。
  • 阻塞式编程:开发者可以用同步风格写网络 I/O(如 conn.Read),看起来像阻塞,实际由 netpoller 转化为非阻塞。
  • 用户态调度:当 goroutine 阻塞时,调度器自动挂起它,让同线程的其他 goroutine 继续跑。
  • 可混合阻塞:真正的系统调用(如文件 I/O)会临时增加 M,但不会阻塞所有 goroutine。

5.3 Redis 单线程模型 与 Go Goroutine 模型对比

维度 Redis 单线程模型 Go Goroutine 模型
线程数量 1 个(严格单线程) 多个(默认 = CPU 核心数)
编程模型 异步/回调(或响应式) 同步阻塞风格(但自动切换)
阻塞操作容忍度 零容忍(任何阻塞都会挂起整个服务) 高容忍(调度器自动切换,或临时加线程)
上下文切换成本 无(单线程,顺序执行) 极低(用户态切换,纳秒级)
底层 I/O 多路复用 是,使用 epoll/kqueue 是,netpoller 封装了 epoll/kqueue/IOCP
是否支持 CPU 密集型任务 不适合(会阻塞事件循环) 适合(多线程并行执行)
  • 相同点:都依赖 I/O 多路复用(epoll/kqueue)来高效管理大量网络连接,都试图用有限的线程处理海量连接。
  • 不同点:Redis 是“单线程 + 显式非阻塞”,开发者必须避免任何阻塞;Go 是“多线程 + 隐式非阻塞”,开发者写同步代码,运行时自动做异步切换。

Go 的 netpoller + 调度器本质上是一个“多线程版的 Redis 事件循环”,但每个 goroutine 对开发者来说是一个独立的任务。


6. Java 为什么不用 Go 这种协程模型?

这是由历史包袱、设计哲学和演进路径共同决定的。

6.1 历史原因:Java 的线程模型早已固化

Java 1.0(1995 年)就选择了 1:1 映射到操作系统线程 的模型。这个选择在当时很合理:跨平台、简单可靠。随后 20 多年里,整个 Java 生态(应用服务器、JDBC、事务管理器、各种框架)都深度绑定在这个模型上。要彻底改成用户态协程,相当于要推倒重来,代价不可接受。

6.2 设计哲学:显式控制 vs. 隐式托管

  • Go 倾向于提供“一体化”的并发方案:运行时自动调度 goroutine,开发者写同步代码即可,不需要关心底层线程数量。
  • Java 偏向提供“可组合的底层构件”:ThreadExecutorServiceLockBlockingQueue 等,允许开发者精细控制并发行为。内置一个固定的调度器可能会限制灵活性。

6.3 时代变了:Java 也引入了虚拟线程

JDK 21 开始,Java 正式推出了虚拟线程(Virtual Threads)。它是一种用户态轻量级线程,由 JVM 调度,挂在少量平台线程(载体线程)上运行。虚拟线程与 Go goroutine 在核心思想上殊途同归

  • 创建成本极低,可以创建数百万个
  • 阻塞操作(如 Thread.sleep、锁、网络 I/O)会自动挂起虚拟线程,释放底层平台线程
  • 编程模型依然是同步风格,无需学习异步 API

两者的差异在于实现细节(例如虚拟线程基于 Continuation,goroutine 基于内置调度器),但已不再是“Java 没有协程”的格局。


6.4 Goroutine vs. Java 线程模型:核心区别一览

特性 Java 平台线程 (pre-JDK21) Go Goroutine Java 虚拟线程 (JDK21+)
映射模型 1:1(Java 线程 = 内核线程) M:N(多个 G 跑在少量 M 上) M:N(多个虚拟线程跑在少量平台线程上)
创建/切换成本 高(内核态切换,微秒级) 极低(用户态切换,纳秒级) 极低(用户态切换,纳秒级)
初始栈大小 ~1MB(固定) ~2KB(动态伸缩) ~KB 级别(由 JVM 实现决定)
阻塞处理 阻塞内核线程,造成资源浪费 挂起 G,释放 M 去运行其他 G 挂起虚拟线程,释放载体线程
并发能力 数千 ~ 数万个 数百万个 数百万个
调度者 操作系统内核 Go 运行时 JVM(用户态)

Go 的 goroutine 之所以快,是因为它绕开了操作系统内核的线程调度,在用户态完成几乎所有并发控制,让一个内核线程可以复用给海量任务。Java 传统线程模型受限于 1:1 映射,但并没有坐以待毙——虚拟线程的引入标志着两套模型在核心思想上走向趋同进化


GO 并发的相关操作

了解了Go 协程相关的概念后,接下来继续探索Go的协程在并发中的使用

知识点1:Goroutine 协程

Java 底层是操作系统原生线程,占用资源大、创建成本高、并发数量受限,关于Java的线程和操作系统线程映射的导致CPU和内存资源浪费的可以参照之前写的一篇【系统架构设计 服务高性能设计】从内核态到锁升级,一篇文章深入理解计算机高性能关键;Go 的 Goroutine 是用户态轻量级协程,由Go运行时调度,占用内存极小,创建成本极低,轻松支持数十万级并发,仅需go关键字即可快速启动执行。

package main

import (
	"fmt"
	"time"
)

// 自定义执行任务
func runTask(num int) {
	fmt.Printf("当前执行协程任务:%d\n", num)
	// 模拟业务耗时操作
	time.Sleep(500 * time.Millisecond)
}

func main() {
	// 普通函数调用:串行顺序执行
	// runTask(1)
	// runTask(2)

	// go 关键字启动Goroutine协程,异步并发执行
	go runTask(1)
	go runTask(2)

	/*
	Java对应:new Thread().start() 开启线程
	区别:
	1. Java线程依赖系统内核,数量上限低;Goroutine轻量,并发量级大
	2. Go启动协程语法极简,仅加go关键字,无需创建线程对象
	3. 主线程执行完毕会直接退出,不会等待子协程执行完成
	*/

	// 主线程休眠,等待子协程执行完成
	time.Sleep(1 * time.Second)
	fmt.Println("主程序执行结束")
}

在这里插入图片描述
执行的顺序也是争用的
在这里插入图片描述

知识点2:sync.WaitGroup 等待组

Java 实现线程等待常用CountDownLatch,Go中sync.WaitGroup作用完全一致,专门用于让主线程等待所有子协程全部执行完毕后,再结束程序,替代休眠等待,代码更加严谨规范。

package main

import (
	"fmt"
	"sync"
	"time"
)

// 定义全局等待组
var wg sync.WaitGroup

func task(index int) {
	// 协程执行完毕,计数器减一
	defer wg.Done()
	fmt.Printf("协程 %d 开始执行\n", index)
	time.Sleep(600 * time.Millisecond)
	fmt.Printf("协程 %d 执行完成\n", index)
}

func main() {
	// 设置等待协程数量,计数器累加
	wg.Add(3)

	// 批量启动协程
	for i := 1; i <= 3; i++ {
		go task(i)
	}

	// 阻塞主线程,等待所有协程计数器归零
	wg.Wait()
	/*
	Java等价:CountDownLatch.await()
	核心用法:
	1. Add(n):确定需要等待的协程数量
	2. Done():单个协程结束,计数-1
	3. Wait():阻塞等待全部完成
	*/
	fmt.Println("所有协程全部执行完毕")
}

Java中的实现是通过信号量来实现的,本质上是锁来实现的,go的sync.WaitGroup内置实现了这个功能

在这里插入图片描述


知识点3:sync.Mutex 互斥锁

多协程同时操作同一个共享变量时,会出现数据竞争、数据错乱问题;Java使用synchronizedReentrantLock解决,Go使用sync.Mutex互斥锁,通过加锁、解锁保证同一时间仅有一个协程操作共享数据,保障并发数据安全。

package main

import (
	"fmt"
	"sync"
)

// 共享全局变量
var totalNum int
// 定义互斥锁
var mutex sync.Mutex
var wg sync.WaitGroup

// 累加任务
func addNum() {
	defer wg.Done()
	// 上锁:锁住共享资源
	mutex.Lock()
	totalNum++
	// 解锁:释放资源
	mutex.Unlock()
	/*
	Java对应:synchronized 代码块 / 手动锁
	区别:Go锁语法更简洁,手动控制加锁解锁范围,精准控制临界区
	*/
}

func main() {
	wg.Add(1000)
	// 启动1000个协程累加
	for i := 0; i < 1000; i++ {
		go addNum()
	}
	wg.Wait()
	// 加锁后结果固定为1000,无锁会出现数据错乱
	fmt.Println("最终累加结果:", totalNum)
}

在这里插入图片描述
如果这里没有锁,那么累加会出现共享变量的争用,例如注销掉锁的调用


// 累加任务
func addNum() {
	defer wg.Done()
	// 上锁:锁住共享资源
	//mutex.Lock()
	totalNum++
	// 解锁:释放资源
	// mutex.Unlock()
	/*
		Java对应:synchronized 代码块 / 手动锁
		区别:Go锁语法更简洁,手动控制加锁解锁范围,精准控制临界区
	*/
}

在这里插入图片描述


知识点4:Channel 通道

Go并发设计理念:不要通过共享内存通信,要通过通信实现数据共享。Channel是协程之间专用的数据通信管道,替代Java共享变量+锁的通信方式,实现协程间安全数据传输,分为无缓冲通道、有缓冲通道。

package main

import "fmt"

func main() {
	// 1. 创建无缓冲通道,用于传输int类型数据
	msgChan := make(chan int)

	// 启动发送数据协程
	go func() {
		// 向通道写入数据
		msgChan <- 666
		fmt.Println("数据已发送至通道")
	}()

	// 从通道读取数据,主线程接收
	res := <-msgChan
	fmt.Printf("从通道接收数据:%d\n", res)
	/*
	无缓冲通道特性:
	1. 必须同时存在发送和接收操作,否则程序阻塞
	2. 数据收发一一对应,同步执行
	Java无原生对等组件,需手动封装队列实现协程通信
	*/

	// 2. 有缓冲通道,指定缓冲区容量
	bufChan := make(chan string, 2)
	// 可先存入数据,无需立即接收,缓冲区满后才阻塞
	bufChan <- "Go并发"
	bufChan <- "Channel通道"

	fmt.Println(<-bufChan)
	fmt.Println(<-bufChan)
}

关闭通道 + 遍历通道

通道使用完成后需要手动关闭,避免协程一直阻塞等待;可通过for range自动遍历通道内所有数据,数据读取完毕自动退出遍历,简化批量数据接收逻辑。

package main

import "fmt"

func sendData(ch chan<- int) {
	// 单向通道:只允许发送数据
	for i := 1; i <= 5; i++ {
		ch <- i
	}
	// 数据发送完成,关闭通道
	close(ch)
}

func main() {
	// 创建双向通道
	ch := make(chan int, 3)
	go sendData(ch)

	// range遍历通道,通道关闭后自动结束遍历
	for val := range ch {
		fmt.Println("读取通道数据:", val)
	}
	fmt.Println("通道数据读取完毕,通道已关闭")
}

知识点6:并发实战:生产者消费者模型

后端开发高频经典并发模型,Java通过线程+队列实现,Go依托协程+Channel实现更加简洁高效,分工明确,生产者负责生产数据,消费者负责处理数据,完美解耦。

package main

import (
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

// 生产者:生产数据写入通道
func producer(ch chan<- int) {
	defer wg.Done()
	for i := 1; i <= 5; i++ {
		ch <- i
		fmt.Printf("生产者生产数据:%d\n", i)
		time.Sleep(300 * time.Millisecond)
	}
	close(ch)
}

// 消费者:读取通道数据并处理
func consumer(ch <-chan int) {
	defer wg.Done()
	for data := range ch {
		fmt.Printf("消费者处理数据:%d\n", data)
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	dataChan := make(chan int, 2)
	wg.Add(2)

	go producer(dataChan)
	go consumer(dataChan)

	wg.Wait()
	fmt.Println("生产者消费者模型执行结束")
}

在这里插入图片描述
消费者不是提前消费,是刚拿到数据就立刻输出了!只是输出比生产者快一点点。现在看到顺序混乱,只是并发协程调度抢占 CPU导致打印先后错位,业务逻辑完全安全。可以改下代码,让生产逻辑的打印睡眠时间短于消费逻辑

package main

import (
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

// 生产者:生产数据写入通道
func producer(ch chan<- int) {
	defer wg.Done()
	for i := 1; i <= 5; i++ {
		ch <- i // 先往通道塞数据
		fmt.Printf("【生产者】生产数据:%d\n", i)
		time.Sleep(300 * time.Millisecond)
	}
	close(ch)
	fmt.Println("【生产者】全部生产完毕,关闭通道")
}

// 消费者:读取通道数据并处理
func consumer(ch <-chan int) {
	defer wg.Done()
	fmt.Println("【消费者】已启动,通道暂无数据,进入阻塞等待...")

	// 通道为空时,直接阻塞在这里,不会执行循环体
	for data := range ch {
		fmt.Printf("【消费者】成功拿到数据开始处理:%d\n", data)
		time.Sleep(500 * time.Millisecond)
	}
	fmt.Println("【消费者】通道关闭,无新数据,消费结束")
}

func main() {
	// 缓冲区大小2
	dataChan := make(chan int, 2)
	wg.Add(2)

	go producer(dataChan)
	go consumer(dataChan)

	wg.Wait()
	fmt.Println("生产者消费者模型执行结束")
}

这样看到的结果就是顺序的了

在这里插入图片描述


总结一下

Go原生对并发的支持确实会比Java好,核心原因是内置 GMP 调度模型,可以在用户态自主完成多数协程调度切换,摆脱对操作系统原生内核线程的强依赖,减少内核态与用户态切换产生的 CPU、内存资源损耗(仅当发生真正的阻塞系统调用或 I/O 时才会进入内核态)

Go 协程与 Java 线程的生命周期状态逻辑相近,但 Java 受限于历史线程模型(1:1 映射内核线程),只能通过线程池、虚拟线程等方式优化开销,无法从底层彻底抛弃内核线程调度架构(即使想实现类似 GMP 的模型,历史替换成本也极高)。

此外Go 语言面向云原生高并发场景做了深度优化,原生封装等待组、互斥锁、通道等常用并发组件,可以快速实现生产者消费者等经典并发模型(这些在Java里都需要自己去搭配相关类库和自己去组合实现),Go锁的使用也更加简洁,将底层的复杂逻辑(原子操作、信号量、goroutine 阻塞/唤醒、排队等)隐性封装在运行时中

总的来说在并发这件事上,Java 大而全,并发开发需要开发者自行整合工具、把控线程池细节;Go 小而美,让开发者更聚焦业务逻辑,并发调度逻辑以隐式方式可靠运行。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐