一、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 是不可变的,其 WithCancel、WithTimeout、WithValue 等方法返回的 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=1s = append(s, 2) // cap=2s = 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] = 100fmt.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 调度流程
go func()创建一个 G,加入 P 的本地队列或全局队列- P 尝试从本地队列取 G,若空则从全局队列或其他 P 偷取(Work Stealing)
- M 从 P 获取 G 执行
- G 调用阻塞系统调用时,M 释放 P,其他 M 捡起 P 继续执行
- G 执行完毕,返回 P 的可运行队列
3.3 Work Stealing
当某个 P 的本地队列为空时,会尝试从全局队列或其他 P 偷取一半的 G 来执行,避免出现空闲的 CPU 核心。
四、Go 的 GC 原理以及写屏障是什么?
Go 使用**三色标记清除(Tri-Color Mark and Sweep)**算法进行垃圾回收。
4.1 三色标记
- 白色:未被标记的垃圾候选对象
- 灰色:已标记但引用的对象未处理
- 黑色:已标记且引用已处理的对象
4.2 回收流程
- 初始所有对象为白色
- 从根对象(GOP 栈、全局变量等)出发,标记可到达的对象为灰色
- 灰色对象被处理,其引用的对象标记为灰色,自身变为黑色
- 直到没有灰色对象,所有白色对象都是垃圾
4.3 写屏障(Write Barrier)
在并发 GC 时,应用线程可能修改对象引用,导致漏标。Go 使用**混合写屏障(Hybrid Write Barrier)**解决这个问题:
// 写屏障在每次写入指针时插入代码// 确保被覆盖的指针对象被标记4.4 GC 的时机
Go 的 GC 触发时机是自适应的:
- 当分配内存达到阈值(
GOGC环境变量控制,默认 100%) - 定期触发(
runtime.gcTrigger) - 手动调用
runtime.GC()
4.5 GC 性能优化建议
- 减少内存分配:对象池(sync.Pool)、复用
- 避免字符串拼接:使用 strings.Builder
- 减少指针使用:值类型可能减少 GC 压力
- 控制 goroutine 数量:及时退出避免资源泄漏
五、内存逃逸分析
内存逃逸是指本该在栈上分配的对象,因为生命周期超出栈范围而被分配到堆上。堆内存需要 GC 回收,增加 GC 压力。
5.1 常见逃逸场景
// 1. 返回局部变量指针func foo() *int { a := 10 return &a // a 逃逸到堆}
// 2. 不确定长度的 slicefunc 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 escape5.3 避免逃逸的原则
- 尽量使用值类型而非指针
- 小对象考虑栈上分配
- 避免返回指向局部变量的指针
- 接口类型开销大,谨慎使用
六、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 阻塞,直到有发送者<-ch6.3 有缓冲 channel
有缓冲 channel 的 dataqsiz > 0,发送和接收不必同时完成:
ch := make(chan int, 3) // 缓冲大小为 3
// 可以发送 3 个元素而不阻塞ch <- 1ch <- 2ch <- 3ch <- 4 // 这里才阻塞(队列满)6.4 关闭后的行为
读取已关闭的 channel 不会 panic:
ch := make(chan int, 3)close(ch)
// 读取已关闭的 channel 返回零值v, ok := <-chfmt.Println(v, ok) // 0, false(ok=false 表示 channel 已关闭且无数据)向已关闭的 channel 发送会 panic:
ch := make(chan int)close(ch)ch <- 1 // panic: send on closed channel6.5 正确的关闭模式
// 生产者关闭 channelfunc 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 0end`分布式锁要点:
- 互斥:确保只有一个客户端能获取锁
- 防死锁:设置 TTL,过期自动释放
- 原子性:解锁时检查 value 防止误删其他客户端的锁
- 可重入(可选):记录持有锁的客户端 ID 和计数
八、参考
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






