0%

goroutine和channel

goroutinechannel 是 Go 语言的灵魂

所有我们谈论的——为什么 Go 被发明、它如何解决“多核 CPU”问题——都汇聚在了这两个概念上。

我们先看“没有并发”的世界是什么样的。


问题:代码是“一根筋”的 (Sequential)

默认情况下,你的 func main() 是一个“工人”。它严格地按照你写的代码,一行一行执行,并且必须等上一行做完,才能做下一行。

例如,一个“很慢”的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"time"
)
//time.Sleep(时间)来让程序睡觉
// (这是一个普通的、很慢的函数)
func doSomethingSlow() {
fmt.Println(" (B) 慢速任务:开始... (需要睡2秒)")

// time.Sleep 会“阻塞”:
// 它会卡住,直到2秒钟过去
time.Sleep(2 * time.Second)

fmt.Println(" (C) 慢速任务:完成!")
}

func main() {
fmt.Println("(A) Main:开始")

// (1) 调用“慢速函数”
doSomethingSlow()

// (2) 只有等 doSomethingSlow() 彻底完成后,
// (3) 这一行才能被执行
fmt.Println("(D) Main:结束")
}

运行这个程序,(执行顺序)是:

  1. 时刻 0: 打印 (A) Main:开始
  2. 时刻 0: 调用 doSomethingSlow()
  3. 时刻 0: 打印 (B) 慢速任务:开始...
  4. 时刻 0: 执行 time.Sleep(2 * time.Second)程序卡住 2 秒
  5. … (1秒过去了) …
  6. … (2秒过去了) …
  7. 时刻 2: 打印 (C) 慢速任务:完成!
  8. 时刻 2: doSomethingSlow() 函数返回
  9. 时刻 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"time"
)

func doSomethingSlow() {
fmt.Println(" (B) 慢速任务:开始... (需要睡2秒)")
time.Sleep(2 * time.Second)
fmt.Println(" (C) 慢速任务:完成!")
}

func main() {
fmt.Println("(A) Main:开始")

// 🚩 关键改动:
// (1) 雇佣一个“新工人”(goroutine)
// (2) 去执行 doSomethingSlow()
go doSomethingSlow()

// (3) “我”(main 工人)不等待!
// (4) 我立刻执行下一行
fmt.Println("(D) Main:结束")
}

运行这个程序,你会看到一个“奇怪的”结果:

(A) Main:开始 (D) Main:结束

(B)(C) 哪里去了?

(Data Flow) 解释 ():

  1. 时刻 0.000: main 工人打印 (A) Main:开始
  2. 时刻 0.001: main 工人执行 go doSomethingSlow()
    • Go 语言运行时(Runtime)创建了一个全新的 Goroutine(我们叫他“工人G”)。
    • “工人G”准备去执行 doSomethingSlow()
  3. 时刻 0.002: main 工人(不等待!)立刻执行下一行,打印 (D) Main:结束
  4. 时刻 0.003: main 函数执行完毕。

🚩 Go 语言有一个规则:
main函数退出return时 整个程序会立即中止 所有其他在工作的goroutinue都会被立刻杀死

修正:让 main 等待

( *这是一种“错误”的修正,但它能帮理解概念。稍后会用 channel 来“正确”地修正它 )

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
fmt.Println("(A) Main:开始")

go doSomethingSlow() // 雇佣“工人G”

fmt.Println("(D) Main:我(main)假装等3秒")

// (1) main 工人强行“睡觉”3秒
// (2) 在这3秒内,“工人G”有足够的时间去工作
time.Sleep(3 * time.Second)

fmt.Println("(E) Main:结束")
}

这次的运行结果:

1
2
3
4
5
(A) Main:开始
(D) Main:我(main)假装等3
(B) 慢速任务:开始... (需要睡2秒)
(C) 慢速任务:完成!
(E) Main:结束

(注意:(B)(D) 的顺序可能是反的,因为 main工人G同时在跑!)

但用time.Sleep来等goroutinue执行显然不合理 只是让我们知道这个概念

看到没 goroutinue在另外一个CPU core里执行

1
2
3
4
5
6
7
8
9
10
  CPU 核心 1 (Core 1)          CPU 核心 2 (Core 2)
+-------------------------+ +-------------------------+
| (A) main 打印 | | |
| (D) main 打印 | | (B) 工人G 打印 |
| ( ) main 开始睡觉(3秒) | | ( ) 工人G 开始睡觉(2秒) |
| ( ) ... | | ( ) ... |
| ( ) ... | | (C) 工人G 打印 |
| (E) main 睡醒, 打印 | | ( ) 工人G 结束 |
| ( ) main 退出 | | |
+-------------------------+ +-------------------------+

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
2
3
4
5
6
7
8
9
10
11
12
13
14
    (你的几百万个 Goroutines)
G1 G2 G3 G4 G5 G6 G7 G8 ... G1,000,000
\ | / \ | / \ |
\ | / \ | / \ |
(Go 运行时 "调度器" - Scheduler)
\ | / \ | /
+-------+ +-------+
| OS 线程1 | | OS 线程2 | (由操作系统管理的“真·工人”)
+-------+ +-------+
^ ^
| |
+-------+ +-------+
| CPU 核心1| | CPU 核心2|
+-------+ +-------+

Channel (The Pipe - 安全管道)

我们现在有能力同时跑几百万个 goroutine(工人)了。

新问题来了:

  1. main 工人如何知道“工人G”什么时候干完?(time.Sleep 显然是猜的,不可靠)
  2. 如果“工人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

Channel 的语法

Channel用make来创建 ch:= make( chan int)

使用←左箭头塞入数字到Channel里

然后变量通过←channel变量来接受

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 创建 (Make)
// 这是一个“管道”,它只能传输 int 类型的数据
ch := make(chan int)

// 2. 发送 (Send)
// 把数字 42 “塞进” 管道 ch
ch <- 42

// 3. 接收 (Receive)
// “等待” ch 管道里有数据,
// 然后把它取出来,放进变量 "value"
value := <- ch

Channel 的核心特性:阻塞 (Blocking)

这是 channel 最神奇、也是最重要的特性。

在这里 阻塞是对的 接受者和发送者必须同时到达

默认情况下,channel 是“无缓冲”的。

  • 这指的是: 它是一个“面对面交接”的管道。它没有中间的“储物柜”。
  • 发送者 (ch <- 42) 必须 接收者 (<- ch) 同时到场

场景1:main 工人先“发送”,工人G 还没准备好“接收”

1
2
3
4
5
6
7
8
  Goroutine (main)            Goroutine (工人G)
+-----------------------+ +-----------------------+
| ch <- 42 | | (正在忙别的事...) |
| (发送...) | | |
| (发现没人接收...) | | |
| (Main 工人被“阻塞”了) | | |
| (暂停,睡觉) | | |
+-----------------------+ +-----------------------+

main 工人会卡在 ch <- 42 这一行,直到 工人G 来了。

场景2:工人G 先“接收”,main 工人还没“发送”

代码段

1
2
3
4
5
6
7
8
  Goroutine (main)            Goroutine (工人G)
+-----------------------+ +-----------------------+
| (正在忙别的事...) | | value := <- ch |
| | | (接收...) |
| | | (发现管道是空的...) |
| | | (工人G 被“阻塞”了) |
| | | (暂停,睡觉) |
+-----------------------+ +-----------------------+

工人G卡在 value := <- ch 这一行,直到 main 工人来了。

场景3:mainG 完美“交接”

  1. G 先到,执行 <- ch被阻塞
  2. main 来了,执行 ch <- 42
  3. Go 运行时发现 G 正在等数据。
  4. 42 这个值被直接main 传递给了 G
  5. 两个 goroutine 都被“唤醒”,它们各自继续往下执行。

结论:channel 同时解决了两个问题:

  1. 数据传递: 42 被安全地传过去了。
  2. 同步 (Synchronization): mainG 通过“等待交接”的方式,知道了对方已经“准备好了”。我们不再需要 time.Sleep 了!

Goroutine + Channel 协同作战 (最佳实践)

我们用 channel 来“正确地”重写我们最开始的那个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"fmt"
"time"
)

// (1) 我们让函数把“完成信号”发送到管道
// (chan bool) 的意思是“一个传输布尔值(true/false)的管道”
参数里 参数变量 chan指明是一个channel bool指明管道传输的具体类型 所以管道需要指明传输的数据类型 不是通用的
func doSomethingSlow(done chan bool) { 在这里定义的时候不需要go关键字

fmt.Println(" (B) 慢速任务:开始... (需要睡2秒)")
time.Sleep(2 * time.Second)
fmt.Println(" (C) 慢速任务:完成!")

// (2) 🚩 工作完成!
// 向 "done" 管道发送一个 "true"
done <- true
}

func main() {
// (A) Main:开始

// (3) 创建一个“信号管道”
doneChannel := make(chan bool)

// (4) 雇佣“工人G”,并把“管道”交给他
go doSomethingSlow(doneChannel)

// (5) 🚩 "main" 工人执行到这里
// 它开始“等待”... (<- doneChannel)
// 它会一直“阻塞”在这里...
fmt.Println("(D) Main:正在等待工人G完成...")

<- doneChannel // (程序会卡在这里)
main函数相当于一个main goroutinue 他在这里等待channel传来数据

// (6) ... 2秒后, “工人G” 发送了 true ...
// (7) ... "main" 工人收到了信号, “阻塞”解除 ...

// (8) "main" 工人继续执行
fmt.Println("(E) Main:结束")
}

这次的运行结果 (完美!):

1
2
3
4
5
(A) Main:开始
(D) Main:正在等待工人G完成...
(B) 慢速任务:开始... (需要睡2秒)
(C) 慢速任务:完成!
(E) Main:结束

代码段

1
2
3
4
5
6
7
8
9
10
11
12
13
  Goroutine (main)                       Goroutine (工人G)
+------------------------------------+ +------------------------------------+
| (A) 打印 | | |
| (3) 创建 doneChannel | | |
| (4) go doSomethingSlow(doneChannel)| | |
| (5) 打印 (D) | | (B) 打印 |
| ( ) 执行 <- doneChannel | | ( ) 睡觉 2 秒... |
| ( ) (被阻塞... 等待信号) | | (C) 打印 |
| | | (2) 执行 doneChannel <- true |
| (7) (收到信号! 阻塞解除) | | ( ) (发送成功! 工人G 结束) |
| (8) 打印 (E) | | |
| ( ) main 退出 | | |
+------------------------------------+ +------------------------------------+

这就是 Go 语言并发的核心模式

  • 使用 go 关键字启动并发任务
  • 使用 channel 在任务间安全地传递数据实现同步