mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1511 字
4 分钟
Go 面试题
2023-11-28

一、map 是否为并发安全的?context 是否为并发安全的?#

map 不是并发安全的。Go 的 map 实现是哈希表,设计为单线程使用。在多个 goroutine 并发读写 map 时,会导致 panic:

// 错误的并发用法
var m = make(map[int]int)
go func() {
for i := 0; i < 10000; i++ {
m[i] = i
}
}()
go func() {
for i := 0; i < 10000; i++ {
_ = m[i]
}
}()
// 运行时会 panic: fatal error: concurrent map read and map write

正确的做法是使用互斥锁(sync.Mutex)读写锁(sync.RWMutex)

var (
m = make(map[int]int)
mu sync.RWMutex
)
func Write(k, v int) {
mu.Lock()
defer mu.Unlock()
m[k] = v
}
func Read(k int) int {
mu.RLock()
defer mu.RUnlock()
return m[k]
}

Go 1.9 引入了 sync.Map,专门为并发场景优化,但并非所有场景都优于 RWMutex

var m sync.Map
// 适合读多写少且 key 相对固定的场景
m.Store(1, "one")
value, ok := m.Load(1)
m.Delete(1)

context 是并发安全的。context 是不可变的,其 WithCancelWithTimeoutWithValue 等方法返回的 context 也是独立的。context 的值传递和取消信号都是线程安全的。

二、slice 切片的实现和扩容机制#

2.1 slice 的结构#

Go 的 slice 是三个字段的结构体:

type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 当前长度
cap int // 容量
}

2.2 扩容机制#

当 slice 容量不足时,Go 会进行扩容:

// 扩容源码(runtime/slice.go)
// newcap = old.cap + old.cap/2
// 即新容量 = 旧容量 + 旧容量/2
// 具体例子:
s := make([]int, 0)
s = append(s, 1) // cap=1
s = append(s, 2) // cap=2
s = append(s, 3) // cap=4(扩容)

扩容规则

  • 如果旧容量小于 256,新容量 = 旧容量 × 2
  • 如果旧容量大于等于 256,新容量 = 旧容量 + 3/4 × 旧容量

2.3 切片的内存布局#

s := make([]int, 0, 10)
s = append(s, 1, 2, 3)
// 底层数组:[1, 2, 3, _, _, _, ...]
// ↑s[0] ↑s[1] ↑s[2]

2.4 切片的拷贝#

// 拷贝是引用拷贝(浅拷贝)
a := []int{1, 2, 3}
b := a // b 和 a 共享底层数组
b[0] = 100
fmt.Println(a[0]) // 输出 100,因为共享底层数组
// 深拷贝需要 copy()
a := []int{1, 2, 3}
b := make([]int, len(a))
copy(b, a)

2.5 切片作为函数参数#

切片作为函数参数是引用传递(实际上是指向底层数组的指针),但要注意以下情况

func modify(s []int) {
s[0] = 100 // 可以修改底层数组
s = append(s, 4) // 这里修改的是副本,不会影响原切片
}
func modifyWithPtr(s *[]int) {
*s = append(*s, 4) // 通过指针可以增长切片
}

三、GMP 模型#

GMP 是 Go 的调度模型,全称是 Goroutine Machine Processor

  • G(Goroutine):Go 轻量级协程,每个约 2KB
  • M(Machine):操作系统线程,对应真实的 CPU 核
  • P(Processor):逻辑处理器,管理 G 的队列

3.1 GMP 结构#

全局 G 队列
┌─────────────────────────────────────────────┐
│ M: Machine (Thread) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ P │ │ P │ │ P │ │
│ │ (本地G) │ │ (本地G) │ │ (本地G) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────┘
  • 全局 G 队列:所有 P 共享的 G 队列
  • P 的本地队列:每个 P 有自己的 G 队列,避免竞争
  • M 必须持有 P 才能运行 G

3.2 调度流程#

  1. go func() 创建一个 G,加入 P 的本地队列或全局队列
  2. P 尝试从本地队列取 G,若空则从全局队列或其他 P 偷取(Work Stealing)
  3. M 从 P 获取 G 执行
  4. G 调用阻塞系统调用时,M 释放 P,其他 M 捡起 P 继续执行
  5. G 执行完毕,返回 P 的可运行队列

3.3 Work Stealing#

当某个 P 的本地队列为空时,会尝试从全局队列或其他 P 偷取一半的 G 来执行,避免出现空闲的 CPU 核心。

四、Go 的 GC 原理以及写屏障是什么?#

Go 使用**三色标记清除(Tri-Color Mark and Sweep)**算法进行垃圾回收。

4.1 三色标记#

  • 白色:未被标记的垃圾候选对象
  • 灰色:已标记但引用的对象未处理
  • 黑色:已标记且引用已处理的对象

4.2 回收流程#

  1. 初始所有对象为白色
  2. 从根对象(GOP 栈、全局变量等)出发,标记可到达的对象为灰色
  3. 灰色对象被处理,其引用的对象标记为灰色,自身变为黑色
  4. 直到没有灰色对象,所有白色对象都是垃圾

4.3 写屏障(Write Barrier)#

在并发 GC 时,应用线程可能修改对象引用,导致漏标。Go 使用**混合写屏障(Hybrid Write Barrier)**解决这个问题:

// 写屏障在每次写入指针时插入代码
// 确保被覆盖的指针对象被标记

4.4 GC 的时机#

Go 的 GC 触发时机是自适应的:

  • 当分配内存达到阈值(GOGC 环境变量控制,默认 100%)
  • 定期触发(runtime.gcTrigger
  • 手动调用 runtime.GC()

4.5 GC 性能优化建议#

  1. 减少内存分配:对象池(sync.Pool)、复用
  2. 避免字符串拼接:使用 strings.Builder
  3. 减少指针使用:值类型可能减少 GC 压力
  4. 控制 goroutine 数量:及时退出避免资源泄漏

五、内存逃逸分析#

内存逃逸是指本该在栈上分配的对象,因为生命周期超出栈范围而被分配到堆上。堆内存需要 GC 回收,增加 GC 压力。

5.1 常见逃逸场景#

// 1. 返回局部变量指针
func foo() *int {
a := 10
return &a // a 逃逸到堆
}
// 2. 不确定长度的 slice
func bar() []int {
s := make([]int, 0)
s = append(s, 1) // 底层数组可能逃逸
return s
}
// 3. interface 类型
var i interface{} = 10 // 装箱导致堆分配

5.2 逃逸分析命令#

go build -gcflags="-m" main.go
# 输出类似:
# ./main.go:10:2: a escapes to heap
# ./main.go:11:15: make([]int, 0) does not escape

5.3 避免逃逸的原则#

  1. 尽量使用值类型而非指针
  2. 小对象考虑栈上分配
  3. 避免返回指向局部变量的指针
  4. 接口类型开销大,谨慎使用

六、channel 的底层实现?有缓冲的、无缓冲的 channel,如果管道已经关闭了,读取会不会发生错误?#

6.1 channel 的结构#

channel 底层是环形队列加上必要的同步原语:

type hchan struct {
qcount uint // 队列中的元素数量
dataqsiz uint // 循环队列的大小
buf unsafe.Pointer // 指向环形队列的指针
elemsize uint16 // 元素大小
closed uint32 // 是否已关闭
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护所有字段的锁
}

6.2 无缓冲 channel#

无缓冲 channel 的 dataqsiz = 0,发送和接收必须同时完成,否则会阻塞:

ch := make(chan int) // 无缓冲
// 发送 goroutine 阻塞,直到有接收者
go func() {
ch <- 1 // 阻塞等待接收
}()
// 接收 goroutine 阻塞,直到有发送者
<-ch

6.3 有缓冲 channel#

有缓冲 channel 的 dataqsiz > 0,发送和接收不必同时完成:

ch := make(chan int, 3) // 缓冲大小为 3
// 可以发送 3 个元素而不阻塞
ch <- 1
ch <- 2
ch <- 3
ch <- 4 // 这里才阻塞(队列满)

6.4 关闭后的行为#

读取已关闭的 channel 不会 panic

ch := make(chan int, 3)
close(ch)
// 读取已关闭的 channel 返回零值
v, ok := <-ch
fmt.Println(v, ok) // 0, false(ok=false 表示 channel 已关闭且无数据)

向已关闭的 channel 发送会 panic

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

6.5 正确的关闭模式#

// 生产者关闭 channel
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
// 消费者用 range 读取
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}

七、聊聊什么是锁。Mutex、RWMutex、Redis 分布式锁#

7.1 sync.Mutex#

互斥锁,同一时刻只有一个 goroutine 能持有锁:

var mu sync.Mutex
func critical() {
mu.Lock()
defer mu.Unlock()
// 临界区
}

不要在互斥锁保护的区域内调用用户自定义代码,可能导致死锁或性能问题。

7.2 sync.RWMutex#

读写锁,区分读写操作,可以多个读者或一个写者:

var (
mu sync.RWMutex
data map[string]int
)
func Read(key string) int {
mu.RLock()
defer mu.RUnlock()
return data[key]
}
func Write(key string, value int) {
mu.Lock()
defer mu.Unlock()
data[key] = value
}

读多写少时 RWMutex 性能优于 Mutex,因为读操作可以并发执行。

7.3 分布式锁#

在多进程/多机器环境下,需要分布式锁。Redis 实现分布式锁:

// 加锁
func AcquireLock(redis *redis.Client, key string, ttl time.Duration) (bool, error) {
result, err := redis.SetNX(key, "locked", ttl).Result()
return result, err
}
// 释放锁(Lua 脚本保证原子性)
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`

分布式锁要点:

  1. 互斥:确保只有一个客户端能获取锁
  2. 防死锁:设置 TTL,过期自动释放
  3. 原子性:解锁时检查 value 防止误删其他客户端的锁
  4. 可重入(可选):记录持有锁的客户端 ID 和计数

八、参考#


参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 面试题
https://blog.souloss.com/posts/interview/golang/
作者
Souloss
发布于
2023-11-28
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时