Interview Go
常见问题
Golang 中除了加 Mutex 锁以外还有哪些方式安全读写共享变量
Golang 中 Goroutine 可以通过 Channel 进行安全读写共享变量,还可以通过原子性操作进行.
无缓冲 Chan 的发送和接收是否同步
|
|
- channel 无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。
- channel 有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。
Golang 并发机制以及它所使用的 CSP 并发模型.
在计算机科学中,通信顺序过程(communicating sequential processes,CSP)是一种描述并发系统中交互模式的正式语言,它是并发数学理论家族中的一个成员,被称为过程算法(process algebras),或者说过程计算(process calculate),是基于消息的通道传递的数学理论。
CSP 模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP 讲究的是“以通信的方式来共享内存”。用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP 中 channel 是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的 channel。
Golang 中 channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到 channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的一个消息被发送到 channel 中,最终是一定要被另外的实体消费掉的,在实现原理上其实类似一个阻塞的消息队列。
Goroutine 是 Golang 实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine 是一种运行在用户态的用户线程,类似于 greenthread,go 底层选择使用 coroutine 的出发点是因为,
它具有以下特点:
- 用户空间 避免了内核态和用户态的切换导致的成本.
- 可以由语言和框架层进行调度.
- 更小的栈空间允许创建大量的实例.
Golang 中的 Goroutine 的特性:
Golang 内部有三个对象: P 对象(processor) 代表上下文(或者可以认为是 cpu),M(work thread)代表工作线程,G 对象(goroutine).
正常情况下一个 cpu 对象启一个工作线程对象,线程去检查并执行 goroutine 对象。碰到 goroutine 对象阻塞的时候,会启动一个新的工作线程,以充分利用 cpu 资源。 所有有时候线程对象会比处理器对象多很多.
我们用如下图分别表示 P、M、G:
G(Goroutine): 我们所说的协程,为用户级的轻量级线程,每个 Goroutine 对象中的 sched 保存着其上下文信息。
M(Machine): 对 Os 内核级线程的封装,数量对应真实的 CPU 数(真正干活的对象)。
P(Processor): 逻辑处理器,即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS()来设置,默认为核心数。
在单核情况下,所有 Goroutine 运行在同一个线程(M0)中,每一个线程维护一个上下文(P),任何时刻,一个上下文中只有一个 Goroutine,其他 Goroutine 在 runqueue 中等待。
一个 Goroutine 运行完自己的时间片后,让出上下文,自己回到 runqueue 中(如下图所示)。
当正在运行的 G0 阻塞的时候(可以需要 IO),会再创建一个线程(M1),P 转到新的线程中去运行。
当 M0 返回时,它会尝试从其他线程中“偷”一个上下文过来,如果没有偷到,会把 Goroutine 放到 Global runqueue中去,然后把自己放入线程缓存中。
上下文会定时检查 Global runqueue。
Golang 是为并发而生的语言,Go 语言是为数不多的在语言层面实现并发的语言;也正是 Go 语言的并发特性,吸引了全球无数的开发者。
Golang 的 CSP 并发模型,是通过 Goroutine 和 Channel 来实现的。
Goroutine 是 Go 语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。 Channel 是 Go 语言中各个并发结构体(Goroutine)之前的通信机制。通常 Channel,是各个 Goroutine 之间通信的”管道“,有点类似于 Linux 中的管道。
通信机制 channel 也很方便,传数据用 channel <- data,取数据用 <-channel。
在通信过程中,传数据 channel <- data和取数据 <-channel必然会成对出现,因为这边传,那边取,两个 goroutine 之间才会实现通信。
而且不管是传还是取,肯定阻塞,直到另外的 goroutine 传或者取为止。
因此 GPM 的简要概括即为:事件循环,线程池,工作队列.
Golang 中常用的并发模型
Golang 中常用的并发模型有三种:
- 通过 channel 通知实现并发控制
无缓冲的通道指的是通道的大小为 0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作。
从上面无缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道。
|
|
当主 goroutine 运行到 <-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制
- 通过 sync 包中的 WaitGroup 实现并发控制
Goroutine 是异步执行的,有的时候为了防止在结束 main 函数的时候结束掉 Goroutine,所以需要同步等待,这个时候就需要用 WaitGroup 了,在 sync 包中,提供了 WaitGroup,它会等待它收集的所有 goroutine 任务全部完成。
在 WaitGroup 里主要有三个方法:
- Add, 可以添加或减少 goroutine 的数量.
- Done, 相当于 Add(-1).
- Wait, 执行后会堵塞主线程,直到 WaitGroup 里的值减至 0.
在主 goroutine 中 Add(delta int) 索要等待 goroutine 的数量。在每一个 goroutine 完成后 Done() 表示这一个 goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回。
|
|
在 Golang 官网中对于 WaitGroup 介绍是 A WaitGroup must not be copied after first use,在 WaitGroup 第一次使用后,不能被拷贝。
应用示例:
|
|
运行:
|
|
它提示所有的 goroutine 都已经睡眠了,出现了死锁。这是因为 wg 给拷贝传递到了 goroutine 中,导致只有 Add 操作,其实 Done 操作是在 wg 的副本执行的。
因此 Wait 就会死锁。
这个第一个修改方式:将匿名函数中 wg 的传入类型改为 *sync.WaitGroup,这样就能引用到正确的 WaitGroup了。
这个第二个修改方式:将匿名函数中的 wg 的传入参数去掉,因为 Go 支持闭包类型,在匿名函数中可以直接使用外面的 wg 变量.
- 在 Go 1.7 以后引进的强大的 Context 上下文,实现并发控制.
通常,在一些简单场景下使用 channel 和 WaitGroup 已经足够了,但是当面临一些复杂多变的网络并发场景下 channel 和 WaitGroup 显得有些力不从心了。
比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其他的 goroutine,比如数据库和 RPC 服务。
所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制他们的目的,这就是 Go 语言为我们提供的 Context,称之为上下文非常贴切,它就是 goroutine 的上下文。
它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常 Go 将这些封装在一个 Context 里,再将它传给要执行的 goroutine 。
context 包主要是用来处理多个 goroutine 之间共享数据,及多个 goroutine 的管理。
context 包的核心是 struct Context,接口声明如下:
|
|
Context 对象是线程安全的,你可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行取消操作时,所有 goroutine 都会接收到取消信号。
一个 Context 不能拥有 Cancel 方法,同时我们也只能 Done channel 接收数据。其中的原因是一致的:接收取消信号的函数和发送信号的函数通常不是一个。
典型的场景是:父操作为子操作操作启动 goroutine,子操作也就不能取消父操作。
Go 中对 nil 的 Slice 和空 Slice 的处理是一致的吗
首先 Go 的 JSON 标准库对 nil slice 和 空 slice 的处理是不一致.
通常错误的用法,会报数组越界的错误,因为只是声明了 slice,却没有给实例化的对象。
|
|
此时 slice 的值是 nil,这种情况可以用于需要返回 slice 的函数,当函数出现异常的时候,保证函数依然会有 nil 的返回值。
empty slice 是指 slice 不为 nil,但是 slice 没有值,slice 的底层的空间是空的,此时的定义如下:
|
|
当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。
总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的.
Golang 的内存模型中为什么小对象多了会造成 GC 压力
通常小对象过多会导致 GC 三色法消耗过多的 GPU。优化思路是,减少对象分配.
Go 中数据竞争问题怎么解决
Data Race 问题可以使用互斥锁 sync.Mutex, 或者也可以通过 CAS 无锁并发解决.
其中使用同步访问共享数据或者 CAS 无锁并发是处理数据竞争的一种有效的方法.
golang 在 1.1 之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。
其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态.
竞争检测器基于 C/C++的 ThreadSanitizer运行时库,该库在 Google 内部代码基地和 Chromium 找到许多错误。这个技术在 2012 年九月集成到 Go 中,从那时开始,它已经在标准库中检测到 42 个竞争条件。现在,它已经是我们持续构建过程的一部分,当竞争条件出现时,它会继续捕捉到这些错误。
竞争检测器已经完全集成到 Go 工具链中,仅仅添加-race 标志到命令行就使用了检测器。
|
|
要想解决数据竞争的问题可以使用互斥锁 sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高.
什么是 channel,为什么它可以做到线程安全
Channel 是 Go 中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel 也可以理解是一个先进先出的队列,通过管道进行通信。
Golang 的 Channel,发送一个数据到 Channel 和从 Channel 接收一个数据都是原子性的。
Go 的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是 Channel。
也就是说,设计 Channel 的主要目的就是在多任务间传递数据的,本身就是安全的。
协程和线程和进程的区别
进程
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
线程
线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源 (如程序计数器, 一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。
协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
Go 的 GMP 如何调度
Go 语言的调度器用于在线程上分配 Goroutine,并在 runtime/proc.go 定义以下三个概念:
|
|
G:Goroutine,也就是 go 里的协程,是用户态的轻量级线程,具体可以创建多个 goroutine ,取决你的内存有多大,一个 goroutine 大概需要 4k 内存,只要你不是在 32 位的机器上,那么创建个几百万个的 goroutine 应该没有问题。M:Thread,也就是操作系统线程,go runtime 最多允许创建 10000 个操作系统线程,超过了就会抛出异常P:Processor,处理器,数量默认等于开机器的 cpu 核心数,若想调小,可以通过 GOMAXPROCS 这个环境变量设置。
在整个 Go 调度器的生命周期中,存在着两个非常重要的队列:
- 全局队列(Global Queue):全局只有一个
- 本地队列(Local Queue):每个 P 都会维护一个本地队列
当你执行 go func() 创建一个 goroutine 时,会优选将该协程放入到当前 P 的本地队列中等待被 P 选中执行。
但若当前 P 的本地队列任务太多了,已经存放不下了,那么这个 goroutine 就只能放入到全局队列中。
策略
- work stealing 机制:当本线程M绑定的P队列无可运行的 G 时,尝试从其他线程绑定的 P队列 偷取 G,而不是销毁线程
- hand off 机制:当本线程 M 因为 G 进行系统调用阻塞时,线程释放绑定的 P 处理器,把 P 转移给其他空闲的线程执行
goroutine 调度器
- M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G,在之后 M0 就和其他的 M 一样了
- G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0
上面生命周期流程说明:
- runtime 创建最初的线程 m0 和 goroutine g0,并把两者进行关联(g0.m = m0)
- 调度器初始化:设置M最大数量,P个数,栈和内存,以及创建 GOMAXPROCS个P
- 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
- 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
- G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
- M 运行 G
- G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
Go 语言的栈空间管理是怎么样的
Go 语言的运行环境(runtime)会在 goroutine 需要的时候动态地分配栈空间,而不是给每个 goroutine 分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。
分块式的栈是最初 Go 语言组织栈的方式。当创建一个 goroutine 的时候,它会分配一个 8KB 的内存空间来给 goroutine 的栈使用。我们可能会考虑当这 8KB 的栈空间被用完的时候该怎么办?
为了处理这种情况,每个 Go 函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用 morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。
在新分配的栈底部,还插入了一个叫做 lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到 lessstack。lessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。
分块式的栈让我们能够按照需求来扩展和收缩栈的大小。 Go 开发者不需要花精力去估计 goroutine 会用到多大的栈。创建一个新的 goroutine 的开销也不大。当 Go 开发者不知道栈会扩展到多少大时,它也能很好的处理这种情况。
这一直是之前 Go 语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。
这就是所谓的热分裂问题(hot split problem)。它是 Go 语言开发者选择新的栈管理方法的主要原因。新的方法叫做 栈复制法(stack copying)。
栈复制法一开始和分块式的栈很像。当 goroutine 运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。
栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。然而,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。
因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用 C 语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。
这也是为什么现在运行环境的开发者正在用 Go 语言重写运行环境的大部分代码。无法用 Go 语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。
这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。
另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题
首先,32 位系统只能支持 4G 字节虚拟内存,并且应用只能用到其中的 3G 空间。由于同时运行百万 goroutines 的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个 goroutine 的 stack 只有 8K。
第二,然而我们可以在 64 位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。
Goroutine 和 Channel 的作用分别是什么
进程是内存资源管理和 cpu 调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但 cpu 调度的最小单元变成了线程。
那协程又是什么呢,以及与线程的差异性?
协程,可以看作是轻量级的线程。但与线程不同的是,线程的切换是由操作系统控制的,而协程的切换则是由用户控制的。
最早支持协程的程序语言应该是 lisp 方言 scheme 里的 continuation(续延),续延允许 scheme 保存任意函数调用的现场,保存起来并重新执行。Lua,C#,python 等语言也有自己的协程实现。
Go 中的 goroutinue 就是协程,可以实现并行,多个协程可以在多个处理器同时跑。而协程同一时刻只能在一个处理器上跑(可以把宿主语言想象成单线程的就好了)。 然而,多个 goroutine 之间的通信是通过 channel,而协程的通信是通过 yield 和 resume()操作。
goroutine 非常简单,只需要在函数的调用前面加关键字 go 即可,例如:
|
|
我们也可以启动 5 个 goroutines 分别打印索引:
|
|
在分析 goroutine 执行的随机性和并发性,启动了 5 个 goroutine,再加上 main 函数的主 goroutine,总共有 6 个 goroutines。由于 goroutine 类似于”守护线程“,异步执行的,如果主 goroutine 不等待片刻,可能程序就没有输出打印了。
在 Golang 中 channel 则是 goroutinues 之间进行通信的渠道。
可以把 channel 形象比喻为工厂里的传送带,一头的生产者 goroutine 往传输带放东西,另一头的消费者 goroutinue 则从输送带取东西。channel 实际上是一个有类型的消息队列,遵循先进先出的特点。
- channel 的操作符号
ch <- data 表示 data 被发送给 channel ch;
data <- ch 表示从 channel ch取一个值,然后赋给 data。
- 阻塞式 channel
channel 默认是没有缓冲区的,也就是说,通信是阻塞的。send 操作必须等到有消费者 accept 才算完成。
应用示例:
|
|
在函数 pump()里的 channel 在接受到第一个元素后就被阻塞了,直到主 goroutinue 取走了数据。最终 channel 阻塞在接受第二个元素,程序只打印 1。
没有缓冲(buffer)的 channel 只能容纳一个元素,而带有缓冲(buffer)channel 则可以非阻塞容纳 N 个元素。发送数据到缓冲(buffer) channel 不会被阻塞,除非 channel 已满;同样的,从缓冲(buffer) channel 取数据也不会被阻塞,除非 channel 空了。
怎么查看 Goroutine 的数量
在 Golang 中,GOMAXPROCS中控制的是未被阻塞的所有 Goroutine,可以被 Multiplex 到多少个线程上运行,通过 GOMAXPROCS可以查看 Goroutine 的数量。
Go 中的锁有哪些
Go 中的三种锁包括:互斥锁,读写锁,sync.Map的安全的锁.
- 互斥锁
Go 并发程序对共享资源进行访问控制的主要手段,由标准库代码包中 sync 中的 Mutex 结构体表示。
|
|
sync.Mutex 包中的类型只有两个公开的指针方法 Lock 和 Unlock。
|
|
声明一个互斥锁:
|
|
不像 C 或 Java 的锁类工具,我们可能会犯一个错误:忘记及时解开已被锁住的锁,从而导致流程异常。但 Go 由于存在 defer,所以此类问题出现的概率极低。关于 defer 解锁的方式如下:
|
|
如果对一个已经上锁的对象再次上锁,那么就会导致该锁定操作被阻塞,直到该互斥锁回到被解锁状态.
|
|
我们在 for 循环之前开始加锁,然后在每一次循环中创建一个协程,并对其加锁,但是由于之前已经加锁了,所以这个 for 循环中的加锁会陷入阻塞直到 main 中的锁被解锁, time.Sleep(time.Second) 是为了能让系统有足够的时间运行 for 循环,输出结果如下:
|
|
这里可以看到解锁后,三个协程会重新抢夺互斥锁权,最终协程 3 获胜。
互斥锁锁定操作的逆操作并不会导致协程阻塞,但是有可能导致引发一个无法恢复的运行时的 panic,比如对一个未锁定的互斥锁进行解锁时就会发生 panic。避免这种情况的最有效方式就是使用 defer。
我们知道如果遇到 panic,可以使用 recover 方法进行恢复,但是如果对重复解锁互斥锁引发的 panic 却是无用的(Go 1.8 及以后)。
|
|
运行:
|
|
这里试图对重复解锁引发的 panic 进行 recover,但是我们发现操作失败,虽然互斥锁可以被多个协程共享,但还是建议将对同一个互斥锁的加锁解锁操作放在同一个层次的代码中。
- 读写锁
读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。
读写锁的访问控制规则如下:
① 多个写操作之间是互斥的 ② 写操作与读操作之间也是互斥的 ③ 多个读操作之间不是互斥的
在这样的控制规则下,读写锁可以大大降低性能损耗。
在 Go 的标准库代码包中 sync 中的 RWMutex 结构体表示为:
|
|
sync 中的 RWMutex 有以下几种方法:
|
|
Unlock 方法会试图唤醒所有想进行读锁定而被阻塞的协程,而 RUnlock 方法只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的协程。
若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的 panic,同理对一个未被读锁定的读写锁进行读写锁也会如此。
由于读写锁控制下的多个读操作之间不是互斥的,因此对于读解锁更容易被忽视。对于同一个读写锁,添加多少个读锁定,就必要有等量的读解锁,这样才能其他协程有机会进行操作。
因此 Go 中读写锁,在多个读线程可以同时访问共享数据,写线程必须等待所有读线程都释放锁以后,才能取得锁.
同样的,读线程必须等待写线程释放锁后,才能取得锁,也就是说读写锁要确保的是如下互斥关系,可以同时读,但是读-写,写-写都是互斥的。
|
|
运行:
|
|
这里可以看到创建了五个协程用于对读写锁的读锁定与读解锁操作。在 rwm.Lock()种会对 main 中协程进行写锁定,但是 for 循环中的读解锁尚未完成,因此会造成 main 中的协程阻塞。当 for 循环中的读解锁操作都完成后就会试图唤醒 main 中阻塞的协程,main 中的写锁定才会完成。
- sync.Map 安全锁
golang 中的 sync.Map 是并发安全的,其实也就是 sync 包中 golang 自定义的一个名叫 Map 的结构体。
应用示例:
|
|
运行 :
|
|
sync.Map 的数据结构:
|
|
read 的数据结构是:
|
|
entry 的数据结构:
|
|
Delete 方法:
|
|
Store 方法:
|
|
因此,每次操作先检查 read,因为 read 并发安全,性能好些;read 不满足,则加锁检查 dirty,一旦是新的键值,dirty 会被 read 更新。
Load 方法:
Load 方法是一个加载方法,查找 key。
|
|
sync.Map 是通过冗余的两个数据结构(read、dirty),实现性能的提升。
为了提升性能,load、delete、store 等操作尽量使用只读的 read;为了提高 read 的 key 击中概率,采用动态调整,将 dirty 数据提升为 read;对于数据的删除,采用延迟标记删除法,只有在提升 dirty 的时候才删除。
怎么限制 Goroutine 的数量
在 Golang 中,Goroutine 虽然很好,但是数量太多了,往往会带来很多麻烦,比如耗尽系统资源导致程序崩溃,或者 CPU 使用率过高导致系统忙不过来。
所以我们可以限制下 Goroutine 的数量,这样就需要在每一次执行 go 之前判断 goroutine 的数量,如果数量超了,就要阻塞 go 的执行。
所以通常我们第一时间想到的就是使用通道。每次执行的 go 之前向通道写入值,直到通道满的时候就阻塞了,
|
|
运行:
|
|
这样每次同时运行的 goroutine 就被限制为 5 个了。但是新的问题于是就出现了,因为并不是所有的 goroutine 都执行完了,在 main 函数退出之后,还有一些 goroutine 没有执行完就被强制结束了。这个时候我们就需要用到 sync.WaitGroup。使用 WaitGroup 等待所有的 goroutine 退出。
|
|
运行:
|
|
其中,Go 的 GOMAXPROCS 默认值已经设置为 CPU 的核数, 这里允许我们的 Go 程序充分使用机器的每一个 CPU,最大程度的提高我们程序的并发性能。runtime.NumGoroutine函数在被调用后,会返回系统中的处于特定状态的 Goroutine 的数量。这里的特指是指 Grunnable\Gruning\Gsyscall\Gwaition。处于这些状态的 Groutine 即被看做是活跃的或者说正在被调度。
这里需要注意下:垃圾回收所在 Groutine 的状态也处于这个范围内的话,也会被纳入该计数器。
Channel 是同步的还是异步的
Channel 是异步进行的, channel 存在 3 种状态:
- nil,未初始化的状态,只进行了声明,或者手动赋值为 nil
- active,正常的 channel,可读或者可写
- closed,已关闭,千万不要误认为关闭 channel 后,channel 的值是 nil
下面我们对 channel 的三种操作解析:
- 零值(nil)通道;
- 非零值但已关闭的通道;
- 非零值并且尚未关闭的通道。
| 操作 | 一个零值 nil 通道 | 一个非零值但已关闭的通道 | 一个非零值且尚未关闭的通道 |
|---|---|---|---|
| 关闭 | 产生恐慌 | 产生恐慌 | 成功关闭 |
| 发送数据 | 永久阻塞 | 产生恐慌 | 阻塞或者成功发送 |
| 接收数据 | 永久阻塞 | 永不阻塞 | 阻塞或者成功接收 |
Goroutine 和线程的区别
从调度上看,goroutine 的调度开销远远小于线程调度开销。
OS 的线程由 OS 内核调度,每隔几毫秒,一个硬件时钟中断发到 CPU,CPU 调用一个调度器内核函数。这个函数暂停当前正在运行的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运行哪一个线程,再从内存中恢复线程的注册表信息,最后继续执行选中的线程。这种线程切换需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的。
Go 运行的时候包涵一个自己的调度器,这个调度器使用一个称为一个 M:N 调度技术,m 个 goroutine 到 n 个 os 线程(可以用 GOMAXPROCS 来控制 n 的数量),Go 的调度器不是由硬件时钟来定期触发的,而是由特定的 go 语言结构来触发的,他不需要切换到内核语境,所以调度一个 goroutine 比调度一个线程的成本低很多。
从栈空间上,goroutine 的栈空间更加动态灵活。
每个 OS 的线程都有一个固定大小的栈内存,通常是 2MB,栈内存用于保存在其他函数调用期间哪些正在执行或者临时暂停的函数的局部变量。这个固定的栈大小,如果对于 goroutine 来说,可能是一种巨大的浪费。作为对比 goroutine 在生命周期开始只有一个很小的栈,典型情况是 2KB, 在 go 程序中,一次创建十万左右的 goroutine 也不罕见(2KB*100,000=200MB)。而且 goroutine 的栈不是固定大小,它可以按需增大和缩小,最大限制可以到 1GB。
goroutine 没有一个特定的标识。
在大部分支持多线程的操作系统和编程语言中,线程有一个独特的标识,通常是一个整数或者指针,这个特性可以让我们构建一个线程的局部存储,本质是一个全局的 map,以线程的标识作为键,这样每个线程可以独立使用这个 map 存储和获取值,不受其他线程干扰。
goroutine 中没有可供程序员访问的标识,原因是一种纯函数的理念,不希望滥用线程局部存储导致一个不健康的超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。
Go 的 Struct 能不能比较
- 相同 struct 类型的可以比较
- 不同 struct 类型的不可以比较,编译都不过,类型不匹配
|
|
Go 的 defer 原理是什么
什么是 defer?如何理解 defer 关键字?Go 中使用 defer 的一些坑。
defer 意为延迟,在 golang 中用于延迟执行一个函数。它可以帮助我们处理容易忽略的问题,如资源释放、连接关闭等。但在实际使用过程中,有一些需要注意的地方.
- 若函数中有多个 defer,其执行顺序为 先进后出,可以理解为栈。
|
|
运行:
|
|
- return 会做什么呢?
Go 的函数返回值是通过堆栈返回的, return 语句不是原子操作,而是被拆成了两步.
- 给返回值赋值 (rval)
- 调用 defer 表达式
- 返回给调用函数(ret)
|
|
运行输出:
|
|
- 若 defer 表达式有返回值,将会被丢弃。
闭包与匿名函数.
- 匿名函数:没有函数名的函数。
- 闭包:可以使用另外一个函数作用域中的变量的函数。
在实际开发中,defer 的使用经常伴随着闭包与匿名函数的使用。
|
|
运行输出:
|
|
之所以这样是因为,defer 表达式中的 i 是对 for 循环中 i 的引用。到最后,i 加到 5,故最后全部打印 5。
如果将 i 作为参数传入 defer 表达式中,在传入最初就会进行求值保存,只是没有执行延迟函数而已。
应用示例:
|
|
有没有得出结果?例 1 的答案不是 0,例 2 的答案不是 10,例 3 的答案也不是 6。
defer 是在 return 之前执行的。这个在官方文档中是明确说明了的。要使用 defer 时不踩坑,最重要的一点就是要明白,return xxx这一条语句并不是一条原子指令!
|
|
f1: 比较简单,参考结论 2,将 0 赋给 result,defer 延迟函数修改 result,最后返回给调用函数。正确答案是 1。
f1 可以修改成长这样的:
|
|
所以这个返回值是 1。
f2: defer 是在 t 赋值给 r 之后执行的,而 defer 延迟函数只改变了 t 的值,r 不变。正确答案 5。
f2 可以修改成这样的:
|
|
所以这个的结果是 5。
f3: 这里将 r 作为参数传入了 defer 表达式。故 func (r int) 中的 r 非 func f() (r int) 中的 r,只是参数命名相同而已。正确答案 1。
f3 可以修改成这样的:
|
|
所以这个例子的结果是 1。
f4: 这里将发生 panic。将方法传给 deferExec,实际上在传的过程中对方法求了值。而此时的 t 任然为 nil。
因此, defer 确实是在 return 之前调用的。但表现形式上却可能不像。根本原因是 return xxx 语句并不是一条原子指令,defer 被插入到了赋值 与 ret 之间,因此可能有机会改变最终的返回值。
defer 关键字的实现跟 go 关键字很类似,不同的是它调用的是 runtime.deferproc而不是 runtime.newproc。
在 defer 出现的地方,插入了指令 call runtime.deferproc,然后在函数返回之前的地方,插入指令 call runtime.deferreturn。
普通的函数返回时,汇编代码类似:
|
|
如果其中包含了 defer 语句,则汇编代码是:
|
|
goroutine 的控制结构中,有一张表记录 defer,调用 runtime.deferproc时会将需要 defer 的表达式记录在表中,而在调用 runtime.deferreturn的时候,则会依次从 defer 表中出栈并执行。
Go 的 select 可以用于什么
Golang 的 select 机制可以理解为是在语言层面实现了和 select, poll, epoll 相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。 golang 的 select 机制是,监听多个 channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置 default,它的作用是:当监听的多个事件都阻塞住会执行 default 的逻辑。
select 的源码在 (runtime/select.go)[https://github.com/golang/go/blob/master/src/runtime/select.go] ,看的时候建议是重点关注 pollorder 和 lockorder.
- pollorder 保存的是 scase 的序号,乱序是为了之后执行时的随机性。
- lockorder 保存了所有 case 中 channel 的地址,这里按照地址大小堆排了一下 lockorder 对应的这片连续内存。对 chan 排序是为了去重,保证之后对所有 channel 上锁时不会重复上锁。
goroutine 作为 Golang 并发的核心,我们不仅要关注它们的创建和管理,当然还要关注如何合理的退出这些协程,不(合理)退出不然可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。goroutine 在退出方面,不像线程和进程,不能通过某种手段强制关闭它们,只能等待 goroutine 主动退出。
goroutine 的优雅退出方法有三种:
- 使用 for-range 退出
for-range 是使用频率很高的结构,常用它来遍历数据,range 能够感知 channel 的关闭,当 channel 被发送数据的协程关闭时,range 就会结束,接着退出 for 循环。
它在并发中的使用场景是:当协程只从 1 个 channel 读取数据,然后进行处理,处理后协程退出。下面这个示例程序,当 in 通道被关闭时,协程可自动退出。
|
|
- 使用 select case ,ok 退出
for-select 也是使用频率很高的结构,select 提供了多路复用的能力,所以 for-select 可以让函数具有持续多路处理多个 channel 的能力。但 select 没有感知 channel 的关闭,这引出了 2 个问题:
继续在关闭的通道上读,会读到通道传输数据类型的零值,如果是指针类型,读到 nil,继续处理还会产生 nil。 继续在关闭的通道上写,将会 panic。
问题 2 可以这样解决,通道只由发送方关闭,接收方不可关闭,即某个写通道只由使用该 select 的协程关闭,select 中就不存在继续在关闭的通道上写数据的问题。
问题 1 可以使用,ok 来检测通道的关闭,使用情况有 2 种。
第一种:如果某个通道关闭后,需要退出协程,直接 return 即可。示例代码中,该协程需要从 in 通道读数据,还需要定时打印已经处理的数量,有 2 件事要做,所有不能使用 for-range,需要使用 for-select,当 in 关闭时,ok=false,我们直接返回。
|
|
第二种:如果某个通道关闭了,不再处理该通道,而是继续处理其他 case,退出是等待所有的可读通道关闭。我们需要使用 select 的一个特征:select 不会在 nil 的通道上进行等待。这种情况,把只读通道设置为 nil 即可解决。
|
|
- 使用退出通道退出
使用,ok 来退出使用 for-select 协程,解决是当读入数据的通道关闭时,没数据读时程序的正常结束。想想下面这 2 种场景,,ok 还能适用吗?
接收的协程要退出了,如果它直接退出,不告知发送协程,发送协程将阻塞。启动了一个工作协程处理数据,如何通知它退出?
使用一个专门的通道,发送退出的信号,可以解决这类问题。以第 2 个场景为例,协程入参包含一个停止通道 stopCh,当 stopCh 被关闭,case <-stopCh 会执行,直接返回即可。
当我启动了 100 个 worker 时,只要 main()执行关闭 stopCh,每一个 worker 都会都到信号,进而关闭。如果 main()向 stopCh 发送 100 个数据,这种就低效了。
|
|
通过 channel 控制子 goroutine 的方法可以总结为:循环监听一个 channel,一般来说是 for 循环里放一个 select 监听 channel 以达到通知子 goroutine 的效果。再借助 Waitgroup,主进程可以等待所有协程优雅退出后再结束自己的运行,这就通过 channel 实现了优雅控制 goroutine 并发的开始和结束。
因此在退出协程的时候需要注意:
- 发送协程主动关闭通道,接收协程不关闭通道。使用技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错。
- 协程处理 1 个通道,并且是读时,协程优先使用 for-range,因为 range 可以关闭通道的关闭自动退出协程。
- ok 可以处理多个读通道关闭,需要关闭当前使用 for-select 的协程。
- 显式关闭通道 stopCh 可以处理主动通知协程退出的场景。
Context 包的用途是什么
在 Go http 包的 Server 中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和 RPC 服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的 token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。
在 Google 内部,我们开发了 Context 包,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。
context 的数据结构是:
|
|
Context 中的方法:
- Done 会返回一个 channel,当该 context 被取消的时候,该 channel 会被关闭,同时对应的使用该 context 的 routine 也应该结束并返回。
- Context 中的方法是协程安全的,这也就代表了在父 routine 中创建的 context,可以传递给任意数量的 routine 并让他们同时访问。
- Deadline 会返回一个超时时间,routine 获得了超时时间后,可以对某些 io 操作设定超时时间。
- Value 可以让 routine 共享一些数据,当然获得数据是协程安全的。
这里需要注意一点的是在 goroutine 中使用 context 包的时候,通常我们需要在 goroutine 中新创建一个上下文的 context,原因是:如果直接传递外部 context 到协层中,一个请求可能在主函数中已经结束,在 goroutine 中如果还没有结束的话,会直接导致 goroutine 中的运行的被取消.
|
|
context.Background 函数的返回值是一个空的 context,经常作为树的根结点,它一般由接收请求的第一个 routine 创建,不能被取消、没有值、也没有过期时间。
Background 函数的声明如下:
|
|
WithCancel 和 WithTimeout 函数 会返回继承的 Context 对象, 这些对象可以比它们的父 Context 更早地取消。
当请求处理函数返回时,与该请求关联的 Context 会被取消。 当使用多个副本发送请求时,可以使用 WithCancel 取消多余的请求。 WithTimeout 在设置对后端服务器请求截止时间时非常有用。 下面是这三个函数的声明:
|
|
调用 CancelFunc 对象将撤销对应的 Context 对象,这样父结点的所在的环境中,获得了撤销子节点 context 的权利,当触发某些条件时,可以调用 CancelFunc 对象来终止子结点树的所有 routine。在子节点的 routine 中,需要判断何时退出 routine:
|
|
根据 cxt.Done()判断是否结束。当顶层的 Request 请求处理结束,或者外部取消了这次请求,就可以 cancel 掉顶层 context,从而使整个请求的 routine 树得以退出。
WithDeadline 和 WithTimeout 比 WithCancel 多了一个时间参数,它指示 context 存活的最长时间。如果超过了过期时间,会自动撤销它的子 context。所以 context 的生命期是由父 context 的 routine 和 deadline 共同决定的。
WithValue 函数能够将请求作用域的数据与 Context 对象建立关系。声明如下:
|
|
WithValue 返回 parent 的一个副本,该副本保存了传入的 key/value,而调用 Context 接口的 Value(key)方法就可以得到 val。注意在同一个 context 中设置 key/value,若 key 相同,值会被覆盖。
Context 上下文数据的存储就像一个树,每个结点只存储一个 key/value对。WithValue()保存一个 key/value对,它将父 context 嵌入到新的子 context,并在节点中保存了 key/value数据。Value()查询 key 对应的 value 数据,会从当前 context 中查询,如果查不到,会递归查询父 context 中的数据。
值得注意的是,context 中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据。
Context 使用原则:
- 不要把 Context 放在结构体中,要以参数的方式传递。
- 以 Context 作为参数的函数方法,应该把 Context 作为第一个参数,放在第一位。
- 给一个函数方法传递 Context 的时候,不要传递 nil,如果不知道传递什么,就使用 context.TODO。
- Context 的 Value 相关方法应该传递必须的数据,不要什么数据都使用这个传递。
- Context 是线程安全的,可以放心的在多个 goroutine 中传递。
Go 主协程如何等其余协程完再操作
Go 提供了更简单的方法——使用 sync.WaitGroup。WaitGroup,就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数.
它提供了三个方法,Add()用来添加计数。Done()用来在操作结束时调用,使计数减一。Wait()用来等待所有的操作结束,即计数变为 0,该函数会在计数不为 0 时等待,在计数为 0 时立即返回。
应用示例:
|
|
运行输出:
|
|
Go 的 Slice 如何扩容
slice 是 Go 中的一种基本的数据结构,使用这种结构可以用来管理数据集合。但是 slice 本身并不是动态数据或者数组指针。slice 常见的操作有 reslice、append、copy。
slice 自身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。slice 本身是一个只读对象,其工作机制类似数组指针的一种封装。
slice 是对数组一个连续片段的引用,所以切片是一个引用类型(因此更类似于 C/C++ 中的数组类型,或者 Python 中的 list 类型)。这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。
这里需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个与指向数组的动态窗口。
slice 是可以看做是一个长度可变的数组。
slice 数据结构如下:
|
|
slice 的结构体由 3 部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的。
通常我们在对 slice 进行 append 等操作时,可能会造成 slice 的自动扩容。
其扩容时的大小增长规则是:
- 如果切片的容量小于 1024 个元素,那么扩容的时候 slice 的 cap 就翻番,乘以 2;一旦元素个数超过 1024 个元素,增长因子就变成 1.25,即每次增加原来容量的四分之一。
- 如果扩容之后,还没有触及原数组的容量,那么,切片中的指针指向的位置,就还是原数组,如果扩容之后,超过了原数组的容量,那么,Go 就会开辟一块新的内存,把原来的值拷贝过来,这种情况丝毫不会影响到原数组。
通过 slice 源码可以看到,append 的实现只是简单的在内存中将旧 slice 复制给新 slice.
|
|
Go 中的 map 如何实现顺序读取
Go 中 map 如果要实现顺序读取的话,可以先把 map 中的 key,通过 sort 包排序.
通过 sort 中的排序包进行对 map 中的 key 进行排序.
|
|
Go 中 CAS 是怎么回事
CAS 算法(Compare And Swap),是原子操作的一种, CAS 算法是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。
该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。
Go 中的 CAS 操作是借用了 CPU 提供的原子性指令来实现。CAS 操作修改共享变量时候不需要对共享变量加锁,而是通过类似乐观锁的方式进行检查,本质还是不断的占用 CPU 资源换取加锁带来的开销(比如上下文切换开销)。
|
|
当主函数 main 首先创建了 5 个信号量,然后开启五个线程执行 incCounter 方法,incCounter 内部执行, 使用 cas 操作递增 counter 的值,atomic.CompareAndSwapInt32具有三个参数,第一个是变量的地址,第二个是变量当前值,第三个是要修改变量为多少,该函数如果发现传递的 old 值等于当前变量的值,则使用第三个变量替换变量的值并返回 true,否则返回 false。
这里之所以使用无限循环是因为在高并发下每个线程执行 CAS 并不是每次都成功,失败了的线程需要重写获取变量当前的值,然后重新执行 CAS 操作。读者可以把线程数改为 10000 或者更多就会发现输出 thread,5329,spinnum,1 其中这个 1 就说明该线程尝试了两个 CAS 操作,第二次才成功。
因此呢, go 中 CAS 操作可以有效的减少使用锁所带来的开销,但是需要注意在高并发下这是使用 cpu 资源做交换的。
Go 中的逃逸分析是什么
在 Go 中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。
当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了。
如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。 逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。
导致内存逃逸的情况比较多,有些可能还是官方未能够实现精确的分析逃逸情况的 bug,通常来讲就是如果变量的作用域不会扩大并且其行为或者大小能够在编译的时候确定,一般情况下都是分配到栈上,否则就可能发生内存逃逸分配到堆上。
内存逃逸的五种情况:
- 发送指针的指针或值包含了指针到
channel中,由于在编译阶段无法确定其作用域与传递的路径,所以一般都会逃逸到堆上分配。 - slices 中的值是指针的指针或包含指针字段。一个例子是类似
[]*string的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上,数据的引用也会转移到堆中。 - slice 由于 append 操作超出其容量,因此会导致 slice 重新分配。这种情况下,由于在编译时 slice 的初始大小的已知情况下,将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展,则它将分配在堆上。
- 调用接口类型的方法。接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片 b 的后续转义并因此分配到堆上。
- 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上.
有效的避免上述的五种逃逸的情况,就可以避免内存逃逸.
Go 值接收者和指针接收者的区别
Go 中的方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。
也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。
|
|
运行
|
|
| 函数和方法 | 值接收者 | 指针接收者 |
|---|---|---|
| 值类型调用者 | 方法会使用调用者的一个副本,类似于“传值” | 使用值的引用来调用方法,上例中,p1.GetAge() 实际上是 (&p1).GetAge(). |
| 指针类型调用者 | 指针被解引用为值,上例中,p2.GetAge()实际上是 (*p1).GetAge() | 实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针 |
如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。
通常我们使用指针作为方法的接收者的理由:
- 使用指针方法能够修改接收者指向的值。
- 可以避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。
因而呢,我们是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。
如果类型具备“原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。
如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体。
接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil。
Go 的对象在内存中是怎样分配的
Go 中的内存分类并不像 TCMalloc 那样分成小、中、大对象,但是它的小对象里又细分了一个 Tiny 对象,Tiny 对象指大小在 1Byte 到 16Byte 之间并且不包含指针的对象。
小对象和大对象只用大小划定,无其他区分。
大对象指大小大于 32kb.小对象是在 mcache 中分配的,而大对象是直接从 mheap 分配的,从小对象的内存分配看起。
Go 的内存分配原则:
Go 在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。
申请到的内存块被分配了三个区域,在 X64 上分别是 512MB,16GB,512GB 大小。
arena 区域就是我们所谓的堆区,Go 动态分配的内存都是在这个区域,它把内存分割成 8KB 大小的页,一些页组合起来称为 mspan。
bitmap 区域标识 arena 区域哪些地址保存了对象,并且用 4bit 标志位表示对象是否包含指针、GC 标记信息。bitmap 中一个 byte 大小的内存对应 arena 区域中 4 个指针大小(指针大小为 8B )的内存,所以 bitmap 区域的大小是 512GB/(4*8B)=16GB。
此外我们还可以看到 bitmap 的高地址部分指向 arena 区域的低地址部分,这里 bitmap 的地址是由高地址向低地址增长的。
spans 区域存放 mspan(是一些 arena 分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以 spans 区域的大小就是 512GB/8KB*8B=512MB。
除以 8KB 是计算 arena 区域的页数,而最后乘以 8 是计算 spans 区域所有指针的大小。创建 mspan 的时候,按页填充对应的 spans 区域,在回收 object 时,根据地址很容易就能找到它所属的 mspan。
栈的内存是怎么分配的
栈和堆只是虚拟内存上 2 块不同功能的内存区域:
- 栈在高地址,从高地址向低地址增长。
- 堆在低地址,从低地址向高地址增长。
栈和堆相比优势:
- 栈的内存管理简单,分配比堆上快。
- 栈的内存不需要回收,而堆需要,无论是主动 free,还是被动的垃圾回收,这都需要花费额外的 CPU。
- 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU 访问的 2 块数据可能在不同的页上,CPU 访问数据的时间可能就上去了。
堆内存管理怎么分配的
通常在 Golang 中,当我们谈论内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。
堆内存管理中主要是三部分, 1.分配内存块,2.回收内存块, 3.组织内存块。
一个内存块包含了 3 类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请 5Byte 内存的时候,就需要进行内存对齐。
释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。
上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将 2 个连续的未使用的内存块合并,减少碎片。
想要深入了解可以看下这个文章,《Writing a Memory Allocator》.
Go 中的 defer 函数使用下面的两种情况下结果是什么
我们看看下面两种 defer 函数的返回的是什么:
|
|
运行:
|
|
第一种情况:
|
|
defer 延迟函数调用的 fmt.Println(a)函数的参数值在 defer 语句出现时就已经确定了,所以无论后面如何修改 a 变量都不会影响延迟函数。
第二种情况:
|
|
defer 延迟函数调用的函数参数的值在 defer 定义时候就确定了,而 defer 延迟函数内部所使用的值需要在这个函数运行时候才确定。
在 Go 函数中为什么会发生内存泄露
通常内存泄漏,指的是能够预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 goroutine 的存在,因此,内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
- 预期能被快速释放的内存因被根对象引用而没有得到迅速释放.
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。
- goroutine 泄漏
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.
例如:
|
|
Go 中 new 和 make 的区别
在 Go 中,的值类型和引用类型:
|
|
这里需要注意的是: 对于引用类型的变量,我们不仅要声明变量,更重要的是,我们得手动为它分配空间.
因此 new 该方法的参数要求传入一个类型,而不是一个值,它会申请一个该类型大小的内存空间,并会初始化为对应的零值,返回指向该内存空间的一个指针。
|
|
而 make 也是用于内存分配,但是和 new 不同,只用来引用对象 slice、map 和 channel 的内存创建,它返回的类型就是类型本身,而不是它们的指针类型。
|
|
G0 的作用
在 Go 中 g0 作为一个特殊的 goroutine,为 scheduler 执行调度循环提供了场地(栈)。对于一个线程来说,g0 总是它第一个创建的 goroutine。
之后,它会不断地寻找其他普通的 goroutine 来执行,直到进程退出。
当需要执行一些任务,且不想扩栈时,就可以用到 g0 了,因为 g0 的栈比较大。
g0 其他的一些“职责”有:创建 goroutine、deferproc 函数里新建 _defer、垃圾回收相关的工作(例如 stw、扫描 goroutine 的执行栈、一些标识清扫的工作、栈增长)等等。
Go 中的锁如何实现
锁是一种同步机制,用于在多任务环境中限制资源的访问,以满足互斥需求。
go 源码 sync 包中经常用于同步操作的方式:
- 原子操作
- 互斥锁
- 读写锁
- waitgroup
我们着重来分析下互斥锁和读写锁.
互斥锁:
下面是互斥锁的数据结构:
|
|
state 和 sema 两个加起来只占 8 字节空间的结构体表示了 Go 语言中的互斥锁。
互斥锁的状态比较复杂,如下图所示,最低三位分别表示 mutexLocked、mutexWoken 和 mutexStarving,剩下的位置用来表示当前有多少个 Goroutine 等待互斥锁的释放.
在默认情况下,互斥锁的所有状态位都是 0,int32 中的不同位分别表示了不同的状态:
- mutexLocked 表示互斥锁的锁定状态;
- mutexWoken 表示从正常模式被从唤醒;
- mutexStarving 当前的互斥锁进入饥饿状态;
- waitersCount 当前互斥锁上等待的 Goroutine 个数;
sync.Mutex 有两种模式,正常模式和饥饿模式。
在正常模式下,锁的等待者会按照先进先出的顺序获取锁。
但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被饿死。
饥饿模式是在 Go 语言 1.9 版本引入的优化的,引入的目的是保证互斥锁的公平性(Fairness)。
在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。
如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会被切换回正常模式。
相比于饥饿模式,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。
互斥锁的加锁是靠 sync.Mutex.Lock 方法完成的, 当锁的状态是 0 时,将 mutexLocked 位置成 1:
|
|
如果互斥锁的状态不是 0 时就会调用 sync.Mutex.lockSlow 尝试通过自旋(Spinnig)等方式等待锁的释放,
这个方法是一个非常大 for 循环,它获取锁的过程:
- 判断当前 Goroutine 能否进入自旋;
- 通过自旋等待互斥锁的释放;
- 计算互斥锁的最新状态;
- 更新互斥锁的状态并获取锁;
那么互斥锁是如何判断当前 Goroutine 能否进入自旋等互斥锁的释放,是通过它的 lockSlow 方法, 由于自旋是一种多线程同步机制,所以呢当前的进程在进入自旋的过程中会一直保持对 CPU 的占用,持续检查某个条件是否为真。 通常在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用得当会对性能带来很大的增益,但是往往使用的不得当就会拖慢整个程序.
所以 Goroutine 进入自旋的条件非常苛刻:
- 互斥锁只有在普通模式才能进入自旋;
runtime.sync_runtime_canSpin需要返回 true: a. 需要运行在多 CPU 的机器上; b. 当前的 Goroutine 为了获取该锁进入自旋的次数小于四次; c. 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空;
一旦当前 Goroutine 能够进入自旋就会调用 runtime.sync_runtime_doSpin 和 runtime.procyield 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间.
处理了自旋相关的特殊逻辑之后,互斥锁会根据上下文计算当前互斥锁最新的状态。
通过几个不同的条件分别会更新 state 字段中存储的不同信息,mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift:
|
|
计算了新的互斥锁状态之后,就会使用 CAS 函数 sync/atomic.CompareAndSwapInt32 更新该状态:
|
|
如果我们没有通过 CAS 获得锁,会调用 runtime.sync_runtime_SemacquireMutex 使用信号量保证资源不会被两个 Goroutine 获取。
runtime.sync_runtime_SemacquireMutex 会在方法中不断调用尝试获取锁并休眠当前 Goroutine 等待信号量的释放,一旦当前 Goroutine 可以获取信号量,它就会立刻返回,sync.Mutex.Lock 方法的剩余代码也会继续执行。
在正常模式下,这段代码会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环. 在饥饿模式下,当前 Goroutine 会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出.
互斥锁的解锁过程 sync.Mutex.Unlock 与加锁过程相比就很简单,该过程会先使用 sync/atomic.AddInt32 函数快速解锁,这时会发生下面的两种情况:
- 如果该函数返回的新状态等于 0,当前 Goroutine 就成功解锁了互斥锁;
- 如果该函数返回的新状态不等于 0,这段代码会调用
sync.Mutex.unlockSlow方法开始慢速解锁:
|
|
sync.Mutex.unlockSlow 方法首先会校验锁状态的合法性, 如果当前互斥锁已经被解锁过了就会直接抛出异常 sync: unlock of unlocked mutex 中止当前程序。
在正常情况下会根据当前互斥锁的状态,分别处理正常模式和饥饿模式下的互斥锁.
|
|
在正常模式下,这段代码会分别处理以下两种情况处理:
- 如果互斥锁不存在等待者或者互斥锁的
mutexLocked、mutexStarving、mutexWoken状态不都为 0,那么当前方法就可以直接返回,不需要唤醒其他等待者; - 如果互斥锁存在等待者,会通过
sync.runtime_Semrelease唤醒等待者并移交锁的所有权;
在饥饿模式下,上述代码会直接调用 sync.runtime_Semrelease 方法将当前锁交给下一个正在尝试获取锁的等待者,等待者被唤醒后会得到锁,在这时互斥锁还不会退出饥饿状态;
互斥锁的加锁过程比较复杂,它涉及自旋、信号量以及调度等概念:
- 如果互斥锁处于初始化状态,就会直接通过置位 mutexLocked 加锁;
- 如果互斥锁处于 mutexLocked 并且在普通模式下工作,就会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;
- 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
- 互斥锁在正常情况下会通过
runtime.sync_runtime_SemacquireMutex函数将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒当前 Goroutine; - 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,当前 Goroutine 会将互斥锁切换回正常模式;
互斥锁的解锁过程与之相比就比较简单,其代码行数不多、逻辑清晰,也比较容易理解:
- 当互斥锁已经被解锁时,那么调用
sync.Mutex.Unlock会直接抛出异常; - 当互斥锁处于饥饿模式时,会直接将锁的所有权交给队列中的下一个等待者,等待者会负责设置
mutexLocked标志位; - 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,就会直接返回;在其他情况下会通过
sync.runtime_Semrelease唤醒对应的 Goroutine.
读写锁:
读写互斥锁 sync.RWMutex 是细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。
sync.RWMutex 中总共包含 5 个字段:
|
|
我们从写锁开始分析:
当我们想要获取写锁时,需要调用 sync.RWMutex.Lock 方法:
|
|
- 这里调用结构体持有的
sync.Mutex的sync.Mutex.Lock方法阻塞后续的写操作;
因为互斥锁已经被获取,其他 Goroutine 在获取写锁时就会进入自旋或者休眠;
- 调用
sync/atomic.AddInt32方法阻塞后续的读操作:
如果仍然有其他 Goroutine 持有互斥锁的读锁 (r != 0),该 Goroutine 会调用 runtime.sync_runtime_SemacquireMutex 进入休眠状态等待所有读锁所有者执行结束后释放 writerSem 信号量将当前协程唤醒。
写锁的释放会调用 sync.RWMutex.Unlock 方法:
|
|
解锁与加锁的过程正好相反,写锁的释放分为以下几个步骤:
- 调用
sync/atomic.AddInt32函数将readerCount变回正数,释放读锁; - 通过 for 循环触发所有由于获取读锁而陷入等待的 Goroutine:
- 调用
sync.Mutex.Unlock方法释放写锁;
获取写锁时会先阻塞写锁的获取,后阻塞读锁的获取,这种策略能够保证读操作不会被连续的写操作饿死。
接着是读锁:
读锁的加锁方法 sync.RWMutex.RLock 就比较简单了,该方法会通过 sync/atomic.AddInt32 将 readerCount 加一:
|
|
如果 RLock该方法返回负数,其他 Goroutine 获得了写锁,当前 Goroutine 就会调用 runtime.sync_runtime_SemacquireMutex 陷入休眠等待锁的释放;
如果 RLock该方法的结果为非负数,没有 Goroutine 获得写锁,当前方法就会成功返回.
当 Goroutine 想要释放读锁时,会调用如下所示的 RUnlock方法:
|
|
该方法会先减少正在读资源的 readerCount 整数,根据 sync/atomic.AddInt32 的返回值不同会分别进行处理:
- 如果返回值大于等于零,表示读锁直接解锁成功.
- 如果返回值小于零 ,表示有一个正在执行的写操作,在这时会调用
rUnlockSlow方法.
|
|
rUnlockSlow该方法会减少获取锁的写操作等待的读操作数 readerWait并在所有读操作都被释放之后触发写操作的信号量,writerSem,该信号量被触发时,调度器就会唤醒尝试获取写锁的 Goroutine。
其实读写互斥锁(sync.RWMutex),虽然提供的功能非常复杂,不过因为它是在互斥锁( sync.Mutex)的基础上,所以整体的实现上会简单很多。
因此呢:
- 调用
sync.RWMutex.Lock尝试获取写锁时;
每次 sync.RWMutex.RUnlock 都会将 readerCount 其减一,当它归零时该 Goroutine 就会获得写锁, 将 readerCount 减少 rwmutexMaxReaders 个数以阻塞后续的读操作.
- 调用
sync.RWMutex.Unlock释放写锁时,会先通知所有的读操作,然后才会释放持有的互斥锁;
读写互斥锁在互斥锁之上提供了额外的更细粒度的控制,能够在读操作远远多于写操作时提升性能。
Go 中的 channel 的实现
在 Go 中最常见的就是通信顺序进程(Communicating sequential processes,CSP)的并发模型,通过共享通信,来实现共享内存,这里就提到了 channel.
Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Go 语言中的 Goroutine 会通过 Channel 传递数据。
Goroutine 通过使用 channel 传递数据,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。
Channel 收发操作均遵循了先入先出(FIFO)的设计,具体规则如下:
- 先从 Channel 读取数据的 Goroutine 会先接收到数据;
- 先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;
Channel 通常会有以下三种类型:
- 同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方;
- 异步 Channel — 基于环形缓存的传统生产者消费者模型;
chan struct{}类型的异步Channel的struct{}类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义;
Channel 在运行时使用 runtime.hchan 结构体表示:
|
|
其中 hchan 结构体中有五个字段是构建底层的循环队列:
|
|
通常, elemsize 和 elemtype 分别表示当前 Channel 能够收发的元素类型和大小.
sendq 和 recvq 存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 runtime.waitq表示,链表中所有的元素都是 runtime.sudog结构.
waitq 表示一个在等待列表中的 Goroutine,该结构体中存储了阻塞的相关信息以及两个分别指向前后 runtime.sudog的指针。
channel 在 Go 中是通过 make 关键字创建,编译器会将 make(chan int,10).
创建管道:
runtime.makechan 和 runtime.makechan64 会根据传入的参数类型和缓冲区大小创建一个新的 Channel 结构,其中后者用于处理缓冲区大小大于 2 的 32 次方的情况.
这里我们来详细看下 makechan 函数:
|
|
Channel 中根据收发元素的类型和缓冲区的大小初始化 runtime.hchan 结构体和缓冲区:
arena 区域就是我们所谓的堆区,Go 动态分配的内存都是在这个区域,它把内存分割成 8KB 大小的页,一些页组合起来称为 mspan。
bitmap 区域标识 arena 区域哪些地址保存了对象,并且用 4bit 标志位表示对象是否包含指针、GC 标记信息。bitmap 中一个 byte 大小的内存对应 arena 区域中 4 个指针大小(指针大小为 8B )的内存,所以 bitmap 区域的大小是 512GB/(4*8B)=16GB。
此外我们还可以看到 bitmap 的高地址部分指向 arena 区域的低地址部分,这里 bitmap 的地址是由高地址向低地址增长的。
spans 区域存放 mspan(是一些 arena 分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以 spans 区域的大小就是 512GB/8KB*8B=512MB。
除以 8KB 是计算 arena 区域的页数,而最后乘以 8 是计算 spans 区域所有指针的大小。创建 mspan 的时候,按页填充对应的 spans 区域,在回收 object 时,根据地址很容易就能找到它所属的 mspan。
栈的内存是怎么分配的
栈和堆只是虚拟内存上 2 块不同功能的内存区域:
- 栈在高地址,从高地址向低地址增长。
- 堆在低地址,从低地址向高地址增长。
栈和堆相比优势:
- 栈的内存管理简单,分配比堆上快。
- 栈的内存不需要回收,而堆需要,无论是主动 free,还是被动的垃圾回收,这都需要花费额外的 CPU。
- 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU 访问的 2 块数据可能在不同的页上,CPU 访问数据的时间可能就上去了。
堆内存管理怎么分配的
通常在 Golang 中,当我们谈论内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。
堆内存管理中主要是三部分, 1.分配内存块,2.回收内存块, 3.组织内存块。
一个内存块包含了 3 类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请 5Byte 内存的时候,就需要进行内存对齐。
释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。
上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将 2 个连续的未使用的内存块合并,减少碎片。
想要深入了解可以看下这个文章,《Writing a Memory Allocator》.
Go 中的 defer 函数使用下面的两种情况下结果是什么
我们看看下面两种 defer 函数的返回的是什么:
|
|
运行:
|
|
第一种情况:
|
|
defer 延迟函数调用的 fmt.Println(a)函数的参数值在 defer 语句出现时就已经确定了,所以无论后面如何修改 a 变量都不会影响延迟函数。
第二种情况:
|
|
defer 延迟函数调用的函数参数的值在 defer 定义时候就确定了,而 defer 延迟函数内部所使用的值需要在这个函数运行时候才确定。
在 Go 函数中为什么会发生内存泄露
通常内存泄漏,指的是能够预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 goroutine 的存在,因此,内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
- 预期能被快速释放的内存因被根对象引用而没有得到迅速释放.
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。
- goroutine 泄漏
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.
例如:
|
|
Go 中 new 和 make 的区别
在 Go 中,的值类型和引用类型:
|
|
这里需要注意的是: 对于引用类型的变量,我们不仅要声明变量,更重要的是,我们得手动为它分配空间.
因此 new 该方法的参数要求传入一个类型,而不是一个值,它会申请一个该类型大小的内存空间,并会初始化为对应的零值,返回指向该内存空间的一个指针。
|
|
而 make 也是用于内存分配,但是和 new 不同,只用来引用对象 slice、map 和 channel 的内存创建,它返回的类型就是类型本身,而不是它们的指针类型。
|
|
- 如果当前 Channel 中不存在缓冲区,那么就只会为
hchan分配一段内存空间. - 如果当前 Channel 中存储的类型不是指针类型,就会为当前的 Channel 和底层的数组分配一块连续的内存空间.
- 在默认情况下会单独为
hchan和缓冲区分配内存.
发送数据:
当我们想要向 Channel 发送数据时,就需要使用 ch <- i 语句.
runtime.chansend1 调用了 runtime.chansend 并传入 Channel 和需要发送的数据。
runtime.chansend 是向 Channel 中发送数据时最终会调用的函数,这个函数负责了发送数据的全部逻辑,如果我们在调用时将 block 参数设置成 true,那么就表示当前发送操作是一个阻塞操作:
|
|
在发送数据的逻辑执行之前会先为当前 Channel 加锁,防止发生竞争条件。如果 Channel 已经关闭,那么向该 Channel 发送数据时就会报"send on closed channel" 错误并中止程序。
因为 runtime.chansend 函数的实现比较复杂,所以我们这里将该函数的执行过程分成以下的三个部分:
- 当存在等待的接收者时,通过 runtime.send 直接将数据发送给阻塞的接收者.
- 当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区.
- 当不存在缓冲区或者缓冲区已满时,等待其他 Goroutine 从 Channel 接收数据.
因此:
当我们使用 ch <- i 表达式向 Channel 发送数据时遇到的几种情况:
- 如果当前 Channel 的
recvq上存在已经被阻塞的 Goroutine,那么会直接将数据发送给当前的 Goroutine 并将其设置成下一个运行的 Goroutine; - 如果 Channel 存在缓冲区并且其中还有空闲的容量,我们就会直接将数据直接存储到当前缓冲区 sendx 所在的位置上;
- 如果不满足上面的两种情况,就会创建一个
runtime.sudog结构并将其加入 Channel 的sendq队列中,当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据;
发送数据的过程中可能包含几个会触发 Goroutine 调度的时机:
- 发送数据时发现 Channel 上存在等待接收数据的 Goroutine,立刻设置处理器的
runnext属性,但是并不会立刻触发调度. - 发送数据时并没有找到接收方并且缓冲区已经满了,这时就会将自己加入 Channel 的
sendq队列并调用runtime.goparkunlock触发 Goroutine 的调度让出处理器的使用权.
接收数据:
接着我们看看接受数据,Go 中可以使用两种不同的方式去接收 Channel 中的数据:
|
|
虽然不同的接收方式会被转换成 runtime.chanrecv1 和 runtime.chanrecv2 两种不同函数的调用,但是这两个函数最终还是会调用 runtime.chanrecv。
当我们从一个空 Channel 接收数据时会直接调用 runtime.gopark 直接让出处理器的使用权。
|
|
如果当前 Channel 已经被关闭并且缓冲区中不存在任何的数据,那么就会清除 ep 指针中的数据并立刻返回。
除了上述两种特殊情况,使用 runtime.chanrecv 从 Channel 接收数据时还包含以下三种不同情况:
- 当存在等待的发送者时,通过
runtime.recv直接从阻塞的发送者或者缓冲区中获取数据. - 当缓冲区存在数据时,从 Channel 的缓冲区中接收数据.
- 当缓冲区中不存在数据时,等待其他 Goroutine 向 Channel 发送数据.
因此接受数据的时候,Channel 中接收数据时可能会发生的五种情况:
- 如果 Channel 为空,那么就会直接调用
runtime.gopark挂起当前 Goroutine; - 如果 Channel 已经关闭并且缓冲区没有任何数据,
runtime.chanrecv函数会直接返回; - 如果 Channel 的 sendq 队列中存在挂起的 Goroutine,就会将
recvx索引所在的数据拷贝到接收变量所在的内存空间上并将sendq队列中 Goroutine 的数据拷贝到缓冲区; - 如果 Channel 的缓冲区中包含数据就会直接读取 recvx 索引对应的数据;
- 在默认情况下会挂起当前的 Goroutine,将
runtime.sudog结构加入recvq队列并陷入休眠等待调度器的唤醒;
从 Channel 接收数据时,会触发 Goroutine 调度的两个时机:
- 当 Channel 为空时;
- 当缓冲区中不存在数据并且也不存在数据的发送者时;
最后就是关闭管道:
编译器会将用于关闭管道的 close 关键字调用 runtime.closechan 的函数关闭。
当 Channel 是一个空指针或者已经被关闭时,Go 语言运行时都会直接 panic 并抛出异常,处理完了这些异常的情况之后就可以开始执行关闭 Channel 的逻辑.
Go 中的 map 的实现
Go 中 Map 是一个 KV 对集合。底层使用 hash table,用链表来解决冲突 ,出现冲突时,不是每一个 Key 都申请一个结构通过链表串起来,而是以 bmap 为最小粒度挂载,一个 bmap 可以放 8 个 kv。
在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。
|
|
每个 map 的底层结构是 hmap,是有若干个结构为 bmap 的 bucket 组成的数组。每个 bucket 底层都采用链表结构。
|
|
bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于 key 的定位我们在 map 的查询和赋值中详细说明。
在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。
但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 hmap 的 extra 字段来。
这样随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
哈希表作为一种数据结构,我们肯定要分析它的常见操作,首先就是读写操作的原理。哈希表的访问一般都是通过下标或者遍历进行的:
|
|
这两种方式虽然都能读取哈希表的数据,但是使用的函数和底层原理完全不同。
第一个需要知道哈希的键并且一次只能获取单个键对应的值,而第二个可以遍历哈希中的全部键值对,访问数据时也不需要预先知道哈希的键。
在编译的类型检查期间,hash[key] 以及类似的操作都会被转换成哈希的 OINDEXMAP 操作,中间代码生成阶段会在 cmd/compile/internal/gc.walkexpr 函数中将这些 OINDEXMAP 操作转换成如下的代码:
|
|
这里根据赋值语句左侧接受参数的个数会决定使用的运行时方法:
当接受一个参数时,会使用 runtime.mapaccess1,该函数仅会返回一个指向目标值的指针;
当接受两个参数时,会使用 runtime.mapaccess2,除了返回目标值之外,它还会返回一个用于表示当前键对应的值是否存在的 bool 值:
mapaccess1 会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 runtime.bucketMask 和 runtime.add 拿到该键值对所在的桶序号和哈希高位的 8 位数字。
如果在 bucket 中没有找到,此时如果 overflow 不为空,那么就沿着 overflow 继续查找,如果还是没有找到,那就从别的 key 槽位查找,直到遍历所有 bucket。
|
|
在 bucketloop 循环中,哈希会依次遍历正常桶和溢出桶中的数据,它先会比较哈希的高 8 位和桶中存储的 tophash,后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。
因此 bucket 里 key 的起始地址就是 unsafe.Pointer(b)+dataOffset;第 i 个 key 的地址就要此基础上加 i 个 key 大小;value 的地址是在 key 之后,所以第 i 个 value,要加上所有的 key 的偏移。
另一个同样用于访问哈希表中数据的 runtime.mapaccess2 只是在 runtime.mapaccess1 的基础上多返回了一个标识键值对是否存在的 bool 值:
|
|
使用 v, ok := hash[k]的形式访问哈希表中元素时,我们能够通过这个布尔值更准确地知道当 v == nil 时,v 到底是哈希中存储的元素还是表示该键对应的元素不存在,所以在访问哈希时,我们更推荐使用这种方式判断元素是否存在。
写入:
当形如 hash[k] 的表达式出现在赋值符号左侧时,该表达式也会在编译期间转换成 mapassign 函数的调用,该函数与 mapaccess1 比较相似:
|
|
我们可以通过遍历比较桶中存储的 tophash 和键的哈希,如果找到了相同结果就会返回目标位置的地址。
如果当前桶已经满了,哈希会调用 newoverflow 创建新桶或者使用 hmap 预先在 noverflow 中创建好的桶来保存数据,新创建的桶不仅会被追加到已有桶的末尾,还会增加哈希表的 noverflow 计数器。
如果当前键值对在哈希中不存在,哈希会为新键值对规划存储的内存地址,通过 typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val。
如果当前键值对在哈希中存在,那么就会直接返回目标区域的内存地址,哈希并不会在 mapassign 这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的.
扩容:
随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以我们需要更多的桶和更大的内存保证哈希的读写性能,这个时候我们就需要用到扩容了.
|
|
mapassign 函数会在以下两种情况发生时触发哈希的扩容:
- 装载因子已经超过 6.5;
- 哈希使用了太多溢出桶;
不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 mapassign 还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
根据触发的条件不同扩容的方式分成两种,如果这次扩容是溢出的桶太多导致的,那么这次扩容就是等量扩容 sameSizeGrow,sameSizeGrow 是一种特殊情况下发生的扩容,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏。
runtime: limit the number of map overflow buckets 引入了 sameSizeGrow 通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存\。
扩容的入口是 hashGrow:
|
|
哈希在扩容的过程中会通过 makeBucketArray 创建一组新桶和预创建的溢出桶,随后将原有的桶数组设置到 oldbuckets 上并将新的空桶设置到 buckets 上,溢出桶也使用了相同的逻辑更新.
这里会申请到了新的 buckets 空间,把相关的标志位都进行了处理,例如标志 nevacuate 被置为 0, 表示当前搬迁进度为 0。
在 hashGrow 中还看不出来等量扩容和翻倍扩容的太多区别,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移。
哈希表的数据迁移的过程在是 evacuate 中完成的,它会对传入桶中的元素进行再分配。
|
|
evacuate 会将一个旧桶中的数据分流到两个新桶,所以它会创建两个用于保存分配上下文的 evacDst 结构体,这两个结构体分别指向了一个新桶:
哈希表扩容目的:
如果这是等量扩容,那么旧桶与新桶之间是一对一的关系,所以两个 evacDst只会初始化一个。而当哈希表的容量翻倍时,每个旧桶的元素会都分流到新创建的两个桶中.
只使用哈希函数是不能定位到具体某一个桶的,哈希函数只会返回很长的哈希,我们还需一些方法将哈希映射到具体的桶上。
那么如何定位 key 呢?
key 经过哈希计算后得到哈希值,共 64 个 bit 位(64 位机,32 位机就不讨论了,现在主流都是 64 位机),计算它到底要落在哪个桶时,只会用到最后 B 个 bit 位。
如果 B = 5,那么桶的数量,也就是 buckets 数组的长度是 2^5 = 32。
例如,现在有一个 key 经过哈希函数计算后,得到的哈希结果是:
|
|
用最后的 5 个 bit 位,也就是 01010,值为 10,那么这个就是 10 号桶。
再用哈希值的高 8 位,找到此 key 在 bucket 中的位置,这是在寻找已有的 key。最开始桶内还没有 key,新加入的 key 会找到第一个空位,放入。
buckets 编号就是桶编号,当两个不同的 key 落在同一个桶中,也就是发生了哈希冲突。
通常哈希冲突的解决手段是用链表法,在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。
因此哈希表扩容的设计和原理,哈希在存储元素过多时会触发扩容操作,每次都会将桶的数量翻倍,扩容过程不是原子的,而是通过 growWork 增量触发的,在扩容期间访问哈希表时会使用旧桶,向哈希表写入数据时会触发旧桶元素的分流。
除了这种正常的扩容之外,为了解决大量写入、删除造成的内存泄漏问题,哈希引入了 sameSizeGrow 这一机制,在出现较多溢出桶时会整理哈希的内存减少空间的占用。
删除:
如果想要删除哈希中的元素,就需要使用 Go 语言中的 delete 关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论是该键对应的值是否存在,这个内建的函数都不会返回任何的结果。
因此呢 Go 采用拉链法来解决哈希碰撞的问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或者方法。
哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash 就成为可以帮助哈希快速遍历桶中元素的缓存。
哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。
随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大损耗。
Go 中的 http 包的实现原理
Golang 中 http 包中处理 HTTP 请求主要跟两个东西相关:ServeMux 和 Handler。
ServeMux 本质上是一个 HTTP 请求路由器(或者叫多路复用器,Multiplexor)。它把收到的请求与一组预先定义的 URL 路径列表做对比,然后在匹配到路径的时候调用关联的处理器(Handler)。
处理器(Handler)负责输出 HTTP 响应的头和正文。任何满足了 http.Handler 接口的对象都可作为一个处理器。通俗的说,对象只要有个如下签名的 ServeHTTP 方法即可:
|
|
Go 语言的 HTTP 包自带了几个函数用作常用处理器,比如 FileServer,NotFoundHandler 和 RedirectHandler。
应用示例:
|
|
在这个应用示例中,首先在 main 函数中我们只用了 http.NewServeMux 函数来创建一个空的 ServeMux。 然后我们使用 http.RedirectHandler 函数创建了一个新的处理器,这个处理器会对收到的所有请求,都执行 307 重定向操作到 http://www.baidu.com。
接下来我们使用 ServeMux.Handle 函数将处理器注册到新创建的 ServeMux,所以它在 URL 路径 /foo 上收到所有的请求都交给这个处理器。 最后我们创建了一个新的服务器,并通过 http.ListenAndServe 函数监听所有进入的请求,通过传递刚才创建的 ServeMux来为请求去匹配对应处理器。
在浏览器中访问 http://localhost:3000/foo,你应该能发现请求已经成功的重定向了。
此刻你应该能注意到一些有意思的事情:ListenAndServer 的函数签名是 ListenAndServe(addr string, handler Handler) ,但是第二个参数我们传递的是个 ServeMux。
通过这个例子我们就可以知道,net/http包在编写 golang web 应用中有很重要的作用,它主要提供了基于 HTTP 协议进行工作的 client 实现和 server 实现,可用于编写 HTTP 服务端和客户端。
Goroutine 发生了泄漏如何检测
通常内存泄漏,指的是能够预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 Goroutine 的存在,因此,内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
- 预期能被快速释放的内存因被根对象引用而没有得到迅速释放.
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。
- Goroutine 泄漏
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象.
可以通过 Go 自带的工具 pprof 或者使用 Gops 去检测诊断当前在系统上运行的 Go 进程的占用的资源.
例如:
|
|
Go 函数返回局部变量的指针是否安全
在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上
Go 中两个 Nil 可能不相等吗
Go 中两个 Nil 可能不相等。
接口(interface) 是对非接口值(例如指针,struct 等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。
两个接口值比较时,会先比较 T,再比较 V。 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。
|
|
这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil。
但是当 i 与 nil 比较时,会将 nil 转换为接口(T=nil, V=nil),与 i(T=*int, V=nil)不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。
Goroutine 和 KernelThread 之间是什么关系
首先我们先看下进程和线程还有协程之间的区别:
- 进程
计算机的操作系统模式是一种多任务系统,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU 由操作系统一进行分配,每个进程根据进程的优先级的高低都有机会得到 CPU,但是如果允许时间超出了一定的时间,操作系统会暂停该进程,将 CPU 资源分配给其他等待的进程。这种 CPU 的分配方式即所谓的抢占式,操作系统可以强制剥夺 CPU 资源并且分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间都很短,即 CPU 在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。
- 线程
线程有时被称为轻量级进程(Lightweight Process),是程序执行流的最小单元,一个标准的线程由线程 ID,当前指令指针(PC)、寄存器集合和堆栈组成,通常意义上,一个进程 🈶 一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。
- 协程
协程(coroutine)是 Go 语言中的轻量级线程实现,由 Go 运行时(runtime)管理。
进程、线程、协程的关系和区别:
- 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
- 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。
- 协程和线程一样共享堆,不共享栈,协程由程序开发者在协程的代码里显示调度。
为什么协程比线程轻量?
a. go 协程调用跟切换比线程效率高
线程并发执行流程: 线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换。线程在等待 IO 操作时线程变为 unrunnable 状态会触发上下文切换。现代操作系统一般都采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换 ESP 指向目标线程内核堆栈,将 EIP 指向目标线程上次被调度出时的指令地址。
go 协程并发执行流程:不依赖操作系统和其提供的线程,golang 自己实现的 CSP 并发模型实现:M, P, G .go 协程也叫用户态线程,协程之间的切换发生在用户态。在用户态没有时钟中断,系统调用等机制,因此效率高
b. go 协程占用内存少
执行 go 协程只需要极少的栈内存(大概是 4 ~ 5KB),默认情况下,线程栈的大小为 1MB。goroutine 就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个 goroutine,但它们并不是被操作系统所调度执行。
因此协程和线程一样共享堆,不共享栈,协程由用户态下面的轻量级线程。
为何 GPM 调度要有 P
我们先看下 go1.0 源码当时是 c 实现的 go 的调度:
|
|
这里调度的作用:
- 调用
schedlock方法来获取全局锁。 - 获取全局锁成功后,将当前 Goroutine 状态从 Running(正在被调度) 状态修改为 Runnable(可以被调度)状态。
- 调用
gput方法来保存当前 Goroutine 的运行状态等信息,以便于后续的使用。 - 调用
nextgandunlock方法来寻找下一个可运行 Goroutine,并且释放全局锁给其他调度使用。 - 获取到下一个待运行的 Goroutine 后,将其运行状态修改为 Running。
- 调用
runtime·gogo方法,将刚刚所获取到的下一个待执行的 Goroutine 运行起来,进入下一轮调度。
GM 模型的缺点:
Go1.0 的 GM 模型的 Goroutine 调度器限制了用 Go 编写的并发程序的可扩展性,尤其是高吞吐量服务器和并行计算程序。
GM 调度存在的问题:
- 存在单一的全局 mutex(Sched.Lock)和集中状态管理: mutex 需要保护所有与 goroutine 相关的操作(创建、完成、重排等),导致锁竞争严重。
- Goroutine 传递的问题: goroutine(G)交接(G.nextg):工作者线程(M’s)之间会经常交接可运行的 goroutine。 而且可能会导致延迟增加和额外的开销。每个 M 必须能够执行任何可运行的 G,特别是刚刚创建 G 的 M。
- 每个 M 都需要做内存缓存(M.mcache):
会导致资源消耗过大(每个 mcache 可以吸纳到 2M 的内存缓存和其他缓存),数据局部性差。
- 频繁的线程阻塞/解阻塞: 在存在 syscalls 的情况下,线程经常被阻塞和解阻塞。这增加了很多额外的性能开销。
为了解决 GM 模型的以上诸多问题,在 Go1.1 时,Dmitry Vyukov 在 GM 模型的基础上,新增了一个 P(Processor)组件。并且实现了 Work Stealing 算法来解决一些新产生的问题。
加了 P 之后会带来什么改变呢?
- 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
- 每个 P 相对的平衡上,在 GMP 模型中也实现了
Work Stealing算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
为什么要有 P 呢?
一般来讲,M 的数量都会多于 P。像在 Go 中,M 的数量默认是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。
M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。
因此使用 M 是不合理的,那么引入新的组件 P,把本地队列关联到 P 上,就能很好的解决这个问题。
编程题
有三个函数分别打印,“dog”,“cat”,“fish”,
要求每个函数起一个 goroutine,请按照 dog,cat,fish 的顺序,打印四次,输出到控制台。
|
|
参考资料
-
No backlinks found.