Go Channel 手撕题详解(西安市场高频考点)

2022-02-11T10:00:00+08:00 | 11分钟阅读 | 更新于 2025-08-16T10:00:00+08:00

@
Channel 手撕题基础操作高级模式原理追问奇偶打印BoundedQueueFan-inFan-outhchan结构select原理Semaphore素数筛nil channeltimeout函数reflect.Select关闭已关闭西安高频!腾讯瑞驰、字节面试常考

学习目标

读完本文,你应该能够:

  • 徒手写出两个 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。

应用场景

在项目中的典型应用:

  1. 生产消费模型的变种:两个消费者交替处理数据
  2. 状态机驱动:不同状态间通过 channel 触发转换
  3. 协程协作:父协程等待子协程完成后继续执行
// 实际项目案例:任务分发器
// 主协程收到请求后,分发到多个 worker 处理
// 这里可以用 channel 实现请求队列和结果收集

题目要求

用两个 channel 交替打印 1, 2, 3, 4… 直到 N。一个 goroutine 打印奇数,另一个打印偶数。

思路解析

面试官
为什么需要用两个 channel?用一个不行吗?
候选人

一个 channel 只能解决"谁先发"的问题,但无法保证"交替"。 两个 channel 形成握手协议:

  • oddCh 让奇数 goroutine 通知偶数 goroutine"轮到你了"
  • evenCh 让偶数 goroutine 通知奇数 goroutine"轮到你了" 这就形成了轮流切换的同步机制。

交替打印:双 channel 握手协议流程

Main:oddCh<-{ } 启动
<rect x="70" y="150" width="250" height="84" rx="10"/>
<text x="195" y="178" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="700" stroke="none">奇数 Goroutine</text>
<text x="195" y="202" text-anchor="middle" fill="#666" font-size="11.5" stroke="none">打印奇数 → 等待 oddCh</text>
<rect x="680" y="150" width="250" height="84" rx="10"/>
<text x="805" y="178" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="700" stroke="none">偶数 Goroutine</text>
<text x="805" y="202" text-anchor="middle" fill="#666" font-size="11.5" stroke="none">等待 evenCh → 打印偶数</text>
<rect x="330" y="50" width="150" height="46" rx="8" stroke-dasharray="7 4"/>
<text x="405" y="78" text-anchor="middle" fill="#1a1a1a" font-size="13" stroke="none">oddCh</text>
<rect x="520" y="50" width="150" height="46" rx="8" stroke-dasharray="7 4"/>
<text x="595" y="78" text-anchor="middle" fill="#1a1a1a" font-size="13" stroke="none">evenCh</text>
② 唤醒奇数③ evenCh<-{} 通知④ 唤醒偶数⑤ oddCh<-{} 回环① 初始启动信号阻塞点:<-oddCh阻塞点:<-evenCh

看图要点:面试常追问「为什么需要两个 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
}

关键点分析

复习提示:启动顺序问题:必须先启动奇数 goroutine,并通过 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 + Mutex?
候选人

一个 channel 可以存储数据,但无法优雅地实现"满时阻塞"和"空时阻塞"。

使用两个 channel 配合一个固定大小的数组:

  • items:实际存储数据的 channel
  • emptySlots:容量为 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 的区别

复习提示:复习提示:channel 更适合生产者-消费者模型,但不支持按索引访问。 有界队列适合需要随机访问或批量操作的场景。

三、Fan-in / Fan-out 模式

概念讲解

Fan-out(扇出):多个消费者从同一个 channel 读取数据,提高吞吐量。

Fan-in(扇入):多个 channel 合并为一个 channel,简化后续处理。

这两种模式是构建Pipeline(管道)的基础,广泛应用于数据处理、任务分发等场景。

应用场景

在项目中的典型应用:

  1. Fan-out:批量任务分发。比如对 1000 个 URL 做爬虫,启动 10 个 worker 并发抓取。
  2. 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-in 和 Fan-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 的区别

复习提示:复习提示:Mutex 是互斥锁(一次只能一个),Semaphore 是计数信号量(可以允许多个)。

五、素数筛(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
	}
}
复习提示:daisy-chain 本质:每个阶段都是一个 goroutine,通过 channel 连接形成链式结构。 素数筛的特殊之处在于每发现一个新素数就创建一个新的过滤器。

六、超时控制

概念讲解

实现带超时的操作,是分布式系统中的常见需求。有两种经典实现方式:

  1. 独立 goroutine + timer channel
  2. 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() 发送ch <- v
<rect x="30" y="300" width="180" height="70" rx="10"/>
<text x="120" y="328" text-anchor="middle" fill="#1a1a1a" font-size="13.5" font-weight="700" stroke="none">recv() 接收</text>
<text x="120" y="350" text-anchor="middle" fill="#666" font-size="11" stroke="none">v := &lt;-ch</text>
hchan 结构体(有缓冲 channel)
<rect x="250" y="62" width="710" height="150" rx="10"/>
<text x="605" y="86" text-anchor="middle" fill="#1a1a1a" font-size="13.5" font-weight="700" stroke="none">buf 环形缓冲区(dataqsiz 个槽位)</text>
<rect x="250" y="232" width="340" height="105" rx="10"/>
<text x="420" y="258" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">sendq 等待发送 G 队列</text>
<text x="420" y="288" text-anchor="middle" fill="#666" font-size="11.5" stroke="none">sudog 链表:G1 → G2 → G3 …</text>
<text x="420" y="312" text-anchor="middle" fill="#666" font-size="11" stroke="none">(缓冲区满时挂入)</text>
<rect x="620" y="232" width="340" height="105" rx="10"/>
<text x="790" y="258" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">recvq 等待接收 G 队列</text>
<text x="790" y="288" text-anchor="middle" fill="#666" font-size="11.5" stroke="none">sudog 链表:G4 → G5 …</text>
<text x="790" y="312" text-anchor="middle" fill="#666" font-size="11" stroke="none">(缓冲区空时挂入)</text>
<rect x="250" y="357" width="710" height="80" rx="8"/>
<text x="605" y="388" text-anchor="middle" fill="#1a1a1a" font-size="13.5" font-weight="700" stroke="none">lock(mutex)</text>
<text x="605" y="412" text-anchor="middle" fill="#666" font-size="11.5" stroke="none">保护 qcount / buf / sendx / recvx / sendq / recvq 全部字段,保证并发安全</text>
elemelemelemelemelemelemrecvx 读指针sendx 写指针qcount:当前元素总数未满→写入 buf已满→挂入 sendq非空→读 buf已空→挂入 recvq

看图要点:面试必问「send / recv 在 channel 满或空时怎么走」。关键点:有缓冲时数据先落 buf 环形数组(sendx/recvx 是读写指针,到尾回绕);缓冲满则当前 G 打包成 sudog 挂到 sendq 并阻塞,缓冲空则挂到 recvq。所有字段由一把 lock 保护。无缓冲 channel 的 buf 为 nil,send 必须直接把数据拷贝给等待的 recv(或反之),因此收发必须同时就绪。

无缓冲 vs 有缓冲

面试官
无缓冲 channel 和有缓冲 channel 有什么区别?
候选人

无缓冲 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
closepanic: close of nil channelpanic: close of closed channel

应用技巧

复习提示:Nil channel 的用途:可以用来"禁用"某个 channel,比如在 select 中暂时忽略某个 case。

典型用法: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 原理
  • 腾讯瑞驰面试经验贴(牛客网)
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!