00 参考资料

笔记资料:

  1. https://www.yuque.com/xguan56/uwu3y5/gzbs7g
  2. 精选 Go 学习资料https://1024casts.com/topics/Lqno6VNP9b71ZYQ5rZBMd02KG3zQwx
  3. 优秀博客:https://lailin.xyz/post/go-training-01.html

实战代码:
https://github.com/marmotedu/iam

01 并发编程

02 Go的泛型

2.1 语法核心:类型参数、类型约束与类型推断

2.1.1 类型参数

就像函数有值参数一样,泛型函数或泛型类型可以有类型参数。类型参数在声明时放在函数名或类型名后面的方括号 [] 中,通常用单个大写字母表示(如 T、K、V)。

1
2
3
4
5
6
7
8
// 泛型函数声明
func PrintSlice[T any](s []T) { /* ... */ } //类型参数 T,约束为 any (任意类型)

// 泛型类型声明
type Node[T any] struct { // Node 是一个泛型类型
Value T
Next *Node[T] // 可以引用自身,但类型参数需一致
}

在函数体或类型定义内部,类型参数 T 可以像普通类型一样使用(比如用作变量类型、参数类型、返回值类型、字段类型等)。

2.1.2 类型约束

类型参数不能是“凭空”存在的,它必须有所约束,这种约束就是类型约束。类型约束可以限制类型参数,明确告知可以使用哪些类型来实例化泛型。同时,类型参数也提供操作许可,编译器根据约束知道能在泛型代码中对类型参数(比如 T) 的值执行哪些操作(如调用方法、使用运算符)。

在 Go 中,类型约束是通过接口类型来定义的,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// T 必须满足约束 MyConstraint
func GenericFunc[T MyConstraint](arg T) { /* ... */ }

// MyConstraint 是一个接口类型,定义了 T 必须具备的能力
type MyConstraint interface {
// 约束可以包含:
// 1. 方法集:要求 T 必须实现某些方法
// SomeMethod() string

// 2. 类型列表 (Type List):限制 T 必须是列表中的某种类型,或其底层类型 (~)
// ~int | ~string // T 的底层类型必须是 int 或 string

// 3. 嵌入其他约束接口
// AnotherConstraint

// 4. 预定义的 comparable 约束 (类型支持 == 和 !=)
// comparable
}

下面是几类常用的类型约束:

  • any:即 interface{},表示 T 可以是任何类型。这是最宽松的约束,但意味着你对 T 的操作知之甚少,几乎不能做任何特定操作。

  • comparable:Go 预定义的约束,表示 T 必须支持 == 和 != 比较。常用于需要比较键或值的场景(如查找函数、map 键)。

  • 基于方法的约束:定义一个包含所需方法的接口。

    1
    2
    type Stringer interface { String() string }
    func Print[T Stringer](val T) { fmt.Println(val.String()) } // T 必须有 String() 方法
  • 基于类型集合的约束(Type Set / Union):使用 | 连接一组允许的类型,可以使用 ~ 表示允许底层类型匹配。

    1
    2
    3
    4
    5
    6
    7
    // 约束 T 的底层类型必须是某种整数或浮点数
    type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
    ~float32 | ~float64
    }
    func Add[T Number](a, b T) T { return a + b } // 可以用 +,因为约束保证了 T 是数值类型

    对于一些常用的类型集合约束,无需自己定义。Go 团队维护的 golang.org/x/exp/constraints 包已经准备好了一些常用的“预定义”约束,我们直接使用即可,示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // golang.org/x/exp/constraints

    type Integer interface {
    Signed | Unsigned
    }

    type Signed interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
    }

    type Unsigned interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
    }

    type Float interface {
    ~float32 | ~float64
    }

    type Complex interface {
    ~complex64 | ~complex128
    }
  • cmp.Ordered(Go 1.21+):标准库 cmp 包提供的约束,包含了所有支持排序操作符 (<, <=, >, >=) 的内置类型,是编写通用比较函数的常用约束。该约束最初也定义在 golang.org/x/exp/constraints 中,后被挪到 Go 标准库中。

    1
    2
    import "cmp"
    func SortSlice[T cmp.Ordered](s []T) { sort.Slice(s, func(i, j int) bool { return s[i] < s[j] }) }
  • 混合约束:接口可以同时包含方法和类型列表。

    1
    2
    3
    4
    5
    // 定义混合约束
    type MyConstraint interface {
    ~int | ~string // 类型列表:允许底层类型为 int 或 string
    MyMethod() // 方法:必须实现 MyMethod() 方法
    }

2.1.3 类型推导

在调用泛型函数时,Go 编译器通常能根据传入的值参数的类型,自动推导出类型参数的具体类型,我们无需显式指定,示例代码如下:

1
2
3
4
5
func Print[T any](val T) { fmt.Println(val) }

Print(10) // 编译器推导出 T 为 int
Print("hello") // 编译器推导出 T 为 string
Print([]int{1, 2}) // 编译器推导出 T 为 []int

这种基于函数实参类型推断类型参数的方式是最常见的。此外,如果约束中包含类型参数关系(如 U []T),则可以互相推导,比如下面的示例:

1
2
func ProcessPair[T any, S []T](first T, second S) {}
ProcessPair(10, []int{1, 2})

在这个例子中,编译器根据 10 推导 T=int,然后根据[]int{…} 推导 S=[]int,并验证 S 的元素类型 T 确实是 int。

当然类型推导也不是“无所不能”的,也有自身的一些限制,比如:

  • 无法基于返回值推导,也就是说不能仅根据期望的返回值类型来推导类型参数,示例如下:
    1
    2
    3
    func Zero[T any]() T { var zero T; return zero }
    // var x = Zero() // 编译错误:cannot infer T
    var x = Zero[string]() // 需要显式指定 T 为 string
  • 实例化泛型类型时通常需要显式指定:
    1
    2
    3
    // type Pair[T any] struct { First, Second T }
    // p := Pair{1, 2} // 编译错误:cannot use generic type Pair without instantiation
    p := Pair[int]{1, 2} // 必须显式指定 Pair[int]

未来 Go 版本可能会放宽对泛型类型实例化的推导限制,但目前通常需要显式指定。总的来说,类型推导极大地简化了泛型函数的使用,让代码看起来更自然。

2.2 应用场景

2.2.1 通用数据结构

有了泛型后,我们不再需要为 int、string、float64 等分别实现 List、Stack、Queue、Set。用泛型可以一次搞定,且类型安全。例如,泛型栈 Stack[T] 示例:

1
2
3
4
5
6
7
8
9
10
type Stack[T any] struct { items []T }
func (s *Stack[T]) Push(item T) { /* ... */ }
func (s *Stack[T]) Pop() (T, bool) { /* ... */ }

intStack := Stack[int]{}
intStack.Push(1)
// intStack.Push("a") // 编译错误

strStack := Stack[string]{}
strStack.Push("hello")

Go 编译器保证了 intStack 只能存 int,strStack 只能存 string。

2.2.2 通用算法函数

一个通用 Map 函数(将切片元素进行转换)的示例,该示例中 Map 泛型函数将一个整型切片“映射”为字符串切片:

1
2
3
4
5
6
7
8
9
10
11
12
// 将 []T 类型的切片 s,通过函数 f(T) U 转换为 []U 类型的新切片
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}

ints := []int{1, 2, 3}
strs := Map(ints, func(i int) string { return fmt.Sprintf("N%d", i) }) // T=int, U=string
fmt.Println(strs) // 输出 [N1 N2 N3]

一个通用 Filter 函数(过滤切片元素)的示例,这里通过 Filter 泛型函数将切片中小于 0 的数值过滤掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 保留切片 s 中满足 predicate(T) bool 的元素
func Filter[T any](s []T, predicate func(T) bool) []T {
result := make([]T, 0, len(s)) // 预分配容量
for _, v := range s {
if predicate(v) {
result = append(result, v)
}
}
return result
}

numbers := []int{-2, -1, 0, 1, 2}
positives := Filter(numbers, func(n int) bool { return n > 0 }) // T=int
fmt.Println(positives) // 输出 [1 2]

2.2.3 减少对interface{}的依赖

在很多原本需要使用 interface{} 和类型断言的场景,现在可以用类型更安全的泛型替代,例如:

  • 需要一个函数接受多种数值类型并进行计算。
  • 需要一个容器能存储多种相似结构但具体类型不同的对象(如果它们能满足某个泛型约束)。

2.3 局限与性能考量:何时不适合泛型

2.3.1 泛型的局限

第一是增加了复杂性
第二是增加了编译时间。
第三是存在运行时开销,通常较小但存在。Go 泛型的实现(GC Shape Stenciling + Dictionaries)虽然旨在平衡性能和代码大小,但相比于非泛型、完全具体类型的代码,可能仍存在一些微小的运行时开销(比如通过字典进行类型相关操作)。在《Go 语言第一课》泛型篇加餐中,我们有对泛型实现原理的简要说明,对泛型原理感兴趣的小伙伴可以去复习一下。
第四是存在代码膨胀(Code Bloat)。
第五是并非所有场景都需要泛型。

13 GO语言实践-Runtime

13.1 Goroutine原理

13.2 内存管理

13.2.1 逃逸分析

通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上

其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸。逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。

  • 减少 GC 压力,栈上的变量,随着函数退出后系统直接回收,不需要标记后再清除
  • 减少内存碎片的产生
  • 减轻分配堆内存的开销,提高程序的运行速度


编译的语句如下:

1
go build -gcflags '-m'

以上的tmp在getRandom函数返回后会“逃逸”到堆上。

上述情况中,num 变量不能指向之前的栈。Go 查找所有变量超过当前函数栈侦的,把它们分配到堆上,避免 outlive 变量。
变量 tmp 在栈上分配,但是它包含了指向堆内存的地址,所以可以安全的从一个函数的栈侦复制到另外一个函数的栈帧。

可能出现“逃逸”的还有如下情况:

  • 一个值被分享到函数栈帧范围之外
  • 在 for 循环外申明,在 for 循环内分配,同理闭包
  • 发送指针或者带有指针的值到 channel 中
  • 在一个切片上存储指针或带指针的值
  • slice 的背后数组被重新分配了
  • 在 interface 类型上调用方法

13.2.2 连续栈

  • 分段栈

Go 应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。栈区的初始大小是2KB(比 x86_64 架构下线程的默认栈2M 要小很多),在 goroutine 运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB。

  • v1.0 ~ v1.1 — 最小栈内存空间为 4KB
  • v1.2 — 将最小栈内存提升到了 8KB
  • v1.3 — 使用连续栈替换之前版本的分段栈
  • v1.4 — 将最小栈内存降低到了 2KB

分段栈会出现hot split问题:
分段栈的实现方式存在 “hot split” 问题,如果栈快满了,那么下一次的函数调用会强制触发栈扩容。当函数返回时,新分配的 “stack chunk” 会被清理掉。如果这个函数调用产生的范围是在一个循环中,会导致严重的性能问题,频繁的 alloc/free。

  • 连续栈

采用复制栈的实现方式,在热分裂场景中不会频发释放内存,即不像分配一个新的内存块并链接到老的栈内存块,而是会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里,当栈缩减回之前大小时,我们不需要做任何事情。

  • runtime.newstack 分配更大的栈内存空间
  • runtime.copystack 将旧栈中的内容复制到新栈中
  • 将指向旧栈对应变量的指针重新指向新栈
  • runtime.stackfree 销毁并回收旧栈的内存空间

如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用 runtime.shrinkstack 进行栈缩容,同样使用 copystack

  • 栈扩容

13.2.3 内存管理

TCMalloc 是 Thread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc:

  • 内存碎片

随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

  • 大锁

同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

13.2.4 内存结构

  • page: 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。
  • span: 内存块,一个或多个连续的 page 组成一个 span。
  • sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。
  • object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。

  • mcache

当程序里发生了 32kb 以下的小块内存申请时,Go 会从一个叫做的 mcache 的本地缓存给程序分配内存。这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。
在 Go 的调度器模型里,每个线程 M 会绑定给一个处理器 P,在单一粒度的时间里只能做多处理运行一个 goroutine,每个 P 都会绑定一个上面说的本地缓存 mcache。当需要进行内存分配时,当前运行的 goroutine 会从 mcache 中查找可用的 mspan。从本地 mcache 里分配内存时不需要加锁,这种分配策略效率更高。

  • mcentral

如果分配内存时 mcachce 里没有空闲的对口 sizeclass 的 mspan 了,Go 里还为每种类别的 mspan 维护着一个 mcentral。
mcentral 的作用是为所有 mcache 提供切分好的 mspan 资源。每个 central 会持有一种特定大小的全局 mspan 列表,包括已分配出去的和未分配出去的。 每个 mcentral 对应一种 mspan,当工作线程的 mcache 中没有合适(也就是特定大小的)的mspan 时就会从 mcentral 去获取。
mcentral 被所有的工作线程共同享有,存在多个 goroutine 竞争的情况,因此从 mcentral 获取资源时需要加锁。mcentral 里维护着两个双向链表,nonempty 表示链表里还有空闲的 mspan 待分配。empty 表示这条链表里的 mspan 都被分配了object 或缓存 mcache 中。

mcache 从 mcentral 获取和归还 mspan 的流程:

  • 获取 加锁;从 nonempty 链表找到一个可用的mspan;并将其从 nonempty 链表删除;将取出的 mspan 加入到 empty 链表;将 mspan 返回给工作线程;解锁。
  • 归还 加锁;将 mspan 从 empty 链表删除;将mspan 加入到 nonempty 链表;解锁。

mcentral 是 sizeclass 相同的 span 会以链表的形式组织在一起, 就是指该 span 用来存储哪种大小的对象

  • mheap

当 mcentral 没有空闲的 mspan 时,会向 mheap 申请。而 mheap 没有资源时,会向操作系统申请新内存。mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。
mheap 中含有所有规格的 mcentral,所以当一个 mcache 从 mcentral 申请 mspan 时,只需要在独立的 mcentral 中使用锁,并不会影响申请其他规格的 mspan。

  • 大于32kb内存分配

Go 没法使用工作线程的本地缓存 mcache 和全局中心缓存 mcentral 上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上(mheap)上分配对应的数量的内存页(每页大小是8KB)给程序。

13.2.5 总结


一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。

  • Go 在程序启动时,会向操作系统申请一大块内存,由 mheap 结构全局管理(现在 Go 版本不需要连续地址了,所以不会申请一大堆地址)
  • Go 内存管理的基本单元是 mspan,每种 mspan 可以分配特定大小的 object