goroutine 和 channel 是 Go 语言的灵魂。
所有我们谈论的——为什么 Go 被发明、它如何解决“多核 CPU”问题——都汇聚在了这两个概念上。
我们先看“没有并发”的世界是什么样的。
问题:代码是“一根筋”的 (Sequential)
默认情况下,你的 func main() 是一个“工人”。它严格地按照你写的代码,一行一行执行,并且必须等上一行做完,才能做下一行。
例如,一个“很慢”的程序:
1 | package main |
运行这个程序,(执行顺序)是:
- 时刻 0: 打印
(A) Main:开始 - 时刻 0: 调用
doSomethingSlow() - 时刻 0: 打印
(B) 慢速任务:开始... - 时刻 0: 执行
time.Sleep(2 * time.Second)… 程序卡住 2 秒 … - … (1秒过去了) …
- … (2秒过去了) …
- 时刻 2: 打印
(C) 慢速任务:完成! - 时刻 2:
doSomethingSlow()函数返回 - 时刻 2: 打印
(D) Main:结束
结论: main 函数(主工人)花了 2 秒钟傻等 doSomethingSlow(慢速任务)。如果 (D) 是一个很重要的任务(比如显示网页),用户就得卡 2 秒。
Goroutine (The Worker - 工人)
- 核心概念:
goroutine就是 Go 语言的“轻量级工人”。 - 如何创建: 极其简单,在任何函数调用前,加上
go关键字。
创建一个goroutinue协程就是在一个函数调用前加上go关键字 而不是函数定义
goroutinue实际上就是一个任务 就是一个运行时函数 - 它做什么:
go myFunction()的意思是:“雇佣一个新工人,让他去执行myFunction。我(main工人)不等他,我立刻去做下一件事。”
就有点像async
1. 使用 go 关键字
我们把上面的例子改一下,只加一个 go 关键字:
1 | package main |
运行这个程序,你会看到一个“奇怪的”结果:
(A) Main:开始 (D) Main:结束
… (B) 和 (C) 哪里去了?
(Data Flow) 解释 ():
- 时刻 0.000:
main工人打印(A) Main:开始。 - 时刻 0.001:
main工人执行go doSomethingSlow()。- Go 语言运行时(Runtime)创建了一个全新的 Goroutine(我们叫他“工人G”)。
- “工人G”准备去执行
doSomethingSlow()。
- 时刻 0.002:
main工人(不等待!)立刻执行下一行,打印(D) Main:结束。 - 时刻 0.003:
main函数执行完毕。
🚩 Go 语言有一个规则:
main函数退出return时 整个程序会立即中止 所有其他在工作的goroutinue都会被立刻杀死
修正:让 main 等待
( *这是一种“错误”的修正,但它能帮理解概念。稍后会用 channel 来“正确”地修正它 )
1 | func main() { |
这次的运行结果:
1 | (A) Main:开始 |
(注意:(B) 和 (D) 的顺序可能是反的,因为 main 和 工人G 是同时在跑!)
但用time.Sleep来等goroutinue执行显然不合理 只是让我们知道这个概念
看到没 goroutinue在另外一个CPU core里执行
1 | CPU 核心 1 (Core 1) CPU 核心 2 (Core 2) |
Goroutine vs. OS Thread (操作系统线程)
为什么不直接用“线程”?为什么 Go 要发明 goroutine?
我先用英文解释 (English Explanation):
An OS Thread (like in Java or C++) is heavy. It’s managed by the Operating System kernel. Creating one takes significant time and memory (e.g., 1MB stack). You can only have a few thousand threads before the OS crashes.
A Goroutine is extremely lightweight. It’s managed by the Go Runtime (not the OS). It starts with a tiny stack (e.g., 2KB). You can easily have millions of goroutines running. The Go Runtime “multiplexes” (M:N scheduling) these millions of goroutines onto a few OS Threads.
协程和线程/进程的管理者不一样 协程是由Go Runtime来管理
- OS Thread (线程):
- 这指的是: Java/C++ 里的“工人”。
- 问题: 它是重量级 (heavy) 的。它由“操作系统”(Windows/Mac/Linux)内核直接管理。
- 例如: 创建一个“线程”需要很多内存(比如 1MB),而且很慢。你的电脑可能只能跑几千个“线程”就会死机。
- Goroutine (Go 的工人):
- 这指的是: Go 的“工人”。
- 优势: 它是极其轻量级 (lightweight) 的。
- 例如: 它由 Go 运行时 (Go Runtime) 管理(Go 程序自己管理自己的工人,操作系统不知道)。
- 例如: 启动一个
goroutine内存开销极小(比如 2KB)。你可以轻松跑几百万个goroutine。
Go 的 M:N 调度)
Go 的“魔法”在于,它(Go 运行时)只向操作系统(OS)申请了少数几个“OS 线程”(比如 8 个,对应 8 个 CPU 核心)。
协程管理由Go Runtime Go Runtime怎么做的呢 他会去向OS申请少数几个OS线程 一般对应CPU Cores的数量
然后,Go 运行时自己扮演“调度员”,把几百万个 goroutine(你的任务)在这 8 个“OS 线程”上飞快地切换。
Go Runtime自己就是那个操作8个OS线程的调度员 他把几百万个goroutinue在这8个OS线程上飞快切换
1 | (你的几百万个 Goroutines) |
Channel (The Pipe - 安全管道)
我们现在有能力同时跑几百万个 goroutine(工人)了。
新问题来了:
main工人如何知道“工人G”什么时候干完?(time.Sleep显然是猜的,不可靠)- 如果“工人G”算出了一个结果(比如
c = a + b),它如何安全地把这个结果传递给main工人?
(我们之前讲过“数据冲突 Race Condition”,如果两个工人同时写一个变量,数据就错了。我们不想用“锁”sync.Mutex。)
channel (通道) 就是 Go 语言的答案。
核心概念: Channel 是一个“类型安全的管道”,goroutine 们可以用它来通信和同步。
Go 的哲学:
“Don’t communicate by sharing memory; instead, share memory by communicating.”
- “Don’t communicate by sharing memory” (不要通过共享内存来通信)
- 这指的是: 不要让
main工人和工人G同时去读写一个共享变量total_views。(这是“旧方法”,需要“锁”,很危险)。
- 这指的是: 不要让
- “share memory by communicating” (通过通信来共享内存)
- 这指的是: 我们创建一个
channel(安全管道)。 工人G算完结果后,把结果放进管道里。main工人守在管道出口,等待结果从管道里出来。
main看的是channel 而工人对接的也是channel
- 这指的是: 我们创建一个
- “Don’t communicate by sharing memory” (不要通过共享内存来通信)
Channel 的语法
Channel用make来创建 ch:= make( chan int)
使用←左箭头塞入数字到Channel里
然后变量通过←channel变量来接受
1 | // 1. 创建 (Make) |
Channel 的核心特性:阻塞 (Blocking)
这是 channel 最神奇、也是最重要的特性。
在这里 阻塞是对的 接受者和发送者必须同时到达
默认情况下,channel 是“无缓冲”的。
- 这指的是: 它是一个“面对面交接”的管道。它没有中间的“储物柜”。
- 发送者 (
ch <- 42) 必须和 接收者 (<- ch) 同时到场。
场景1:main 工人先“发送”,工人G 还没准备好“接收”
1 | Goroutine (main) Goroutine (工人G) |
main 工人会卡在 ch <- 42 这一行,直到 工人G 来了。
场景2:工人G 先“接收”,main 工人还没“发送”
代码段
1 | Goroutine (main) Goroutine (工人G) |
工人G 会卡在 value := <- ch 这一行,直到 main 工人来了。
场景3:main 和 G 完美“交接”
G先到,执行<- ch,被阻塞。main来了,执行ch <- 42。- Go 运行时发现
G正在等数据。 42这个值被直接从main传递给了G。- 两个 goroutine 都被“唤醒”,它们各自继续往下执行。
结论:channel 同时解决了两个问题:
- 数据传递:
42被安全地传过去了。 - 同步 (Synchronization):
main和G通过“等待交接”的方式,知道了对方已经“准备好了”。我们不再需要time.Sleep了!
Goroutine + Channel 协同作战 (最佳实践)
我们用 channel 来“正确地”重写我们最开始的那个例子。
1 | package main |
这次的运行结果 (完美!):
1 | (A) Main:开始 |
代码段
1 | Goroutine (main) Goroutine (工人G) |
这就是 Go 语言并发的核心模式:
- 使用
go关键字启动并发任务。 - 使用
channel在任务间安全地传递数据并实现同步。