基础
1. Go里面的int和int32是同一个概念吗
不是一个概念。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。
int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。
2. 两个interface可以比较吗
在 Go 语言中,两个 interface 类型的变量是可以比较的,但需要满足一定的条件。以下是详细的说明:
- 动态类型和动态值
两个interface变量可以使用==或!=运算符进行比较的前提是,它们的动态类型必须是可比较的。如果两个接口的动态类型相同,并且它们的动态值也相等,那么这两个接口被认为是相等的。 - 不可比较的动态类型
如果接口的动态类型包含不可比较的类型(例如切片、映射、函数等),则尝试比较这些接口会导致编译错误或运行时 panic。 - 空接口的情况
对于空接口(interface{}),只要它们的动态类型和动态值都相同,就可以比较。例如,两个空接口分别存储了相同的整数值,则它们可以通过==判断为相等。 - 特殊情况:nil 接口
如果一个接口变量是nil,它的动态类型和动态值都被认为是nil。此时,它与另一个nil接口变量相等,但与非nil的接口变量不相等。 - 深度比较
如果需要对复杂数据结构(如结构体、数组等)进行深度比较,可以使用reflect.DeepEqual函数。它可以接受两个interface{}类型的参数,并递归地比较它们的内容。
示例代码
package main
import (
"fmt"
"reflect"
)
func main() {
var a, b interface{}
// 情况1:动态类型和动态值都相同
a = 42
b = 42
fmt.Println(a == b) // 输出: true
// 情况2:动态类型相同,但动态值不同
a = 42
b = 43
fmt.Println(a == b) // 输出: false
// 情况3:动态类型不同
a = 42
b = "42"
fmt.Println(a == b) // 输出: false
// 情况4:nil 接口
a = nil
b = nil
fmt.Println(a == b) // 输出: true
// 情况5:使用 reflect.DeepEqual 进行深度比较
type Person struct {
Name string
Age int
}
p1 := Person{"Alice", 30}
p2 := Person{"Alice", 30}
a = p1
b = p2
fmt.Println(a == b) // 输出: false (因为结构体不能直接比较)
fmt.Println(reflect.DeepEqual(a, b)) // 输出: true
}总结
- 两个
interface变量可以比较,但前提是它们的动态类型和动态值都满足可比较性。 - 对于复杂类型的深度比较,推荐使用
reflect.DeepEqual方法。 - 需要注意的是,某些类型(如切片、映射等)本身是不可比较的,因此包含这些类型的接口变量也无法直接比较 。
3. 两个nil可能不相等吗
在 Go 语言中,两个 nil 确实可能不相等。这种现象的根本原因在于 Go 的接口类型和 nil 的动态特性。
原因分析
在 Go 中,nil 不仅仅是一个简单的零值,它还包含了类型信息。对于接口类型的变量,其内部由两部分组成:
- 动态类型(Type) :表示接口当前存储的值的具体类型。
- 动态值(Value) :表示接口当前存储的实际值。
只有当接口的动态类型和动态值都为 nil 时,该接口才等于 nil。如果两个接口变量的动态类型不同,即使它们的动态值都是 nil,它们也不会相等。
示例代码
以下是一些具体的例子来说明两个 nil 可能不相等的情况:
情况 1:接口的动态类型不同
package main
import "fmt"
func main() {
var a interface{} = nil
var b *int = nil
fmt.Println(a == b) // 输出: false
}- 在这个例子中,
a是一个空接口(interface{}),它的动态类型和动态值都为nil。 b是一个指向int的指针,虽然它的动态值是nil,但它的动态类型是*int。- 因此,
a和b不相等 。
情况2:接口与非接口的比较
package main
import "fmt"
func main() {
var a interface{} = (*int)(nil)
var b *int = nil
fmt.Println(a == b) // 输出: true
}- 在这个例子中,
a是一个接口,其动态类型是*int,动态值是nil。 b是一个普通的指针类型,值为nil。- 尽管
a和b的动态值相同,但由于a的动态类型是*int,它们实际上是相等的。
情况3:接口的动态类型不为 nil
package main
import "fmt"
func main() {
var a interface{} = (*int)(nil)
fmt.Println(a == nil) // 输出: false
}- 这里,
a是一个接口,其动态类型是*int,动态值是nil。 - 虽然动态值为
nil,但由于动态类型不为nil,因此a不等于nil。
4. 函数返回局部变量的指针是否安全
这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。
5. 相比较其他语言,Go有什么优势或者特点
- Go允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行
- Go在语言层次上天生支持高并发,通过goroutine和channel实现。channel的理论依据是CSP并发模型,即所谓的通过通信来共享内存;Go在runtime运行时里实现了属于自己的调度机制:GMP,降低了内核态和用户态的切换成本。
- Go语法简单,代码风格比较统一
6. Go是面向对象的吗
Go不是面向对象语言,但可以进行面向对象风格的编程。
1. 封装(Encapsulation)
- 结构体(struct):类似于类的数据封装,通过定义结构体封装属性和方法。
- 方法(method):通过为结构体定义方法(函数前加
(s StructType)接收者)实现对象的行为。 - 访问控制:通过标识符首字母的大小写控制可见性(大写为公有,小写为私有)。
// 定义结构体(封装数据)
type Person struct {
name string // 私有字段(小写开头)
Age int // 公有字段(大写开头)
}
// 定义方法(封装行为)
func (p *Person) SayHello() {
fmt.Printf("Hello, my name is %s\n", p.name)
}
// 构造函数(模拟封装逻辑)
func NewPerson(name string, age int) *Person {
return &Person{name: name, Age: age}
}2. 组合代替继承(Composition over Inheritance)
Go 不支持传统继承,但通过结构体嵌套(组合)实现代码复用和层次关系。
type Animal struct {
Name string
}
func (a *Animal) Move() {
fmt.Println(a.Name, "is moving")
}
// 通过组合 Animal 复用其字段和方法
type Dog struct {
Animal // 匿名嵌套(类似继承)
Breed string
}
// 扩展方法
func (d *Dog) Bark() {
fmt.Println(d.Name, "says: Woof!")
}
func main() {
d := Dog{
Animal: Animal{Name: "Buddy"},
Breed: "Golden Retriever",
}
d.Move() // 调用组合类型的方法
d.Bark() // 调用自身方法
}3. 多态(Polymorphism)
通过接口(interface)实现多态。接口定义一组方法签名,任何类型只要实现了这些方法,就隐式实现了该接口(无需显式声明)。
// 定义接口
type Speaker interface {
Speak() string
}
// 类型1:实现接口
type Human struct{}
func (h Human) Speak() string {
return "Hello, I'm a human."
}
// 类型2:实现接口
type Robot struct{}
func (r Robot) Speak() string {
return "Beep boop, I'm a robot."
}
// 多态函数
func Greet(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
Greet(Human{}) // 输出:Hello, I'm a human.
Greet(Robot{}) // 输出:Beep boop, I'm a robot.
}7. Go中make和new的区别
- make只能用来分配及初始化类型为slice、map、chan的数据。
- new分配返回的是指针,即类型*Type。make返回数据类型本身,即Type。
- new分配的空间被清零。make分配空间后,会进行初始化。
8. 数组和切片的区别,切片怎么扩容
区别:
数组是值类型,长度固定;切片是引用类型,长度不固定,可以动态扩容。
扩容:
见Golang Basic那边文章
9. Go中for i, v := range 切片,v地址会变化吗
Go 1.22 之前的行为
现象
- 变量
v的地址固定:在每次迭代中,v的地址不会改变,Go 会复用同一个内存地址来存储当前迭代的值。 - 闭包陷阱:如果在循环中使用闭包(如
goroutine或延迟函数)并引用v,所有闭包会共享同一个v的地址,最终捕获的值可能是最后一次迭代的值。
示例代码
slice := []int{1, 2, 3}
for i, v := range slice {
fmt.Printf("迭代 %d: v值=%d, v地址=%p\n", i, v, &v)
}输出(Go 1.21 及之前)
迭代 0: v值=1, v地址=0xc00001a0a0
迭代 1: v值=2, v地址=0xc00001a0a0
迭代 2: v值=3, v地址=0xc00001a0a0v的地址始终相同,但值被覆盖。
闭包问题示例
var funcs []func()
slice := []int{1, 2, 3}
for _, v := range slice {
funcs = append(funcs, func() { fmt.Println(v) })
}
for _, f := range funcs { f() }- 输出:
3 3 3(所有闭包共享v的地址,最终值为最后一次迭代的值)。
Go 1.22 及之后的行为
现象
- 变量
v的地址变化:每次迭代会为v分配新的内存地址,确保每个迭代的v是独立变量。 - 闭包安全:闭包捕获的是当前迭代的独立
v,行为更符合直觉。
示例代码(同上)
slice := []int{1, 2, 3}
for i, v := range slice {
fmt.Printf("迭代 %d: v值=%d, v地址=%p\n", i, v, &v)
}输出(Go 1.22 及之后)
迭代 0: v值=1, v地址=0xc00001a0a0
迭代 1: v值=2, v地址=0xc00001a0a8
迭代 2: v值=3, v地址=0xc00001a0b0v的地址每次迭代都不同,每个v是独立的。
闭包问题修复
var funcs []func()
slice := []int{1, 2, 3}
for _, v := range slice {
funcs = append(funcs, func() { fmt.Println(v) })
}
for _, f := range funcs { f() }- 输出:
1 2 3(每个闭包捕获独立的v值)。
为何 Go 1.22 要改变这种行为?
- 解决历史痛点
在旧版本中,开发者需要显式复制
v到闭包内才能避免共享问题,例如:for _, v := range slice { v := v // 显式复制 funcs = append(funcs, func() { fmt.Println(v) }) }- Go 1.22 通过自动为每次迭代生成独立变量,消除了这种样板代码,减少了错误。
- 提升代码安全性
- 旧行为容易导致隐蔽的并发 Bug(如
goroutine中意外共享变量)。 - 新行为让循环变量的作用域更符合直觉,避免“最后一值陷阱”。
- 语言一致性
- 旧行为中,
for循环的变量作用域与if、switch等语句不一致(后者的变量在每次代码块中是独立的)。 - Go 1.22 统一了变量作用域规则,提升语言设计的一致性。
10. Go多个defer的执行顺序
defer的执行顺序类似于栈,是LIFO,先声明的defer语句后执行
11. Go中解析tag是怎么实现的?反射的原理是什么
Go中解析tag是通过反射实现的。
反射是指计算机程序在运行时动态检测、访问和修改自身结构或行为的能力。例如,程序可获取对象的类型、属性、方法等元数据,并动态调用或修改它们。
Go反射是通过接口来实现的,通过隐式转换,普通类型被转换为interface类型,这个过程涉及到类型转换的过程,首先从Go类型转为interface类型,再从interface类型转换为反射类型,再从反射类型得到想要的类型和值的信息。
12. 空struct{}占用空间吗
可以使用unsafe.Sizeof计算出一个数据类型实例需要占用的字节数:
package main
import (
"fmt"
"unsafe"
)
func main() {
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
}结论:空struct{}实例不占用空间
13. 介绍一下CSP模型,并讲讲和共享变量通信有什么区别
CSP 模型(Communicating Sequential Processes)
CSP(通信顺序进程) 是由计算机科学家 Tony Hoare 在 1978 年提出的并发编程模型,核心思想是通过通信而非共享内存来实现并发协作。Go 语言的设计(如 goroutine 和 channel)正是基于 CSP 模型的经典实现。
核心概念
进程(Processes):
- 并发执行的独立单元(在 Go 中对应 goroutine)。
- 每个进程独立运行,通过通信与其他进程交互。
通信(Communication):
- 进程间通过通道(Channel)传递消息(数据)。
- 通信是同步的(默认行为):发送方和接收方必须同时准备好才能完成数据传递。
顺序性(Sequentiality):
- 每个进程内部是顺序执行的,但多个进程可以并发运行。
CSP 与共享变量通信的区别
| 特性 | CSP 模型 | 共享变量通信 |
|---|---|---|
| 数据共享方式 | 通过通道传递数据,不直接共享内存 | 直接通过共享内存区域读写数据 |
| 同步机制 | 隐式同步(通过 channel 的发送/接收操作) | 显式同步(锁、信号量、原子操作等) |
| 竞态条件风险 | 天然避免数据竞争(无共享内存) | 需开发者手动管理锁,否则易出现数据竞争 |
| 代码复杂度 | 低(逻辑清晰,避免锁的嵌套) | 高(需处理死锁、锁粒度、条件变量等问题) |
| 设计哲学 | “通过通信共享内存” | “通过共享内存通信” |
| 适用场景 | 高并发、松耦合的任务协作(如管道、事件驱动) | 低层性能优化、需精细控制内存访问的场景 |
Context
1. Go中context的结构是怎么样的
Context接口
Context接口定义了四个方法,所有具体实现都必须满足这些方法:
type Context interface {
Deadline() (deadline time.Time, ok bool) // 返回设置的截止时间(如有)
Done() <-chan struct{} // 返回一个通道,用于监听取消信号
Err() error // 返回context被取消的原因
Value(key any) any // 获取与key关联的值
}Context的实现类型
标准库中提供了几种主要的Context实现:
(1) emptyCtx
- 根Context:不可取消、无值、无截止时间。
- 实例:通过
context.Background()和context.TODO()创建。 - 用途:作为其他Context的父节点。
(2) cancelCtx
- 可取消的Context:通过
WithCancel(parent)创建。 结构:
type cancelCtx struct { Context // 嵌入父Context mu sync.Mutex // 保护以下字段 done atomic.Value // 关闭的通道(懒初始化) children map[canceler]struct{} // 子Context集合 err error // 取消原因 }- 特点:调用
cancel()时,关闭done通道,递归取消所有子Context。
(3) timerCtx
- 定时取消的Context:通过
WithDeadline()或WithTimeout()创建。 结构:
type timerCtx struct { cancelCtx // 继承cancelCtx timer *time.Timer // 定时器 deadline time.Time // 截止时间 }- 特点:在截止时间到达时自动调用
cancel()。
(4) valueCtx
- 携带键值对的Context:通过
WithValue(parent, key, val)创建。 结构:
type valueCtx struct { Context // 嵌入父Context key, val any // 键值对 }- 特点:通过链式查找(向父Context回溯)获取值。
3. Context的派生关系
Context通过树形结构组织,形成父子关系:
- 根节点:
emptyCtx(如Background())。 - 子节点:通过
WithCancel、WithDeadline、WithValue等函数派生的新Context。 - 取消传播:父Context被取消时,所有子Context会递归取消。
4. 使用场景与示例
(1) 传递取消信号
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保资源释放
go func(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("任务取消")
case <-time.After(2 * time.Second):
fmt.Println("任务完成")
}
}(ctx)
// 提前取消任务
cancel()(2) 设置超时
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
select {
case <-ctx.Done():
fmt.Println("超时:", ctx.Err()) // 输出:超时: context deadline exceeded
}(3) 传递请求作用域的值
type keyType string
ctx := context.WithValue(context.Background(), keyType("user"), "Alice")
func getUser(ctx context.Context) {
if user := ctx.Value(keyType("user")); user != nil {
fmt.Println("用户:", user) // 输出:用户: Alice
}
}5. 最佳实践
- 参数位置:Context应作为函数的第一个参数传递(如
func Do(ctx context.Context, ...))。 - 避免存储:不要将Context存储在结构体中,应显式传递。
- 谨慎使用Value:仅传递请求作用域的值(如请求ID、认证令牌),避免滥用。
- 及时取消:调用
WithCancel后使用defer cancel(),防止goroutine泄露。
2. Context使用场景和用途
- Context主要用来在goroutine之间传递上下文信息,比如传递请求的trace_id,以便于追踪全局唯一请求
- 另一个用处是可以用来做取消控制,通过取消信号和超时时间来控制子goroutine的退出,防止goroutine泄露
Channel
1. Channel是否线程安全,锁用在什么地方
channel 的线程安全性
(1) 为什么 channel 是线程安全的?
- 内部锁机制:每个 channel 内部维护一个互斥锁(
hchan.lock),保护对 channel 状态(如缓冲区、等待队列)的并发访问。 - 原子操作:发送(
ch <- v)和接收(<-ch)操作的底层实现通过锁和调度机制保证原子性。 - 同步语义:无缓冲 channel 的通信是同步的(直接传递数据),有缓冲 channel 的缓冲区访问受锁保护。
(2) 用户无需额外加锁
开发者直接使用 channel 的发送/接收操作时,无需显式加锁,因为 Go 运行时已处理并发安全问题。
channel 内部锁的用途
channel 的底层结构 hchan(定义在 runtime/chan.go)包含一个互斥锁 lock,用于保护以下关键部分:
type hchan struct {
lock mutex // 互斥锁,保护 channel 状态
sendq waitq // 发送等待队列(sudog 链表)
recvq waitq // 接收等待队列(sudog 链表)
buf unsafe.Pointer // 环形缓冲区指针
qcount uint // 缓冲区当前元素数量
dataqsiz uint // 缓冲区总容量
// ... 其他字段
}(1) 锁保护的操作
| 操作 | 锁的作用 |
|---|---|
| 发送数据(send) | 加锁 → 检查缓冲区或接收队列 → 写入数据或加入发送队列 → 解锁 |
| 接收数据(recv) | 加锁 → 检查缓冲区或发送队列 → 读取数据或加入接收队列 → 解锁 |
| 关闭 channel(close) | 加锁 → 标记关闭状态 → 唤醒所有等待的 goroutine → 解锁 |
(2) 示例:发送操作(简化逻辑)
func chansend(c *hchan, ep unsafe.Pointer, block bool) bool {
lock(&c.lock) // 加锁
// 检查 channel 是否已关闭、缓冲区是否可写入等
// ...
unlock(&c.lock) // 解锁
return true
}2. channel的底层实现原理

底层数据结构:hchan
Channel 的底层结构体 hchan(定义在 runtime/chan.go)包含以下关键字段:
type hchan struct {
qcount uint // 缓冲区当前元素数量
dataqsiz uint // 缓冲区总容量
buf unsafe.Pointer // 环形缓冲区指针(有缓冲 channel 专用)
elemsize uint16 // 元素类型大小
closed uint32 // 关闭标记(0-未关闭,1-已关闭)
sendx uint // 缓冲区发送索引(指向下一个写入位置)
recvx uint // 缓冲区接收索引(指向下一个读取位置)
lock mutex // 互斥锁,保护 channel 状态
recvq waitq // 接收等待队列(sudog 链表)
sendq waitq // 发送等待队列(sudog 链表)
}核心组件说明
| 字段 | 作用 |
|---|---|
buf | 有缓冲 channel 的环形缓冲区,存储待处理元素 |
sendx/recvx | 指向缓冲区写入/读取位置,实现环形队列 |
recvq/sendq | 阻塞的接收者/发送者队列(每个元素为 sudog,封装等待的 goroutine 信息) |
lock | 互斥锁,保护 hchan 所有字段的并发访问 |
发送(Send)流程
当执行 ch <- v 时,底层调用 chansend 函数,流程如下:
(1) 快速路径(Fast Path)
- 加锁:获取
hchan.lock。 直接传递(无缓冲且存在接收者):
- 将数据直接拷贝到接收者的栈内存。
- 唤醒接收者 goroutine。
缓冲区写入(有缓冲且缓冲区未满):
- 将数据写入
buf的sendx位置。 - 更新
sendx和qcount。
- 将数据写入
- 解锁:释放
hchan.lock,操作完成。
(2) 阻塞路径(Slow Path)
若上述条件不满足(缓冲区已满或无接收者):
- 封装为
sudog:将当前 goroutine 和待发送数据封装为sudog。 - 加入
sendq队列:将sudog加入hchan.sendq。 - 挂起 goroutine:调用
gopark让出 CPU,进入阻塞状态。 - 唤醒后处理:当被接收者唤醒时,完成数据传递。
接收(Receive)流程
当执行 <-ch 时,底层调用 chanrecv 函数,流程如下:
(1) 快速路径(Fast Path)
- 加锁:获取
hchan.lock。 直接接收(无缓冲且存在发送者):
- 从发送者的栈内存拷贝数据。
- 唤醒发送者 goroutine。
缓冲区读取(有缓冲且缓冲区非空):
- 从
buf的recvx位置读取数据。 - 更新
recvx并减少qcount。
- 从
- 解锁:释放
hchan.lock,操作完成。
(2) 阻塞路径(Slow Path)
若上述条件不满足(缓冲区为空或无发送者):
- 封装为
sudog:将当前 goroutine 封装为sudog。 - 加入
recvq队列:将sudog加入hchan.recvq。 - 挂起 goroutine:调用
gopark让出 CPU。 - 唤醒后处理:当被发送者唤醒时,接收数据。
阻塞与唤醒机制
(1) sudog 结构体
type sudog struct {
g *g // 关联的 goroutine
elem unsafe.Pointer // 数据指针(发送值或接收变量地址)
next *sudog // 链表指针
prev *sudog // 链表指针
c *hchan // 关联的 channel
// ... 其他字段(如等待时间、唤醒标记等)
}- 作用:表示一个等待在 channel 上的 goroutine,用于管理等待队列。
(2) 唤醒条件
- 发送者唤醒接收者:当发送者写入数据到缓冲区或直接传递数据时,唤醒
recvq中的等待者。 - 接收者唤醒发送者:当接收者读取数据后,缓冲区腾出空间,唤醒
sendq中的等待者。
关闭(Close)流程
当执行 close(ch) 时:
- 加锁:获取
hchan.lock。 - 标记关闭:设置
hchan.closed = 1。 唤醒所有等待者:
- 遍历
recvq和sendq,唤醒所有阻塞的 goroutine。 - 接收者收到零值,发送者触发 panic。
- 遍历
- 解锁:释放
hchan.lock。
3. 对channel进行读写数据的流程是怎样的
无缓冲Channel的读写流程
发送操作(Send)
- 锁定channel:获取互斥锁,防止并发冲突。
检查接收队列:
- 存在等待的接收方:直接将数据拷贝到接收方的内存,唤醒接收方goroutine。
- 无接收方:将当前goroutine加入发送队列(sendq),阻塞等待。
- 释放锁:操作完成后释放互斥锁。
接收操作(Receive)
- 锁定channel:获取互斥锁。
检查发送队列:
- 存在等待的发送方:直接从发送方拷贝数据,唤醒发送方goroutine。
- 无发送方:将当前goroutine加入接收队列(recvq),阻塞等待。
- 释放锁:操作完成后释放互斥锁。
有缓冲Channel的读写流程
发送操作(Send)
- 锁定channel。
检查缓冲区状态:
- 缓冲区未满:将数据写入缓冲区,更新写入索引。
- 缓冲区已满:加入发送队列(sendq),阻塞等待。
- 释放锁。
接收操作(Receive)
- 锁定channel。
检查缓冲区状态:
- 缓冲区非空:读取数据,更新读取索引。
- 缓冲区为空:加入接收队列(recvq),阻塞等待。
- 释放锁。
4. Select的底层原理
Select也被称为多路select,指的是一个goroutine可以服务多个channel的读或写操作,包含非阻塞型select(包含default分支)和阻塞型select(不包含default分支)
- Select的核心原理是,按照随机的顺序执行case,直到某个case完成操作,如果所有case都没有完成操作,则看有没有default分支,如果有default分支,则直接走default,防止阻塞。
- 如果没有的话,需要将当前goroutine加入到所有case对应channel的等待队列中,并挂起当前goroutine,等待唤醒。
- 如果当前goroutine被某一个case上的channel操作唤醒后,还需要将当前的goroutine从所有的case对应channel的等待队列中剔除。
5. 阻塞型select的执行流程
前置条件
- 阻塞型
select:所有case均为 channel 操作(发送或接收),且无default分支。 - 触发场景:所有
case关联的 channel 均无法立即完成操作(例如无缓冲 channel 无配对操作,有缓冲 channel 缓冲区已满/空)。
执行流程
步骤 1:初始化与随机轮询
- 生成随机顺序:编译器为所有
case生成一个随机轮询顺序(pollorder),避免固定顺序导致饥饿问题。 - 封装
scase:每个case被转换为scase结构体,包含 channel 指针、操作类型(发送/接收)和数据指针。
步骤 2:快速路径检查
遍历所有
case:按照随机顺序检查每个case是否可立即操作:- 发送操作:检查 channel 的缓冲区是否有空间(有缓冲)或是否有等待的接收方(无缓冲)。
- 接收操作:检查 channel 的缓冲区是否有数据(有缓冲)或是否有等待的发送方(无缓冲)。
- 立即执行:若发现某个
case就绪,直接执行操作并返回该case的索引。
步骤 3:慢速路径(阻塞等待)
若所有 case 均未就绪:
挂起当前 goroutine:
- 将当前 goroutine 封装为
sudog结构体。 加入所有
case的等待队列:- 发送操作(
caseSend)加入 channel 的sendq(发送队列)。 - 接收操作(
caseRecv)加入 channel 的recvq(接收队列)。
- 发送操作(
- 将当前 goroutine 封装为
休眠让出 CPU:
- 调用
gopark函数,将当前 goroutine 状态设置为Gwaiting,脱离调度器的运行队列。 - 触发调度器切换,执行其他就绪的 goroutine。
- 调用
步骤 4:唤醒与响应
唤醒条件:当某个
case对应的 channel 操作就绪时(例如其他 goroutine 发送/接收数据):- 发送方唤醒:接收方从 channel 读取数据后,唤醒发送队列中的 goroutine。
- 接收方唤醒:发送方向 channel 写入数据后,唤醒接收队列中的 goroutine。
随机选择就绪
case:- 若多个
case同时就绪,按照随机顺序选择一个执行。 - 将 goroutine 从所有关联 channel 的等待队列中移除,避免重复唤醒。
- 若多个
步骤 5:执行就绪操作
完成数据传递:
- 发送操作:将数据写入 channel 缓冲区或直接拷贝到接收方内存。
- 接收操作:从 channel 缓冲区读取数据或直接从发送方内存拷贝。
- 返回结果:
select返回已执行case的索引,程序继续运行。
6. 对值为nil的channel和已关闭的channel进行读、写、关闭会发生什么
nil channel(未初始化的 channel)
var ch chan int // ch 为 nil| 操作 | 行为 | 结果 |
|---|---|---|
| 发送数据 | ch <- v | 永久阻塞 |
| 接收数据 | <-ch 或 v := <-ch | 永久阻塞 |
| 关闭 | close(ch) | 触发 panic |
说明:
- nil channel 未被初始化,所有操作均无法正常执行。
- 发送/接收:操作会永久阻塞当前 goroutine(类似死锁)。
- 关闭:直接触发运行时 panic(
panic: close of nil channel)。
已关闭的 channel(closed channel)
ch := make(chan int)
close(ch) // ch 已关闭| 操作 | 行为 | 结果 |
|---|---|---|
| 发送数据 | ch <- v | 触发 panic |
| 接收数据 | <-ch 或 v := <-ch | 返回零值 |
| 再次关闭 | close(ch) | 触发 panic |
说明:
- 已关闭的 channel 无法再写入数据,但仍可读取剩余数据(若有缓冲)。
- 发送:触发 panic(
panic: send on closed channel)。 接收:
- 有缓冲且未读完:返回缓冲区中的剩余数据。
- 缓冲已空或为无缓冲:返回元素类型的零值(如
int返回0)。
- 重复关闭:触发 panic(
panic: close of closed channel)。
Map
1. Map是否是线程安全的
Map不是线程安全的。
如果某个线程正在对map进行写操作,那么其他线程就不能对该map执行并发操作(读、写、删除),否则会导致进程崩溃。
在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志等于1,则直接fatal退出程序。赋值和删除函数在检测完写标志是0之后,先将写标志改成1,才会进行之后的操作。
2. Map为何不设计成线程安全
- 性能考量:加锁会带来额外开销,而许多场景下
map仅在单 goroutine 中使用。 - 设计哲学:Go 强调“通过通信共享内存”,而非依赖全局锁。开发者应根据需求选择同步策略。
3. Map遍历是有序的还是无序的?为什么要这样设计
- Map遍历是无序的,map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,随意是无序的。
- 因为map在扩容后,会发生key的搬迁,原来落在同一个bucket的key,搬迁后,有些key的位置就会发生改变。而遍历的过程,就是按顺序遍历bucket,同时按顺序遍历bucket中的key。搬迁后,key的位置发生了重大的变化,这样,遍历map的结果就不可能按照原来的顺序了。所以,go语言强调每次遍历都随机开始。
4. Map中删除一个key,它的内存会释放吗
不会释放,删除一个key,可以认为是标记删除,只是修改key对应内存位置的值为空,并不会释放内存,只有在置空这个map的时候,整个map的空间才会被垃圾回收后释放。
5. Go中怎么处理对map进行并发访问?有没有其他方案?区别是什么?
使用互斥锁(Mutex/RWMutex)
实现方式:使用
sync.Mutex或sync.RWMutex对map的读写操作加锁。type SafeMap struct { data map[string]interface{} mu sync.RWMutex } func (m *SafeMap) Get(key string) interface{} { m.mu.RLock() defer m.mu.RUnlock() return m.data[key] } func (m *SafeMap) Set(key string, value interface{}) { m.mu.Lock() defer m.mu.Unlock() m.data[key] = value }- 适用场景:适用于大多数简单场景,尤其是读写操作较均衡的情况。
- 优点:实现简单,代码直观。
- 缺点:高并发时锁竞争可能成为性能瓶颈,RWMutex在读多写少时性能更优。
使用sync.Map
实现方式:使用Go标准库的
sync.Map,专为并发访问设计。var m sync.Map m.Store("key", "value") value, ok := m.Load("key")- 适用场景:读多写少,尤其是不同goroutine操作不同键的情况。
- 优点:无需显式加锁,内部优化减少锁竞争;适合动态键集。
- 缺点:接口与普通map不同,复杂操作(如遍历)效率较低,频繁写同一键性能差。
区别
实现原理
| 方案 | 锁(Mutex/RWMutex) | sync.Map |
|---|---|---|
| 底层机制 | 通过显式加锁(互斥锁或读写锁)保护临界区。 | 内部通过原子操作(atomic)+ 分段锁优化,结合读写分离设计。 |
| 数据结构 | 普通的 map,需手动加锁。 | 内部维护两个部分:read(原子读)和 dirty(临时写)。 |
| 锁粒度 | 粗粒度锁:整个 map 或分片被锁住。 | 细粒度锁:仅在写操作时加锁,读操作无锁(利用原子操作)。 |
适用场景
| 场景 | 锁(Mutex/RWMutex) | sync.Map |
|---|---|---|
| 读写比例 | 读多写少(RWMutex)或读写均衡(Mutex)。 | 读多写极少,尤其是不同协程操作不同键(Key 分散)。 |
| 键的分布 | 键分布均匀或集中均可。 | 键分散(不同协程操作不同键)。 |
| 动态键操作 | 需要手动处理扩容和删除。 | 自动处理动态键,内置垃圾回收机制(清理 dirty 数据)。 |
| 性能敏感场景 | 高频写入同一键时性能较差。 | 高频读取、低频写入时性能更优。 |
性能对比
| 操作 | 锁(Mutex/RWMutex) | sync.Map |
|---|---|---|
| 读操作(Read) | RWMutex 支持并发读,性能较好。 | 无锁读(原子操作),性能极高。 |
| 写操作(Write) | 需要加互斥锁,高并发写同一键时性能差。 | 写操作需要加锁,但内部优化减少锁竞争(写时复制)。 |
| 遍历(Range) | 遍历时需要加锁,但支持普通 for-range 语法。 | 必须用 Range(func) 方法,遍历效率较低。 |
总结
- 锁(Mutex/RWMutex):
适合通用场景,尤其是需要兼容普通map接口或需要类型安全的场景。通过RWMutex在读多写少时优化性能。 sync.Map:
专为读多写极少且键分散的场景设计,通过无锁读和写时复制优化性能。但接口不兼容普通map,且类型不安全。
6. nil map和空map有何不同
nil map
- 往值为nil的map添加值,会触发panic。
- 读取值为nil的map,不会报错。
- 删除值为nil的map,不会报错。
空map
已经初始化,没有任何元素的map为空map,对空map增删查改不会报错。
7. Map的数据结构
type hmap struct {
count int // 当前元素个数(len(map) 直接返回此值)
flags uint8 // 状态标记(如是否正在扩容)
B uint8 // 桶的数量 = 2^B(哈希表的桶数量是2的幂)
noverflow uint16 // 溢出桶(overflow buckets)的大致数量
hash0 uint32 // 哈希种子(用于哈希函数计算)
buckets unsafe.Pointer // 指向桶数组的指针(主桶)
oldbuckets unsafe.Pointer // 扩容时指向旧桶数组的指针, 非扩容时为空
nevacuate uintptr // 迁移进度计数器(扩容时用), 小于该值的桶已完成迁移
extra *mapextra // 指向mapextra结构的指针,mapextra存储map中的溢出桶
}
Map的底层实现数据结构实际上是一个哈希表,在运行时表现为指向hmap结构的指针,hmap中有记录了桶数组指针buckets,溢出桶指针以及元素个数等字段。每个桶是一个bmap的数据结构,可以存储8个键值对和8个tophash以及指向下一个溢出桶的指针overflow。为了内存紧凑,采用的是先存8个key过后才存value。
8. Map怎么实现扩容
扩容时机
向map插入新key的时候,会进行条件检测,符合以下这两个条件,就会出发扩容
- 超过负载,map元素个数 > 6.5(负载因子)* 桶个数,触发双倍扩容
溢出桶太多,触发等量扩容(delete删除大量key,导致有大量内存碎片)
- 当桶总数 < 2^15时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多
- 当桶总数 > 2^15时,如果溢出桶总数 >= 2^15,则认为溢出桶过多
扩容机制
- 双倍扩容:新建一个buckets数组,新的buckets数量是原来的2倍,然后旧buckets数据搬迁到新的buckets。
- 等量扩容:并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁工作,把松散的键值对重新排列一次,使得同一个bucket中的key排列更紧密,节省空间,提高bucket利用率,进而保证更快的存取。
扩容方式
扩容过程并不是一次性进行的,而是采用渐进式扩容,在插入修改删除key的时候,都会尝试进行搬迁桶的工作,每次都会检查oldbucket是否为nil,如果不是nil则每次搬迁2个桶。
9. Map的key为什么得是可比较类型的
Map在插入key时,如果在槽中发生了哈希冲突,则需要比较该槽中的key是否跟插入的key相等,若相等,则直接覆盖值,若不等,则还需找到下一个空槽。如果key不可比较(如slice,func,map),则会导致哈希冲突处理失败。
内存相关
1. 内存泄漏是什么?什么情况下会发生内存泄露
概念
在Go语言中,内存泄漏是指程序在运行过程中申请了内存资源,但由于设计或实现缺陷,导致这些内存无法被垃圾回收器(GC)及时释放,最终引发内存占用持续增长的问题。
内存泄漏场景
Goroutine泄漏
- 原因:Goroutine因阻塞(如未关闭的channel、死锁)无法退出,导致其占用的栈内存(初始2KB,可增长)及关联资源无法释放。
示例:
func leak() { ch := make(chan int) go func() { val := <-ch // 阻塞,无发送方 fmt.Println(val) }() }
全局变量或长生命周期对象引用
- 原因:全局缓存、静态集合等持有对象引用,即使不再使用,GC也无法回收。
示例:
var cache = make(map[string]interface{}) func storeData(key string, data interface{}) { cache[key] = data // 未实现淘汰策略,数据永久保留 }
切片的底层引用
- 原因:大切片截取小部分使用,但底层数组仍被引用。
示例:
func sliceLeak() { bigData := make([]byte, 1<<30) // 1GB切片 smallPart := bigData[:10] // 实际bigData底层数组未被释放 }
未关闭的资源
- 定时器泄漏:未调用
Stop()的time.Ticker。 - 文件/网络句柄:未关闭的
os.File或net.Conn。 示例:
func tickerLeak() { ticker := time.NewTicker(time.Second) // 未调用 ticker.Stop() }
闭包意外捕获变量
- 原因:闭包持有外部变量的引用,导致其无法释放。
示例:
func closureLeak() { data := make([]byte, 1<<20) go func() { // 闭包捕获data,即使外部函数退出,data仍被引用 fmt.Println(len(data)) }() }
2. 什么是内存逃逸,什么情况下会发生逃逸
在 Go 语言中,内存逃逸(Memory Escape) 是编译器在编译阶段通过逃逸分析(Escape Analysis)确定变量必须分配到堆(Heap)而非栈(Stack)的过程。
逃逸情景见Golang Basic那篇文章。
3. Channel分配在栈上还是堆上
Channel分配在堆上,Channel被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以golang直接将其分配在堆上。
4. 哪些对象分配在堆上,哪些对象分配在栈上
一般而言,大的对象直接分配在堆上,如果一个局部变量会被外部引用,生命周期不确定,也会分配到堆上。其他小对象会优先分配在栈上。
5. 介绍一下大对象小对象,什么情况下会导致GC压力大
- go语言中小于等于32kB的对象就是小对象,其他是大对象
- 当有大量小对象逃逸到堆上,或者又巨大的元素类型为指针的map和slice的情况下,GC压力会较大
锁
1. CAS
CAS(Compare And Swap) 是一种无锁(lock-free)的原子操作,用于在多线程/协程并发环境下,实现数据的安全修改。它是并发编程中的核心机制之一,常用于替代传统的锁(如互斥锁)来保证线程安全,同时减少锁竞争带来的性能开销。
CAS 的工作原理
CAS 操作包含三个参数:
- 内存地址(内存中的某个值)
- 期望的旧值(Expected Value)
- 要写入的新值(New Value)
执行流程:
检查当前内存地址的值是否与期望的旧值相等。
- 如果相等,将内存地址的值更新为新值,并返回操作成功(
true)。 - 如果不相等,说明其他线程/协程已修改过该值,放弃更新,返回操作失败(
false)。
- 如果相等,将内存地址的值更新为新值,并返回操作成功(
整个过程是原子性的,即执行期间不会被其他操作中断。
Go 语言中的 CAS
在 Go 中,CAS 通过 atomic 包提供的方法实现,例如:
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var value int32 = 0
// 尝试将 value 从 0 改为 1
success := atomic.CompareAndSwapInt32(&value, 0, 1)
fmt.Println(success, value) // 输出: true 1
// 再次尝试将 value 从 0 改为 1(此时 value 已经是 1)
success = atomic.CompareAndSwapInt32(&value, 0, 1)
fmt.Println(success, value) // 输出: false 1
}Go 的 atomic 包支持多种类型的 CAS 操作:
CompareAndSwapInt32CompareAndSwapInt64CompareAndSwapPointerCompareAndSwapUint32等。
CAS 的典型应用场景
- 实现无锁数据结构
- 例如无锁队列、计数器、缓存等。
示例:原子计数器
go
Copy
type Counter struct { value int32 } func (c *Counter) Increment() { for { old := atomic.LoadInt32(&c.value) new := old + 1 if atomic.CompareAndSwapInt32(&c.value, old, new) { return } } }
- 乐观锁(Optimistic Locking)
- 在分布式系统或数据库事务中,通过版本号(Version)实现并发控制。
示例:更新用户余额
go
Copy
type Account struct { balance int32 version int32 // 版本号 } func (a *Account) UpdateBalance(newBalance int32) { for { oldVersion := atomic.LoadInt32(&a.version) oldBalance := atomic.LoadInt32(&a.balance) // 检查版本是否变化 if atomic.CompareAndSwapInt32(&a.version, oldVersion, oldVersion+1) { atomic.StoreInt32(&a.balance, newBalance) return } } }
- 状态标志管理
- 控制并发任务的状态(如启动/停止)。
示例:单例初始化
var initialized int32 func Init() { if atomic.CompareAndSwapInt32(&initialized, 0, 1) { // 执行初始化逻辑 } }
CAS 的优缺点
| 优点 | 缺点 |
|---|---|
| 无锁,减少线程阻塞和上下文切换 | 可能因竞争导致重试(自旋),浪费 CPU |
| 适用于低竞争场景,性能高 | 无法解决复杂操作的原子性问题 |
| 避免死锁风险 | 需要处理 ABA 问题(见下文) |
CAS 的 ABA 问题
问题描述:
- 线程 1 读取内存值为
A。 - 线程 2 修改内存值为
B,再改回A。 - 线程 1 执行 CAS,发现值仍为
A,认为未被修改,继续操作。
后果:虽然值相同,但中间状态的变化可能导致逻辑错误。
解决方案:
版本号标记:每次修改时递增版本号,CAS 同时检查值和版本号。
type ValueWithVersion struct { value int32 version int32 } func (v *ValueWithVersion) Update(newValue int32) { for { old := atomic.LoadInt64((*int64)(unsafe.Pointer(v))) // 原子读取 oldValue := int32(old & 0xFFFFFFFF) oldVersion := int32(old >> 32) if oldValue != newValue { new := (int64(newValue) & 0xFFFFFFFF) | (int64(oldVersion+1) << 32) if atomic.CompareAndSwapInt64((*int64)(unsafe.Pointer(v)), old, new) { return } } } }- 使用语言提供的 ABA 安全机制:
Go 的atomic包未直接支持,但可通过指针的CompareAndSwapPointer结合内存地址变化间接解决。
CAS 与锁(Mutex)的对比
| 特性 | CAS | 锁(Mutex) |
|---|---|---|
| 并发模型 | 无锁,乐观并发控制 | 悲观并发控制 |
| 性能 | 低竞争时性能高 | 高竞争时可能更稳定 |
| 适用场景 | 简单原子操作(如计数器、标志位) | 复杂临界区(如多步数据修改) |
| 复杂度 | 需要处理自旋和 ABA 问题 | 简单直观,但可能引发死锁 |
2. 可重入锁和不可重入锁
一、核心区别
| 特性 | 可重入锁 | 不可重入锁 |
|---|---|---|
| 同一线程重复获取 | ✅ 允许同一线程多次获取锁,不会死锁。 | ❌ 线程重复获取会导致阻塞或死锁。 |
| 锁持有计数 | 维护计数器,记录锁被当前线程获取的次数。 | 无计数器,仅记录锁是否被占用。 |
| 释放机制 | 必须释放与获取次数相同的次数,锁才会完全释放。 | 只需释放一次即可解除锁定。 |
| 典型应用场景 | 递归调用、嵌套同步代码块。 | 简单同步场景,无需嵌套获取锁。 |
| 实现复杂度 | 较高(需管理线程标识和计数器)。 | 较低(仅需管理锁状态)。 |
| 性能开销 | 略高(需维护计数器和线程状态)。 | 较低(无额外状态管理)。 |
二、实现原理
1. 可重入锁
核心机制:
- 记录当前持有锁的线程(
owner)和重入次数(count)。 当线程尝试获取锁时:
- 若锁未被占用,则获取锁,设置
owner为当前线程,count=1。 - 若锁已被当前线程占用,则
count++。
- 若锁未被占用,则获取锁,设置
- 释放锁时,
count--,直到count=0时彻底释放锁。
- 记录当前持有锁的线程(
2. 不可重入锁
核心机制:
- 仅记录锁是否被占用,不关心持有者是谁。
- 线程重复获取锁时会直接阻塞或抛出异常。