Lazy loaded imageGo语言设计与实现 笔记
2025-1-27
| 2025-2-26
字数 9381阅读时长 24 分钟
type
summary
status
category
tags
slug
date
password
icon

一、基础知识

1.数据结构

rune

tips:
  • 表示Unicode联盟为超过100万个字符分配的 code point
  • 是int32的别名
  • 打印的时候用%v占位,得到code point
  • 打印的时候用%c占位,得到字符

byte

tips:
  • 表示二进制数据
  • 是uint8的别名
  • byte可以表示由ASCII定义的英语字符,它是Unicode的一个子集(共128个字符)

big.int

tips:
  • 超过int64范围的整数,比float精准
  • 两种声明方法:

string

tips:
  • string是用UTF-8编码的(UTF-8是一种可变长度编码,每个code pint可以是8位、16位或32位
  • 使用len()函数返回的是字节数(byte),对英文以外的语言使用可能会返回错误的字符数,解决方法:
  • 使用range:

float

  • 不适合用于金融类计算
  • 为了尽量最小化舍入错误,建议先做乘法,再做除法
 

数组

notion image
数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素
 
a.初始化
两种声明方式在运行期间得到结果完全相同,后一种在编译期间会被转换为前一种(编译器对数组大小推导)
  • 当元素数量小于或等于4个时,会直接将数组中的元素放在栈上(在栈上初始化)
  • 当元素数量大于4个时,会将数组中的元素放置在静态区并在运行时取出(在静态存储区初始化后拷贝到栈上)
 
b.访问和赋值
  • 数组和字符串的一些简单越界错误都会在编译期间发现,例如:直接使用整数或者常量访问数组;但是如果使用变量去访问数组或者字符串时,编译器就无法提前发现错误,我们需要 Go 语言运行时阻止不合法的访问
 
tips:
  • 无论数组赋值给新的变量还是将它传递给函数,都会产生一个完整的数组副本(深拷贝,与切片区分)

切片

notion image
在运行时切片可以由如下的结构体表示:
  • Data 指向数组的指针
  • Len 当前切片的长度
  • Cap 当前切片的容量(Data数组的大小)
 
a.初始化
  • 通过下标的方式获得数组或者切片的一部分
需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片
编译期间:
SliceMake 操作会接受四个参数创建新的切片,元素类型数组指针切片大小容量
  • 使用字面量初始化新的切片
编译期间:
  1. 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组;
  1. 将这些字面量元素存储到初始化的数组中;
  1. 创建一个同样指向 [3]int 类型的数组指针;
  1. 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址;
  1. 通过 [:] 操作获取一个底层使用 vauto 的切片;
  • 使用关键字 make 创建切片
使用 make 关键字创建切片时,很多工作都需要运行时的参与
 
b.追加和扩容
notion image
  • 如果 append 返回的新切片不需要赋值回原有的变量,会先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,就会调用 runtime.growslice 对切片进行扩容并将新的元素依次加入切片。
    • 处理流程
  • 如果使用 slice = append(slice, 1, 2, 3) 语句,那么 append 后的切片会覆盖原切片,这时 cmd/compile/internal/gc.state.append 方法会使用另一种方式展开关键字。
    • 处理流程:
  • 在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
    • 策略:
      1. 如果期望容量大于当前容量的两倍就会使用期望容量;
      1. 如果当前切片的长度小于 1024 就会将容量翻倍;
      1. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
 
c.拷贝切片
notion image
  • 无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过 runtime.memmove 将整块内存的内容拷贝到目标的内存区域中
  • 相比于依次拷贝元素,runtime.memmove 能够提供更好的性能。需要注意的是,整块拷贝内存仍然会占用非常多的资源,在大切片上执行拷贝操作时一定要注意对性能的影响。
 
tips:
  • 切片是指向数组的窗口,每个slice内部都会被表示为包含三个元素的结构:数组的指针、slice的容量、slice的长度
  • 当slice被直接传递至函数或方法时,slice的内部指针就可以对底层数据进行修改(所以被传至函数或方法时,只会复制指针,新指针仍然指向原数组,仍然是对原数组进行修改)

map

结构体
  1. count 表示当前哈希表中的元素数量;
  1. B 表示当前哈希表持有的 buckets 数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是 len(buckets) == 2^B
  1. hash0 是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
  1. oldbuckets 是哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半;
notion image
  • 如上图所示哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。
    • bmap
      桶的结构体 runtime.bmap 在 Go 语言源代码中的定义只包含一个简单的 tophash 字段,tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能:
      在运行期间,runtime.bmap 结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,不过我们能根据编译期间的 cmd/compile/internal/gc.bmap 函数重建它的结构:
  • 上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的 runtime.bmap 就是正常桶,绿色的 runtime.bmap 是溢出桶,溢出桶是在 Go 语言还使用 C 语言实现时使用的设计3,由于它能够减少扩容的频率所以一直使用至今。
 
tips:
  • map在被赋值或者被作为参数传递的时候不会被复制,因为map就是一种隐式指针

指针

tips:
  • 指针保存的是变量地址(pointer := &elem),*放在变量用来解引用(从地址获取值),&表示取地址符(从变量获取地址)
  • *放在类型前表示声明指针类型
  • 数组在执行索引或切片操作时会自动解引用,切片和map的复合值前面也可以放置&操作符,但Go并没有为它们提供自动解引用的功能

2.语言基础

接口

a.指针和接口
结构体实现接口
结构体指针实现接口
结构体初始化变量
通过
不通过
结构体指针初始化变量
通过
通过
 
b.基准测试
直接调用
动态派发
结构体指针
~3.03ns
~3.58ns
结构体
~3.09ns
~6.98ns
从上述表格我们可以看到使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。
 
c.类型
notion image
iface
带有方法的接口
eface
不带任何方法的接口
 

反射

Go 语言的 反射(reflection) 主要用于在运行时检查和操作变量的类型和值。Go 的反射是通过 reflect 包实现的,核心概念包括 类型(Type)值(Value)

3.常用关键字

for和range

a.循环永动机
对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝,而我们又通过 len 关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象。
 
b.神奇的指针
遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝
因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针的现象。因此当我们想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2,而应该使用 &a[index] 这种形式,即:
注意:在 Go 1.22 版本之后,for range 循环在每次迭代时都会创建新的循环变量,避免了变量共享问题。
 
c.遍历清空数组
依次遍历切片和哈希看起来是非常耗费性能的,因为数组、切片和哈希占用的内存空间都是连续的,所以最快的方法是直接清空这片内存中的内容
相比于依次清除数组或者切片中的数据,Go 语言会直接使用 runtime.memclrNoHeapPointers 或者 runtime.memclrHasPointers 清除目标数组内存空间中的全部数据,并在执行完成后更新遍历数组的索引。
 
d.随机遍历
Go 语言在运行时为哈希表的遍历引入了不确定性,也是告诉所有 Go 语言的使用者,程序不要依赖于哈希表的稳定遍历。
该函数会初始化 runtime.hiter 结构体中的字段,并通过 runtime.fastrand 生成一个随机数帮助我们随机选择一个遍历桶的起始位置。Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。
notion image
简单总结一下哈希表遍历的顺序,首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成。
 
e.字符串
遍历时会获取字符串中索引对应的字节并将字节转换成 rune
 
f.通道
  • 如果不存在当前值,意味着当前的管道已经被关闭;
  • 如果存在当前值,会为 v1 赋值并清除 hv1 变量中的数据,然后重新陷入阻塞等待新数据;
 

select

a.非阻塞的收发
select 能在 Channel 上进行非阻塞的收发操作。当我们运行下面的代码时就不会阻塞当前的 Goroutine,它会直接执行 default 中的代码。
在很多场景下我们不希望 Channel 操作阻塞当前 Goroutine,只是想看看 Channel 的可读或者可写状态,如下所示:
在上面这段代码中,我们不关心到底多少个任务执行失败了,只关心是否存在返回错误的任务,最后的 select 语句能很好地完成这个任务。
但上述代码存在闭包、goroutine泄露、修改为range后可能死锁的问题,优化:
  • 这里 go func() 内部的 tasks[i] 直接引用了 i,但 i 是循环变量,在 goroutine 执行时,i 可能已经变成了新的值。因此,tasks[i] 可能会访问到错误的索引,甚至可能导致 index out of range 错误。
  • errCh <- err 是异步的,并不能保证在defer之前执行
 
b.随机执行
select 在遇到多个 <-ch 同时满足可读或者可写条件时会随机选择一个 case 执行其中的代码。如果我们按照顺序依次判断,那么后面的条件永远都会得不到执行,而随机的引入就是为了避免饥饿问题的发生。

defer

  • LIFO 后进先出
  • defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用(作用域)
  • 在机制上可以理解为是用链表实现的栈(个人理解)
 
a.预计算参数
假设我们想要计算 main 函数运行的时间,可能会写出以下的代码:
调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s。
解决方法:
向 defer 关键字传入匿名函数。虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt) 会在 main 函数返回前调用并打印出符合预期的结果。
 
b.数据结构
notion image
runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。
  • siz 是参数和结果的内存大小;
  • sp 和 pc 分别代表栈指针和调用方的程序计数器;
  • fn 是 defer 关键字中传入的函数;
  • _panic 是触发延迟调用的结构体,可能为空;
  • openDefer 表示当前 defer 是否经过开放编码的优化;
 

panic 和 recover

notion image
  • panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer
  • recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;
 
a.panic 只会触发当前 Goroutine 的 defer
notion image
 
b.数据结构
  1. argp 是指向 defer 调用时参数的指针;
  1. arg 是调用 panic 时传入的参数;
  1. link 指向了更早调用的 runtime._panic 结构;
  1. recovered 表示当前 runtime._panic 是否被 recover 恢复;
  1. aborted 表示当前的 panic 是否被强行终止;
从数据结构中的 link 字段我们就可以推测出以下的结论:panic 函数可以被连续多次调用,它们之间通过 link 可以组成链表
 
c.panic 函数是终止程序的实现原理
  1. 创建新的 runtime._panic 并添加到所在 Goroutine 的 _panic 链表的最前面;
  1. 在循环中不断从当前 Goroutine 的 _defer 中链表获取 runtime._defer 并调用 runtime.reflectcall 运行延迟调用函数;
  1. 调用 runtime.fatalpanic 中止整个程序;
 
d.崩溃修复(recover)
编译器会将关键字 recover 转换成 runtime.gorecover
如果当前 Goroutine 没有调用 panic,那么该函数会直接返回 nil,这也是崩溃恢复在非 defer 中调用会失效的原因。在正常情况下,它会修改 runtime._panic 的 recovered 字段。

make 和 new

notion image
  • make 的作用是初始化内置的数据结构,也就是我们在前面提到的切片、哈希表和 Channel;
  • new 的作用是根据传入的类型分配一片内存空间并返回指向这片内存空间的指针;
 
 

二、运行时

1.并发编程

上下文Context

上下文 context.Context Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。
详细解释:
  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  1. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;
  1. Err — 返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值;
    1. 如果 context.Context 被取消,会返回 Canceled 错误;
    2. 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  1. Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
 
a.设计原理
不使用Context同步信号:
notion image
使用Context同步信号:
notion image
例子:
因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容:
如果我们将处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止:
 
b.默认上下文
  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生出来;
  • context.TODO 应该仅在不确定应该使用哪种上下文时使用;
在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。
扩展:
这两个私有变量都是通过 new(emptyCtx) 语句初始化的,它们是指向私有结构体 context.emptyCtx 的指针,这是最简单、最常用的上下文类型:
 
c.取消信号
context.WithCancel 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。
  • context.propagateCancel 会构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消
 
d.传值方法
context.valueCtx 结构体会将除了 Value 之外的 ErrDeadline 等方法代理到父上下文中,它只会响应 context.valueCtx.Value 方法。
如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到某个父上下文中返回 nil 或者查找到对应的值。

同步原语与锁

Mutex
a.数据结构
  • state 表示当前互斥锁的状态
状态:
notion image
  • mutexLocked — 表示互斥锁的锁定状态;
  • mutexWoken — 表示从正常模式被从唤醒;
  • mutexStarving — 当前的互斥锁进入饥饿状态;
  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;
  • sema 是用于控制锁状态的信号量
 
b.加锁和解锁、正常模式和饥饿模式
加锁
  • 如果互斥锁处于初始化状态,会通过置位 mutexLocked 加锁;
  • 如果互斥锁处于 mutexLocked 状态并且在普通模式下工作,会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;
  • 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
  • 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,那么它会将互斥锁切换回正常模式;
解锁
  • 当互斥锁处于饥饿模式时,将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked 标志位;
  • 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,会直接返回;在其他情况下会通过 sync.runtime_Semrelease 唤醒对应的 Goroutine;
notion image
  • Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』
  • 在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine
也就是说,mutex分为普通模式和饥饿模式,在普通模式下,goroutine会自旋,而饥饿模式下,mutex将尝试获取锁的goroutine切换为休眠状态等待被唤醒。
Mutex
goroutine
正常模式
自旋
饥饿模式
休眠
RWMutex
解释:
  • w — 复用互斥锁提供的能力;
  • writerSem 和 readerSem — 分别用于写等待读和读等待写:
  • readerCount 存储了当前正在执行的读操作数量;
  • readerWait 表示当写操作被阻塞时等待的读操作个数;
Y
N
N
N
WaitGroup
解释:
  • noCopy — 保证 sync.WaitGroup 不会被开发者通过再赋值的方式拷贝;
  • state1 — 存储着状态和信号量;
 
a.接口
sync.WaitGroup.Add
  • 传入的参数可以为负数,但是计数器只能是非负数,一旦出现负数就会发生程序崩溃
  • 当调用计数器归零,即所有任务都执行完成时,才会通过 sync.runtime_Semrelease 唤醒处于等待状态的 Goroutine
sync.WaitGroup.Done
只是向 sync.WaitGroup.Add 方法传入了 -1
sync.WaitGroup.Wait
当 sync.WaitGroup 的计数器归零时,陷入睡眠状态的 Goroutine 会被唤醒,上述方法也会立刻返回
Once
解释:
  • done:用于标识代码块是否执行过
  • m:互斥锁
 
a.接口
sync.Once.Do
  • 如果传入的函数已经执行过,会直接返回;
  • 如果传入的函数没有执行过,会调用 sync.Once.doSlow 执行传入的函数:
  1. 为当前 Goroutine 获取互斥锁;
  1. 执行传入的无入参函数;
  1. 运行延迟函数调用,将成员变量 done 更新成 1;
Cond
sync.Cond可以让一组的 Goroutine 都在满足特定条件时被唤醒。
notion image
 
a.数据结构
解释:
  • noCopy — 用于保证结构体不会在编译期间拷贝;
  • copyChecker — 用于禁止运行期间发生的拷贝;
  • L — 用于保护内部的 notify 字段,Locker 接口类型的变量;
  • notify — 一个 Goroutine 的链表,它是实现同步机制的核心结构;
 
b.接口
  • sync.Cond.Wait 方法会将当前 Goroutine 陷入休眠状态(在调用之前一定要使用获取互斥锁,否则会触发程序崩溃);
  • sync.Cond.Signal 方法会唤醒队列最前面的 Goroutine(唤醒的 Goroutine 都是队列最前面、等待最久的 Goroutine);
  • sync.Cond.Broadcast 方法会唤醒队列中全部的 Goroutine(会按照一定顺序广播通知等待的全部 Goroutine);
 
ErrGroup
例子:
golang/sync/errgroup.Group.Go 方法能够创建一个 Goroutine 并在其中执行传入的函数,而 golang/sync/errgroup.Group.Wait 会等待所有 Goroutine 全部返回,该方法的不同返回结果也有不同的含义:
  • 如果返回错误 — 这一组 Goroutine 最少返回一个错误;
  • 如果返回空值 — 所有 Goroutine 都成功执行;
 
a.数据结构
解释:
  1. cancel — 创建 context.Context 时返回的取消函数,用于在多个 Goroutine 之间同步取消信号;
  1. wg — 用于等待一组 Goroutine 完成子任务的同步原语;
  1. errOnce — 用于保证只接收一个子任务返回的错误;
 
b.接口
golang/sync/errgroup.WithContext:
构造器创建新的 golang/sync/errgroup.Group 结构体。
golang/sync/errgroup.Group.Go:
  1. 调用 sync.WaitGroup.Add 增加待处理的任务;
  1. 创建新的 Goroutine 并运行子任务;
  1. 返回错误时及时调用 cancel 并对 err 赋值,只有最早返回的错误才会被上游感知到,后续的错误都会被舍弃:
golang/sync/errgroup.Group.Wait:
只是调用了 sync.WaitGroup.Wait,在子任务全部完成时取消 context.Context 并返回可能出现的错误。
Semaphore
Go 语言的扩展包中就提供了带权重的信号量 golang/sync/semaphore.Weighted,我们可以按照不同的权重对资源的访问进行管理,这个结构体对外也只暴露了四个方法:
SingleFlight
notion image
golang/sync/singleflight.Group 是 Go 语言扩展包中提供了另一种同步原语,它能够在一个服务中抑制对下游的多次重复请求。一个比较常见的使用场景是:我们在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时。但是 golang/sync/singleflight.Group 能有效地解决这个问题,它能够限制对同一个键值对的多次重复请求,减少对下游的瞬时流量。
例子:
 
a.数据结构
  • val 和 err 字段都只会在执行传入的函数时赋值一次并在 sync.WaitGroup.Wait 返回时被读取
  • dups 和 chans 两个字段分别存储了抑制的请求数量以及用于同步结果的 Channel
 
b.接口
DoDoChan
这两个方法在功能上没有太多的区别,只是在接口的表现上稍有不同。
 
c.tips:
  • 一旦调用的函数返回了错误,所有在等待的 Goroutine 也都会接收到同样的错误;

定时器


 
 
 
 
 

其他

安全并发

channel实现定时器

流水线判断channel是否关闭

  • Go
  • grpc使用方法Github fork项目同步分支
    Loading...