Skip to content

1. 请介绍 Go 语言中闭包的底层原理

1.1. 核心要点

闭包是一个函数,它捕获并保存了其外部作用域中的变量,即使在该函数执行完毕后,这些变量仍然可以被访问和修改。

闭包实现原理是通过将函数与其引用的外部变量一起打包成一个结构体来实现。

结构体的字段如下:

  • 函数指针:指向实际的函数代码。
  • 环境变量:被捕获的外部变量的副本或引用。

因此闭包在调用时能够访问其捕获的环境变量。

并且当闭包捕获外部变量时,这些变量会被分配到堆上,以确保它们的生命周期超过函数的作用域。(这依赖于 Go 的逃逸分析机制,判断变量是否需要从栈逃逸到堆)

以下是一个简单的闭包示例:

go
package main

import "fmt"

func outer() func() int {
    x := 0
    return func() int {
        x++
        return x
    }
}

func main() {
    counter := outer()
    fmt.Println(counter()) // 输出: 1
    fmt.Println(counter()) // 输出: 2
}

在这个例子中,匿名函数作为闭包,捕获了外部函数 outer 的局部变量 x。即使在 outer 返回后,闭包仍然可以访问并修改 x 的值。

1.2. 扩展知识

1.2.1. 底层实现伪代码说明

闭包在底层是通过结构体和指针来实现的。这些结构体包含了函数指针和捕获的上下文变量。对于每个闭包,编译器会生成一个相关的结构体,结构体指针存储在函数值中。

举个例子,假如有个闭包如下:

go
func main() {
    x := 10
    f := func(y int) int {
        return x + y
    }
    fmt.Println(f(5))
}

在这个例子中,f 这个闭包会被实现为大致如下的伪代码

go
type closure struct {
    x int
}

func (c *closure) f(y int) int {
    return c.x + y
}

f 将会是一个 closure 类型的实例,并且 x 的值会被存储在 closure 实例中。

1.2.2. 闭包的应用场景

  • 状态保持:闭包可以用于在函数调用之间保持状态,例如实现计数器、累加器等。
  • 函数工厂:根据不同的参数生成不同功能的函数。
  • 回调函数:在异步操作或事件处理中,闭包可以携带上下文信息。

2. Go 语言中两个 interface 类型的变量可以进行比较吗?

2.1. 核心要点

如果两个interface接口的动态类型是可比较的,那么两个 interface 变量可以使用 ==!= 运算符进行比较。

如果接口的动态类型包含未实现可比较性的字段(例如,包含 slicemapfunction 等不可比较类型),则直接比较这类接口变量会导致运行时错误(panic)。

2.2. 扩展知识

2.2.1. 接口的内部表示

在 Go 语言中,接口由两个部分组成:

  • 动态类型(Type):接口实际引用的具体类型。
  • 动态值(Value):接口实际包含的值。

只有当两个接口的动态类型和动态值都相同时,接口才被认为是相等的。

代码示例:

go
package main

import "fmt"

func main() {
    var a interface{} = 100
    var b interface{} = 100
    fmt.Println(a == b) // 输出: true

    var c interface{} = []int{1, 2, 3}
    var d interface{} = []int{1, 2, 3}
    // fmt.Println(c == d) // 运行时会发生错误:panic: runtime error: comparing uncomparable type []int
}
  • 可比较的接口:变量 ab 的动态类型都是 int,且动态值都为 100,因此 a == b 的结果为 true
  • 不可比较的接口:变量 cd 的动态类型是 []int(切片),切片是不可比较的类型。直接比较 cd 会导致运行时错误(panic)。

2.2.2. nil 接口的陷阱

go
package main

import "fmt"

func main() {
    var p *int = nil
    var i interface{} = p
    fmt.Println(i == nil) // 输出: false
}

在这个例子中,虽然指针 pnil,但将 p 赋值给接口变量 i 后,i 的动态类型是 *int,动态值是 nil。由于 i 的动态类型非 nil,因此 i == nil 的结果为 false

go
func main() {
	var i interface{} = nil
	fmt.Println(i == nil) // 输出: true
}

这样才是 true

2.2.3. 实现自定义比较方法

对于包含不可比较字段的结构体,可以通过实现自定义的比较方法来进行比较。

go
package main

import "reflect"

type MyStruct struct {
    Field1 int
    Field2 []int // 切片,非直接可比较
}

func (m MyStruct) Equal(other MyStruct) bool {
    return m.Field1 == other.Field1 && reflect.DeepEqual(m.Field2, other.Field2)
}

在这个例子中,MyStruct 包含一个不可直接比较的切片字段 Field2。通过实现 Equal 方法,并使用 reflect.DeepEqual,可以比较两个 MyStruct 实例的内容是否相等。

3. 为什么 Go 语言中的常量、字符串和字典不可寻址?

3.1. 核心要点

1)常量(Constants)不可寻址:因为常量在编译时已确定其值,并且在程序运行期间不可更改。由于常量没有分配可寻址的内存空间,尝试获取常量的地址会导致编译错误。

go
const Pi = 3.14
fmt.Println(&Pi) // 编译错误:cannot take the address of Pi

2)字符串(Strings)不可寻址:在 Go 中,字符串是不可变的,即一旦创建,其内容无法修改。因此,字符串的字节或字符也不可寻址,防止通过指针修改其内容。

go
s := "hello"
fmt.Println(&s[0]) // 编译错误:cannot take the address of s[0]

3)字典(Maps)元素不可寻址:字典的实现使得其元素的地址可能在内部发生变化(扩容重新哈希或调整大小,元素的存储位置可能发生变化。因此,即使能够获取某个元素的地址,也可能在操作时该地址已无效),且当键不存在时会返回零值,这些零值是不可变的。因此,Go 语言禁止对字典元素进行寻址操作,以避免潜在的错误。

go
m := map[string]int{"one": 1}
fmt.Println(&m["one"]) // 编译错误:cannot take the address of m["one"]

3.2. 扩展知识

3.2.1. 不可寻址的其他情况

除了常量、字符串和字典元素,Go 中还有其他不可寻址的情况,例如字面量、函数返回的临时值等。这些值没有持久的内存地址,因而无法对其进行寻址操作

go
fmt.Println(&(42)) // 编译错误:cannot take the address of 42

3.2.2. 可寻址的条件

在 Go 中,一个值是可寻址的,意味着可以对其取地址并通过指针修改其值。通常,变量、数组的元素、切片的元素、结构体的字段等都是可寻址的。

go
x := 10
p := &x // 可对变量取地址
*p = 20 // 通过指针修改值

4. 在 Go 语言中两个 nil 可能不相等吗?

4.1. 核心要点

因为在 Go 中,接口类型的变量由两个部分组成:类型信息实际值

当一个接口变量的实际值为 nil,但其类型信息非空时,该接口变量与 nil 比较将返回 false。因此,两个接口变量,即使其实际值都是 nil,但只要它们的类型信息不同,比较结果也会是 false

示例代码

go
package main

import "fmt"

func main() {
   var err1 error                 // 声明一个 error 类型的接口变量,初始为 nil
   var err2 error = (*MyError)(nil) // 将一个具体类型的 nil 指针赋值给接口变量

   fmt.Println(err1 == nil) // 输出: true
   fmt.Println(err2 == nil) // 输出: false
   fmt.Println(err1 == err2) // 输出: false
}

type MyError struct{}

func (e *MyError) Error() string {
   return "MyError"
}

在上述代码中:

  • err1 是一个未赋值的接口变量,其类型信息和实际值都为 nil
  • err2 被赋值为 (*MyError)(nil),此时 err2 的类型信息为 *MyError,但实际值为 nil
  • 因此,err2nil 比较时,因类型信息非空,结果为 false
  • 同时,err1err2 的类型信息不同,故它们之间的比较结果也为 false

5. Go 语言中 WaitGroup 实现原理是什么?

5.1. 核心要点

sync.WaitGroup是一个用于并发控制的同步原语,主要用于等待一组协程完成。它的实现原理基于计数器机制,可以利用三个简单的方法实现协程同步:AddDoneWait

1)Add(delta int):增加或减少计数器的值,delta 可以为正数或负数。

2)Done():减少计数器的值,相当于 Add(-1)

3)Wait():阻塞调用方的协程,直到计数器的值变为零。

底层本质上是使用 CAS + state 原子操作 + 信号量来实现的。

  • state 是一个 64 位的原子变量,高 32 位保存计数器,低 32 位保存当前等待的 goroutine 数量。
  • 信号量用于管理阻塞和唤醒 goroutine。如果计数器减为 0 且等待数大于 0 则会通过信号量唤醒等待的 goroutine

5.2. 扩展知识

5.2.1. WaitGroup 使用示例

go
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	// 创建一个 WaitGroup 用于等待所有 goroutine 完成
	var wg sync.WaitGroup

	// 启动 5 个 goroutine
	for i := 1; i <= 5; i++ {
		// 每启动一个 goroutine 调用 Add(1) 表示增加一个待等待的任务
		wg.Add(1)

		go func(i int) {
			// 确保任务完成时调用 Done() 来减少 WaitGroup 的计数
			defer wg.Done()

			// 模拟每个 goroutine 执行一些任务
			fmt.Printf("Goroutine %d is starting...\n", i)
			time.Sleep(time.Second * time.Duration(i)) // 模拟不同的执行时间
			fmt.Printf("Goroutine %d has finished.\n", i)
		}(i)
	}

	// 等待所有 goroutine 完成
	wg.Wait()

	// 所有 goroutine 完成后打印
	fmt.Println("All goroutines have finished.")
}

运行结果示例

text
Goroutine 5 is starting...
Goroutine 2 is starting...
Goroutine 3 is starting...
Goroutine 1 is starting...
Goroutine 4 is starting...
Goroutine 1 has finished.
Goroutine 2 has finished.
Goroutine 3 has finished.
Goroutine 4 has finished.
Goroutine 5 has finished.
All goroutines have finished.
  • wg.Add(1):每次启动一个新的 goroutine 时,调用 Add(1) 来通知 WaitGroup 需要等待一个新的任务。
  • wg.Done():每个 goroutine 完成后调用 Done() 来表示该任务已经完成,并减少计数器的值。
  • wg.Wait():主 goroutine 调用 Wait(),直到 WaitGroup 的计数器为 0,即所有 goroutine 都调用了 Done(),主 goroutine 才会继续执行。

5.2.2. WaitGroup 源码分析(省略部分代码,仅保留核心逻辑)

go

type WaitGroup struct {
	noCopy noCopy  // 防止 WaitGroup 被复制
	state  atomic.Uint64 // 高 32 位是计数器,低 32 位是等待计数器
	sema   uint32  // 信号量,用于阻塞和唤醒 goroutine
}

5.2.3. Add 方法

go

func (wg *WaitGroup) Add(delta int) {

    state := wg.state.Add(uint64(delta) << 32)  // 增加或减少计数器,delta 左移 32 位
    v := int32(state >> 32)  // 取出高 32 位的计数值
    w := uint32(state)       // 取出低 32 位的等待计数

    // race 检测
    if race.Enabled && delta > 0 && v == int32(delta) {
            race.Read(unsafe.Pointer(&wg.sema)) // 第一次增加必须同步
    }

    // 如果计数器为负,表示存在错误,抛出 panic
    if v < 0 {
            panic("sync: negative WaitGroup counter")
    }
    // 检查是否存在并发调用 Add 和 Wait 的情况
    if w != 0 && delta > 0 && v == int32(delta) {
            panic("sync: WaitGroup misuse: Add called concurrently with Wait")
    }
    // 如果计数器大于 0 或者等待计数器为 0,则返回
    if v > 0 || w == 0 {
            return
    }

    // 计数器为 0,且等待计数器大于 0,执行唤醒操作
    if wg.state.Load() != state {
            panic("sync: WaitGroup misuse: Add called concurrently with Wait")
    }

    // 重置等待计数器
    wg.state.Store(0)
    // 启动信号量唤醒
    for ; w != 0; w-- {
        runtime_Semrelease(&wg.sema, false, 0)
    }
}

分析

  • Add 方法是 WaitGroup 的核心,用于增加或减少计数器。delta 的值可能为负,表示减少计数器。
  • state 是一个 64 位的原子变量,高 32 位保存计数器,低 32 位保存当前等待的 goroutine 数量。

5.2.4. Done 方法

go
func (wg *WaitGroup) Done() {
	wg.Add(-1)  // 调用 Add 减少计数器
}

Done 方法是 Add 的一个封装,直接将计数器减 1。当一个 goroutine 执行完成后调用该方法,表示任务完成。

5.2.5. Wait 方法

go
func (wg *WaitGroup) Wait() {

    for {
            state := wg.state.Load()  // 获取当前状态
            v := int32(state >> 32)   // 高 32 位是计数器
            w := uint32(state)        // 低 32 位是等待计数器

            // 如果计数器为 0,直接返回,不需要等待
            if v == 0 {
                    return
            }

            // 增加等待计数器
            if wg.state.CompareAndSwap(state, state+1) {

                runtime_Semacquire(&wg.sema)  // 等待信号量

                return
            }
    }
}

分析

  • CompareAndSwap 是一个原子操作,用于增加等待计数器,并确保在并发环境下没有其他 goroutine 同时修改该状态。
  • runtime_Semacquireruntime_Semrelease 是 Go 内部的信号量机制,用于管理阻塞和唤醒 goroutine。

Go 语言中的引用类型和指针有什么不同?

主要区别:指针是一个直接存储地址的类型(本身是值类型),而引用类型包含指向数据的指针并附带其他元数据。指针支持间接操作和修改内存,而引用类型则通过值传递(引用类型的拷贝并不是拷贝底层的数据,而是拷贝了底层数据的引用结构)的方式简化了内存管理。

  • 引用类型:引用类型是指在内存中存储数据的引用(地址),而非直接存储数据。例如切片、映射、通道等,它们包含指向底层数据的指针、长度(在某些情况下)和容量等信息,并由 Go 的垃圾回收机制管理内存。
  • 指针:用于存储其他变量的内存地址。指针提供对内存的直接访问,但不会像引用类型那样自动管理内存。

引用类型(如切片)赋值时复制的是其引用结构(指针/长度/容量),底层数据保持不变。这是引用类型与值类型的关键区别。

  • 指针:直接存储变量的内存地址
  • 引用类型:底层是一个包含指针的结构体(如slice 包含指向数组的指针,长度和容量)