Skip to content

Channel 基础:无缓冲与有缓冲通道

channel 是 Go 并发编程的灵魂。如果说 goroutine 是 Go 并发的执行单元,那么 channel 就是它们之间通信的管道。本篇我们将系统学习 channel 的创建、收发、关闭、遍历、方向限制,并深入分析无缓冲与有缓冲通道的区别,以及常见的死锁场景。掌握 channel 是写出地道 Go 并发代码的关键。

一、Channel 的本质:并发安全的队列

从实现角度看,channel 就是一个并发安全的 FIFO 队列。goroutine 可以向其中发送数据,也可以从中接收数据,这些操作都是原子且线程安全的,不需要额外的锁来保护。

可以这样理解 channel:

  • 它是一个管道,一端发送,一端接收。
  • 它是线程安全的,多个 goroutine 同时收发不会出错。
  • 它是阻塞的(默认情况下),发送方在没有接收方时会阻塞,接收方在没有数据时也会阻塞——这种阻塞正是 goroutine 间同步的基础。
  • 它是有类型的,一个 chan int 只能传递 int,chan string 只能传递 string。

channel 实现了「不要通过共享内存来通信,而要通过通信来共享内存」这一哲学——数据在 goroutine 之间通过 channel 传递,同一时刻只有一个 goroutine 持有数据,天然避免了数据竞争。

二、创建 channel

channel 是引用类型,必须用 make 创建。有两种创建方式:

1. 无缓冲通道

go
ch := make(chan int) // 无缓冲通道

无缓冲通道没有容量,发送方必须等接收方就绪才能发送成功,反之亦然。它是一种同步通信机制。

2. 有缓冲通道

go
ch := make(chan int, 5) // 容量为 5 的有缓冲通道

有缓冲通道有一个固定大小的缓冲区。缓冲区未满时发送不会阻塞,缓冲区未空时接收不会阻塞。它是一种异步通信机制。

3. channel 的零值

未初始化的 channel 是 nil

go
package main

import "fmt"

func main() {
	var ch chan int // 零值是 nil
	fmt.Printf("ch = %v, 是否 nil: %v\n", ch, ch == nil)
	// 对 nil channel 的操作会永久阻塞(后面会详细讲)
}

输出:

ch = <nil>, 是否 nil: true

4. channel 的类型

channel 可以传递任何类型的值,包括基本类型、结构体、指针、甚至另一个 channel:

go
package main

import "fmt"

func main() {
	chInt := make(chan int)
	chStr := make(chan string)
	chChan := make(chan chan int) // 传递 channel 的 channel

	fmt.Printf("chan int 的类型: %T\n", chInt)
	fmt.Printf("chan string 的类型: %T\n", chStr)
	fmt.Printf("chan chan int 的类型: %T\n", chChan)
}

三、发送与接收

channel 的基本操作就两个:发送和接收。

1. 基本语法

go
ch <- value   // 发送 value 到 channel
v := <-ch     // 从 channel 接收,赋值给 v
<-ch          // 从 channel 接收,丢弃结果
v, ok := <-ch // 接收,ok 表示是否收到有效值(用于检测关闭)

箭头 <- 的方向就是数据流动的方向,形象地表示「数据流入/流出 channel」。

2. 简单示例

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		time.Sleep(500 * time.Millisecond)
		ch <- "hello from goroutine" // 发送
	}()

	msg := <-ch // 接收,会阻塞直到有数据
	fmt.Println("收到:", msg)
}

主 goroutine 在 <-ch 处阻塞,直到子 goroutine 发送数据后才继续执行。

3. 收发的阻塞行为

理解什么时候会阻塞是关键:

操作无缓冲 channel有缓冲 channel(未满/未空时)
发送 ch<-v阻塞,直到有接收方缓冲区未满时不阻塞,满时阻塞
接收 <-ch阻塞,直到有发送方缓冲区未空时不阻塞,空时阻塞

四、无缓冲通道:同步通信

无缓冲通道(make(chan T))是同步的:发送和接收必须同时就绪,就像一次「握手」。

1. 握手模型

想象两个人交接一份文件:发送方拿着文件等接收方,接收方来了之后,两人「交接」这一刻同时完成——发送方知道文件被接收了,接收方拿到了文件。在交接完成前,双方都在等待。

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		fmt.Println("发送方:准备发送数据...")
		ch <- "数据" // 阻塞,直到主 goroutine 接收
		fmt.Println("发送方:数据已被接收")
	}()

	time.Sleep(time.Second) // 故意延迟,让发送方先阻塞
	fmt.Println("接收方:准备接收数据...")
	msg := <-ch // 接收,发送方此时被唤醒
	fmt.Println("接收方:收到", msg)

	time.Sleep(time.Second) // 确保发送方的打印执行
}

输出:

发送方:准备发送数据...
接收方:准备接收数据...
发送方:数据已被接收
接收方:收到 数据

可以看到,「数据已被接收」是在主 goroutine 接收后才打印的——发送方的 ch <- 一直阻塞到接收发生。

2. 无缓冲通道用于同步

无缓冲通道常用于 goroutine 间的同步点,确保某个事件发生后另一个才继续:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	done := make(chan struct{}) // 用空结构体做信号

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("worker:工作中...")
		// 工作完成后,通知主 goroutine
		close(done) // 用 close 发送信号(接收方能感知到关闭)
	}()

	<-done // 阻塞,直到 done 被关闭
	fmt.Println("主 goroutine:收到完成信号")

	wg.Wait()
}

chan struct{} 是一种常见的「信号」channel,struct{} 不占内存,纯粹用于同步。

五、有缓冲通道:异步通信

有缓冲通道(make(chan T, n))有一个容量为 n 的缓冲区。只要缓冲区没满,发送就不会阻塞;只要缓冲区没空,接收就不会阻塞。它解耦了发送方和接收方的时序。

1. 基本示例

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int, 3) // 容量为 3

	// 发送 3 个数据,不会阻塞(缓冲区未满)
	ch <- 1
	ch <- 2
	ch <- 3
	fmt.Println("已发送 3 个数据到缓冲通道")

	// 第 4 个会阻塞,因为缓冲区满了
	// ch <- 4 // 这里会死锁(没有接收方)

	// 接收
	fmt.Println("接收:", <-ch)
	fmt.Println("接收:", <-ch)
	fmt.Println("接收:", <-ch)

	time.Sleep(time.Second)
}

2. 生产者-消费者模式

有缓冲通道非常适合解耦生产者和消费者:

go
package main

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

func producer(ch chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 1; i <= 5; i++ {
		ch <- i
		fmt.Printf("生产者:发送 %d\n", i)
		time.Sleep(100 * time.Millisecond)
	}
	close(ch) // 生产完毕,关闭通道
}

func consumer(ch <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for v := range ch { // 遍历直到通道关闭
		fmt.Printf("  消费者:接收 %d\n", v)
		time.Sleep(200 * time.Millisecond)
	}
}

func main() {
	ch := make(chan int, 3) // 缓冲区容量 3
	var wg sync.WaitGroup

	wg.Add(1)
	go producer(ch, &wg)

	wg.Add(1)
	go consumer(ch, &wg)

	wg.Wait()
	fmt.Println("全部完成")
}

缓冲区起到了「削峰填谷」的作用:生产者快时,数据先存在缓冲区;消费者忙时,从缓冲区取。

3. len 和 cap

可以用 len(ch) 查看缓冲区中当前元素数量,cap(ch) 查看缓冲区容量:

go
package main

import "fmt"

func main() {
	ch := make(chan int, 3)
	ch <- 1
	ch <- 2
	fmt.Printf("len=%d, cap=%d\n", len(ch), cap(ch)) // len=2, cap=3
	<-ch
	fmt.Printf("len=%d, cap=%d\n", len(ch), cap(ch)) // len=1, cap=3
}

注意:len 反映的是「当前」快照,在并发环境下它的值可能已经变化,不要用它做同步判断。

六、缓冲区大小选择策略

缓冲区大小选多少,没有万能答案,但有一些原则:

1. 容量为 0(无缓冲)

  • 适用场景:需要严格同步,发送方必须确认接收方收到。
  • 特点:发送和接收强耦合。

2. 容量为 1

  • 适用场景:简单的信号传递、结果返回。
  • 特点:相当于「一个槽位的信箱」。

3. 容量等于任务数

  • 适用场景:批量任务,想一次性投递完。
  • 特点:需要预知任务数,且内存能容纳。

4. 经验法则

  • 大多数情况用无缓冲或小缓冲(1~几个)。缓冲越大,越容易掩盖设计问题(比如生产者比消费者快太多,缓冲区只是延缓了阻塞)。
  • 不要用大缓冲试图解决性能问题。如果生产消费速度长期不匹配,应该增加消费者或限流生产者,而不是堆缓冲。
  • 缓冲区不能无限大。无界缓冲会导致内存暴涨。Go 的 channel 容量是固定的,这是好事。

一个常见的反模式:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	// ❌ 反模式:以为大缓冲能解决速度不匹配
	ch := make(chan int, 10000)
	go func() {
		for i := 0; ; i++ {
			ch <- i // 迟早会阻塞,只是推迟了问题
		}
	}()
	go func() {
		for v := range ch {
			time.Sleep(time.Second) // 消费太慢
			fmt.Println(v)
		}
	}()
	time.Sleep(3 * time.Second)
}

七、channel 的关闭

close(ch) 关闭一个 channel。关闭后:

  • 不能再向其发送数据(会 panic)。
  • 可以继续接收数据:缓冲区中剩余的数据可以正常接收,缓冲区空后再接收会立即返回零值。
  • close 应该由发送方调用,不应该由接收方调用。

1. 为什么要关闭

关闭 channel 主要有两个目的:

  1. 通知接收方「没有更多数据了」,这样 for range 遍历可以正常结束。
  2. 允许多个接收方都感知到结束信号(关闭的 channel 对所有接收方都可见)。

2. close 示例

go
package main

import "fmt"

func main() {
	ch := make(chan int, 5)

	// 发送方:发送完数据后关闭
	go func() {
		for i := 1; i <= 5; i++ {
			ch <- i
		}
		close(ch) // 关闭,通知接收方没有更多数据
	}()

	// 接收方:用 for range 遍历,通道关闭后自动退出
	for v := range ch {
		fmt.Println("收到:", v)
	}
	fmt.Println("通道已关闭,遍历结束")
}

3. 关闭的注意事项

  • 不能关闭已关闭的 channel,会 panic。
  • 不能向已关闭的 channel 发送数据,会 panic。
  • 关闭 nil channel 会 panic
  • 可以从已关闭的 channel 接收,缓冲区有数据就返回数据,没有就返回零值。
go
package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)

	// 缓冲区有数据,正常接收
	fmt.Println(<-ch) // 1
	fmt.Println(<-ch) // 2

	// 缓冲区空了,返回零值(不会阻塞)
	v := <-ch
	fmt.Println("关闭后接收:", v) // 0(int 的零值)

	// 重复关闭会 panic
	// close(ch) // panic: close of closed channel
}

八、从 channel 读取时检测关闭

用「逗号 ok」模式可以区分「接收到的零值是真实数据还是通道已关闭」:

go
value, ok := <-ch
  • oktrue:收到了有效数据。
  • okfalse:通道已关闭且缓冲区为空,value 是零值。
go
package main

import "fmt"

func main() {
	ch := make(chan int, 3)
	ch <- 1
	ch <- 2
	close(ch)

	for {
		v, ok := <-ch
		if !ok {
			fmt.Println("通道已关闭,退出")
			break
		}
		fmt.Println("收到:", v)
	}
}

这种模式在需要精细控制接收逻辑时很有用,而 for range 已经内置了这个检测,更简洁。

九、遍历 channel:for range

for v := range ch 是遍历 channel 的惯用方式,它会持续接收直到 channel 关闭:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		messages := []string{"你好", "世界", "Go", "并发"}
		for _, m := range messages {
			ch <- m
			time.Sleep(200 * time.Millisecond)
		}
		close(ch) // 必须 close,否则 for range 会一直阻塞
	}()

	// for range 会在 ch 关闭后自动退出
	for msg := range ch {
		fmt.Println("收到:", msg)
	}
	fmt.Println("遍历结束")
}

关键点:如果 channel 永远不关闭,for range 会永远阻塞。所以发送方在发送完毕后关闭 channel 是良好的实践。

十、channel 的方向限制

默认 channel 是双向的,但我们可以用类型系统限制为「只发送」或「只接收」,这在函数参数中很有用,能从类型层面防止误用。

  • chan<- T:只发送(send-only),只能往里写,不能读。
  • <-chan T:只接收(receive-only),只能从里读,不能写。

1. 用方向限制表达意图

go
package main

import "fmt"

// producer 只能发送
func producer(out chan<- int) {
	for i := 1; i <= 3; i++ {
		out <- i
	}
	close(out)
}

// consumer 只能接收
func consumer(in <-chan int) {
	for v := range in {
		fmt.Println("消费:", v)
	}
}

func main() {
	ch := make(chan int) // 双向 channel

	// 双向 channel 可以隐式转换为单向
	go producer(ch) // chan int -> chan<- int
	consumer(ch)    // chan int -> <-chan int
}

2. 方向限制的好处

  • 编译期检查:在 consumer 里写 ch <- 1 会编译报错,防止逻辑错误。
  • 自文档化:函数签名直接说明了它是生产者还是消费者。
  • 常见于接口设计:返回一个 <-chan T 让调用方只能读不能关。
go
package main

import (
	"fmt"
	"time"
)

// 返回只读 channel,调用方不能关闭或发送
func counter() <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for i := 1; i <= 5; i++ {
			ch <- i
			time.Sleep(100 * time.Millisecond)
		}
	}()
	return ch
}

func main() {
	c := counter()
	for v := range c {
		fmt.Println(v)
	}
	// close(c) // 编译错误:c 是只读的,不能 close
}

3. 单向转双向不可

注意:双向可以隐式转单向,但单向不能转回双向:

go
package main

func main() {
	ch := make(chan int)
	var sendOnly chan<- int = ch // ✅ 双向转单向
	var recvOnly <-chan int = ch // ✅ 双向转单向

	_ = sendOnly
	_ = recvOnly

	// var bi chan int = sendOnly // ❌ 编译错误:单向不能转双向
}

十一、常见死锁场景分析

死锁是 channel 编程中最容易遇到的问题。Go 运行时能检测到「所有 goroutine 都阻塞」的情况,会直接 panic 并打印堆栈。

1. 无缓冲通道无接收方

go
package main

func main() {
	ch := make(chan int)
	ch <- 1 // 死锁:没有接收方,主 goroutine 永久阻塞
}

运行会 panic:fatal error: all goroutines are asleep - deadlock!

2. 无缓冲通道无发送方

go
package main

func main() {
	ch := make(chan int)
	_ = <-ch // 死锁:没有发送方
}

3. 向已满的有缓冲通道发送

go
package main

func main() {
	ch := make(chan int, 1)
	ch <- 1
	ch <- 2 // 死锁:缓冲区满,且无接收方
}

4. 在同一个 goroutine 里收发(无缓冲)

go
package main

func main() {
	ch := make(chan int)
	ch <- 1 // 阻塞,等待接收方
	v := <-ch // 永远执行不到
	_ = v
}

发送阻塞了,根本到不了接收那一行。

5. 正确的写法:用 goroutine 解耦

go
package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 1 // 在另一个 goroutine 发送
	}()

	v := <-ch // 主 goroutine 接收
	fmt.Println("收到:", v)
}

只要有一个 goroutine 能推进,就不会死锁。

6. 用 select 避免死锁

后面章节会讲 select,这里先看一眼它如何避免死锁:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	// 用 select + default 做非阻塞发送
	select {
	case ch <- 1:
		fmt.Println("发送成功")
	default:
		fmt.Println("无接收方,跳过") // 不会死锁
	}

	// 用 select + time.After 做超时
	go func() {
		time.Sleep(2 * time.Second)
		ch <- 42
	}()

	select {
	case v := <-ch:
		fmt.Println("收到:", v)
	case <-time.After(time.Second):
		fmt.Println("超时") // 1 秒后超时,避免永久阻塞
	}
}

十二、小结

本篇我们系统学习了 channel 的基础,核心要点:

  1. 本质:channel 是并发安全的 FIFO 队列,实现了「通过通信来共享内存」的思想。

  2. 创建make(chan T) 无缓冲,make(chan T, n) 有缓冲。未初始化的 channel 是 nil。

  3. 收发ch <- v 发送,<-ch 接收。无缓冲通道收发同步(握手),有缓冲通道缓冲区未满/未空时异步。

  4. 无缓冲通道:强同步,发送方阻塞直到接收方就绪,反之亦然。常用于同步点、信号传递。chan struct{} 是常用的信号类型。

  5. 有缓冲通道:解耦生产消费时序,缓冲区起削峰填谷作用。len 看当前元素数,cap 看容量,但不要用于同步判断。

  6. 缓冲区大小:默认用无缓冲或小缓冲,不要用大缓冲掩盖设计问题,缓冲不能无界。

  7. 关闭close(ch) 由发送方调用,通知接收方「没有更多数据」。不能重复关闭、不能向已关闭 channel 发送、关闭 nil channel 会 panic。

  8. 检测关闭v, ok := <-chok 为 false 表示通道已关闭且缓冲区空。for range 内置了关闭检测。

  9. 方向限制chan<- T 只发送,<-chan T 只接收。双向可隐式转单向,反向不行。用于函数参数表达意图、编译期防错。

  10. 死锁:常见于无缓冲通道无对应收发方、向满缓冲发送、同 goroutine 收发。运行时检测到「全部 goroutine 阻塞」会 panic。用 goroutine 解耦或 select 避免死锁。

下一篇我们将学习 select 语句,它是处理多个 channel 的利器,能实现超时、非阻塞读写、多路复用等高级模式。