# Golang面试题

#

大家好,我是小宇宙。

Go 语言这几年在后端岗位的面试里出现得越来越频繁,尤其是字节、腾讯、美团这类大厂,Go 岗位的面试题难度其实不低。很多人第一次接触 Go 面试会有点懵,语言本身学起来挺快的,但面试要考的那些底层原理,比如 GMP 调度模型、GC 三色标记、Channel 的阻塞机制,根本不是会用就能回答上来的。

这篇文章把 Go 面试中最常考的知识点都整理进来了,从基础语法到并发编程,再到内存管理和垃圾回收,基本覆盖了你在 Go 面试中会遇到的绝大多数问题。每道题尽量把背后的原理讲清楚,不只是给一个结论。

几个特别值得花时间搞透的模块:

Slice 和 Map:不只是会用,要知道扩容机制、底层结构、并发安全问题,这两个基本是每场面试必提。

Channel 和并发:Go 最有特色的地方,Channel 的收发过程、select 的执行机制、各种锁的底层实现,面试官特别喜欢在这里深挖。

GMP 调度模型:Go 高并发的核心,P 的作用、work-stealing、goroutine 调度时机,这块是 Go 进阶面试的分水岭。

GC 垃圾回收:三色标记法、写屏障、STW,理解原理比死记结论重要得多。

如果你刚开始准备 Go 面试,建议先把基础和 Slice/Map/Channel 这些核心数据结构吃透,并发和 GMP 相对抽象,可以结合代码多跑几个例子来理解。

# 1. Go基础面试题

#

# 1.1 与其他语言相比,使用 Go 有什么好处?

#

与其他语言不同,Go 代码的设计是务实的,Go的语法更简洁。每个功能和语法决策都旨在让程序员的开发效率更高

与其他语言不同,Go 代码的设计是务实的,Go的语法更简洁。每个功能和语法决策都旨在让程序员的开发效率更高

Golang 针对并发进行了优化,支持协程,并且实现了高效的GMP调度模型。

Golang 针对并发进行了优化,支持协程,并且实现了高效的GMP调度模型。

由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。

由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。

有高效的垃圾回收机制,支持并行垃圾回收,垃圾回收效率比比 Java 或 Python 更高

有高效的垃圾回收机制,支持并行垃圾回收,垃圾回收效率比比 Java 或 Python 更高

# 1.2 什么是协程?

#

协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上 go 关键字就能实现并发。一个 Goroutine 会以一个很小的栈启动(自 Go 1.4 起初始 2KB,早期版本曾为 8KB),当遇到栈空间不足时,栈会自动伸缩,因此可以轻易实现成千上万个 goroutine 同时启动。

# 1.3 协程和线程和进程的区别?

#

进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

# 1.4 Golang 中 make 和 new 的区别?

#

回答:

make 和 new 都是用于内存分配的内建函数,但它们的使用场景和功能有所不同:

make
new

make: 用于初始化并分配内存,只能用于创建 slice、map 和 channel 三种类型。 返回的是初始化后的数据结构,而不是指针。

make:

make

用于初始化并分配内存,只能用于创建 slice、map 和 channel 三种类型。

用于初始化并分配内存,只能用于创建 slice、map 和 channel 三种类型。

slice
map
channel

返回的是初始化后的数据结构,而不是指针。

返回的是初始化后的数据结构,而不是指针。

new: 用于分配内存,但不初始化,返回的是指向该内存的指针。 可以用于任何类型的内存分配。

new:

new

用于分配内存,但不初始化,返回的是指向该内存的指针。

用于分配内存,但不初始化,返回的是指向该内存的指针。

可以用于任何类型的内存分配。

可以用于任何类型的内存分配。

分析:

// 使用 make 创建 slice
s := make([]int, 5) // 创建一个长度为 5 的 slice
fmt.Println(s)      // 输出: [0 0 0 0 0]// 使用 new 创建 int 指针
p := new(int)       // 分配内存给 int 类型
fmt.Println(*p)     // 输出: 0 (初始值)
// 使用 make 创建 slice
s := make([]int, 5) // 创建一个长度为 5 的 slice
fmt.Println(s)      // 输出: [0 0 0 0 0]// 使用 new 创建 int 指针
p := new(int)       // 分配内存给 int 类型
fmt.Println(*p)     // 输出: 0 (初始值)

make 函数创建的是数据结构(slice、map、channel)本身,且返回初始化后的值。而new 函数创建的是可以指向任意类型的指针,返回指向未初始化零值的内存地址。

make
slice
map
channel
new

# 1.5 Golang 中数组和切片的区别?

#

数组:

数组固定长度。数组长度是数组类型的一部分,所以[3]int 和[4]int 是两种不 同的数组类型数组需要指定大小,不指定也会根据初始化,自动推算出大小, 大小不可改变。数组是通过值传递的

切片:

切片可以改变长度。切片是轻量级的数据结构,三个属性:指针、长度、容量;不需要指定大小。切片在传参时按值传递的是 slice header(包含底层数组指针、长度、容量)的副本,但因为副本中的指针指向同一底层数组,所以函数内对底层数组元素的修改对调用方可见。切片可以通过数组来初始化,也可以通过内置函数 make() 来初始化,初始化的时候 len=cap,然后进行扩容。

分析:

slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

// runtime/slice.go
type slice struct {
        array unsafe.Pointer // 元素指针
        len   int // 长度 
        cap   int // 容量
}
// runtime/slice.go
type slice struct {
        array unsafe.Pointer // 元素指针
        len   int // 长度 
        cap   int // 容量
}

# 1.6 使用for range 的时候,它的地址会发生变化吗?

#

在 Go 1.22 之前,对于 for range 循环中的迭代变量,其内存地址是不会发生变化的(在每次迭代中复用同一个变量)。Go 1.22 起,循环变量改为 per-iteration(每次迭代都是新变量),地址也会发生变化,不再共享内存。注意:该行为依赖于 go.mod 中声明的 Go 版本(go directive ≥ 1.22)才会生效。

for range
go.mod

分析:

Go1.22之前:

for index, value := range collection {
    // ...
}
for index, value := range collection {
    // ...
}

这里 value 是一个副本。在每次迭代中,collection 中的当前元素值会被复制到 value 这个变量中。Go 编译器通常会为 value 分配一块固定的内存地址,然后在每次迭代时,将当前元素的值覆盖到这块内存中。所以,当你打印 &value 时,你会发现它的内存地址在整个循环过程中都是保持不变的。

value
collection
value
value
&value

但是在 Go 1.22 及以后,使用 for range 遍历一个集合时,迭代变量的地址会发生变化。这是因为 for range 每次迭代时都会重新生成迭代变量(如 value),这些变量在内存中是不同的地址。

for range
for range
value

# 1.7 如何高效地拼接字符串?

#

拼接字符串的方式有:+ , fmt.Sprintf , strings.Builder (opens new window), bytes.Buffer (opens new window), strings.Join

+
fmt.Sprintf

strings.Builder (opens new window)

strings.Builder

bytes.Buffer (opens new window)

bytes.Buffer
strings.Join

"+"

使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。

+

fmt.Sprintf

由于采用了接口参数,必须要用反射获取值,因此有性能损耗。

strings.Builder:

用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。

bytes.Buffer

bytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte,

bytes.Buffer
byte
byte

bytes.buffer底层也是一个[]byte切片。

bytes.buffer
[]byte

strings.join

strings.join也是基于strings.builder来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。

strings.join
strings.builder

性能比较:

strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf

5种拼接方法的实例代码

func main(){
        a := []string{"a", "b", "c"}
        //方式1:+
        ret := a[0] + a[1] + a[2]
        //方式2:fmt.Sprintf
        ret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2])
        //方式3:strings.Builder
        var sb strings.Builder
        sb.WriteString(a[0])
        sb.WriteString(a[1])
        sb.WriteString(a[2])
        ret := sb.String()
        //方式4:bytes.Buffer
        buf := new(bytes.Buffer)
        buf.Write(a[0])
        buf.Write(a[1])
        buf.Write(a[2])
        ret := buf.String()
        //方式5:strings.Join
        ret := strings.Join(a,"")
}
func main(){
        a := []string{"a", "b", "c"}
        //方式1:+
        ret := a[0] + a[1] + a[2]
        //方式2:fmt.Sprintf
        ret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2])
        //方式3:strings.Builder
        var sb strings.Builder
        sb.WriteString(a[0])
        sb.WriteString(a[1])
        sb.WriteString(a[2])
        ret := sb.String()
        //方式4:bytes.Buffer
        buf := new(bytes.Buffer)
        buf.Write(a[0])
        buf.Write(a[1])
        buf.Write(a[2])
        ret := buf.String()
        //方式5:strings.Join
        ret := strings.Join(a,"")
}

# 1.8 defer 的执行顺序是怎样的?defer 的作用或者使用场景是什么?

#

defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO)

defer 的作用是:当 defer 语句被执行时,跟在 defer 后面的函数会被延迟执行。直到 包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含 defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。 你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。

defer 的常用场景:

defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、 加锁、释放锁。

defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、 加锁、释放锁。

通过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资 源被释放。

通过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资 源被释放。

释放资源的defer应该直接跟在请求资源的语句后。

释放资源的defer应该直接跟在请求资源的语句后。

分析:

func test() int {
        i := 0
        defer func() {
                fmt.Println("defer1")
        }()
        defer func() {
                i += 1
                fmt.Println("defer2")
        }()
        return i
}

func main() {
        fmt.Println("return", test())
}

// 输出:
// defer2
// defer1
// return 0
func test() int {
        i := 0
        defer func() {
                fmt.Println("defer1")
        }()
        defer func() {
                i += 1
                fmt.Println("defer2")
        }()
        return i
}

func main() {
        fmt.Println("return", test())
}

// 输出:
// defer2
// defer1
// return 0

上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值func test() (i int))

func test() (i int)
func test() (i int) {
        i = 0
        defer func() {
                i += 1
                fmt.Println("defer2")
        }()
        return i
}

func main() {
        fmt.Println("return", test())
}
// 输出:
// defer2
// return 1
func test() (i int) {
        i = 0
        defer func() {
                i += 1
                fmt.Println("defer2")
        }()
        return i
}

func main() {
        fmt.Println("return", test())
}
// 输出:
// defer2
// return 1

这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

# 1.9 什么是 rune 类型?

#

Go 语言的字符有以下两种:

uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。

uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。

rune 类型,代表一个 Unicode 码点(code point),当需要处理中文、日文或者其他非 ASCII 字符时,则需要用到 rune 类型。rune 是 int32 的别名(UTF-8 是 Go 字符串的底层编码方式,rune 表示的是 Unicode 标量值,两者概念不同)。

rune 类型,代表一个 Unicode 码点(code point),当需要处理中文、日文或者其他非 ASCII 字符时,则需要用到 rune 类型。rune 是 int32 的别名(UTF-8 是 Go 字符串的底层编码方式,rune 表示的是 Unicode 标量值,两者概念不同)。

package main
import "fmt"

func main() {
    var str = "hello 你好" //思考下 len(str) 的长度是多少?
    
    //golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算  
    //所以一个汉字占3个字节算了3个长度
    fmt.Println("len(str):", len(str))  // len(str): 12

    //通过rune类型处理unicode字符
    fmt.Println("rune:", len([]rune(str))) //rune: 8
}
package main
import "fmt"

func main() {
    var str = "hello 你好" //思考下 len(str) 的长度是多少?
    
    //golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算  
    //所以一个汉字占3个字节算了3个长度
    fmt.Println("len(str):", len(str))  // len(str): 12

    //通过rune类型处理unicode字符
    fmt.Println("rune:", len([]rune(str))) //rune: 8
}

# 1.10 Go 语言 tag 有什么用?

#

tag可以为结构体成员提供属性。常见的:

json序列化或反序列化时字段的名称

json序列化或反序列化时字段的名称

db: sqlx模块中对应的数据库字段名

db: sqlx模块中对应的数据库字段名

form: gin框架中对应的前端的数据字段名

form: gin框架中对应的前端的数据字段名

binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端

binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端

# 1.11 go 打印时 %v %+v %#v 的区别?

#

%v 只输出所有的值;

%v 只输出所有的值;

%+v 先输出字段名字,再输出该字段的值;

%+v 先输出字段名字,再输出该字段的值;

%#v 先输出结构体名字值,再输出结构体(字段名字+字段的值);

%#v 先输出结构体名字值,再输出结构体(字段名字+字段的值);

package main
import "fmt"
 
type student struct {
  id   int32
  name string
}
 
func main() {
  a := &student{id: 1, name: "微客鸟窝"}

  fmt.Printf("a=%v  \n", a) // a=&{1 微客鸟窝}  
  fmt.Printf("a=%+v  \n", a) // a=&{id:1 name:微客鸟窝}  
  fmt.Printf("a=%#v  \n", a) // a=&main.student{id:1, name:"微客鸟窝"}
}
package main
import "fmt"
 
type student struct {
  id   int32
  name string
}
 
func main() {
  a := &student{id: 1, name: "微客鸟窝"}

  fmt.Printf("a=%v  \n", a) // a=&{1 微客鸟窝}  
  fmt.Printf("a=%+v  \n", a) // a=&{id:1 name:微客鸟窝}  
  fmt.Printf("a=%#v  \n", a) // a=&main.student{id:1, name:"微客鸟窝"}
}

# 1.12 Go语言中空 struct{} 占用空间么?

#

可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数,空struct{}不占用任何空间

package main

import (
  "fmt"
  "unsafe"
)

func main() {
  fmt.Println(unsafe.Sizeof(struct{}{}))  //0
}
package main

import (
  "fmt"
  "unsafe"
)

func main() {
  fmt.Println(unsafe.Sizeof(struct{}{}))  //0
}

# 1.13 Go语言中,空 struct{} 有什么用?

#

用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。

type Set map[string]struct{}

func main() {
        set := make(Set)

        for _, item := range []string{"A", "A", "B", "C"} {
                set[item] = struct{}{}
        }
        fmt.Println(len(set)) // 3
        if _, ok := set["A"]; ok {
                fmt.Println("A exists") // A exists
        }
}
type Set map[string]struct{}

func main() {
        set := make(Set)

        for _, item := range []string{"A", "A", "B", "C"} {
                set[item] = struct{}{}
        }
        fmt.Println(len(set)) // 3
        if _, ok := set["A"]; ok {
                fmt.Println("A exists") // A exists
        }
}

有时候给通道发送一个空结构体,channel<-struct{}{},可以节省空间

func main() {
        ch := make(chan struct{}, 1)
        go func() {
                <-ch
                // do something
        }()
        ch <- struct{}{}
        // ...
}
func main() {
        ch := make(chan struct{}, 1)
        go func() {
                <-ch
                // do something
        }()
        ch <- struct{}{}
        // ...
}

表示仅有方法的结构体

type Lamp struct{}
type Lamp struct{}

# 1.14 init() 函数是什么时候执行的?

#

简答: 在main函数之前执行。

详细:init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。

init()
init()
init()
init()

执行顺序:import –> const –> var –>init()–>main()

init()
main()

一个文件可以有多个init()函数!

init()

# 1.15 2 个 interface 可以比较吗 ?

#

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况

T
V
==
!=

两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)

两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)

类型 T 相同,且对应的值 V 相等。

类型 T 相同,且对应的值 V 相等。

type Stu struct {
     Name string
}

type StuInt interface{}

func main() {
     var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
     var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
     fmt.Println(stu1 == stu2) // false
     fmt.Println(stu3 == stu4) // true
}
type Stu struct {
     Name string
}

type StuInt interface{}

func main() {
     var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
     var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
     fmt.Println(stu1 == stu2) // false
     fmt.Println(stu3 == stu4) // true
}

stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。

stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。

stu1
stu2
*Stu
stu3
stu4
Stu

# 1.16 2 个 nil 可能不相等吗?

#

可能不等。interface 内部由 (动态类型 T, 动态值 V) 两部分组成,只有当 T 和 V 都为 nil 时,接口才等于 nil。把一个类型化的 nil 指针(typed nil,如 (*int)(nil))装入接口后,接口的动态类型不是 nil,所以接口不等于 nil——这是 Go 中最常见的"nil 陷阱"。举个例子:

(*int)(nil)
func foo() error {
    var err *MyError = nil
    return err            // 这里把 typed nil 装进了 error 接口
}

if foo() != nil {         // 进入此分支!因为接口的动态类型是 *MyError,不为 nil
    // ...
}
func foo() error {
    var err *MyError = nil
    return err            // 这里把 typed nil 装进了 error 接口
}

if foo() != nil {         // 进入此分支!因为接口的动态类型是 *MyError,不为 nil
    // ...
}

总结:判断接口是否为 nil 时,必须动态类型和动态值都为 nil 才相等;不要把"typed nil 装入接口"和"nil 接口"混为一谈。

# 1.17 Go 语言函数传参是值类型还是引用类型?

#

在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。

在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。

另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。

另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。

# 1.18 如何知道一个对象是分配在栈上还是堆上?

#

Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

go build -gcflags '-m -m -l' xxx.go.

go build -gcflags '-m -m -l' xxx.go

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

# 1.19 Go语言的多返回值是如何实现的?

#

Go 语言的多返回值是通过在函数调用栈帧上预留空间并进行值复制来实现的。在函数调用发生时,Go 编译器会计算出函数所有返回值的总大小。在为该函数创建栈帧时,就会在调用方(caller)的栈帧上,为这些返回值预留出连续的内存空间。

当函数执行到 return 语句时,它会将其要返回的各个值复制到这些预留好的栈空间中。函数执行完毕后,控制权返回给调用方。此时,调用方可以直接从它自己的栈帧上(即之前为返回值预留的空间)获取这些返回的值。

return

# 1.20 Go语言中"_"的作用

#

忽略多返回值:在 Go 语言中,函数可以返回多个值。如果你只关心其中的一部分返回值,而不需要使用其余的,就可以用 _ 来忽略它们,从而避免编译器报错

忽略多返回值:在 Go 语言中,函数可以返回多个值。如果你只关心其中的一部分返回值,而不需要使用其余的,就可以用 _ 来忽略它们,从而避免编译器报错

_

当你导入一个包时,通常会使用它的某个功能。但有时你可能只想执行包的 init() 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 _ 来进行匿名导入

当你导入一个包时,通常会使用它的某个功能。但有时你可能只想执行包的 init() 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 _ 来进行匿名导入

init()
_

示例:

package main

import (
        "fmt"
        _ "net/http/pprof" // 导入 pprof 包,只为了执行其 init 函数注册 profiling 接口
)

func main() {
        fmt.Println("Application started. Profiling tools are likely registered.")
        // 实际应用中,你可能还会启动一个 HTTP 服务器来暴露 pprof 接口
        // go func() {
        //         log.Println(http.ListenAndServe("localhost:6060", nil))
        // }()
}
package main

import (
        "fmt"
        _ "net/http/pprof" // 导入 pprof 包,只为了执行其 init 函数注册 profiling 接口
)

func main() {
        fmt.Println("Application started. Profiling tools are likely registered.")
        // 实际应用中,你可能还会启动一个 HTTP 服务器来暴露 pprof 接口
        // go func() {
        //         log.Println(http.ListenAndServe("localhost:6060", nil))
        // }()
}

# 1.21 Go语言普通指针和unsafe.Pointer有什么区别?

#

普通指针比如int、string,它们有明确的类型信息,编译器会进行类型检查和垃圾回收跟踪。不同类型的指针之间不能直接转换,这是Go类型安全的体现。

*int
*string

而unsafe.Pointer是Go的通用指针类型,可以理解为C语言中的void*,它绕过了Go的类型系统。unsafe.Pointer可以与任意类型的指针相互转换,也可以与uintptr进行转换来做指针运算。

void*

另外,普通指针受 GC 管理和类型约束,unsafe.Pointer 不受类型约束但仍受 GC 跟踪。

# 1.22 unsafe.Pointer与uintptr有什么区别和联系

#

unsafe.Pointer和uintptr可以相互转换,这是Go提供的唯一合法的指针运算方式。典型用法是先将unsafe.Pointer转为uintptr做算术运算,然后再转回unsafe.Pointer使用。

最关键的区别在于GC跟踪。unsafe.Pointer会被垃圾回收器跟踪,它指向的内存不会被错误回收;而uintptr只是一个普通整数,GC完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。

所以记住:unsafe.Pointer有GC保护,uintptr没有,这是它们最本质的区别。

# 2. Slice面试题

#

# 2.1 slice的底层结构是怎样的?

#

slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

// runtime/slice.go
type slice struct {
        array unsafe.Pointer // 元素指针
        len   int // 长度 
        cap   int // 容量
}
// runtime/slice.go
type slice struct {
        array unsafe.Pointer // 元素指针
        len   int // 长度 
        cap   int // 容量
}

# 2.2 Go语言里slice是怎么扩容的?

#

1.17及以前

如果期望容量大于当前容量的两倍就会使用期望容量;

如果期望容量大于当前容量的两倍就会使用期望容量;

如果当前切片的容量小于 1024 就会将容量翻倍;

如果当前切片的容量小于 1024 就会将容量翻倍;

如果当前切片的容量大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

如果当前切片的容量大于等于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

Go1.18及以后,引入了新的扩容规则:

当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4

# 2.3 从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗

#

在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。

示例:

package main

import "fmt"func main() {
        slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
        s1 := slice[2:5]
        s2 := s1[2:6:7]

        s2 = append(s2, 100)
        s2 = append(s2, 200)

        s1[2] = 20

        fmt.Println(s1)
        fmt.Println(s2)
        fmt.Println(slice)
}
package main

import "fmt"func main() {
        slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
        s1 := slice[2:5]
        s2 := s1[2:6:7]

        s2 = append(s2, 100)
        s2 = append(s2, 200)

        s1[2] = 20

        fmt.Println(s1)
        fmt.Println(s2)
        fmt.Println(slice)
}

运行结果:

[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]
[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

s1 从 slice 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 s2 从 s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。

s1
slice
s2
s1

接着,向 s2 尾部追加一个元素 100:

s2
s2 = append(s2, 100)
s2 = append(s2, 100)

s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。

s2
s1

再次向 s2 追加元素200

s2
s2 = append(s2, 200)
s2 = append(s2, 200)

这时,s2 的容量不够用,该扩容了。于是,s2 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。

s2
s2
append
s2
buffer

最后,修改 s1 索引为2位置的元素:

s1
s1[2] = 20
s1[2] = 20

这次只会影响原始数组相应位置的元素。它影响不到 s2 了,人家已经远走高飞了。

s2

再提一点,打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。

s1
s1

# 2.4 slice作为函数参数传递,会改变原slice吗?

#

当 slice 作为函数参数时,因为会拷贝一份新的slice作为实参,所以原来的 slice 结构并不会被函数中的操作改变,也就是说,slice 其实是一个结构体,包含了三个成员:len, cap, array并不会变化。但是需要注意的是,尽管slice结构不会变,但是其底层数组的数据如果有修改的话,则会发生变化。若传的是 slice 的指针,则原 slice 结构会变,底层数组的数据也会变。

示例:

package main

func main() {
        s := []int{1, 1, 1}
        f(s)
        fmt.Println(s)
}

func f(s []int) {
        // i只是一个副本,不能改变s中元素的值
        /*for _, i := range s {
                i++
        }
        */

        for i := range s {
                s[i] += 1
        }
}
package main

func main() {
        s := []int{1, 1, 1}
        f(s)
        fmt.Println(s)
}

func f(s []int) {
        // i只是一个副本,不能改变s中元素的值
        /*for _, i := range s {
                i++
        }
        */

        for i := range s {
                s[i] += 1
        }
}

程序输出:

[2 2 2]
[2 2 2]

果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 f 函数中,s 只是 main 函数中 s 的一个拷贝。在f 函数内部,对 s 的作用并不会改变外层 main 函数的 s的结构。

f
s
main
s
f
s
main
s

要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子:

slice
package main

import "fmt"

func myAppend(s []int) []int {
        // 这里 s 虽然改变了,但并不会影响外层函数的 s
        s = append(s, 100)
        return s
}

func myAppendPtr(s *[]int) {
        // 会改变外层 s 本身
        *s = append(*s, 100)
        return
}

func main() {
        s := []int{1, 1, 1}
        newS := myAppend(s)

        fmt.Println(s)
        fmt.Println(newS)

        s = newS

        myAppendPtr(&s)
        fmt.Println(s)
}
package main

import "fmt"

func myAppend(s []int) []int {
        // 这里 s 虽然改变了,但并不会影响外层函数的 s
        s = append(s, 100)
        return s
}

func myAppendPtr(s *[]int) {
        // 会改变外层 s 本身
        *s = append(*s, 100)
        return
}

func main() {
        s := []int{1, 1, 1}
        newS := myAppend(s)

        fmt.Println(s)
        fmt.Println(newS)

        s = newS

        myAppendPtr(&s)
        fmt.Println(s)
}

程序输出

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]。

myAppend
s
s
[1 1 1]

而 newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]。

newS
slice
s
100
[1 1 1 100]

最后,将 newS 赋值给了 s,s 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]

newS
s
s
myAppendPtr
s 指针
[1 1 1 100 100]

# 3. Map面试题

#

# 3.1 Go语言Map的底层实现原理是怎样的?

#

map的就是一个hmap的结构。Go Map的底层实现是一个哈希表。它在运行时表现为一个指向hmap结构体的指针,hmap中记录了桶数组指针buckets、溢出桶指针以及元素个数等字段。每个桶是一个bmap结构体,能存储8个键值对和8个tophash,并有指向下一个溢出桶的指针overflow。为了内存紧凑,bmap中采用的是先存8个键再存8个值的存储方式。

hmap
hmap
buckets
bmap
tophash
overflow
bmap

分析:

hmap结构定义:

// A header for a Go map.
type hmap struct {
   count     int // map中元素个数
   flags     uint8 // 状态标志位,标记map的一些状态
   B         uint8  // 桶数以2为底的对数,即B=log_2(len(buckets)),比如B=3,那么桶数为2^3=8
   noverflow uint16 //溢出桶数量近似值
   hash0     uint32 // 哈希种子

   buckets    unsafe.Pointer // 指向buckets数组的指针
   oldbuckets unsafe.Pointer // 是一个指向buckets数组的指针,在扩容时,oldbuckets 指向老的buckets数组(大小为新buckets数组的一半),非扩容时,oldbuckets 为空
   nevacuate  uintptr        // 表示扩容进度的一个计数器,小于该值的桶已经完成迁移

   extra *mapextra // 指向mapextra 结构的指针,mapextra 存储map中的溢出桶
}
// A header for a Go map.
type hmap struct {
   count     int // map中元素个数
   flags     uint8 // 状态标志位,标记map的一些状态
   B         uint8  // 桶数以2为底的对数,即B=log_2(len(buckets)),比如B=3,那么桶数为2^3=8
   noverflow uint16 //溢出桶数量近似值
   hash0     uint32 // 哈希种子

   buckets    unsafe.Pointer // 指向buckets数组的指针
   oldbuckets unsafe.Pointer // 是一个指向buckets数组的指针,在扩容时,oldbuckets 指向老的buckets数组(大小为新buckets数组的一半),非扩容时,oldbuckets 为空
   nevacuate  uintptr        // 表示扩容进度的一个计数器,小于该值的桶已经完成迁移

   extra *mapextra // 指向mapextra 结构的指针,mapextra 存储map中的溢出桶
}

bmap结构如下:

# 3.2 Go语言Map的遍历是有序的还是无序的?

#

Go语言里Map的遍历是完全随机的,并没有固定的顺序。map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。

# 3.3 Go语言Map的遍历为什么要设计成无序的?

#

map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。

Go团队为了避免开发者写出依赖底层实现细节的脆弱代码,而有意为之的一个设计。通过在遍历时引入随机数,Go从根本上杜绝了程序员依赖特定遍历顺序的可能性,强制我们写出更健壮的代码。

# 3.4 Map如何实现顺序读取?

#

如果业务上确实需要有序遍历,最规范的做法就是将Map的键(Key)取出来放入一个切片(Slice)中,用sort包对切片进行排序,然后根据这个有序的切片去遍历Map。

sort
package main

import (
   "fmt"
   "sort"
)

func main() {
   keyList := make([]int, 0)
   m := map[int]int{
      3: 200,
      4: 200,
      1: 100,
      8: 800,
      5: 500,
      2: 200,
   }
   for key := range m {
      keyList = append(keyList, key)
   }
   sort.Ints(keyList)
   for _, key := range keyList {
      fmt.Println(key, m[key])
   }
}
package main

import (
   "fmt"
   "sort"
)

func main() {
   keyList := make([]int, 0)
   m := map[int]int{
      3: 200,
      4: 200,
      1: 100,
      8: 800,
      5: 500,
      2: 200,
   }
   for key := range m {
      keyList = append(keyList, key)
   }
   sort.Ints(keyList)
   for _, key := range keyList {
      fmt.Println(key, m[key])
   }
}

# 3.5 Go语言的Map是否是并发安全的?

#

map 不是线程安全的。

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志已经被置位(说明已经有 goroutine 在写),则直接 throw(runtime 触发 fatal error,不可被 recover 捕获)。赋值和删除函数在检测到写标志为复位状态后,会先将写标志位置位,再进行后续操作。

检测写标志:

if h.flags&hashWriting != 0 {
                throw("concurrent map writes")
        }
if h.flags&hashWriting != 0 {
                throw("concurrent map writes")
        }

设置写标志:

h.flags |= hashWriting
h.flags |= hashWriting

# 3.6 Map的Key一定要是可比较的吗?为什么?

#

Map的Key必须要可比较。

首先,Map会对我们提供的Key进行哈希运算,得到一个哈希值。这个哈希值决定了这个键值对大概存储在哪个位置(也就是哪个"桶"里)。然而,不同的Key可能会产生相同的哈希值,这就是"哈希冲突"。当多个Key被定位到同一个"桶"里时,Map就没法只靠哈希值来区分它们了。此时,它必须在桶内进行逐个遍历,用我们传入的Key和桶里已有的每一个Key进行相等(==)比较。这样才能确保我们操作的是正确的键值对。

# 3.7 Go语言Map的扩容时机是怎样的?

#

向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容

装载因子超过阈值(源码里定义的阈值是 6.5),触发双倍扩容(B+1,buckets 数量翻倍)。

装载因子超过阈值(源码里定义的阈值是 6.5),触发双倍扩容(B+1,buckets 数量翻倍)。

overflow 的 bucket 数量过多(触发等量扩容,B 不变,仅重新整理键值对让分布更紧凑): 当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B; 当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

overflow 的 bucket 数量过多(触发等量扩容,B 不变,仅重新整理键值对让分布更紧凑):

当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;

当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;

当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

# 3.8 Go语言Map的扩容过程是怎样的?

#

Go的扩容是渐进式(gradual)的。它不会在触发扩容时"stop the world"来一次性把所有数据搬迁到新空间,而是只分配新空间,然后在后续的每一次插入、修改或删除操作时,才会顺便搬迁一两个旧桶的数据。这种设计将庞大的扩容成本分摊到了多次操作中,极大地减少了服务的瞬间延迟(STW),保证了性能的平滑性。

如果是触发双倍扩容,会新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。如果是等量扩容,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,这样节省空间,存取效率更高

# 3.9 可以对Map的元素取地址吗?

#

无法对 map 的 key 或 value 进行取址。会发生编译报错,这样设计主要是因为map一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

示例:

package main

import "fmt"

func main() {
        m := make(map[string]int)

        fmt.Println(&m["qcrao"])
}
package main

import "fmt"

func main() {
        m := make(map[string]int)

        fmt.Println(&m["qcrao"])
}

会出现编译报错:

./main.go:8:14: cannot take the address of m["qcrao"]
./main.go:8:14: cannot take the address of m["qcrao"]

# 3.10 Map 中删除一个 key,它的内存会释放么?

#

不会,delete一个key,并不会立刻释放或收缩Map占用的内存。具体来说,delete(m, key) 这个操作,只是把key和value对应的内存块标记为"空闲",让它们的内容可以被后续的垃圾回收(GC)处理掉。但是,Map底层为了存储这些键值对而分配的"桶"(buckets)数组,它的规模是不会缩小的。只有当 map 失去所有引用(被置为 nil 或离开作用域)后,整个 map 的空间才会被垃圾回收后释放。

delete
delete(m, key)

# 3.11 Map可以边遍历边删除吗

#

map 并不是一个线程安全的数据结构。如果多个线程边遍历,边删除,同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。

如果是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。这种情况

下,可以通过加读写锁sync.RWMutex来保证

# 4. Channel面试题

#

# 4.1 什么是CSP?

#

CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点:

避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信

避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信

天然同步:Channel 的发送/接收自带同步机制,无需手动加锁

天然同步:Channel 的发送/接收自带同步机制,无需手动加锁

易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制)

易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制)

# 4.2 Channel的底层实现原理是怎样的?

#

Channel的底层是一个名为hchan的结构体,核心包含几个关键组件:

hchan

环形缓冲区:有缓冲channel内部维护一个固定大小的环形队列,用buf指针指向缓冲区,sendx和recvx分别记录发送和接收的位置索引。这样设计能高效利用内存,避免数据搬移。

buf
sendx
recvx

两个等待队列sendq和recvq:用来管理阻塞的goroutine。sendq存储因channel满而阻塞的发送者,recvq存储因channel空而阻塞的接收者。这些队列用双向链表实现,当条件满足时会唤醒对应的goroutine。

sendq和recvq
sendq
recvq

互斥锁:hchan内部有个mutex,所有的发送、接收操作都需要先获取锁,用来保证并发安全。虽然看起来可能影响性能,但Go的调度器做了优化,大多数情况下锁竞争并不激烈。

hchan

分析:

hchan定义如下:

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
}
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
}

# 4.3 向channel发送数据的过程是怎样的?

#

向channel发送数据的整个过程都会在mutex保护下进行,保证并发安全。会经历几个关键步骤:

首先是检查是否有等待的接收者。如果recvq队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。

首先是检查是否有等待的接收者。如果recvq队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。

recvq

如果没有等待接收者,就尝试写入缓冲区。检查缓冲区是否还有空间,如果qcount < dataqsiz,就把数据复制到buf[sendx]位置,然后更新sendx索引和qcount计数。这是无缓冲或缓冲区未满时的正常流径。

如果没有等待接收者,就尝试写入缓冲区。检查缓冲区是否还有空间,如果qcount < dataqsiz,就把数据复制到buf[sendx]位置,然后更新sendx索引和qcount计数。这是无缓冲或缓冲区未满时的正常流径。

qcount < dataqsiz
buf[sendx]
sendx
qcount

当缓冲区满了就需要阻塞等待。创建一个sudog结构体包装当前goroutine和要发送的数据,加入到sendq等待队列中,然后调用gopark让当前goroutine进入阻塞状态,让出CPU给其他goroutine。

当缓冲区满了就需要阻塞等待。创建一个sudog结构体包装当前goroutine和要发送的数据,加入到sendq等待队列中,然后调用gopark让当前goroutine进入阻塞状态,让出CPU给其他goroutine。

sudog
sendq
gopark

被唤醒后继续执行。当有接收者从channel读取数据后,会从sendq中唤醒一个等待的发送者,被唤醒的goroutine会完成数据发送并继续执行。

sendq

还有个特殊情况是向已关闭的channel发送数据会直接panic。这是Go语言的设计原则,防止向已关闭的通道写入数据。

分析:

package main

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{})
}
package main

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{})
}

在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。

sender 发现 ch 的 recvq 里有 receiver 在等待着接收,就会出队一个 sudog,把 recvq 里 first 指针的 sudo "推举"出来了,并将其加入到 P 的可运行 goroutine 队列中。然后,sender 把发送元素拷贝到 sudog 的 elem 地址处,最后会调用 goready 将 G1 唤醒,状态变为 runnable。

当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine。

这里其实涉及到一个协程写另一个协程栈的操作。有两个 receiver 在 channel 的一边虎视眈眈地等着,这时 channel 另一边来了一个 sender 准备向 channel 发送数据,为了高效,用不着通过 channel 的 buf "中转"一次,直接从源地址把数据 copy 到目的地址就可以了,效率高啊!

上图是一个示意图,3 会被拷贝到 G1 栈上的某个位置,也就是 val 的地址处,保存在 elem 字段。

3

# 4.4 从Channel读取数据的过程是怎样的?

#

从channel读取数据也有几个关键步骤:

首先检查是否有等待的发送者。如果sendq队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。

首先检查是否有等待的发送者。如果sendq队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。

sendq

如果没有等待发送者,尝试从缓冲区读取。检查qcount > 0,如果缓冲区有数据,就从buf[recvx]位置取出数据,然后更新recvx索引和qcount计数。这是缓冲区有数据时的正常路径。

如果没有等待发送者,尝试从缓冲区读取。检查qcount > 0,如果缓冲区有数据,就从buf[recvx]位置取出数据,然后更新recvx索引和qcount计数。这是缓冲区有数据时的正常路径。

qcount > 0
buf[recvx]
recvx
qcount

缓冲区为空时需要阻塞等待。创建sudog结构体包装当前goroutine,加入到recvq等待队列,调用gopark进入阻塞状态。当有发送者写入数据时会被唤醒继续执行。

sudog
recvq
gopark

从已关闭channel读取有特殊处理。如果channel已关闭且缓冲区为空,会返回零值和false标志;如果缓冲区还有数据,可以正常读取直到清空。这就是为什么v, ok := <-ch中的ok能判断channel状态的原因。

v, ok := <-ch

# 4.5 从一个已关闭Channel仍能读出数据吗?

#

从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。

示例:

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.")
        }
}
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.")
        }
}

程序输出:

received:  18
channel closed, data invalid.
received:  18
channel closed, data invalid.

先创建了一个有缓冲的 channel,向其发送一个元素,然后关闭此 channel。之后两次尝试从 channel 中读取数据,第一次仍然能正常读出值。第二次返回的 ok 为 false,说明 channel 已关闭,且通道里没有数据。

# 4.6 Channel在什么情况下会引起内存泄漏?

#

Channel引起内存泄漏最常见的是引起goroutine泄漏从而导致的间接内存泄漏,当goroutine阻塞在channel操作上永远无法退出时,goroutine本身和它引用的所有变量都无法被GC回收。比如一个goroutine在等待接收数据,但发送者已经退出了,这个接收者就会永远阻塞下去。或者select语句使用不当,在没有default分支的select中,如果所有case都无法执行,goroutine会永远阻塞。出现内存泄漏

# 4.7 关闭Channel会产生异常吗?

#

试图重复关闭一个channel、,关闭一个nil值的channel、关闭一个只有接收方向的channel都将导致panic异常。

# 4.8 往一个关闭的Channel写入数据会发生什么?

#

往已关闭的channel写入数据会直接panic。

向已关闭的channel发送数据时,runtime会检测到channel的closed标志位已经设置,立即抛出"send on closed channel"的panic。这个检查发生在发送操作的最开始阶段,甚至在获取mutex锁之前就会进行判断,所以不会有任何数据写入的尝试,直接就panic了。

closed

# 4.9 什么是select?

#

select是Go语言专门为channel操作设计的多路复用控制结构,类似于网络编程中的select系统调用。

核心作用是同时监听多个channel操作。当有多个channel都可能有数据收发时,select能够选择其中一个可执行的case进行操作,而不是按顺序逐个尝试。比如同时监听数据输入、超时信号、取消信号等。

# 4.10 select的执行机制是怎样的?

#

select的执行机制是随机选择。如果多个case同时满足条件,Go会随机选择一个执行,这避免了饥饿问题。如果没有case能执行就会执行default,如果没有default,当前goroutine会阻塞等待。

select {
case data := <-ch1:
    // 处理ch1的数据
case ch2 <- value:
    // 向ch2发送数据  
case <-timeout:
    // 超时处理
default:
    // 所有channel都不可用时执行
}
select {
case data := <-ch1:
    // 处理ch1的数据
case ch2 <- value:
    // 向ch2发送数据  
case <-timeout:
    // 超时处理
default:
    // 所有channel都不可用时执行
}

# 4.11 select的实现原理是怎样的?

#

Go语言实现select时,定义了一个数据结构scase表示每个case语句(包含default)。scase结构包含channel指针、操作类型等信息。select操作的整个过程通过selectgo函数在runtime层面实现。

select
case
default

Go运行时会将所有case进行随机排序,这是为了避免饥饿问题。然后执行两轮扫描策略:第一轮直接检查每个channel是否可读写,如果找到就绪的立即执行;如果都没就绪,第二轮就把当前goroutine加入到所有channel的发送或接收队列中,然后调用gopark进入睡眠状态,使当前goroutine让出CPU。

当某个channel变为可操作时,调度器会唤醒对应的goroutine,此时需要从其他channel的等待队列中清理掉这个goroutine,然后执行对应的case分支。

其核心原理是:case随机化 + 双重循环检测

分析:

scase结构定义:

type scase struct {
    c    *hchan   // channel指针
    elem unsafe.Pointer  // 数据元素指针,用于存放发送/接收的数据
    kind uint16   // case类型:caseNil、caseRecv、caseSend、caseDefault
    pc   uintptr  // 程序计数器,用于调试
    releasetime int64  // 释放时间,用于竞态检测
}
type scase struct {
    c    *hchan   // channel指针
    elem unsafe.Pointer  // 数据元素指针,用于存放发送/接收的数据
    kind uint16   // case类型:caseNil、caseRecv、caseSend、caseDefault
    pc   uintptr  // 程序计数器,用于调试
    releasetime int64  // 释放时间,用于竞态检测
}

在默认的情况下,select 语句会在编译阶段经过如下过程的处理:

将所有的 case 转换成包含Channel以及类型等信息的 scase 结构体;

将所有的 case 转换成包含Channel以及类型等信息的 scase 结构体;

case
Channel

调用运行时函数 selectgo获取被选择的scase 结构体索引,如果当前的scase是一个接收数据的操作,还会返回一个指示当前case 是否是接收的布尔值;

调用运行时函数 selectgo获取被选择的scase 结构体索引,如果当前的scase是一个接收数据的操作,还会返回一个指示当前case 是否是接收的布尔值;

selectgo
scase
scase
case

通过for循环生成一组if语句,在语句中判断自己是不是被选中的 case。

通过for循环生成一组if语句,在语句中判断自己是不是被选中的 case。

for
if
case

# 5. Sync面试题

#

# 5.1 除了 mutex 以外还有那些方式安全读写共享变量?

#

除了Mutex,主要还有信号量、通道(Channel),原子操作(atomic)这几种方式。

信号量的实现其实跟mutex差不多,实现起来也很方便,主要通过信号量计数来保证。chanenl是Go最推崇的方式,它通过通信来传递数据所有权,从根源上避免竞争,更适合复杂的业务逻辑;而原子操作则针对最简单的整型或指针等进行无锁操作,性能最高,常用于实现计数器或状态位。选择哪种,完全取决于数据结构的复杂度和业务的读写模型。

# 5.2 Go 语言是如何实现原子操作的?

#

Go语言实现原子操作,其根本是依赖底层CPU硬件提供的原子指令,而不是通过操作系统或更上层的锁机制。

具体来说,Go的sync/atomic包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台(如x86、ARM)的单条原子机器指令。例如,在x86架构上,atomic.AddInt64这类操作会对应到像LOCK; ADD这样的指令。前面的LOCK前缀是关键,它会锁住总线或缓存行,确保后续的ADD指令在执行期间,其他CPU核心不能访问这块内存,从而保证了整个操作的原子性。

sync/atomic
atomic.AddInt64
LOCK; ADD
LOCK
ADD

# 5.3 聊聊原子操作和锁的区别?

#

原子操作和锁最核心的区别在于它们的实现层级和保护范围。

原子操作是CPU硬件层面的"微观"机制,它保证对单个数据(通常是整型或指针)的单次读改写操作是绝对不可分割的,性能极高,因为它不涉及操作系统内核的介入和goroutine的挂起。

锁则是操作系统或语言运行时提供的"宏观"机制,它保护的是一个代码块(临界区),而不仅仅是单个变量。当获取锁失败时,它会让goroutine休眠,而不是空耗CPU。虽然锁的开销远大于原子操作,但它能保护一段复杂的、涉及多个变量的业务逻辑。

所以,对于简单的计数器或标志位更新,用原子操作追求极致性能;而只要需要保护一段逻辑或多个变量的一致性,就必须用锁。

# 5.4 Go语言互斥锁mutex底层是怎么实现的?

#

mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒

分析

互斥锁对应的是底层结构是sync.Mutex结构体

type Mutex struct {  
  state int32  
  sema  uint32
 }
type Mutex struct {  
  state int32  
  sema  uint32
 }

state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式

sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒

# 5.5 Mutex 有几种模式?

#

Go的Mutex主要有两种模式:正常模式(Normal Mode)和饥饿模式(Starvation Mode)。

Mutex

正常模式:这是默认模式,讲究的是性能。新请求锁的goroutine会和等待队列头部的goroutine竞争,新来的goroutine有几次"自旋"的机会,如果在此期间锁被释放,它就可以直接抢到锁。这种方式吞吐量高,但可能会导致队列头部的goroutine等待很久,即"不公平"。

正常模式:这是默认模式,讲究的是性能。新请求锁的goroutine会和等待队列头部的goroutine竞争,新来的goroutine有几次"自旋"的机会,如果在此期间锁被释放,它就可以直接抢到锁。这种方式吞吐量高,但可能会导致队列头部的goroutine等待很久,即"不公平"。

饥饿模式:当一个 goroutine 在等待队列中等待超过 1 毫秒(1ms)后,Mutex 就会切换到此模式,讲究的是公平。在此模式下,锁的所有权会直接从解锁的goroutine移交给等待队列的头部,新来的goroutine不会自旋,必须排到队尾。这样可以确保队列中的等待者不会被"饿死"。

饥饿模式:当一个 goroutine 在等待队列中等待超过 1 毫秒(1ms)后,Mutex 就会切换到此模式,讲究的是公平。在此模式下,锁的所有权会直接从解锁的goroutine移交给等待队列的头部,新来的goroutine不会自旋,必须排到队尾。这样可以确保队列中的等待者不会被"饿死"。

当等待队列为空,或者一个goroutine拿到锁时发现它的等待时间小于1ms,饥饿模式就会结束,切换回正常模式。这两种模式的动态切换,是Go在性能和公平性之间做的精妙平衡。

# 5.6 在Mutex上自旋的goroutine 会占用太多资源吗

#

并不会,因为Go的自旋设计得非常"克制"和"智能"。

首先,自旋不是无休止的空转,它有严格的次数和时间限制,通常只持续几十纳秒。其次,自旋仅仅在特定条件下才会发生,比如CPU核数大于1,并且当前机器不算繁忙(没有太多goroutine在排队)。它是在赌,与其付出"goroutine挂起和唤醒"这种涉及内核调度的巨大代价,不如原地"稍等一下",因为锁可能马上就释放了。

所以,这种自旋是一种机会主义的短线优化,目的是用极小的CPU开销去避免一次昂贵的上下文切换,在锁竞争不激烈、占用时间极短的场景下,它反而是节省了资源。

# 5.7 Mutex 已经被一个 Goroutine 获取了, 其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢?

#

取决于Mutex当前处于正常模式还是饥饿模式。

在正常模式下,锁的分配是"不公平"的。当锁被释放时,等待队列中的第一个goroutine会被唤醒,但它不一定能拿到锁。它需要和那些此刻刚刚到达、正在自旋的新goroutine进行竞争。新来的goroutine因为正在CPU上运行,很有可能"插队"成功,直接抢到锁。这种策略的优点是吞吐量高,但缺点是可能导致等待队列中的goroutine被饿死。

而一旦Mutex进入饥饿模式,锁的分配就变得"绝对公平"。锁被释放后,会直接移交给等待队列的队头goroutine,任何新来的goroutine都不会参与竞争,必须乖乖排到队尾。

# 5.8 sync.Once 的作用是什么,讲讲它的底层实现原理?

#

sync.Once的作用是确保一个函数在程序生命周期内,无论在多少个goroutine中被调用,都只会被执行一次。它常用于单例对象的初始化或一些只需要执行一次的全局配置加载

sync.Once

sync.Once保证代码段只执行1次的原理主要是其内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了

sync.Once

分析

Once其实是一个结构体

type Once struct {
    done uint32  // 标识位
    m    Mutex
}
type Once struct {
    done uint32  // 标识位
    m    Mutex
}

核心依赖一个uint32的done标志位和一个互斥锁Mutex,

uint32
done
Mutex

当Once.Do(f)首次被调用时:

Once.Do(f)

它首先会通过原子操作(atomic.LoadUint32)快速检查done标志位。如果done为1,说明初始化已完成,直接返回,这个路径完全无锁,开销极小。

它首先会通过原子操作(atomic.LoadUint32)快速检查done标志位。如果done为1,说明初始化已完成,直接返回,这个路径完全无锁,开销极小。

atomic.LoadUint32
done
done

如果done为0,说明可能是第一次调用,这时它会进入一个慢路径(doSlow)。

如果done为0,说明可能是第一次调用,这时它会进入一个慢路径(doSlow)。

done
doSlow

在慢路径里,它会先加锁,然后再次检查done标志位。这个"双重检查"(Double-Checked Locking)是关键,它防止了在多个goroutine同时进入慢路径时,函数f被重复执行。

在慢路径里,它会先加锁,然后再次检查done标志位。这个"双重检查"(Double-Checked Locking)是关键,它防止了在多个goroutine同时进入慢路径时,函数f被重复执行。

done
f

如果此时done仍然为0,那么当前goroutine就会执行传入的函数f。执行完毕后,它会通过原子操作(atomic.StoreUint32)将done标志位置为1,最后解锁。

如果此时done仍然为0,那么当前goroutine就会执行传入的函数f。执行完毕后,它会通过原子操作(atomic.StoreUint32)将done标志位置为1,最后解锁。

done
f
atomic.StoreUint32
done

之后任何再调用Do的goroutine,都会在第一步的原子Load操作时发现done为1而直接返回。整个过程结合了原子操作的速度和互斥锁的安全性,高效且线程安全地实现了"仅执行一次"的保证

Do
Load
done

# 5.9 WaitGroup 是怎样实现协程等待?

#

WaitGroup实现等待,本质上是一个原子计数器和一个信号量的协作。

WaitGroup

调用Add会增加计数值,Done会减计数值。而Wait方法会检查这个计数器,如果不为零,就利用信号量将当前goroutine高效地挂起。直到最后一个Done调用将计数器清零,它就会通过这个信号量,一次性唤醒所有在Wait处等待的goroutine,从而实现等待目的。

Add
Done
Wait
Done
Wait

分析:

waitgroup的结构定义:

// A WaitGroup waits for a collection of goroutines to finish.
// The main goroutine calls Add to set the number of goroutines to wait for.
// Then each of the goroutines runs and calls Done when finished. At the same
// time, Wait can be used to block until all goroutines have finished.
//
// A WaitGroup must not be copied after first use.
type WaitGroup struct {
        noCopy noCopy // 用于vet工具检查是否被复制

        // 64位的值:高32位是计数器,低32位是等待的goroutine数量。
        // 通过原子操作访问,保存了状态和等待者数量。
        state atomic.Uint64

        // 用于等待者休眠的信号量。
        sema uint32
}
// A WaitGroup waits for a collection of goroutines to finish.
// The main goroutine calls Add to set the number of goroutines to wait for.
// Then each of the goroutines runs and calls Done when finished. At the same
// time, Wait can be used to block until all goroutines have finished.
//
// A WaitGroup must not be copied after first use.
type WaitGroup struct {
        noCopy noCopy // 用于vet工具检查是否被复制

        // 64位的值:高32位是计数器,低32位是等待的goroutine数量。
        // 通过原子操作访问,保存了状态和等待者数量。
        state atomic.Uint64

        // 用于等待者休眠的信号量。
        sema uint32
}

noCopy: 这是一个特殊的字段,用于静态分析工具(go vet)在编译时检查WaitGroup实例是否被复制。WaitGroup被复制后会导致状态不一致,可能引发程序错误,因此该字段的存在旨在防止此类问题的发生。

noCopy
go vet
WaitGroup
WaitGroup

state: 这是WaitGroup的核心,一个64位的无符号整型,通过sync/atomic包进行原子操作,以保证并发安全。这个64位的空间被巧妙地分成了两部分:

state
WaitGroup
sync/atomic

高32位: 作为计数器(counter),记录了需要等待的 goroutine 的数量。

高32位: 作为计数器(counter),记录了需要等待的 goroutine 的数量。

低32位: 作为等待者计数器(waiter count),记录了调用Wait()方法后被阻塞的 goroutine 的数量。

低32位: 作为等待者计数器(waiter count),记录了调用Wait()方法后被阻塞的 goroutine 的数量。

Wait()

sema: 这是一个信号量,用于实现 goroutine 的阻塞和唤醒。当主 goroutine 调用Wait()方法且计数器不为零时,它会通过这个信号量进入休眠状态。当所有子 goroutine 完成任务后,会通过这个信号量来唤醒等待的主 goroutine。

sema
Wait()

# 5.10 讲讲sync.Map的底层原理

#

sync.Map的底层核心是"空间换时间",通过两个Map(read和dirty)** 的冗余结构,实现"读写分离",最终达到针对特定场景的"读"操作无锁优化。

sync.Map
read
dirty

它的read是一个只读的map,提供无锁的并发读取,速度极快。写操作则会先操作一个加了锁的、可读写的dirty map。当dirty map的数据积累到一定程度,或者read map中没有某个key时,sync.Map会将dirty map里的数据"晋升"并覆盖掉旧的read map,完成一次数据同步。

read
map
dirty
dirty
read
sync.Map
dirty
read

分析:

sync.Map的结构定义

sync.Map
type Map struct {
   mu Mutex             //  用于保护dirty字段的锁
   read atomic.Value    // 只读字段,其实际的数据类型是一个readOnly结构
   dirty map[interface{}]*entry  //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素以及新加入的元素
   misses int // 计数器,记录在从read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read
}
type Map struct {
   mu Mutex             //  用于保护dirty字段的锁
   read atomic.Value    // 只读字段,其实际的数据类型是一个readOnly结构
   dirty map[interface{}]*entry  //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素以及新加入的元素
   misses int // 计数器,记录在从read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read
}

read字段的类型是atomic.Value,但是在使用中里面其实存储的是readOnly结构,readOnly结构定义如下:

read
atomic.Value
readOnly
readOnly
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
   m       map[interface{}]*entry   // key为任意可比较类型,value为entry指针
   amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中都存在
}
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
   m       map[interface{}]*entry   // key为任意可比较类型,value为entry指针
   amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中都存在
}

entry这个结构:

entry
type entry struct {
    p unsafe.Pointer  // p指向真正的value所在的地址
}
type entry struct {
    p unsafe.Pointer  // p指向真正的value所在的地址
}

# 5.11 read map和dirty map之间有什么关联?

#

它们之间是"只读缓存"和"最新全集"的关联。

read map是dirty map的一个不完全、且可能是过期的只读快照。dirty map则包含了所有的最新数据。

read
dirty
dirty

具体来说,read map中的所有数据,在dirty map里一定存在。一个key如果在read map里,那它的value要么就是最终值,要么就是一个特殊指针,指向dirty map里对应的条目。而dirty map里有,read map里却可能没有,因为dirty是最新、最全的。

read
dirty
read
dirty
dirty
read
dirty

当dirty map积累了足够多的新数据后,它会"晋升"为新的read map,旧的read map则被废弃。这个过程,就完成了"缓存"的更新。

dirty
read
read

# 5.12 为什么要设计nil和expunged两种删除状态?

#

设计nil和expunged这两个状态,是为了解决在sync.Map的"读写分离"架构下,如何高效、无锁地处理"删除"操作。

nil
expunged
sync.Map

因为 read map 本身是只读的,我们不能直接从中删除一个 key。所以,当用户调用 Delete 时,如果这个 key 存在于 read 中,系统会通过原子操作把 entry.p 置为 nil(这就是"逻辑删除"状态),后续的读操作如果看到 nil 就直接返回 nil, false。

read
Delete
read
nil
nil
nil, false

而 expunged 是更彻底的删除标记:当之后某次写操作需要基于 read 重建 dirty map 时,会扫描 read,把所有 p == nil 的 entry 进一步标记为 expunged,表示"该 key 不再被复制到新的 dirty 中"。后续如果要重新 Store 一个 expunged 的 key,必须先把它从 expunged 回退为 nil 并同步到 dirty,再写入。

expunged
read
dirty
read
p == nil
expunged
Store
expunged
nil
dirty