vault backup: 2026-06-07 12:14:39
This commit is contained in:
+142
-174
@@ -1,243 +1,211 @@
|
||||
---
|
||||
tags:
|
||||
- Go
|
||||
- golang
|
||||
- channel
|
||||
- 面试题
|
||||
- channel面试题
|
||||
tags: [go, golang, interview, channel-questions]
|
||||
create time: 2026-06-07 14:30
|
||||
---
|
||||
|
||||
# Channel面试题
|
||||
# Channel 面试题 📡
|
||||
|
||||
## 1. 什么是CSP?
|
||||
## 概述
|
||||
|
||||
CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点:
|
||||
本文件涵盖 Go Channel 的 11 道高频面试题,涉及 CSP 模型、底层原理、收发流程、select 机制等核心考点。Channel 是 Go 并发编程的灵魂,理解 Channel 是掌握 Go 并发的关键。
|
||||
|
||||
1. 避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信
|
||||
## 关联笔记
|
||||
|
||||
2. 天然同步:Channel 的发送/接收自带同步机制,无需手动加锁
|
||||
- [[hzh/GolangStar/Go语言进阶/Channel]] — Channel 详细讲解
|
||||
- [[hzh/GolangStar/Go语言进阶/Select]] — select 多路复用详解
|
||||
- [[hzh/GolangStar/Go语言进阶/Context]] — Context + Channel 结合使用
|
||||
- [[hzh/GolangStar/Go语言原理/channel原理]] — hchan 源码级分析
|
||||
- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — goroutine/channel 泄漏
|
||||
|
||||
3. 易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制)
|
||||
## 正文
|
||||
|
||||
## 2. Channel的底层实现原理是怎样的?
|
||||
### Q1:什么是 CSP 模型? 🟢简单
|
||||
|
||||
Channel的底层是一个名为`hchan`的结构体,核心包含几个关键组件:
|
||||
> [!question] ❓ 思考一下
|
||||
> "不要通过共享内存来通信,而要通过通信来共享内存"——这句话怎么理解?
|
||||
|
||||
**环形缓冲区:**有缓冲channel内部维护一个固定大小的环形队列,用`buf`指针指向缓冲区,`sendx`和`recvx`分别记录发送和接收的位置索引。这样设计能高效利用内存,避免数据搬移。
|
||||
## 参考答案
|
||||
|
||||
**两个等待队列`sendq和recvq`:**用来管理阻塞的goroutine。`sendq`存储因channel满而阻塞的发送者,`recvq`存储因channel空而阻塞的接收者。这些队列用双向链表实现,当条件满足时会唤醒对应的goroutine。
|
||||
CSP(Communicating Sequential Processes)并发编程模型的核心思想:**通过通信共享内存,而不是通过共享内存来通信。**
|
||||
|
||||
**互斥锁:**`hchan`内部有个mutex,所有的发送、接收操作都需要先获取锁,用来保证并发安全。虽然看起来可能影响性能,但Go的调度器做了优化,大多数情况下锁竞争并不激烈。
|
||||
| 特点 | 说明 |
|
||||
|------|------|
|
||||
| 避免共享内存 | Goroutine 不直接修改变量,而是通过 Channel 传递数据 |
|
||||
| 天然同步 | Channel 的 send/recv 自带同步,无需手动加锁 |
|
||||
| 易于组合 | Channel 可以嵌套构建复杂模式(管道、超时控制) |
|
||||
|
||||
分析:
|
||||
> [!tip] 💡 面试技巧
|
||||
> 对比 Java 的线程通信方式(wait/notify + synchronized),强调 Go 用 Channel 统一了"数据传递"和"同步"两个问题,设计更优雅。
|
||||
|
||||
hchan定义如下:
|
||||
---
|
||||
|
||||
### Q2:Channel 的底层实现原理? 🟡中等
|
||||
|
||||
## 参考答案
|
||||
|
||||
Channel 的底层是 `hchan` 结构体,包含三大组件:
|
||||
|
||||
```go
|
||||
type hchan struct {
|
||||
// chan 里元素数量
|
||||
qcount uint
|
||||
// chan 底层循环数组的长度
|
||||
dataqsiz uint
|
||||
// 指向底层循环数组的指针
|
||||
// 只针对有缓冲的 channel
|
||||
buf unsafe.Pointer
|
||||
// chan 中元素大小
|
||||
elemsize uint16
|
||||
// chan 是否被关闭的标志
|
||||
closed uint32
|
||||
// chan 中元素类型
|
||||
elemtype *_type // element type
|
||||
// 已发送元素在循环数组中的索引
|
||||
sendx uint // send index
|
||||
// 已接收元素在循环数组中的索引
|
||||
recvx uint // receive index
|
||||
// 等待接收的 goroutine 队列
|
||||
recvq waitq // list of recv waiters
|
||||
// 等待发送的 goroutine 队列
|
||||
sendq waitq // list of send waiters
|
||||
|
||||
// 保护 hchan 中所有字段
|
||||
lock mutex
|
||||
qcount uint // 队列中元素数量
|
||||
dataqsiz uint // 环形缓冲区大小
|
||||
buf unsafe.Pointer // 指向缓冲区的指针(仅 buffered channel)
|
||||
elemsize uint16 // 元素大小
|
||||
closed uint32 // 是否关闭
|
||||
elemtype *_type // 元素类型
|
||||
sendx uint // 发送索引
|
||||
recvx uint // 接收索引
|
||||
recvq waitq // 等待接收的 goroutine 队列
|
||||
sendq waitq // 等待发送的 goroutine 队列
|
||||
lock mutex // 保护所有字段
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
> [!note] 📝 核心考点
|
||||
> 三个关键点:**环形缓冲区**(高效利用内存)、**两个等待队列**(sendq/recvq,双向链表)、**互斥锁**(保证并发安全)。
|
||||
|
||||
## 3. 向channel发送数据的过程是怎样的?
|
||||
> [!info] 🔗 延伸阅读
|
||||
> - [[hzh/GolangStar/Go语言原理/channel原理]] — send/recv 全流程图解
|
||||
|
||||
向channel发送数据的整个过程都会在mutex保护下进行,保证并发安全。会经历几个关键步骤:
|
||||
---
|
||||
|
||||
1. **首先是检查是否有等待的接收者**。如果`recvq`队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。
|
||||
### Q3-Q4:Channel 的发送和读取过程? 🟡中等
|
||||
|
||||
2. **如果没有等待接收者,就尝试写入缓冲区**。检查缓冲区是否还有空间,如果`qcount < dataqsiz`,就把数据复制到`buf[sendx]`位置,然后更新`sendx`索引和`qcount`计数。这是无缓冲或缓冲区未满时的正常流径。
|
||||
## 参考答案
|
||||
|
||||
3. **当缓冲区满了就需要阻塞等待**。创建一个`sudog`结构体包装当前goroutine和要发送的数据,加入到`sendq`等待队列中,然后调用`gopark`让当前goroutine进入阻塞状态,让出CPU给其他goroutine。
|
||||
#### 发送数据流程(按优先级)
|
||||
|
||||
**被唤醒后继续执行**。当有接收者从channel读取数据后,会从`sendq`中唤醒一个等待的发送者,被唤醒的goroutine会完成数据发送并继续执行。
|
||||
```mermaid
|
||||
graph TD
|
||||
A[发送数据] --> B{recvq 有等待者?}
|
||||
B -->|是| C[直接传给等待者, 唤醒 receiver]
|
||||
B -->|否| D{缓冲区有空位?}
|
||||
D -->|是| E[写入 buf[sendx], 更新索引]
|
||||
D -->|否| F[创建 sudog 加入 sendq, gopark 阻塞]
|
||||
C --> G[继续执行]
|
||||
E --> G
|
||||
F --> H[被唤醒后继续]
|
||||
```
|
||||
|
||||
**还有个特殊情况是向已关闭的channel发送数据会直接panic**。这是Go语言的设计原则,防止向已关闭的通道写入数据。
|
||||
1. **最优路径**:recvq 有等待者 → 直接拷贝数据给 receiver,跳过缓冲区
|
||||
2. **正常路径**:缓冲区未满 → 写入 buf[sendx]
|
||||
3. **阻塞路径**:缓冲区已满 → 加入 sendq,gopark 阻塞等待
|
||||
|
||||
**分析:**
|
||||
#### 读取数据流程(按优先级)
|
||||
|
||||
1. **最优路径**:sendq 有等待者 → 从发送者直接接收数据
|
||||
2. **正常路径**:缓冲区有数据 → 从 buf[recvx] 取出
|
||||
3. **阻塞路径**:缓冲区为空 → 加入 recvq,gopark 阻塞
|
||||
|
||||
> [!warning] ⚠️ 高频陷阱
|
||||
> 向已关闭的 Channel 发送数据会 panic;从已关闭的 Channel 读取会返回零值和 false。
|
||||
|
||||
---
|
||||
|
||||
### Q5:从已关闭的 Channel 还能读出数据吗? 🟢简单
|
||||
|
||||
## 参考答案
|
||||
|
||||
**能!** 只要缓冲区还有数据,就能继续读到有效值。只有当 ok == false 时,读出的数据才是无效的。
|
||||
|
||||
```go
|
||||
package main
|
||||
ch := make(chan int, 5)
|
||||
ch <- 18
|
||||
close(ch)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func goroutineA(a <-chan int) {
|
||||
val := <-a
|
||||
fmt.Println("goroutine A received data: ", val)
|
||||
return
|
||||
}
|
||||
|
||||
func goroutineB(b <-chan int) {
|
||||
val := <-b
|
||||
fmt.Println("goroutine B received data: ", val)
|
||||
return
|
||||
}
|
||||
|
||||
func main() {
|
||||
ch := make(chan int)
|
||||
go goroutineA(ch)
|
||||
go goroutineB(ch)
|
||||
ch <- 3
|
||||
time.Sleep(time.Second)
|
||||
|
||||
ch1 := make(chan struct{})
|
||||
}
|
||||
x, ok := <-ch // x=18, ok=true (还能读到)
|
||||
x, ok = <-ch // x=0, ok=false (通道空了)
|
||||
```
|
||||
|
||||
在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。
|
||||
> [!tip] 💡 面试技巧
|
||||
> "range ch" 会自动在 ok==false 时退出循环,这是遍历 Channel 的标准写法。
|
||||
|
||||
sender 发现 ch 的 recvq 里有 receiver 在等待着接收,就会出队一个 sudog,把 recvq 里 first 指针的 sudo “推举”出来了,并将其加入到 P 的可运行 goroutine 队列中。然后,sender 把发送元素拷贝到 sudog 的 elem 地址处,最后会调用 goready 将 G1 唤醒,状态变为 runnable。
|
||||
---
|
||||
|
||||

|
||||
### Q6:Channel 什么情况下引起内存泄漏? 🟡中等
|
||||
|
||||
当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine。
|
||||
## 参考答案
|
||||
|
||||
这里其实涉及到一个协程写另一个协程栈的操作。有两个 receiver 在 channel 的一边虎视眈眈地等着,这时 channel 另一边来了一个 sender 准备向 channel 发送数据,为了高效,用不着通过 channel 的 buf “中转”一次,直接从源地址把数据 copy 到目的地址就可以了,效率高啊!
|
||||
最常见的泄漏场景:**goroutine 永久阻塞**。
|
||||
|
||||

|
||||
| 泄漏原因 | 示例 |
|
||||
|---------|------|
|
||||
| 发送者退出,接收者永远等待 | producer 结束但没关 channel |
|
||||
| select 无 default 分支 | 所有 case 都无法执行,goroutine 永远阻塞 |
|
||||
| 未关闭的 channel | consumer 持有引用无法 GC |
|
||||
|
||||
上图是一个示意图,`3` 会被拷贝到 G1 栈上的某个位置,也就是 val 的地址处,保存在 elem 字段。
|
||||
> [!warning] ⚠️ 高频陷阱
|
||||
> goroutine 泄漏会导致它所引用的所有变量都无法被 GC 回收,间接造成内存泄漏。
|
||||
|
||||
## 4. 从Channel读取数据的过程是怎样的?
|
||||
> [!tip] 💡 面试技巧
|
||||
> 回答时给出解决方案:"使用 context.WithTimeout 设置超时"、"确保生产者结束后关闭 channel"、"select 加 default 分支避免死锁"。
|
||||
|
||||
从channel读取数据也有几个关键步骤:
|
||||
---
|
||||
|
||||
1. **首先检查是否有等待的发送者**。如果`sendq`队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。
|
||||
### Q7-Q8:关闭 Channel 的异常场景 🟢简单
|
||||
|
||||
2. **如果没有等待发送者,尝试从缓冲区读取**。检查`qcount > 0`,如果缓冲区有数据,就从`buf[recvx]`位置取出数据,然后更新`recvx`索引和`qcount`计数。这是缓冲区有数据时的正常路径。
|
||||
## 参考答案
|
||||
|
||||
**缓冲区为空时需要阻塞等待**。创建`sudog`结构体包装当前goroutine,加入到`recvq`等待队列,调用`gopark`进入阻塞状态。当有发送者写入数据时会被唤醒继续执行。
|
||||
以下操作都会导致 panic:
|
||||
|
||||
**从已关闭channel读取有特殊处理**。如果channel已关闭且缓冲区为空,会返回零值和false标志;如果缓冲区还有数据,可以正常读取直到清空。这就是为什么`v, ok := <-ch`中的ok能判断channel状态的原因。
|
||||
| 操作 | Panic 信息 |
|
||||
|------|-----------|
|
||||
| 重复关闭 | `panic: close of closed channel` |
|
||||
| 关闭 nil Channel | `panic: close of nil channel` |
|
||||
| 关闭只收 Channel | 编译错误 |
|
||||
| 往已关闭 Channel 写数据 | `panic: send on closed channel` |
|
||||
|
||||
## 5. 从一个已关闭Channel仍能读出数据吗?
|
||||
---
|
||||
|
||||
从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。
|
||||
### Q9-Q11:select 的执行机制 🟡中等
|
||||
|
||||
**示例:**
|
||||
> [!question] ❓ 思考一下
|
||||
> 如果多个 case 同时满足条件,select 选哪一个?为什么这样设计?
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ch := make(chan int, 5)
|
||||
ch <- 18
|
||||
close(ch)
|
||||
x, ok := <-ch
|
||||
if ok {
|
||||
fmt.Println("received: ", x)
|
||||
}
|
||||
## 参考答案
|
||||
|
||||
x, ok = <-ch
|
||||
if !ok {
|
||||
fmt.Println("channel closed, data invalid.")
|
||||
}
|
||||
}
|
||||
**随机选择。** 如果多个 case 同时就绪,Go 会随机选择一个执行。
|
||||
|
||||
```
|
||||
|
||||
程序输出:
|
||||
|
||||
```go
|
||||
received: 18
|
||||
channel closed, data invalid.
|
||||
```
|
||||
|
||||
先创建了一个有缓冲的 channel,向其发送一个元素,然后关闭此 channel。之后两次尝试从 channel 中读取数据,第一次仍然能正常读出值。第二次返回的 ok 为 false,说明 channel 已关闭,且通道里没有数据。
|
||||
|
||||
## 6. Channel在什么情况下会引起内存泄漏?
|
||||
|
||||
Channel引起内存泄漏最常见的是引起goroutine泄漏从而导致的间接内存泄漏,当goroutine阻塞在channel操作上永远无法退出时,goroutine本身和它引用的所有变量都无法被GC回收。比如一个goroutine在等待接收数据,但发送者已经退出了,这个接收者就会永远阻塞下去。或者**select语句使用不当,**在没有default分支的select中,如果所有case都无法执行,goroutine会永远阻塞。出现内存泄漏
|
||||
|
||||
## 7. 关闭Channel会产生异常吗?
|
||||
|
||||
试图重复关闭一个channel、,关闭一个nil值的channel、关闭一个只有接收方向的channel都将导致panic异常。
|
||||
|
||||
## 8. 往一个关闭的Channel写入数据会发生什么?
|
||||
|
||||
往已关闭的channel写入数据会直接panic。
|
||||
|
||||
向已关闭的channel发送数据时,runtime会检测到channel的`closed`标志位已经设置,立即抛出"send on closed channel"的panic。这个检查发生在发送操作的最开始阶段,甚至在获取mutex锁之前就会进行判断,所以不会有任何数据写入的尝试,直接就panic了。
|
||||
|
||||
## 9. 什么是select?
|
||||
|
||||
select是Go语言专门为channel操作设计的多路复用控制结构,类似于网络编程中的select系统调用。
|
||||
|
||||
核心作用是同时监听多个channel操作。当有多个channel都可能有数据收发时,select能够选择其中一个可执行的case进行操作,而不是按顺序逐个尝试。比如同时监听数据输入、超时信号、取消信号等。
|
||||
|
||||
## 10. select的执行机制是怎样的?
|
||||
|
||||
select的执行机制是随机选择。如果多个case同时满足条件,Go会随机选择一个执行,这避免了饥饿问题。如果没有case能执行就会执行default,如果没有default,当前goroutine会阻塞等待。
|
||||
**设计目的**:避免饥饿问题——防止某个 channel 总是被忽略。
|
||||
|
||||
```go
|
||||
select {
|
||||
case data := <-ch1:
|
||||
// 处理ch1的数据
|
||||
// 处理 ch1
|
||||
case ch2 <- value:
|
||||
// 向ch2发送数据
|
||||
// 发送数据
|
||||
case <-timeout:
|
||||
// 超时处理
|
||||
default:
|
||||
// 所有channel都不可用时执行
|
||||
// 所有 channel 都不可用时执行
|
||||
}
|
||||
```
|
||||
|
||||
## 11. select的实现原理是怎样的?
|
||||
#### select 实现原理
|
||||
|
||||
Go语言实现`select`时,定义了一个数据结构scase表示每个`case`语句(包含`default`)。scase结构包含channel指针、操作类型等信息。select操作的整个过程通过selectgo函数在runtime层面实现。
|
||||
|
||||
Go运行时会将所有case进行**随机排序**,这是为了避免饥饿问题。然后执行**两轮扫描策略**:**第一轮**直接检查每个channel是否可读写,如果找到就绪的立即执行;如果都没就绪,第二轮就把当前goroutine加入到所有channel的发送或接收队列中,然后调用gopark进入睡眠状态,使**当前goroutine**让出CPU。
|
||||
|
||||
当某个channel变为可操作时,调度器会唤醒对应的goroutine,此时需要从其他channel的等待队列中清理掉这个goroutine,然后执行对应的case分支。
|
||||
|
||||
其核心原理是:case随机化 + 双重循环检测
|
||||
|
||||
**分析:**
|
||||
|
||||
scase结构定义:
|
||||
|
||||
```go
|
||||
type scase struct {
|
||||
c *hchan // channel指针
|
||||
elem unsafe.Pointer // 数据元素指针,用于存放发送/接收的数据
|
||||
kind uint16 // case类型:caseNil、caseRecv、caseSend、caseDefault
|
||||
pc uintptr // 程序计数器,用于调试
|
||||
releasetime int64 // 释放时间,用于竞态检测
|
||||
}
|
||||
```mermaid
|
||||
graph LR
|
||||
A[编译阶段] --> B[生成 scase 结构体数组]
|
||||
B --> C[运行时 selectgo]
|
||||
C --> D[随机排序 case]
|
||||
D --> E{第一轮扫描}
|
||||
E -->|找到就绪| F[立即执行]
|
||||
E -->|全阻塞| G[第二轮: 加入等待队列]
|
||||
G --> H[gopark 睡眠]
|
||||
H --> I[channel 就绪后唤醒]
|
||||
I --> F
|
||||
```
|
||||
|
||||

|
||||
1. **随机排序**:避免饥饿
|
||||
2. **两轮扫描**:第一轮直接检查可读写性,找不到则全部加入等待队列后睡眠
|
||||
3. **scase 结构**:每个 case 封装为 scase,包含 channel 指针、操作类型等信息
|
||||
|
||||
在默认的情况下,select 语句会在编译阶段经过如下过程的处理:
|
||||
> [!note] 📝 核心考点
|
||||
> select 的核心设计:**case 随机化 + 双重循环检测**。面试官可能会追问"为什么不按顺序一个个判断",答案是"为了避免某些 case 长期得不到执行(饥饿)"。
|
||||
|
||||
1. 将所有的 `case` 转换成包含` Channel `以及类型等信息的 scase 结构体;
|
||||
|
||||
2. 调用运行时函数 `selectgo `获取被选择的`scase` 结构体索引,如果当前的` scase `是一个接收数据的操作,还会返回一个指示当前`case` 是否是接收的布尔值;
|
||||
|
||||
3. 通过` for `循环生成一组` if `语句,在语句中判断自己是不是被选中的 `case`。
|
||||
## 关联笔记
|
||||
|
||||
- [[hzh/GolangStar/Go语言进阶/Channel]]
|
||||
- [[hzh/GolangStar/Go语言进阶/Select]]
|
||||
- [[hzh/GolangStar/Go语言原理/channel原理]]
|
||||
- [[hzh/GolangStar/Go面试题库/代码面试题]]
|
||||
|
||||
Reference in New Issue
Block a user