【Go语言-Day 41】并发编程的基石:Goroutine 从入门到精通
本文将深入浅出地介绍并发编程的核心基石 Goroutine。我们将从并发与并行的基本概念讲起,对比进程、线程与协程,详细阐述 Goroutine 的创建、使用方法,并解决并发编程中遇到的第一个经典问题——主 Goroutine 与子 Goroutine 的同步。最后,我们将介绍优雅地同步多个 Goroutine 的利器 `sync.WaitGroup`,并简要探讨 Go 的 GMP 调度模型,旨在
Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
01-【Go语言-Day 1】扬帆起航:从零到一,精通 Go 语言环境搭建与首个程序
02-【Go语言-Day 2】代码的基石:深入解析Go变量(var, :=)与常量(const, iota)
03-【Go语言-Day 3】从零掌握 Go 基本数据类型:string
, rune
和 strconv
的实战技巧
04-【Go语言-Day 4】掌握标准 I/O:fmt 包 Print, Scan, Printf 核心用法详解
05-【Go语言-Day 5】掌握Go的运算脉络:算术、逻辑到位的全方位指南
06-【Go语言-Day 6】掌控代码流:if-else 条件判断的四种核心用法
07-【Go语言-Day 7】循环控制全解析:从 for 基础到 for-range 遍历与高级控制
08-【Go语言-Day 8】告别冗长if-else:深入解析 switch-case 的优雅之道
09-【Go语言-Day 9】指针基础:深入理解内存地址与值传递
10-【Go语言-Day 10】深入指针应用:解锁函数“引用传递”与内存分配的秘密
11-【Go语言-Day 11】深入浅出Go语言数组(Array):从基础到核心特性全解析
12-【Go语言-Day 12】解密动态数组:深入理解 Go 切片 (Slice) 的创建与核心原理
13-【Go语言-Day 13】切片操作终极指南:append、copy与内存陷阱解析
14-【Go语言-Day 14】深入解析 map:创建、增删改查与“键是否存在”的奥秘
15-【Go语言-Day 15】玩转 Go Map:从 for range 遍历到 delete 删除的终极指南
16-【Go语言-Day 16】从零掌握 Go 函数:参数、多返回值与命名返回值的妙用
17-【Go语言-Day 17】函数进阶三部曲:变参、匿名函数与闭包深度解析
18-【Go语言-Day 18】从入门到精通:defer、return 与 panic 的执行顺序全解析
19-【Go语言-Day 19】深入理解Go自定义类型:Type、Struct、嵌套与构造函数实战
20-【Go语言-Day 20】从理论到实践:Go基础知识点回顾与综合编程挑战
21-【Go语言-Day 21】从值到指针:一文搞懂 Go 方法 (Method) 的核心奥秘
22-【Go语言-Day 22】解耦与多态的基石:深入理解 Go 接口 (Interface) 的核心概念
23-【Go语言-Day 23】接口的进阶之道:空接口、类型断言与 Type Switch 详解
24-【Go语言-Day 24】从混乱到有序:Go 语言包 (Package) 管理实战指南
25-【Go语言-Day 25】从go.mod到go.sum:一文彻底搞懂Go Modules依赖管理
26-【Go语言-Day 26】深入解析error:从errors.New到errors.As的演进之路
27-【Go语言-Day 27】驾驭 Go 的异常处理:panic 与 recover 的实战指南与陷阱分析
28-【Go语言-Day 28】文本处理利器:strings
包函数全解析与实战
29-【Go语言-Day 29】从time.Now()到Ticker:Go语言time包实战指南
30-【Go语言-Day 30】深入探索Go文件读取:从os.ReadFile到bufio.Scanner的全方位指南
31-【Go语言-Day 31】精通文件写入与目录管理:os
与filepath
包实战指南
32-【Go语言-Day 32】从零精通 Go JSON:Marshal
、Unmarshal
与 Struct Tag 实战指南
33-【Go语言-Day 33】告别“能跑就行”:手把手教你用testing
包写出高质量的单元测试
34-【Go语言-Day 34】告别凭感觉优化:手把手教你 Go Benchmark 性能测试
35-【Go语言-Day 35】Go 反射核心:reflect
包从入门到精通
36-【Go语言-Day 36】构建专业命令行工具:flag
包入门与实战
37-【Go语言-Day 37】深入C世界:Go与C语言交互的桥梁——Cgo入门指南
38-【Go语言-Day 38】编写地道Go代码:Go语言官方代码规范与最佳实践深度解析
39-【Go语言-Day 39】Go 工具链深度游:掌握 build, vet, pprof 和交叉编译四大神器
40-【Go语言-Day 40】项目实战:从零到一打造一个功能完备的命令行 Todo List 应用
41-【Go语言-Day 41】并发编程的基石:Goroutine 从入门到精通
文章目录
摘要
本文是 Go 语言从入门到精通系列的第 41 篇,我们将正式踏入 Go 语言最引以为傲的领域——并发编程。本文将深入浅出地介绍并发编程的核心基石 Goroutine。我们将从并发与并行的基本概念讲起,对比进程、线程与协程,详细阐述 Goroutine 的创建、使用方法,并解决并发编程中遇到的第一个经典问题——主 Goroutine 与子 Goroutine 的同步。最后,我们将介绍优雅地同步多个 Goroutine 的利器 sync.WaitGroup
,并简要探讨 Go 的 GMP 调度模型,旨在为读者构建一个坚实的 Go 并发编程基础。
一、并发与并行的区别:从厨房的故事说起
在深入 Goroutine 之前,我们必须厘清两个经常被混淆的概念:并发(Concurrency)和并行(Parallelism)。这对于理解 Go 语言的设计哲学至关重要。
1.1 什么是并发 (Concurrency)?
并发是指在一段时间内,宏观上处理多个任务的能力。 这些任务可能不是同时执行的,而是在单个处理器上通过快速切换来交替执行,给人一种“同时”运行的错觉。
想象一个厨师在厨房里。他需要完成切菜、炒菜、煮汤三项任务。如果只有一个灶台(单核 CPU),他会:
- 先切一部分菜。
- 然后把菜下锅翻炒几下。
- 在炒菜的间隙,去看一下汤的火候。
- 再回来继续翻炒。
在任何一个瞬间,他只在做一件事。但在 10 分钟这个时间段内,他同时“推进”了三项任务的进度。这就是并发。
1.2 什么是并行 (Parallelism)?
并行是指在同一时刻,物理上真正地同时执行多个任务。 这通常需要多个处理器核心的支持。
回到厨房的故事,现在老板为厨师配备了三个灶台(多核 CPU)。他可以:
- 在一个灶台上炒菜。
- 在第二个灶台上煮汤。
- 同时,他的助手(另一个核心)在第三个灶台上炖肉。
在同一时刻,切菜、炒菜、煮汤这三件事是真正同时发生的。这就是并行。
核心区别:并发是逻辑上的“同时”,是关于构造和管理任务的能力;并行是物理上的“同时”,是关于执行任务的能力。并发是并行的前提,一个好的并发程序可以在多核处理器上实现真正的并行,从而大幅提升性能。
1.3 Go 语言的并发哲学
Go 语言的设计者们推崇 “不要通过共享内存来通信,而要通过通信来共享内存” 的哲学。他们提供了 Goroutine(用于并发构造)和 Channel(用于 Goroutine 间通信)这两个强大的工具,使得编写并发程序变得异常简单和安全,让开发者能更专注于任务的分解与协作,而不是陷入复杂的锁机制和内存同步问题中。
二、深入理解 Goroutine:Go 的并发利器
Goroutine 是 Go 语言并发设计的核心。它是一种轻量级的执行单元,由 Go 运行时(Go Runtime)负责调度和管理。
2.1 进程、线程与协程
为了更好地理解 Goroutine,我们先来回顾一下操作系统中几个相关的概念。
2.1.1 进程 (Process)
进程是操作系统进行资源分配和调度的基本单位。每个进程都有自己独立的内存空间,一个进程崩溃通常不会影响其他进程。进程间的通信(IPC)相对复杂且开销较大。
2.1.2 线程 (Thread)
线程是进程内的执行单元,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的内存空间。线程的创建和切换开销比进程小,但仍然存在显著的系统开销(通常需要几百 KB 到几 MB 的栈空间)。
2.1.3 协程 (Coroutine)
协程是一种用户态的、比线程更轻量的执行单元。它的创建、销毁和切换完全由程序自身(或语言的运行时)控制,不涉及操作系统内核。因此,协程的开销极小,可以轻松创建成千上万个。
2.1.4 Goroutine:Go 语言的答案
Goroutine 本质上就是 Go 语言实现的协程。Go 运行时通过一个精巧的调度器,将成千上万的 Goroutine 映射到少量的操作系统线程上执行,实现了高效的并发模型。
2.2 Goroutine 的核心优势
特性 | 进程 (Process) | 线程 (Thread) | Goroutine |
---|---|---|---|
资源占用 | 大 (MB 级别) | 较大 (KB-MB 级别) | 极小 (初始 2KB 栈) |
创建/销毁开销 | 大 | 较大 (涉及内核态切换) | 极小 (用户态操作) |
切换开销 | 大 (涉及内核态) | 较大 (涉及内核态) | 极小 (用户态调度) |
数量 | 有限 (几十到几百) | 有限 (几百到几千) | 海量 (轻松上万) |
通信方式 | IPC (复杂) | 共享内存 (需加锁) | Channel (推荐), 共享内存 |
Goroutine 的这些优势使其成为构建高并发服务的理想选择,尤其是在网络编程、微服务等场景下,可以为每一个用户请求或每一个连接都创建一个 Goroutine 来处理,而无需担心资源耗尽。
三、Goroutine 实战:开启你的第一个并发程序
理论讲了这么多,让我们动手实践一下。
3.1 使用 go
关键字创建 Goroutine
在 Go 语言中,开启一个 Goroutine 非常简单,只需要在函数调用前加上 go
关键字即可。
3.1.1 基本语法
go functionName(arguments)
这会创建一个新的 Goroutine,并在这个新的 Goroutine 中执行 functionName
函数。原有的执行流会继续向下执行,不会等待新创建的 Goroutine 结束。
3.1.2 代码示例:初识 Goroutine
我们来编写一个程序,让主函数(运行在主 Goroutine 中)和另一个我们创建的 Goroutine 同时打印信息。
package main
import (
"fmt"
"time"
)
// sayHello 将在子 Goroutine 中运行
func sayHello() {
fmt.Println("Hello from sub goroutine!")
}
func main() {
// 使用 go 关键字开启一个新的 Goroutine
go sayHello()
fmt.Println("Hello from main goroutine!")
// 等待一下,否则主 Goroutine 退出,子 Goroutine 可能没机会执行
time.Sleep(1 * time.Second)
fmt.Println("Main goroutine finished.")
}
输出可能如下(顺序可能变化):
Hello from main goroutine!
Hello from sub goroutine!
Main goroutine finished.
3.2 主 Goroutine 与子 Goroutine
3.2.1 一个常见问题:程序为何直接退出了?
现在,我们把上面代码中的 time.Sleep
注释掉,再运行一次。
package main
import (
"fmt"
)
func sayHello() {
fmt.Println("Hello from sub goroutine!")
}
func main() {
go sayHello()
fmt.Println("Hello from main goroutine!")
// time.Sleep(1 * time.Second) // 注释掉这行
}
这次的输出很可能是:
Hello from main goroutine!
你会发现,"Hello from sub goroutine!"
这句话不见了!这是为什么呢?
3.2.2 原因解析:主 Goroutine 的生命周期
Go 程序的运行始于主 Goroutine(执行 main
函数的那个),当主 Goroutine 执行结束时,整个程序就会立即退出,无论其他子 Goroutine 是否执行完毕。
在上一个例子中,主 Goroutine 启动了 sayHello
这个子 Goroutine 后,就立刻打印了自己的信息,然后 main
函数执行完毕,程序退出。子 Goroutine 刚刚被创建,还没来得及被 Go 运行时调度执行,整个程序就已经结束了。
3.2.3 简单粗暴的解决方案:time.Sleep
我们最初加入 time.Sleep(1 * time.Second)
的目的,就是为了强行让主 Goroutine “等一等”,给子 Goroutine 足够的时间去执行。但这种方法非常不可靠:
- 时间不确定:你无法准确预知子 Goroutine 需要多久才能执行完。睡 1 秒可能够用,也可能不够。睡太久则会浪费时间。
- 不优雅:这是一种硬编码的等待,不是一个健壮的同步机制。
我们需要一种更可靠、更优雅的方式来等待所有子 Goroutine 完成任务。
四、优雅地等待:sync.WaitGroup
Go 标准库中的 sync.WaitGroup
就是为了解决这个问题而生的。它是一个计数信号量,可以用来等待一组 Goroutine 完成它们的执行。
4.1 为何需要 WaitGroup?
WaitGroup
内部维护一个计数器。当我们需要等待多个并发任务时,可以:
- 开始时,设置计数器的值为任务的总数。
- 每个任务完成时,将计数器减 1。
- 主 Goroutine 可以阻塞等待,直到计数器归零。
4.2 sync.WaitGroup
核心方法
WaitGroup
主要有三个方法:
4.2.1 Add(delta int)
增加计数器的值。delta
可以是正数或负数,但通常我们在启动 Goroutine 前调用 Add(1)
。
4.2.2 Done()
将计数器减 1。它等价于 Add(-1)
。通常在 Goroutine 的任务执行完毕后,使用 defer
语句来调用 Done()
,以确保即使 Goroutine 发生 panic
也能被执行。
4.2.3 Wait()
阻塞当前 Goroutine,直到 WaitGroup
的计数器归零。
4.3 实战代码:使用 WaitGroup 改造程序
让我们用 WaitGroup
来重写之前的例子,实现可靠的同步。
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
// 在函数退出时,调用 Done() 通知 WaitGroup 该 Goroutine 已完成
defer wg.Done()
fmt.Printf("Worker %d starting...\n", id)
// 模拟耗时任务
time.Sleep(time.Second)
fmt.Printf("Worker %d done.\n", id)
}
func main() {
// 创建一个 WaitGroup 实例
var wg sync.WaitGroup
// 我们要启动 3 个 worker Goroutine
numWorkers := 3
// 1. 增加计数器
// 一次性增加计数器的值,等于我们要等待的 Goroutine 数量
wg.Add(numWorkers)
for i := 1; i <= numWorkers; i++ {
// 启动 Goroutine,并将 wg 的指针传递进去
go worker(i, &wg)
}
fmt.Println("Main goroutine is waiting for workers to finish...")
// 3. 等待所有 Goroutine 完成
// Wait() 会阻塞,直到计数器变为 0
wg.Wait()
fmt.Println("All workers have finished. Main goroutine is exiting.")
}
输出(Worker 的完成顺序可能不同,但主 Goroutine 的消息总在最后):
Main goroutine is waiting for workers to finish...
Worker 3 starting...
Worker 1 starting...
Worker 2 starting...
Worker 3 done.
Worker 1 done.
Worker 2 done.
All workers have finished. Main goroutine is exiting.
这个版本的程序是健壮的。无论 worker
函数执行多久,主 Goroutine 都会耐心等待,直到所有 worker
都调用了 Done()
方法。
4.4 WaitGroup
的使用注意事项
- 计数器不能为负:调用
Done()
的次数不能超过Add()
增加的总数,否则会引发panic
。 Add()
应在go
关键字之前:应该在主 Goroutine 中,启动子 Goroutine 之前就调用Add()
。如果在子 Goroutine 中调用Add()
,可能会存在竞态条件:Wait()
可能在所有Add()
调用完成前就执行了。WaitGroup
可重用:一个WaitGroup
在计数器归零后可以被重用,但必须确保Wait()
返回后,下一次Add()
才开始调用。
五、Goroutine 调度模型 GMP (进阶)
对于初学者,了解如何使用 Goroutine 和 WaitGroup
已经足够。对于希望深入的读者,可以简要了解一下 Go 的调度器是如何工作的。
5.1 GMP 模型简介
Go 的调度器核心是 GMP 模型:
- G (Goroutine): 我们编写的并发执行单元,它有自己的栈、指令指针和状态。
- M (Machine/Thread): 操作系统的线程,是真正执行代码的实体。
- P (Processor): 处理器,一个逻辑概念,它包含一个可运行的 G 队列。P 将 G 和 M 连接起来,M 必须获取一个 P 才能执行 P 队列中的 G。
5.2 GMP 工作流程图
下面是一个简化的 GMP 工作流程示意图。
这个模型使得 Go 调度器非常高效:
- 工作窃取 (Work Stealing): 当一个 P 的本地队列空了,它可以从其他 P 的队列或全局队列中“窃取”G 来执行,保持 M 的高利用率。
- 用户态调度: 大部分的 G 切换都在用户态完成,避免了昂贵的内核态切换。
六、总结
本文作为 Go 并发编程的开篇,带大家走进了 Goroutine 的世界。我们掌握了以下核心知识点:
- 并发与并行: 理解了并发是逻辑上的多任务处理,并行是物理上的同时执行。Go 语言擅长并发编程。
- Goroutine 概念: 学习了 Goroutine 是 Go 实现的轻量级协程,相比线程和进程,它拥有极低的资源消耗和切换开销。
- 创建 Goroutine: 掌握了使用
go
关键字非常简单地启动一个并发任务。 - 主 Goroutine 生命周期: 认识到主 Goroutine 的退出会导致整个程序的终结,这是初学者常见的陷阱。
sync.WaitGroup
: 学会了使用WaitGroup
作为优雅、可靠的同步机制,通过Add
,Done
,Wait
三个核心方法来等待一组 Goroutine 完成。- GMP 模型 (初探): 对 Go 语言底层的调度器模型有了初步了解,知道了 G、M、P 是如何协同工作的。
掌握了 Goroutine,你就拿到了开启 Go 高性能并发编程大门的钥匙。在下一篇文章中,我们将学习 Goroutine 之间如何安全、高效地通信——Channel
。
更多推荐
所有评论(0)