基础

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 类型的变量是可以比较的,但需要满足一定的条件。以下是详细的说明:

  1. 动态类型和动态值
    两个 interface 变量可以使用 ==!= 运算符进行比较的前提是,它们的动态类型必须是可比较的。如果两个接口的动态类型相同,并且它们的动态值也相等,那么这两个接口被认为是相等的。
  2. 不可比较的动态类型
    如果接口的动态类型包含不可比较的类型(例如切片、映射、函数等),则尝试比较这些接口会导致编译错误或运行时 panic。
  3. 空接口的情况
    对于空接口(interface{}),只要它们的动态类型和动态值都相同,就可以比较。例如,两个空接口分别存储了相同的整数值,则它们可以通过 == 判断为相等。
  4. 特殊情况:nil 接口
    如果一个接口变量是 nil,它的动态类型和动态值都被认为是 nil。此时,它与另一个 nil 接口变量相等,但与非 nil 的接口变量不相等。
  5. 深度比较
    如果需要对复杂数据结构(如结构体、数组等)进行深度比较,可以使用 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 不仅仅是一个简单的零值,它还包含了类型信息。对于接口类型的变量,其内部由两部分组成:

  1. 动态类型(Type) :表示接口当前存储的值的具体类型。
  2. 动态值(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
  • 因此,ab不相等 。

情况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
  • 尽管 ab 的动态值相同,但由于 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地址=0xc00001a0a0
  • 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() }
  • 输出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地址=0xc00001a0b0
  • v 的地址每次迭代都不同,每个 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 要改变这种行为?

  1. 解决历史痛点
  • 在旧版本中,开发者需要显式复制 v 到闭包内才能避免共享问题,例如:

    for _, v := range slice {
        v := v // 显式复制
        funcs = append(funcs, func() { fmt.Println(v) })
    }
  • Go 1.22 通过自动为每次迭代生成独立变量,消除了这种样板代码,减少了错误。
  1. 提升代码安全性
  • 旧行为容易导致隐蔽的并发 Bug(如 goroutine 中意外共享变量)。
  • 新行为让循环变量的作用域更符合直觉,避免“最后一值陷阱”。
  1. 语言一致性
  • 旧行为中,for 循环的变量作用域与 ifswitch 等语句不一致(后者的变量在每次代码块中是独立的)。
  • 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 模型的经典实现。

核心概念

  1. 进程(Processes)

    • 并发执行的独立单元(在 Go 中对应 goroutine)。
    • 每个进程独立运行,通过通信与其他进程交互。
  2. 通信(Communication)

    • 进程间通过通道(Channel)传递消息(数据)。
    • 通信是同步的(默认行为):发送方和接收方必须同时准备好才能完成数据传递。
  3. 顺序性(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())。
  • 子节点:通过WithCancelWithDeadlineWithValue等函数派生的新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的底层实现原理

img-353545

底层数据结构: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)

  1. 加锁:获取 hchan.lock
  2. 直接传递(无缓冲且存在接收者):

    • 将数据直接拷贝到接收者的栈内存。
    • 唤醒接收者 goroutine。
  3. 缓冲区写入(有缓冲且缓冲区未满):

    • 将数据写入 bufsendx 位置。
    • 更新 sendxqcount
  4. 解锁:释放 hchan.lock,操作完成。

(2) 阻塞路径(Slow Path)

若上述条件不满足(缓冲区已满或无接收者):

  1. 封装为 sudog:将当前 goroutine 和待发送数据封装为 sudog
  2. 加入 sendq 队列:将 sudog 加入 hchan.sendq
  3. 挂起 goroutine:调用 gopark 让出 CPU,进入阻塞状态。
  4. 唤醒后处理:当被接收者唤醒时,完成数据传递。

接收(Receive)流程

当执行 <-ch 时,底层调用 chanrecv 函数,流程如下:

(1) 快速路径(Fast Path)

  1. 加锁:获取 hchan.lock
  2. 直接接收(无缓冲且存在发送者):

    • 从发送者的栈内存拷贝数据。
    • 唤醒发送者 goroutine。
  3. 缓冲区读取(有缓冲且缓冲区非空):

    • bufrecvx 位置读取数据。
    • 更新 recvx 并减少 qcount
  4. 解锁:释放 hchan.lock,操作完成。

(2) 阻塞路径(Slow Path)

若上述条件不满足(缓冲区为空或无发送者):

  1. 封装为 sudog:将当前 goroutine 封装为 sudog
  2. 加入 recvq 队列:将 sudog 加入 hchan.recvq
  3. 挂起 goroutine:调用 gopark 让出 CPU。
  4. 唤醒后处理:当被发送者唤醒时,接收数据。

阻塞与唤醒机制

(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) 时:

  1. 加锁:获取 hchan.lock
  2. 标记关闭:设置 hchan.closed = 1
  3. 唤醒所有等待者

    • 遍历 recvqsendq,唤醒所有阻塞的 goroutine。
    • 接收者收到零值,发送者触发 panic。
  4. 解锁:释放 hchan.lock

3. 对channel进行读写数据的流程是怎样的

无缓冲Channel的读写流程

发送操作(Send)

  1. 锁定channel:获取互斥锁,防止并发冲突。
  2. 检查接收队列

    • 存在等待的接收方:直接将数据拷贝到接收方的内存,唤醒接收方goroutine。
    • 无接收方:将当前goroutine加入发送队列(sendq),阻塞等待。
  3. 释放锁:操作完成后释放互斥锁。

接收操作(Receive)

  1. 锁定channel:获取互斥锁。
  2. 检查发送队列

    • 存在等待的发送方:直接从发送方拷贝数据,唤醒发送方goroutine。
    • 无发送方:将当前goroutine加入接收队列(recvq),阻塞等待。
  3. 释放锁:操作完成后释放互斥锁。

有缓冲Channel的读写流程

发送操作(Send)

  1. 锁定channel
  2. 检查缓冲区状态

    • 缓冲区未满:将数据写入缓冲区,更新写入索引。
    • 缓冲区已满:加入发送队列(sendq),阻塞等待。
  3. 释放锁

接收操作(Receive)

  1. 锁定channel
  2. 检查缓冲区状态

    • 缓冲区非空:读取数据,更新读取索引。
    • 缓冲区为空:加入接收队列(recvq),阻塞等待。
  3. 释放锁

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 均未就绪:

  1. 挂起当前 goroutine

    • 将当前 goroutine 封装为 sudog 结构体。
    • 加入所有 case 的等待队列

      • 发送操作(caseSend)加入 channel 的 sendq(发送队列)。
      • 接收操作(caseRecv)加入 channel 的 recvq(接收队列)。
  2. 休眠让出 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永久阻塞
接收数据<-chv := <-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
接收数据<-chv := <-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.Mutexsync.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中的溢出桶
}

image-20250323164205625

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.Filenet.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 操作包含三个参数:

  1. 内存地址(内存中的某个值)
  2. 期望的旧值(Expected Value)
  3. 要写入的新值(New Value)

执行流程

  1. 检查当前内存地址的值是否与期望的旧值相等。

    • 如果相等,将内存地址的值更新为新值,并返回操作成功(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 操作:

  • CompareAndSwapInt32
  • CompareAndSwapInt64
  • CompareAndSwapPointer
  • CompareAndSwapUint32 等。

CAS 的典型应用场景

  1. 实现无锁数据结构
  • 例如无锁队列、计数器、缓存等。
  • 示例:原子计数器

    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
            }
        }
    }
  1. 乐观锁(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
            }
        }
    }
  1. 状态标志管理
  • 控制并发任务的状态(如启动/停止)。
  • 示例:单例初始化

    var initialized int32
    
    func Init() {
        if atomic.CompareAndSwapInt32(&initialized, 0, 1) {
            // 执行初始化逻辑
        }
    }

CAS 的优缺点

优点缺点
无锁,减少线程阻塞和上下文切换可能因竞争导致重试(自旋),浪费 CPU
适用于低竞争场景,性能高无法解决复杂操作的原子性问题
避免死锁风险需要处理 ABA 问题(见下文)

CAS 的 ABA 问题

问题描述

  • 线程 1 读取内存值为 A
  • 线程 2 修改内存值为 B,再改回 A
  • 线程 1 执行 CAS,发现值仍为 A,认为未被修改,继续操作。
    后果:虽然值相同,但中间状态的变化可能导致逻辑错误。

解决方案

  1. 版本号标记:每次修改时递增版本号,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
                }
            }
        }
    }
  2. 使用语言提供的 ABA 安全机制
    Go 的 atomic 包未直接支持,但可通过指针的 CompareAndSwapPointer 结合内存地址变化间接解决。

CAS 与锁(Mutex)的对比

特性CAS锁(Mutex)
并发模型无锁,乐观并发控制悲观并发控制
性能低竞争时性能高高竞争时可能更稳定
适用场景简单原子操作(如计数器、标志位)复杂临界区(如多步数据修改)
复杂度需要处理自旋和 ABA 问题简单直观,但可能引发死锁

2. 可重入锁和不可重入锁

一、核心区别

特性可重入锁不可重入锁
同一线程重复获取✅ 允许同一线程多次获取锁,不会死锁。❌ 线程重复获取会导致阻塞或死锁。
锁持有计数维护计数器,记录锁被当前线程获取的次数。无计数器,仅记录锁是否被占用。
释放机制必须释放与获取次数相同的次数,锁才会完全释放。只需释放一次即可解除锁定。
典型应用场景递归调用、嵌套同步代码块。简单同步场景,无需嵌套获取锁。
实现复杂度较高(需管理线程标识和计数器)。较低(仅需管理锁状态)。
性能开销略高(需维护计数器和线程状态)。较低(无额外状态管理)。

二、实现原理

1. 可重入锁

  • 核心机制

    • 记录当前持有锁的线程(owner)和重入次数(count)。
    • 当线程尝试获取锁时:

      • 若锁未被占用,则获取锁,设置 owner 为当前线程,count=1
      • 若锁已被当前线程占用,则 count++
    • 释放锁时,count--,直到 count=0 时彻底释放锁。

2. 不可重入锁

  • 核心机制

    • 仅记录锁是否被占用,不关心持有者是谁。
    • 线程重复获取锁时会直接阻塞或抛出异常。
最后修改:2025 年 03 月 24 日
如果觉得我的文章对你有用,请随意赞赏