学习目标
读完本文,你应该能够:
- 徒手写出两个 channel 交替打印奇偶数的完整代码
- 实现有界队列(Bounded Queue)并理解超时获取的实现机制
- 掌握 Fan-in / Fan-out 两种经典 channel 模式的应用场景
- 手写 Semaphore(信号量)控制并发度,并解释其工作原理
- 实现素数筛(daisy-chain pipeline)的经典题目
- 深入理解 channel 底层结构(hchan)及 select 随机选择机制
- 能说清楚 nil channel、关闭已关闭 channel 的行为差异
一、两个 Channel 交替打印奇偶数
这是腾讯瑞驰面试真题,考察对 channel 同步机制的理解。
概念讲解
Channel 是 Go 中 goroutine 间通信的核心机制。它像一个管道,可以让数据在多个 goroutine 之间传递,同时保证同步和互斥。
在交替打印场景中,我们需要两个 channel 形成握手协议:
oddCh:通知偶数 goroutine “轮到你了”evenCh:通知奇数 goroutine “轮到你了”
这种设计避免了使用锁,利用了 channel 原子的 send/receive 特性来实现 synchronization。
应用场景
在项目中的典型应用:
- 生产消费模型的变种:两个消费者交替处理数据
- 状态机驱动:不同状态间通过 channel 触发转换
- 协程协作:父协程等待子协程完成后继续执行
// 实际项目案例:任务分发器
// 主协程收到请求后,分发到多个 worker 处理
// 这里可以用 channel 实现请求队列和结果收集
题目要求
用两个 channel 交替打印 1, 2, 3, 4… 直到 N。一个 goroutine 打印奇数,另一个打印偶数。
思路解析
一个 channel 只能解决"谁先发"的问题,但无法保证"交替"。 两个 channel 形成握手协议:
- oddCh 让奇数 goroutine 通知偶数 goroutine"轮到你了"
- evenCh 让偶数 goroutine 通知奇数 goroutine"轮到你了" 这就形成了轮流切换的同步机制。
交替打印:双 channel 握手协议流程
看图要点:面试常追问「为什么需要两个 channel 而不是一个」。核心是握手协议形成闭环——奇数 G 打印后通过
evenCh唤醒偶数 G,偶数 G 打印后通过oddCh唤醒奇数 G,双方轮流在<-oddCh/<-evenCh上阻塞等待。若只用一个 channel,无法区分「该谁打印」,会丢失交替语义。还要注意启动必须由 Main 先发oddCh信号,否则奇数 G 永久阻塞。
代码实现
package main
import (
"fmt"
"sync"
)
// 交替打印奇偶数
func printOddEven(n int) {
oddCh := make(chan struct{})
evenCh := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
// 奇数打印器
go func() {
defer wg.Done()
for i := 1; i <= n; i += 2 {
fmt.Printf("%d ", i)
<-oddCh // 等待自己的信号
if i < n {
evenCh <- struct{}{} // 通知偶数 goroutine
}
}
}()
// 偶数打印器
go func() {
defer wg.Done()
for i := 2; i <= n; i += 2 {
<-evenCh // 等待自己的信号
fmt.Printf("%d ", i)
if i < n {
oddCh <- struct{}{} // 通知奇数 goroutine
}
}
}()
// 启动:先让奇数 goroutine 打印第一个数字
oddCh <- struct{}{}
wg.Wait()
fmt.Println()
}
func main() {
printOddEven(10) // 输出: 1 2 3 4 5 6 7 8 9 10
}
关键点分析
oddCh <- struct{}{} 发送第一个信号,否则奇数 goroutine 会永久阻塞在 <-oddCh 上。二、有界队列(Bounded Queue)
概念讲解
有界队列是一种固定容量的缓冲区,用于在生产者和消费者之间解耦。当队列满时,生产者阻塞等待;当队列空时,消费者阻塞等待。
这是理解背压(Backpressure)机制的基础——防止快速生产者的数据淹没慢速消费者。
应用场景
- 限流系统:限制并发处理的请求数
- 批量任务:控制内存中待处理任务的数量
- 流量削峰:平滑突发流量,保护下游系统
// 项目案例:消息队列消费者
// 从 Kafka 消费消息时,限制并发处理数,避免数据库连接池耗尽
type MessageHandler struct {
queue chan Message
sem semaphore // 信号量控制并发
}
题目要求
实现一个支持超时获取的有界队列,包含以下操作:
Put(item):添加元素,队列满时阻塞Get(timeout time.Duration) (item, ok):获取元素,超时返回 false
思路解析
一个 channel 可以存储数据,但无法优雅地实现"满时阻塞"和"空时阻塞"。
使用两个 channel 配合一个固定大小的数组:
items:实际存储数据的 channelemptySlots:容量为 N 的空闲槽位 channel,控制最大并发写入
这样 Put 时先取一个空槽位,Get 时先取一个数据,天然实现了有界缓冲的效果。
代码实现
package main
import (
"fmt"
"sync"
"time"
)
// BoundedQueue 有界队列
type BoundedQueue struct {
items chan interface{}
empty chan struct{}
mu sync.Mutex
size int
}
// NewBoundedQueue 创建有界队列
func NewBoundedQueue(size int) *BoundedQueue {
bq := &BoundedQueue{
items: make(chan interface{}, size),
empty: make(chan struct{}, size),
size: size,
}
// 初始化空槽位
for i := 0; i < size; i++ {
bq.empty <- struct{}{}
}
return bq
}
// Put 添加元素,队列满时阻塞
func (bq *BoundedQueue) Put(item interface{}) error {
select {
case <-bq.empty:
// 有空槽位,直接放入
bq.items <- item
return nil
case <-time.After(time.Second * 5):
return fmt.Errorf("timeout waiting for space")
}
}
// Get 获取元素,超时返回 false
func (bq *BoundedQueue) Get(timeout time.Duration) (interface{}, bool) {
select {
case item := <-bq.items:
bq.empty <- struct{}{} // 归还空槽位
return item, true
case <-time.After(timeout):
return nil, false
}
}
// Len 当前队列长度
func (bq *BoundedQueue) Len() int {
select {
case item := <-bq.items:
// 临时取出再放回去
bq.items <- item
return len(bq.items)
default:
return len(bq.items)
}
}
func main() {
bq := NewBoundedQueue(3)
// 生产者
go func() {
for i := 0; i < 5; i++ {
bq.Put(i)
fmt.Printf("Put %d\n", i)
}
}()
// 消费者
go func() {
for i := 0; i < 5; i++ {
item, ok := bq.Get(time.Second * 2)
if ok {
fmt.Printf("Get %v\n", item)
} else {
fmt.Println("Timeout waiting for item")
}
}
}()
time.Sleep(time.Second * 10)
}
与 channel 的区别
三、Fan-in / Fan-out 模式
概念讲解
Fan-out(扇出):多个消费者从同一个 channel 读取数据,提高吞吐量。
Fan-in(扇入):多个 channel 合并为一个 channel,简化后续处理。
这两种模式是构建Pipeline(管道)的基础,广泛应用于数据处理、任务分发等场景。
应用场景
在项目中的典型应用:
- Fan-out:批量任务分发。比如对 1000 个 URL 做爬虫,启动 10 个 worker 并发抓取。
- Fan-in:多路聚合。比如从 3 个数据库查询不同 shard 的数据,合并后统一处理。
// 项目案例:批量订单处理
// 1. Fan-out:将订单列表分发给 N 个 worker 并发处理
// 2. Worker 内部:每个 worker 再 fan-out 给多个子任务
// 3. Fan-in:收集所有 worker 的结果,生成汇总报告
3.1 Fan-out(扇出)
多个消费者从同一个 channel 读取数据,提高吞吐量。
// Fan-out: 从单一 channel 读取数据分发给多个消费者
func fanOut(data []int, workers int) []<-chan int {
channels := make([]chan int, workers)
for i := 0; i < workers; i++ {
channels[i] = make(chan int)
}
// 启动 worker
for i := 0; i < workers; i++ {
go func(ch <-chan int, idx int) {
for v := range ch {
fmt.Printf("Worker %d got: %d\n", idx, v)
}
}(channels[i], i)
}
// 分发数据
go func() {
for _, v := range data {
// 轮询分发给不同的 worker
for i := 0; i < workers; i++ {
channels[i] <- v
break
}
}
// 关闭所有 channel
for _, ch := range channels {
close(ch)
}
}()
return channels
}
3.2 Fan-in(扇入)
将多个 channel 合并为一个 channel。
// Fan-in: 将多个 channel 合并为一个
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, ch := range channels {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
综合示例
- Fan-out:批量任务分发。比如对 1000 个 URL 做爬虫,启动 10 个 worker 并发抓取。
- Fan-in:多路聚合。比如从 3 个数据库查询不同 shard 的数据,合并后统一处理。
两者结合就是经典的Pipeline 模式:数据经过多个阶段,每阶段内部 Fan-out 并行,阶段之间 Fan-in 汇聚。
四、Semaphore(信号量)
概念讲解
信号量用于控制最大并发数,是限流的核心工具。它维护一个计数器,每次 Acquire 减 1,Release 加 1。当计数为 0 时,Acquire 会阻塞等待。
应用场景
- 数据库连接池:限制同时打开的数据库连接数
- API 调用限流:控制并发调用第三方 API 的次数
- 缓存预热:限制同时写缓存的请求数
代码实现
package main
import (
"fmt"
"sync"
"time"
)
// Semaphore 基于 channel 实现
type Semaphore struct {
sem chan struct{}
}
func NewSemaphore(limit int) *Semaphore {
return &Semaphore{
sem: make(chan struct{}, limit),
}
}
func (s *Semaphore) Acquire() {
s.sem <- struct{}{}
}
func (s *Semaphore) Release() {
<-s.sem
}
func main() {
sem := NewSemaphore(3) // 最多3个并发
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sem.Acquire()
defer sem.Release()
fmt.Printf("Worker %d started\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d finished\n", id)
}(i)
}
wg.Wait()
}
与 Mutex 的区别
五、素数筛(Daisy-chain Pipeline)
概念讲解
素数筛是一种经典的 channel pipeline 实现。每个过滤器都是一个 goroutine,通过 channel 连接形成链式结构。
代码实现
package main
import (
"fmt"
)
// Generate 产生自然数序列 2, 3, 4, ...
func Generate(ch chan<- int) {
for i := 2; ; i++ {
ch <- i
}
}
// Sieve 筛掉能被 prime 整除的数
func Sieve(in <-chan int, prime int, out chan<- int) {
for {
if v := <-in; v%prime != 0 {
out <- v
}
}
}
func main() {
ch := make(chan int)
go Generate(ch) // 启动生成器
for i := 0; i < 10; i++ {
prime := <-ch
fmt.Printf("Prime: %d\n", prime)
ch1 := make(chan int)
go Sieve(ch, prime, ch1) // 链式连接
ch = ch1
}
}
六、超时控制
概念讲解
实现带超时的操作,是分布式系统中的常见需求。有两种经典实现方式:
- 独立 goroutine + timer channel
- select 语句 + time.After
代码实现
package main
import (
"fmt"
"time"
)
// Timeout 带超时的函数执行
func Timeout(d time.Duration, fn func()) bool {
done := make(chan struct{})
go func() {
fn()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// SelectWithTimeout 带超时的 channel 读取
func SelectWithTimeout(ch <-chan int, timeout time.Duration) (int, bool) {
select {
case v := <-ch:
return v, true
case <-time.After(timeout):
return 0, false
}
}
func main() {
// 测试 1:正常完成
result := Timeout(time.Second, func() {
time.Sleep(time.Millisecond * 500)
fmt.Println("Operation completed")
})
fmt.Printf("Result: %v\n", result) // true
// 测试 2:超时
result = Timeout(time.Millisecond*100, func() {
time.Sleep(time.Second)
fmt.Println("This won't print")
})
fmt.Printf("Result: %v\n", result) // false
}
七、Channel 底层结构
hchan 结构体
type hchan struct {
qcount uint // 队列中元素总数
dataqsiz uint // 环形缓冲区大小
buf unsafe.Pointer // 环形缓冲区
elemsize uint16 // 元素大小
closed uint32 // 是否已关闭
elemtype *_type // 元素类型
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 等待接收的 G 队列
sendq waitq // 等待发送的 G 队列
lock mutex // 保护上述所有字段
}
channel 底层结构:hchan 与环形缓冲区
看图要点:面试必问「send / recv 在 channel 满或空时怎么走」。关键点:有缓冲时数据先落
buf环形数组(sendx/recvx是读写指针,到尾回绕);缓冲满则当前 G 打包成 sudog 挂到sendq并阻塞,缓冲空则挂到recvq。所有字段由一把lock保护。无缓冲 channel 的 buf 为 nil,send 必须直接把数据拷贝给等待的 recv(或反之),因此收发必须同时就绪。
无缓冲 vs 有缓冲
无缓冲 channel:send 和 receive 必须同时准备好,否则会阻塞。
有缓冲 channel:只要缓冲区没满,send 就不会阻塞;只要缓冲区没空,receive 就不会阻塞。
内存布局:
- 无缓冲:buf 指针为 nil,数据直接在 send/receive 之间拷贝
- 有缓冲:buf 指向环形数组,send 写入数组,receive 从数组读取
八、select 随机选择机制
实现原理
当多个 channel 都准备好时,Go 使用随机选择来保证公平性:
// 伪代码:select 的实现逻辑
func sigselect(ncase int, PCSlice, cases *hchan) (sel int, gotOK bool) {
// 1. 找出所有可执行的 case
var selectedCases [maxSelCase]*sudog
var shuffledIndices [maxSelCase]int
var randomIndex int
for i := 0; i < ncase; i++ {
c := cases[i]
if c == nil {
continue
}
// 检查是否可以发送或接收
if canSend(c.ch) || canRecv(c.ch) {
selectedCases[randomIndex] = c
shuffledIndices[randomIndex] = i
randomIndex++
}
}
// 2. 随机选择一个候选 case
if randomIndex > 0 {
idx := fastrand() % uint32(randomIndex)
return shuffledIndices[idx], true
}
// 3. 没有候选:如果有 default,执行 default
for i := 0; i < ncase; i++ {
if cases[i].dir == _SELECTdefault {
return i, false
}
}
// 4. 没有 default:挂起当前 goroutine
gopark(...)
return 0, false
}
九、nil channel 的行为
行为对比
| 操作 | nil channel | 已关闭的 channel |
|---|---|---|
| send | 永久阻塞 | panic: send on closed channel |
| receive | 永久阻塞 | 返回零值,ok=false |
| close | panic: close of nil channel | panic: close of closed channel |
应用技巧
典型用法:
select { case <-disabledCh: ... } 其中 disabledCh 为 nil。十、本章小结
复习提示:
- Channel 是 Go 并发的核心,理解其底层结构有助于写出高性能代码
- Fan-in/Fan-out 是构建 Pipeline 的基础模式,值得熟练掌握
- Semaphore 基于 channel 实现,比 Mutex 更适合控制并发度
- nil channel 和关闭的 channel 行为不同,使用时要区分
- select 的随机选择保证了公平性,但要避免忙等待导致的 CPU 浪费
参考
- Go 源码:
src/runtime/chan.go - 《Go 并发编程实战》第 3 章:Channel 原理
- 腾讯瑞驰面试经验贴(牛客网)