Files
notes/resource/go/Go 语言的 Channel.md
2026-03-01 01:43:46 +08:00

478 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 0. 序言:Go 的并发哲学
Go 语言在并发编程领域推崇一个核心哲学:
> “不要通过共享内存来通信,而应该通过通信来共享内存。”
>
> "Don't communicate by sharing memory, share memory by communicating."
这句话的含义是,相比于传统并发模型中使用锁(`sync.Mutex` 等)来保护共享数据(共享内存),Go 更推荐使用 **Channel** 来在 Goroutine 之间传递数据(通信),从而自然地避免数据竞争问题。
- **精炼说明**: 这并不是说锁在 Go 中是无用的。锁在保护状态(State)时非常重要,而 Channel 在传递数据和同步事件(Communication and Synchronization)时更具优势。选择哪种工具取决于具体场景。
# 1. Channel 基础
## 1.1 什么是 Channel
Channel(通道)是 Goroutine 之间的通信管道。您可以把它想象成一个传送带,一个 Goroutine 将数据放到传送带的一端,另一个 Goroutine 从另一端取走。这确保了数据的安全传递。
## 1.2 创建与基本操作
Channel 是一种引用类型,需要使用 `make` 函数来创建。
- **声明**: `var ch chan int`
- **创建**: `ch = make(chan int)` (创建一个类型为 `int` 的无缓冲通道)
- **发送 (存)**: `ch <- 10` (将值 `10` 发送到通道 `ch`)
- **接收 (取)**: `data := <-ch` (从通道 `ch` 接收值并赋给 `data`)
## 1.3 基础使用示例
**所有 Channel 的操作都必须发生在至少两个或以上的 Goroutine 中**,否则会因阻塞而导致死锁。`main` 函数本身运行在一个主 Goroutine 中。
下面的代码演示了主 Goroutine 如何等待一个子 Goroutine 完成任务后发出的“完成”信号。
```go
package main
import (
"fmt"
"time"
)
func main() {
// 1. 创建一个用于通信的 bool 类型通道
// make(chan bool) 创建的是一个无缓冲通道
done := make(chan bool)
// 2. 启动一个新的 goroutine 来执行耗时任务
go func() {
fmt.Println("子 goroutine 开始执行任务...")
// 模拟工作
for i := 0; i < 5; i++ {
fmt.Println("goroutine-", i)
time.Sleep(500 * time.Millisecond)
}
fmt.Println("子 goroutine 任务完成。")
// 3. 任务完成后,向通道发送一个信号
done <- true
}()
// 4. 主 goroutine 在这里阻塞,等待从 `done` 通道接收信号
// 一旦子 goroutine 向 `done` 中写入数据,这里就会解除阻塞
<-done
fmt.Println("主 goroutine 收到完成信号,程序退出。")
}
```
# 2. Channel 的核心特性:阻塞
默认情况下,Channel 的发送和接收操作都是 **阻塞** 的。这是一种强大的同步机制。
- **发送阻塞**: 当一个 Goroutine 向无缓冲 Channel 发送数据时,它会**阻塞**,直到另一个 Goroutine 从该 Channel 接收数据。
- **接收阻塞**: 当一个 Goroutine 尝试从一个空的 Channel 接收数据时,它会**阻塞**,直到另一个 Goroutine 向该 Channel 发送数据。
这种“成对”的同步特性意味着,每一次通过无缓冲 Channel 的通信,都发生在发送方和接收方都准备好的那一刻。
## WaitGroup` vs `Channel`
在等待多个 Goroutine 完成的场景下,除了用 Channel,`sync.WaitGroup` 也是常用工具。
- **`sync.WaitGroup`**: 主要用于等待一组 Goroutine **全部完成**。它是一个计数器,不关心 Goroutine 的执行结果,只关心“是否做完”。
- **`Channel`**: 不仅可以等待 Goroutine 完成,还可以**接收它返回的数据**。功能更强大,但用于纯粹的等待时,代码可能比 `WaitGroup` 稍显繁琐。
# 3. 常见陷阱:死锁 (Deadlock)
死锁是指所有 Goroutine 都在互相等待,但谁也无法继续执行,导致程序永久挂起。这是使用 Channel 时最需要警惕的问题。
## 3.1 什么是死锁?
当一个 Goroutine 对一个 Channel 进行操作(发送或接收),但**永远没有**其他 Goroutine 来与之配对(接收或发送),就会发生死lock。
## 3.2 死锁的常见场景
**场景一:在单 Goroutine 中同时读写**
```go
package main
// fatal error: all goroutines are asleep - deadlock!
func main() {
// 创建一个无缓冲通道
ch := make(chan int)
// 错误!main goroutine 在这里发送数据,但被立即阻塞。
// 因为没有其他 goroutine 来接收数据,所以这个阻塞永远无法解除。
ch <- 10 // 程序在此处死锁
// 这行代码永远不会执行
// <-ch
}
```
**场景二:缓冲通道写入超出容量**
```go
package main
import "fmt"
// fatal error: all goroutines are asleep - deadlock!
func main() {
// 创建一个容量为 2 的缓冲通道
ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println("已成功发送 2 个数据到缓冲通道")
// 错误!缓冲区已满(容量为 2),main goroutine 尝试再次发送。
// 由于没有其他 goroutine 从通道中取出数据,发送操作将被阻塞,导致死锁。
ch <- 3 // 程序在此处死锁
fmt.Println("这行代码永远不会执行到")
}
```
**场景三:从空缓冲通道读取**
```go
package main
import "fmt"
// fatal error: all goroutines are asleep - deadlock!
func main() {
ch := make(chan int, 2)
ch <- 1
x := <-ch // 正常,取出数据 1
fmt.Printf("成功取出数据: %d\n", x)
// 错误!通道中已无数据,main goroutine 尝试再次读取。
// 由于没有其他 goroutine 往通道中放入数据,读取操作将被阻塞,导致死锁。
x = <-ch // 程序在此处死锁
fmt.Println("这行代码永远不会执行到")
}
```
---
# 4. 关闭 Channel
## 4.1 为何及如何关闭
`close(ch)` 用于关闭一个 Channel,它向接收方发出一个明确信号:**“通道中不会再有新的数据发送过来了”**。
- **关闭原则:**
1. **由发送方关闭**:永远不要让接收方关闭 Channel,因为接收方无法知道发送方是否还会发送数据。让发送方在确定所有数据都已发送完毕后关闭 Channel,是最安全的实践。
2. 关闭已关闭的 Channel 会引发 `panic`
3. 向已关闭的 Channel 发送数据会引发 `panic`
4. 从已关闭的 Channel 接收数据不会阻塞。会立即返回该 Channel 类型的零值。
## 4.2 安全地接收数据
当接收方不确定 Channel 是否关闭时,可以使用多重返回值来判断。
`data, ok := <-ch`
- `ok``true`:表示成功从 Channel 中读取到数据 `data`
- `ok``false`:表示 Channel 已被关闭,且其中已无数据可读。此时 `data` 是该 Channel 类型的零值(如 `int``0``string``""`)。
```go
package main
import (
"fmt"
"time"
)
// producer 函数负责生产数据并发送到通道,完成后关闭通道
func producer(ch chan int) {
for i := 0; i < 5; i++ {
fmt.Printf("发送数据: %d\n", i)
ch <- i
time.Sleep(200 * time.Millisecond)
}
fmt.Println("所有数据已发送完毕,关闭通道。")
close(ch) // 发送方在发送完所有数据后关闭通道
}
func main() {
ch1 := make(chan int)
go producer(ch1)
// 使用 for 循环和 ok 判断来消费数据
for {
// ok 模式可以安全地判断通道是否已关闭
data, ok := <-ch1
if !ok {
fmt.Println("通道已关闭,读取结束。")
break // 退出循环
}
fmt.Printf("接收到数据: %d\n", data)
}
}
```
## 4.3 使用 `for range` 遍历 Channel
使用 `for range` 循环可以更优雅地从 Channel 中读取数据。该循环会自动监听 Channel,直到 Channel 被关闭并取完所有值后,循环会自动结束。
```go
package main
import (
"fmt"
"time"
)
// test7 函数(保持原名)生产数据并发送,完成后关闭通道
func test7(ch chan int) {
for i := 0; i < 10; i++ {
ch <- i
}
// 发送方负责关闭
close(ch)
}
func main() {
ch1 := make(chan int)
go test7(ch1)
// for range 会自动处理通道的关闭状态。
// 当通道 ch1 被关闭且缓冲区为空时,循环会自动退出。
// 这比使用 `data, ok := <-ch1` 的无限循环更简洁。
fmt.Println("开始使用 for range 接收数据...")
for data := range ch1 {
fmt.Printf("接收到: %d\n", data)
time.Sleep(200 * time.Millisecond) // 模拟消费耗时
}
fmt.Println("接收循环结束,主程序退出。")
}
```
# 5. 缓冲 Channel (Buffered Channel)
## 5.1 定义与特性
在创建 Channel 时,可以指定一个容量,这就是缓冲 Channel。
`ch := make(chan int, 3)` // 创建一个容量为 3 的 int 类型缓冲通道
- **发送**: 向缓冲 Channel 发送数据时,只有在**缓冲区已满**的情况下才会阻塞。
- **接收**: 从缓冲 Channel 接收数据时,只有在**缓冲区为空**的情况下才会阻塞。
缓冲 Channel 是一种**异步**通信方式,可以减少 Goroutine 间因同步阻塞而导致的等待,有助于提升性能,尤其是在生产者和消费者速度不匹配的场景下。
## 5.2 正确使用示例
```go
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个容量为 3 的缓冲通道
// 这意味着发送方可以连续发送 3 个值而不会被阻塞
ch := make(chan int, 3)
go func() {
defer close(ch) // 确保 goroutine 退出时关闭通道
for i := 1; i <= 5; i++ {
fmt.Printf("发送方: 准备发送 %d\n", i)
ch <- i
fmt.Printf("发送方: 已发送 %d\n", i)
}
fmt.Println("发送方: 所有数据发送完毕。")
}()
// 主 goroutine (接收方) 稍等一下,让发送方先填满缓冲区
fmt.Println("接收方: 等待 2 秒...")
time.Sleep(2 * time.Second)
fmt.Println("接收方: 开始接收数据...")
// 使用 for range 消费通道中的所有数据
for val := range ch {
fmt.Printf("接收方: 收到 %d\n", val)
// 模拟消费数据的耗时
time.Sleep(500 * time.Millisecond)
}
fmt.Println("接收方: 通道已关闭,所有数据接收完毕。")
}
```
---
# 6. 高级用法
## 6.1 定向 Channel (Directional Channel)
为了增强代码的类型安全和可读性,可以将 Channel 限制为**只发送**或**只接收**。这在函数签名中特别有用,可以明确规定函数对 Channel 的操作权限。
- **只发送通道**: `chan<- int`
- **只接收通道**: `<-chan int`
```go
package main
import (
"fmt"
"time"
)
// writeOnly 函数被限制为只能向通道中写入数据
// 参数类型 `chan<- int` 明确了其职责,防止误操作
func writeOnly(ch chan<- int) {
fmt.Println("开始写入数据...")
for i := 0; i < 3; i++ {
val := (i + 1) * 100
ch <- val
fmt.Printf("写入: %d\n", val)
}
close(ch) // 写入者负责关闭
}
// readOnly 函数被限制为只能从通道中读取数据
// 参数类型 `<-chan int` 保证了函数内部不会污染通道
func readOnly(ch <-chan int) {
fmt.Println("开始读取数据...")
for data := range ch {
fmt.Printf("读取到: %d\n", data)
}
fmt.Println("读取完毕。")
}
func main() {
// 创建一个双向通道
ch1 := make(chan int)
// 当 ch1 传递给函数时,会自动转换为指定的单向类型
go writeOnly(ch1)
go readOnly(ch1)
// 等待两个 goroutine 执行完毕
// 这里的等待逻辑可以更健壮,例如使用 WaitGroup
time.Sleep(1 * time.Second)
fmt.Println("程序执行完毕。")
}
```
## 6.2 `select` 语句
`select` 语句让一个 Goroutine 可以**同时等待多个 Channel 操作**,其行为类似于 `switch` 语句,但每个 `case` 必须是一个 Channel 操作。
- **规则 1**: `select` 会阻塞,直到其中一个 `case` 的 Channel 操作就绪(即可读或可写)。
- **规则 2**: 如果有多个 `case` 同时就绪,`select` 会**随机选择一个**执行。
- **规则 3**: default` 子句:如果 `select` 中包含 `default`,它会使 `select` 变为**非阻塞**的。如果没有任何 `case` 就绪,`default` 会被立即执行。
```go
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// Goroutine 1: 2秒后向 ch1 发送消息
go func() {
time.Sleep(2 * time.Second)
ch1 <- "来自 ch1 的消息"
}()
// Goroutine 2: 1秒后向 ch2 发送消息
go func() {
time.Sleep(1 * time.Second)
ch2 <- "来自 ch2 的消息"
}()
fmt.Println("等待消息...")
// select 会等待 ch1 或 ch2 中任意一个先准备好
// 在本例中,ch2 会在 1 秒后先就绪
select {
case msg1 := <-ch1:
fmt.Println("收到了:", msg1)
case msg2 := <-ch2:
fmt.Println("收到了:", msg2) // 这条 case 会被执行
}
fmt.Println("第一次 select 结束。")
// [补充示例] 带 default 的非阻塞 select
// 此时 ch1 和 ch2 都为空,也没有 goroutine 会向它们发送数据
select {
case msg1 := <-ch1:
fmt.Println("第二次收到了:", msg1)
case msg2 := <-ch2:
fmt.Println("第二次收到了:", msg2)
default:
// 因为 ch1 和 ch2 都不可读,所以会立即执行 default
fmt.Println("没有任何消息可以立即接收。")
}
}
```
# 7. 应用场景:定时器
`time` 包中的定时器功能是 Channel 的一个极佳应用范例。
- **`time.NewTimer`**: 创建一个定时器对象,在指定时间后,会将当前时间发送到其内部的 `C` Channel 中。可以调用 `Stop()` 来取消。
- **`time.After`**: 一个更简洁的用法,返回一个 Channel。在指定时间后,该 Channel 会接收到一个时间值。它像一个一次性的闹钟。
- **`time.AfterFunc`**: 在指定时间后,直接执行一个函数,不使用 Channel。
```go
package main
import (
"fmt"
"time"
)
func sendEmail() {
fmt.Println("定时任务触发:正在发送邮件...")
}
func main() {
// 示例 1: 使用 time.After 等待一个事件
fmt.Println("程序启动时间:", time.Now().Format("15:04:05"))
fmt.Println("设置一个 2 秒后触发的事件...")
// time.After 返回一个 <-chan Time 类型的通道
// main goroutine 会阻塞在这里,直到 2 秒后通道接收到值
timerChan := time.After(2 * time.Second)
<-timerChan
fmt.Println("2 秒已到,事件触发!当前时间:", time.Now().Format("15:04:05"))
// 示例 2: 使用 time.AfterFunc 安排一个未来的回调函数
// 这是一种 "fire and forget" 的方式,不会阻塞当前 goroutine
fmt.Println("\n设置一个 3 秒后执行的邮件发送任务...")
time.AfterFunc(3*time.Second, sendEmail)
// 主程序需要等待足够长的时间,以确保 AfterFunc 的任务有机会执行
// 在实际应用中,主程序通常有自己的主循环,不会这样简单地退出
fmt.Println("主程序继续执行其他任务...")
time.Sleep(4 * time.Second) // 等待超过 3 秒
fmt.Println("\n主程序退出。")
}
```
---
# 8. 拓展学习
- **Go 并发模型 (GPM)**: 要深入理解 Goroutine 是如何被 Go 运行时调度的,可以学习 GPMGoroutine, Processor, Machine)模型。您提供的链接是一个很好的起点:[GPM模型讲解](https://www.bilibili.com/video/BV1hv411x7we?p=16)