一、面试题相关
1.channel 是否线程安全?锁用在什么地方?
分析:
channel配合goroutine可以用来实现并发编程,并且是go语言推荐的并发编程模式,那么肯定是可以保证线程安全的,可以先回顾下channel的底层定义,channel用make函数创建初始化的时候会在堆上分配一个hchan(runtime/chan.go包中)类型的数据结构
可以看到channel的底层实现中是有锁的,是通过mutex来保证线程安全的,所以在回答的时候要突出底层实现有锁。
回答:
一般来说,我们对channel就只有读,写,关闭三种操作,这三种操作,channel底层数据结构都用同一把runtime.Mutex来进行保护。
2.channel 的底层实现原理(数据结构)
分析:
这个问题其实是上一个问题的补充,channel的底层实现是一个hchan的结构,hchan的结构定义。
Go
type hchan struct {
qcount uint // 当前队列中剩余的元素个数
dataqsiz uint // 环形队列的大小,即可以缓存的元素数量(make(chan T, N) 中的 N)
buf unsafe.Pointer // 指向环形队列的指针(有缓冲 channel 才非 nil)
elemsize uint16 // 每个元素的大小
closed uint32 // channel 是否已关闭的标志
elemtype *_type // 元素类型,用于在运行时进行类型检查
sendx uint // 发送索引(send index),指向环形队列中下一个要发送的位置
recvx uint // 接收索引(receive index),指向环形队列中下一个要接收的位置
recvq waitq // 等待接收的 goroutine 队列(链表结构)
sendq waitq // 等待发送的 goroutine 队列(链表结构)
lock mutex // 互斥锁,保护 hchan 中的所有字段
}
// waitq 是一个 sudog 的链表,sudog 代表了一个等待中的 goroutine
type waitq struct {
first *sudog
last *sudog
}
回答:

补充讲一下等待队列
- 发送等待队列 (sendq)
作用:存储所有因无法立即发送数据而被阻塞的 goroutine。
触发条件:
无缓冲 Channel:没有接收者在等待时
有缓冲 Channel:缓冲区已满时
队列中的元素:每个被阻塞的发送者 goroutine 被打包成一个 sudog 节点。
- 接收等待队列 (recvq)
作用:存储所有因无法立即接收数据而被阻塞的 goroutine。
触发条件:
无缓冲 Channel:没有发送者在等待时
有缓冲 Channel:缓冲区为空时
队列中的元素:每个被阻塞的接收者 goroutine 被打包成一个 sudog 节点。
3.nil、关闭的 channel、再进行读、写、关闭会怎么样?有数据的 channel,(各类变种题型)
分析:
主要是考察对channel在各个状态下进行读写操作会出现什么结果,这块建议自己代码跑一下各个场景,加深一下理解
回答:
1.对nil的channel进行读和写
都会造成当前goroutine永久阻寨(如果当前goroutine是main goroutine,则会让整个程序直接报fatal error 退出,也就是报错deadlock),关闭则会发生panic。
2.对已经关闭的channel进行写 和 再次关闭
都会导致panic,而读操作的话,会一直将channel中的数据读完2读完之后,每次读channel都会获得一个对应类型的零值。
3.对一个正常的channel进行读写都有两种情况:
a.读:阻塞挂起或者成功发送
b.写:阻塞挂起或者成功接收
c.关闭:正常close
4、Go 语言中有缓存(Buffered)和无缓存(Unbuffered)的 Channel

性能考虑
无缓存 Channel 由于涉及更直接的 goroutine 调度(挂起和唤醒),在频繁通信且没有同步需求的场景下,性能开销可能稍大。
有缓存 Channel 在缓冲区未满/未空时,操作接近于一个简单的队列,性能更好。但如果缓冲区大小设置不当(太小仍会频繁阻塞,太大浪费内存并增加延迟),也会影响性能。
Go
package main
import (
"fmt"
"sync"
"time"
)
// 场景1:任务协同(无缓存 - 适合)
func taskCoordination() {
fmt.Println("=== 任务协同(无缓存Channel)===")
done := make(chan struct{}) // 无缓存,用于信号通知
go func() {
fmt.Println("Worker: 处理任务中...")
time.Sleep(1 * time.Second)
fmt.Println("Worker: 任务完成!")
done <- struct{}{} // 发送完成信号
}()
<-done // 等待任务完成
fmt.Println("Main: 收到完成信号,继续执行")
}
// 场景2:限流器(有缓存 - 适合)
func rateLimiter() {
fmt.Println("\n=== 限流器(有缓存Channel)===")
limiter := make(chan time.Time, 3) // 容量3的限流器
// 初始化限流器
for i := 0; i < 3; i++ {
limiter <- time.Now()
}
// 每秒补充一个令牌
go func() {
for t := range time.Tick(1 * time.Second) {
limiter <- t
}
}()
// 模拟10个请求
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-limiter // 获取令牌(缓冲区有数据立即返回,空则阻塞)
fmt.Printf("请求 %d 执行 at %v\n", id, time.Now().Format("15:04:05"))
}(i)
}
wg.Wait()
}
func main() {
taskCoordination()
rateLimiter()
}
5.对channel 进行读写数据的流程是怎样的?
分析:
考察对channel 底层结构以及chansend和chanrecv流程的掌握程度,下面回答不区分有缓冲channel 和 无缓冲channel,注意理解。
回答:
操作一个不为nil,并且未关闭的channel,读和写都有两种情况:
1.读操作:
成功读取:
**如果channel中有数据,**直接从channel里面读取,并且此时如果写等待队列里面有goroutine,还需要将队列头部goroutine数据放入到channel中,并唤醒这个goroutine。
channel没有数据,就尝试从 写等待队列 头部goroutine读取数据,并如果做对应的唤醒操作。
阻塞挂起:channel里面没有数据 并且 写等待队列为空,则将当前goroutine 加入 读等待队列中,并挂起,等待唤醒。
2.写操作
成功写入:
**如果当前读等待队列为空,**将数据写入到channel环形缓冲中。
如果channel 读等待队列不为空,则取 头部goroutine,将数据直接复制给这个头部goroutine,并将其唤醒,流程结束。
阻塞挂起:通道里面无法存放数据 并且 读等待队列为空,则当前goroutine 加入写等待队列中,并挂起,等待唤醒。
6、select的底层原理
分析:
**select 的核心目标是:**监听多个 channel 上的发送或接收操作,当其中一个可以立即执行时,就执行它。如果多个同时可执行,则随机选择一个,以保证公平性。如果都没有,则要么阻塞(无 default 时),要么执行 default 语句。

有default的分支是非阻塞性select,下面代码能够较好的展示:
Go
package main
import "fmt"
func main() {
ch := make(chan string)
select {
case msg := <-ch: // 尝试从ch接收
fmt.Println("Received:", msg)
default: // 如果ch没有立即可用的数据,就执行这里
fmt.Println("No message received")
}
// 尝试向ch发送,但因为没有接收者,会失败
select {
case ch <- "hello": // 尝试向ch发送
fmt.Println("Sent message")
default:
fmt.Println("No message sent")
}
}
/*
No message received
No message sent
*/
select也被称为多路select,指的是一个goroutine 可以服务多个 channel的读或写操作,要清楚的知道 select分为两种,包含非阻塞型select(包含default分支的) 和 阻塞型select(不包含default分支的),然后再回答其对应原理。
回答:
select的核心原理是,按照随机的顺序执行case,直到某个case完成操作,如果所有case的都没有完成操作,则看有没有defaut分支,如果有default分支,则直接走default,防止阻塞。
如果没有的话,需要将当前goroutine 加入到所有case对应channel的等待队列中,并挂起当前goroutine,等待唤醒。
如果当前goroutine被某一个case 上的channel操作唤醒后,还需要将当前goroutine从所有case对应channel的等待队列中剔除。