type
summary
status
category
tags
slug
date
password
icon
一、基础知识1.数据结构runebytebig.intstringfloat数组切片map指针2.语言基础接口反射3.常用关键字for和rangeselectdeferpanic 和 recovermake 和 new二、运行时1.并发编程上下文Context同步原语与锁定时器其他
一、基础知识
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
- 不适合用于金融类计算
- 为了尽量最小化舍入错误,建议先做乘法,再做除法
数组

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

在运行时切片可以由如下的结构体表示:
- Data 指向数组的指针
- Len 当前切片的长度
- Cap 当前切片的容量(Data数组的大小)
a.初始化
- 通过下标的方式获得数组或者切片的一部分
需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。
编译期间:
SliceMake
操作会接受四个参数创建新的切片,元素类型、数组指针、切片大小和容量。- 使用字面量初始化新的切片
编译期间:
- 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组;
- 将这些字面量元素存储到初始化的数组中;
- 创建一个同样指向
[3]int
类型的数组指针;
- 将静态存储区的数组
vstat
赋值给vauto
指针所在的地址;
- 通过
[:]
操作获取一个底层使用vauto
的切片;
- 使用关键字
make
创建切片
使用
make
关键字创建切片时,很多工作都需要运行时的参与b.追加和扩容

- 如果
append
返回的新切片不需要赋值回原有的变量,会先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,就会调用runtime.growslice
对切片进行扩容并将新的元素依次加入切片。
处理流程
- 如果使用
slice = append(slice, 1, 2, 3)
语句,那么append
后的切片会覆盖原切片,这时cmd/compile/internal/gc.state.append
方法会使用另一种方式展开关键字。
处理流程:
- 在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍就会使用期望容量;
- 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
策略:
c.拷贝切片

- 无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过
runtime.memmove
将整块内存的内容拷贝到目标的内存区域中
- 相比于依次拷贝元素,
runtime.memmove
能够提供更好的性能。需要注意的是,整块拷贝内存仍然会占用非常多的资源,在大切片上执行拷贝操作时一定要注意对性能的影响。
tips:
- 切片是指向数组的窗口,每个slice内部都会被表示为包含三个元素的结构:数组的指针、slice的容量、slice的长度
- 当slice被直接传递至函数或方法时,slice的内部指针就可以对底层数据进行修改(所以被传至函数或方法时,只会复制指针,新指针仍然指向原数组,仍然是对原数组进行修改)
map
结构体
count
表示当前哈希表中的元素数量;
B
表示当前哈希表持有的buckets
数量,但是因为哈希表中桶的数量都 2 的倍数,所以该字段会存储对数,也就是len(buckets) == 2^B
;
hash0
是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入;
oldbuckets
是哈希在扩容时用于保存之前buckets
的字段,它的大小是当前buckets
的一半;

- 如上图所示哈希表
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.类型

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 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。
简单总结一下哈希表遍历的顺序,首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成。
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.数据结构

runtime._defer
结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link
字段串联成链表。siz
是参数和结果的内存大小;
sp
和pc
分别代表栈指针和调用方的程序计数器;
fn
是defer
关键字中传入的函数;
_panic
是触发延迟调用的结构体,可能为空;
openDefer
表示当前defer
是否经过开放编码的优化;
panic 和 recover

panic
能够改变程序的控制流,调用panic
后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的defer
;
recover
可以中止panic
造成的程序崩溃。它是一个只能在defer
中发挥作用的函数,在其他作用域中调用不会发挥作用;
a.
panic
只会触发当前 Goroutine 的 defer

b.数据结构
argp
是指向defer
调用时参数的指针;
arg
是调用panic
时传入的参数;
link
指向了更早调用的runtime._panic
结构;
recovered
表示当前runtime._panic
是否被recover
恢复;
aborted
表示当前的panic
是否被强行终止;
从数据结构中的
link
字段我们就可以推测出以下的结论:panic
函数可以被连续多次调用,它们之间通过 link
可以组成链表。c.
panic
函数是终止程序的实现原理- 创建新的
runtime._panic
并添加到所在 Goroutine 的_panic
链表的最前面;
- 在循环中不断从当前 Goroutine 的
_defer
中链表获取runtime._defer
并调用runtime.reflectcall
运行延迟调用函数;
- 调用
runtime.fatalpanic
中止整个程序;
d.崩溃修复(recover)
编译器会将关键字
recover
转换成 runtime.gorecover
:如果当前 Goroutine 没有调用
panic
,那么该函数会直接返回 nil
,这也是崩溃恢复在非 defer
中调用会失效的原因。在正常情况下,它会修改 runtime._panic
的 recovered
字段。make 和 new

make
的作用是初始化内置的数据结构,也就是我们在前面提到的切片、哈希表和 Channel;
new
的作用是根据传入的类型分配一片内存空间并返回指向这片内存空间的指针;
二、运行时
1.并发编程
上下文Context
上下文
context.Context
Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。详细解释:
Deadline
— 返回context.Context
被取消的时间,也就是完成工作的截止日期;
Done
— 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用Done
方法会返回同一个 Channel;
Err
— 返回context.Context
结束的原因,它只会在Done
方法对应的 Channel 关闭时返回非空的值;- 如果
context.Context
被取消,会返回Canceled
错误; - 如果
context.Context
超时,会返回DeadlineExceeded
错误;
Value
— 从context.Context
中获取键对应的值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,该方法可以用来传递请求特定的数据;
a.设计原理
不使用Context同步信号:

使用Context同步信号:

例子:
因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容:
如果我们将处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止:
b.默认上下文
context.Background
是上下文的默认值,所有其他的上下文都应该从它衍生出来;
context.TODO
应该仅在不确定应该使用哪种上下文时使用;
在多数情况下,如果当前函数没有上下文作为入参,我们都会使用
context.Background
作为起始的上下文向下传递。扩展:
这两个私有变量都是通过
new(emptyCtx)
语句初始化的,它们是指向私有结构体 context.emptyCtx
的指针,这是最简单、最常用的上下文类型:c.取消信号
context.WithCancel
函数能够从 context.Context
中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。context.newCancelCtx
将传入的上下文包装成私有结构体context.cancelCtx
;
context.propagateCancel
会构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消
d.传值方法
如果
context.valueCtx
中存储的键值对与 context.valueCtx.Value
方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到某个父上下文中返回 nil
或者查找到对应的值。同步原语与锁
Mutex
a.数据结构
state
表示当前互斥锁的状态
状态:

mutexLocked
— 表示互斥锁的锁定状态;
mutexWoken
— 表示从正常模式被从唤醒;
mutexStarving
— 当前的互斥锁进入饥饿状态;
waitersCount
— 当前互斥锁上等待的 Goroutine 个数;
sema
是用于控制锁状态的信号量
b.加锁和解锁、正常模式和饥饿模式
加锁
- 如果互斥锁处于初始化状态,会通过置位
mutexLocked
加锁;
- 如果互斥锁处于
mutexLocked
状态并且在普通模式下工作,会进入自旋,执行 30 次PAUSE
指令消耗 CPU 时间等待锁的释放;
- 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
- 互斥锁在正常情况下会通过
runtime.sync_runtime_SemacquireMutex
将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒;
- 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,那么它会将互斥锁切换回正常模式;
解锁
- 当互斥锁已经被解锁时,调用
sync.Mutex.Unlock
会直接抛出异常;
- 当互斥锁处于饥饿模式时,将锁的所有权交给队列中的下一个等待者,等待者会负责设置
mutexLocked
标志位;
- 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,会直接返回;在其他情况下会通过
sync.runtime_Semrelease
唤醒对应的 Goroutine;

- Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』
- 在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine
也就是说,mutex分为普通模式和饥饿模式,在普通模式下,goroutine会自旋,而饥饿模式下,mutex将尝试获取锁的goroutine切换为休眠状态等待被唤醒。
Mutex | goroutine |
正常模式 | 自旋 |
饥饿模式 | 休眠 |
RWMutex
解释:
w
— 复用互斥锁提供的能力;
writerSem
和readerSem
— 分别用于写等待读和读等待写:
readerCount
存储了当前正在执行的读操作数量;
readerWait
表示当写操作被阻塞时等待的读操作个数;
ㅤ | 读 | 写 |
读 | Y | N |
写 | N | N |
- 写操作使用
sync.RWMutex.Lock
和sync.RWMutex.Unlock
方法;
- 读操作使用
sync.RWMutex.RLock
和sync.RWMutex.RUnlock
方法;
WaitGroup
解释:
noCopy
— 保证sync.WaitGroup
不会被开发者通过再赋值的方式拷贝;
state1
— 存储着状态和信号量;
a.接口
sync.WaitGroup.Add
- 更新计数器
sync.WaitGroup
中的 counter
- 传入的参数可以为负数,但是计数器只能是非负数,一旦出现负数就会发生程序崩溃
- 当调用计数器归零,即所有任务都执行完成时,才会通过
sync.runtime_Semrelease
唤醒处于等待状态的 Goroutine
sync.WaitGroup.Done
只是向
sync.WaitGroup.Add
方法传入了 -1sync.WaitGroup.Wait
当
sync.WaitGroup
的计数器归零时,陷入睡眠状态的 Goroutine 会被唤醒,上述方法也会立刻返回Once
解释:
- done:用于标识代码块是否执行过
- m:互斥锁
a.接口
sync.Once.Do
- 如果传入的函数已经执行过,会直接返回;
- 如果传入的函数没有执行过,会调用
sync.Once.doSlow
执行传入的函数:
- 为当前 Goroutine 获取互斥锁;
- 执行传入的无入参函数;
- 运行延迟函数调用,将成员变量
done
更新成 1;
Cond
sync.Cond
可以让一组的 Goroutine 都在满足特定条件时被唤醒。
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.数据结构
解释:
cancel
— 创建context.Context
时返回的取消函数,用于在多个 Goroutine 之间同步取消信号;
wg
— 用于等待一组 Goroutine 完成子任务的同步原语;
errOnce
— 用于保证只接收一个子任务返回的错误;
b.接口
golang/sync/errgroup.Group.Go
:
- 调用
sync.WaitGroup.Add
增加待处理的任务;
- 创建新的 Goroutine 并运行子任务;
- 返回错误时及时调用
cancel
并对err
赋值,只有最早返回的错误才会被上游感知到,后续的错误都会被舍弃:
golang/sync/errgroup.Group.Wait
:
只是调用了
sync.WaitGroup.Wait
,在子任务全部完成时取消 context.Context
并返回可能出现的错误。Semaphore
Go 语言的扩展包中就提供了带权重的信号量
golang/sync/semaphore.Weighted
,我们可以按照不同的权重对资源的访问进行管理,这个结构体对外也只暴露了四个方法:golang/sync/semaphore.NewWeighted
用于创建新的信号量;
golang/sync/semaphore.Weighted.Acquire
阻塞地获取指定权重的资源,如果当前没有空闲资源,会陷入休眠等待;
golang/sync/semaphore.Weighted.TryAcquire
非阻塞地获取指定权重的资源,如果当前没有空闲资源,会直接返回false
;
golang/sync/semaphore.Weighted.Release
用于释放指定权重的资源;
SingleFlight

golang/sync/singleflight.Group
是 Go 语言扩展包中提供了另一种同步原语,它能够在一个服务中抑制对下游的多次重复请求。一个比较常见的使用场景是:我们在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时。但是 golang/sync/singleflight.Group
能有效地解决这个问题,它能够限制对同一个键值对的多次重复请求,减少对下游的瞬时流量。例子:
a.数据结构
val
和err
字段都只会在执行传入的函数时赋值一次并在sync.WaitGroup.Wait
返回时被读取
dups
和chans
两个字段分别存储了抑制的请求数量以及用于同步结果的 Channel
b.接口
D
o
和 D
oChan
golang/sync/singleflight.Group.Do
— 同步等待的方法;
golang/sync/singleflight.Group.DoChan
— 返回 Channel 异步等待的方法;
这两个方法在功能上没有太多的区别,只是在接口的表现上稍有不同。
- 当不存在对应的
golang/sync/singleflight.call
时: - 初始化一个新的
golang/sync/singleflight.call
指针; - 增加
sync.WaitGroup
持有的计数器; - 将
golang/sync/singleflight.call
指针添加到映射表; - 释放持有的互斥锁;
- 阻塞地调用
golang/sync/singleflight.Group.doCall
方法等待结果的返回;
- 当存在对应的
golang/sync/singleflight.call
时; - 增加
dups
计数器,它表示当前重复的调用次数; - 释放持有的互斥锁;
- 通过
sync.WaitGroup.Wait
等待请求的返回;
c.tips:
golang/sync/singleflight.Group.Do
和golang/sync/singleflight.Group.DoChan
一个用于同步阻塞调用传入的函数,一个用于异步调用传入的参数并通过 Channel 接收函数的返回值;
golang/sync/singleflight.Group.Forget
可以通知golang/sync/singleflight.Group
在持有的映射表中删除某个键,接下来对该键的调用就不会等待前面的函数返回了;
- 一旦调用的函数返回了错误,所有在等待的 Goroutine 也都会接收到同样的错误;