修🐶的家

总有一条蜿蜒在童话镇里七彩的河

  menu
50 文章
0 浏览
1 当前访客
ღゝ◡╹)ノ❤️

golang

1.Go语言——垃圾回收

在这里插入图片描述

Go V1.3之前的标记-清除:
1.暂停业务逻辑,找到不可达的对象,和可达对象
2.开始标记,程序找出它所有可达的对象,并做上标记
3.标记完了之后,然后开始清除未标记的对象。
4.停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束

延伸:
1)可达对象是指程序中仍然可以被访问到的对象。通过从根对象开始进行遍历,将所有可达对象进行标记,然后将未标记的对象视为不可达对象。

2)在标记阶段中,程序从根对象开始,遍历所有指向的对象,并递归地遍历所有可达对象,直到所有可达对象都被标记为可达。

3)在清除阶段中,所有未标记的对象都被视为不可达对象,并且它们所占用的内存空间会被释放回系统,以供其他对象使用。

4)在垃圾回收期间,程序的性能和可用性可能会受到影响,因为垃圾回收需要暂停应用程序的运行以进行垃圾回收操作。在某些情况下,这种停顿时间可能会非常长,导致应用程序的响应时间和性能降低。为了缓解这种影响,可以采用增量垃圾回收、并发垃圾回收、分代垃圾回收等技术。

5)标记-清除的缺点:STW(stop the world):让程序暂停,程序出现卡顿。标记需要扫描整个heap。清除数据会产生heap碎片

为了减少STW的时间,后来对上述的第三步和第四步进行了替换。

Go V1.5 三色标记法
1.把新创建的对象,默认的颜色都标记为“白色”

在这里插入图片描述

2.每次GC回收开始,然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合

在这里插入图片描述

3.遍历灰色集合,将灰色对象引用的对象从白色集合放入到灰色集合,之后将此灰色对象放入到黑色集合

在这里插入图片描述

4.重复第三步,直到灰色中无任何对象

在这里插入图片描述

在这里插入图片描述

5.回收所有的白色标记的对象,也就是回收垃圾

三色标记法在不采用STW保护时会出现:
1.一个白色对象被黑色对象引用
2.灰色对象与它之间的可达关系的白色对象遭到破坏

这两种情况同时满足,会出现对象丢失

解决方案:
1.强三色不变式:强制性的不允许黑色对象引用白色对象(破坏1)
2.弱三色不变式:黑色对象可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象(破坏2)

屏障:
1.插入屏障:在A对象引用B对象的时候,B对象被标记为灰色(满足强三色不变式,黑色引用的白色对象会被强制转坏为灰色)。只有堆上的对象触发插入屏障,栈上的对象不触发插入屏障。在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰。

在这里插入图片描述

不足:结束时需要使用STW来重新扫描栈

2.删除屏障:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色(满足弱三色不变式)。

在这里插入图片描述

删除屏障的不足:回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

Go V1.8的三色标记法+混合写屏障机制
具体操作:
1.GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
2.GC期间,任何在栈上创建的新对象,均为黑色
3.被删除对象标记为灰色
4.被添加的对象标记为灰色
满足:变形的弱三色不变式(结合了插入、删除写屏障的优点)

延伸:

1)三色标记算法中的三种颜色分别代表什么含义?

三色标记算法中的三种颜色分别代表:白色(未访问过的对象)、灰色(已访问但还未标记的对象)和黑色(已访问且已标记的对象)。

2)为什么需要写屏障机制?它可以解决什么问题?

写屏障机制是一种技术,它可以捕获程序在运行时对内存的写操作,并执行相关的垃圾回收操作。写屏障机制的作用是解决 GC 过程中的“屏障问题”,即当程序在访问和修改对象时,如何确保 GC 过程中对象的可达性分析结果是正确的。

3)混合写屏障机制是什么?它如何在垃圾回收中发挥作用?

混合写屏障机制是指将插入式和删除式写屏障结合起来使用的一种方法。这种方法可以减少插入式写屏障和删除式写屏障带来的性能开销,并在某些情况下能够提高 GC 的效率和性能。

4)在 Go V1.8 的三色标记法+混合写屏障机制中,如何判断一个对象的颜色?

在 Go V1.8 的三色标记法+混合写屏障机制中,一个对象的颜色是通过它是否可以从根对象访问到来确定的。如果一个对象可以从根对象访问到,则它被标记为黑色,否则被标记为白色。

5)在 GC 过程中,会发生什么情况,使得一个对象的颜色会发生改变?

在 GC 过程中,一个对象的颜色会发生改变的情况有两种:如果一个对象是白色的,则在程序访问它时,它将被标记为灰色;如果一个对象是灰色的,则在标记它的子对象时,如果发现子对象是白色的,则将子对象标记为灰色。

6)相对于标记-清除算法和标记-压缩算法,三色标记算法的优点和缺点是什么?

三色标记算法相对于标记-清除算法和标记-压缩算法的优点是可以快速处理大量的内存数据,同时也可以减少内存碎片问题。缺点是它需要一些额外的元数据信息来记录对象的颜色,并且在某些情况下可能会影响程序的性能和可用性。

2.GPM调度和CSP模型

在Golang中,没有官方定义的GPM(Goroutine, Processor, and Machine)模型。GPM是一种并发执行模型,用于描述Golang运行时系统(Goroutine Scheduler)如何管理和调度goroutine并在多个处理器(Processor)上执行。

Golang的并发模型基于goroutine和channel,通过轻量级的线程(goroutine)实现高并发和并行的编程。Goroutine是由Golang运行时系统调度的,而不是由操作系统内核调度的,因此Golang具有更轻量级的线程和更高效的上下文切换。

虽然Golang没有明确的GPM模型定义,但可以基于Golang运行时系统的行为和特性来理解GPM模型的概念。以下是对GPM模型的简要描述:

  1. Goroutine(G):Goroutine是Golang并发的基本单位,它代表着一个独立的执行线程。Goroutine是由Go关键字启动的,并由Golang运行时系统调度执行。Goroutine之间可以通过channel进行通信和同步。
  2. Processor(P):Processor代表Golang运行时系统中的逻辑处理器,它负责调度和执行goroutine。Golang运行时系统会根据可用的物理处理器数量创建一组逻辑处理器,并将goroutine分配给这些处理器上的线程来执行。
  3. Machine(M):Machine指的是底层的物理机器或操作系统。Golang运行时系统会将物理机器的处理器资源映射到逻辑处理器上,并通过调度goroutine在这些处理器上执行。

在GPM模型中,Golang运行时系统通过调度和管理goroutine的执行,将其分配给可用的逻辑处理器进行并发执行。Golang的调度器会根据一定的策略,如工作窃取(work-stealing)和抢占式调度,来确保goroutine的高效利用和公平分配。

总结起来,虽然Golang没有官方定义的GPM模型,但可以从Golang运行时系统的角度理解GPM模型的概念,其中goroutine(G)代表并发执行的基本单位,逻辑处理器(P)负责调度和执行goroutine,物理机器(M)提供处理器资源和底层支持。这种模型使得Golang能够实现高并发和并行的编程。

Golang中的goroutine调度是由Golang运行时系统负责的,它采用了一些调度策略来管理和执行goroutine。以下是Golang中常见的goroutine调度策略:

  1. M:N调度模型:
    Golang采用了M:N调度模型,其中M代表逻辑处理器(P),N代表系统线程(OS Thread)。逻辑处理器是Golang运行时系统的一部分,而系统线程是由操作系统内核管理的。每个逻辑处理器(P)会绑定到一个系统线程(OS Thread)上,多个逻辑处理器可以在多个系统线程上并行执行。
  2. 工作窃取(work-stealing):
    Golang的调度器使用了工作窃取策略来实现负载均衡和提高并发性能。每个逻辑处理器(P)维护一个本地任务队列,当当前逻辑处理器的任务队列为空时,它会从其他逻辑处理器的任务队列中窃取任务来执行。这样可以确保逻辑处理器间的任务均衡分配,减少任务的等待时间。
  3. 抢占式调度:
    Golang的调度器采用了抢占式调度策略,即一个goroutine在运行过程中,可以被更高优先级的goroutine抢占执行。这种调度策略可以防止某个goroutine长时间占用逻辑处理器,确保其他goroutine的公平执行和响应能力。
  4. Goroutine的阻塞和唤醒:
    当一个goroutine遇到阻塞操作(如通信、I/O等)时,它会主动释放所占用的逻辑处理器,让其他可运行的goroutine执行。一旦阻塞的操作完成,阻塞的goroutine将被重新唤醒,并继续执行。
  5. GOMAXPROCS:
    GOMAXPROCS是一个环境变量,用于设置可同时执行的逻辑处理器(P)的最大数量。默认情况下,Golang会根据可用的物理处理器数量来设置GOMAXPROCS的值,以充分利用多核处理器的并行性能。开发者也可以手动设置GOMAXPROCS的值来调整并发度。

总的来说,Golang的goroutine调度器使用M:N调度模型,采用工作窃取、抢占式调度等策略来实现负载均衡、并发执行和公平性。这种调度策略使得Golang能够高效地管理和执行大量的goroutine,并充分利用多核处理器的并行性能。

在Golang中,GPM(Goroutine, Processor, and Machine)模型是描述Golang运行时系统(Goroutine Scheduler)如何管理和调度goroutine的执行的一种模型。GPM模型的作用是提供并发调度和管理的机制,以实现高效的并发编程。

作用原理如下:

  1. Goroutine(G):
    Goroutine是Golang中并发的基本单位,它代表着一个独立的执行线程。Goroutine由关键字"go"启动,并由Golang运行时系统调度执行。Goroutine可以轻量级地创建和销毁,并且具有较低的开销。它们可以并发地执行,并通过channel进行通信和同步。
  2. Processor(P):
    Processor代表Golang运行时系统中的逻辑处理器,它负责调度和执行goroutine。Golang运行时系统会根据可用的物理处理器数量创建一组逻辑处理器,并将goroutine分配给这些处理器上的线程来执行。每个逻辑处理器都有自己的本地任务队列。
  3. Machine(M):
    Machine指的是底层的物理机器或操作系统。Golang运行时系统将物理机器的处理器资源映射到逻辑处理器上,并通过调度goroutine在这些处理器上执行。每个逻辑处理器(P)绑定到一个系统线程(OS Thread),可以在多个系统线程上并行执行。

GPM模型的工作原理如下:

  1. 调度器初始化:
    Golang运行时系统在程序启动时初始化调度器。它根据可用的物理处理器数量创建一组逻辑处理器(P),并将它们与系统线程(OS Thread)进行绑定。
  2. Goroutine创建和调度:
    当创建一个goroutine时,Golang运行时系统会将其放入全局任务队列(Global Queue)中。调度器会根据一定的策略从全局任务队列中选择一个goroutine,并将其分配给一个可用的逻辑处理器(P)。
  3. 任务窃取:
    当一个逻辑处理器(P)的本地任务队列为空时,它会从其他逻辑处理器的本地任务队列中窃取(Steal)一些任务来执行。这种工作窃取策略可以实现负载均衡和高效利用多核处理器。
  4. 抢占式调度:
    Golang的调度器采用了抢占式调度策略,即一个goroutine在运行过程中,可以被更高优先级的goroutine抢占执行。这种调度策略可以防止某个goroutine长时间占用逻辑处理器,确保其他goroutine的公平执行和响应能力。
  5. 阻塞和唤醒:
    当一个goroutine遇到阻塞操作(如通信、I/O等)时,它会主动释放所占用的逻辑处理器,让其他可运行的goroutine执行。一旦阻塞的操作完成,阻塞的goroutine将被重新唤醒,并继续执行。这种阻塞和唤醒的机制确保了在等待外部资源时,不会浪费逻辑处理器的执行时间,使得其他可执行的goroutine能够继续执行。

总结起来,GPM模型在Golang中的作用是提供了并发调度和管理的机制,使得Golang能够高效地管理和执行大量的goroutine,并充分利用多核处理器的并行性能。它通过调度器将goroutine分配给逻辑处理器,利用工作窃取策略实现负载均衡,采用抢占式调度保证公平性,以及阻塞和唤醒机制来处理阻塞操作,从而实现高效的并发编程。

延伸:

1)请介绍一下GPM模型在Go语言中的作用和作用原理是什么?

GPM模型在Go语言中的作用是调度Goroutine的执行,其中Goroutine是轻量级的协程。Goroutine的执行由M负责,而M是在P中运行的。每个P都有一个本地的Goroutine队列,以及一个全局的Goroutine队列。当Goroutine执行时,它可能会发生阻塞,如等待I/O完成。此时,M会从P中取出另一个Goroutine执行。如果所有的P都没有可执行的Goroutine,则M会从全局队列中取出一个Goroutine执行。这样,GPM模型能够高效地利用多核CPU,并提高Goroutine的调度效率。

2)在Goroutine调度策略中,你提到了队列轮转和系统调用,你能详细介绍一下它们是如何实现的吗?

队列轮转是一种基于时间片轮转的调度策略。每个P都有一个本地Goroutine队列和一个全局Goroutine队列。P会周期性地将Goroutine从本地队列中调度到M中执行。执行一段时间后,将Goroutine保存上下文,将其放回到队列尾部,并从队列中取出下一个Goroutine执行。P还会周期性地查看全局队列是否有Goroutine等待调度到M中执行。

系统调用是一种让M在执行Goroutine时释放P并尝试获取其他P的策略。当Goroutine进入系统调用时,M会释放P并尝试获取其他空闲P,以继续执行队列中的其他Goroutine。如果没有可用的P,则将Goroutine放入全局队列等待调度。

3)在Goroutine的调度中,M会从P中获取G并执行,你能介绍一下这个过程中的锁和同步机制吗?

在Goroutine调度中,M会从P中获取Goroutine并执行。获取Goroutine时,P使用一个锁来保护本地队列。当本地队列为空时,M会尝试从其他P的本地队列或全局队列获取Goroutine。当M执行Goroutine时,会将其绑定到P上,并在执行结束后将其放回本地队列。这样,其他M就不会执行这个Goroutine,从而避免了竞争和冲突。

4)在多核情况下,Goroutine的调度是如何利用多核的,并且如何实现调度的平衡?

在多核情况下,Go语言会根据实际情况创建多个M,并将它们分配到不同的CPU上执行。这样可以充分利用CPU的多核特性,提高程序的并发性和吞吐量。Go语言中还有一些特殊的调度器,如抢占式调度器,它可以在某些情况下强制调度执行权,并确保不会发生饥饿现象。

5)你能描述一下Goroutine的栈是如何分配和管理的吗?

在Go语言中,每个Goroutine的栈是由运行时动态分配的,这种动态分配的方式可以避免过度分配内存,也可以避免栈空间浪费的问题。默认情况下,每个Goroutine的栈大小为2KB,但可以通过设置环境变量GOMAXPROCS来改变这个值。

当Goroutine需要更多的栈空间时,Go运行时系统会自动扩展栈的大小,以满足Goroutine的需要。这种动态的栈空间分配机制使得Goroutine可以高效地运行,同时也能够在需要时动态调整内存,避免了栈溢出等问题。需要注意的是,由于Goroutine的栈空间是由Go运行时系统动态分配的,因此需要确保操作系统可用的虚拟内存足够大,以便可以动态分配足够的栈空间。

在这里插入图片描述

3.chan原理

底层是一个环形队列
结构体:

type hchan struct {
qcount uint // 队列中的总元素个数
dataqsiz uint // 环形队列大小,即可存放元素的个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 //每个元素的大小
closed uint32 //标识关闭状态
elemtype *_type // 元素类型
sendx uint // 发送索引,元素写入时存放到队列中的位置

recvx uint // 接收索引,元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex //互斥锁,chan不允许并发读写
}

从channel中读数据:
1.若等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G ,把 G 中数据读出,最后把 G 唤醒,结束读取过程。

2.如果等待发送队列 sendq 不为空,说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程。

3.如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程。.将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒

从channel中写数据

1.若等待接收队列 recvq 不为空,则缓冲区中无数据或无缓冲区,将直接从 recvq 取出 G ,并把数据写入,最后把该 G 唤醒,结束发送过程。

2.若缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程。

3.若缓冲区中没有空余位置,则将发送数据写入 G,将当前 G 加入 sendq ,进入睡眠,等待被读 goroutine 唤醒。

关闭 channel

关闭 channel 时会将 recvq 中的 G 全部唤醒,本该写入 G 的数据位置为 nil。将 sendq 中的 G 全部唤醒,但是这些 G 会 panic。

延伸:

1)你能解释一下Go语言中同步和异步通道的区别吗?

同步通道和异步通道的区别在于读写操作的阻塞行为不同。在同步通道中,读写操作会导致阻塞,直到对端准备好数据或接受数据;而在异步通道中,读写操作不会阻塞,数据会被立即返回。

2)在Go语言中,select语句和通道有什么关系?能否举个例子?

select语句和通道密切相关,用于在多个通道之间进行非阻塞选择。select语句包含多个case子句,每个子句表示一个通道操作,可以是读或写操作。当select执行时,它会等待任意一个case子句准备就绪,然后执行该子句。如果多个子句都准备就绪,则会随机选择一个执行。以下是一个使用select语句的简单示例:


package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(time.Second)
		ch1 <- "one"
	}()

	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "two"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("received", msg1)
		case msg2 := <-ch2:
			fmt.Println("received", msg2)
		}
	}
}

3)有哪些区别缓冲通道和非缓冲通道?

缓冲通道和非缓冲通道的主要区别在于容量是否为0。非缓冲通道容量为0,必须有一个接收者准备好接收数据才能继续发送数据;而缓冲通道容量为大于0的值,可以在没有接收者的情况下继续发送数据,直到通道被填满为止。

4)你能给出一个使用通道实现并发的示例吗?

以下是一个使用通道实现并发的示例,该示例启动了10个goroutine,每个goroutine会向一个通道发送数字,另一个goroutine会从通道接收数字并将其打印到控制台上:

package main

import (
	"fmt"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Println("worker", id, "started job", j)
		time.Sleep(time.Second)
		fmt.Println("worker", id, "finished job", j)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	for w := 1; w <= 10; w++ {
		go worker(w, jobs, results)
	}

	for j := 1; j <= 10; j++ {
		jobs <- j
	}
	close(jobs)

	for a := 1; a <= 10; a++ {
		<-results
	}
}

4.context结构原理

Context(上下文)是Golang应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}

「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
「Err」 方法:返回Context 被取消的原因。
「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。

延伸:

1)什么情况下需要使用 Context?

通常情况下,当我们需要控制一组相关的 goroutine 时,可以使用 Context。比如,在一个 HTTP 请求中,我们可以创建一个 Context,然后把它传递给所有涉及的 goroutine,这样可以方便地控制这些 goroutine 的生命周期,避免泄露和资源浪费。

2)Context 是如何实现并发安全的?

Context 内部使用了互斥锁来保证并发安全,同时也使用了类似于管道的同步机制来保证多个 goroutine 之间的同步和通信。

3)Context 的使用有哪些注意点?

首先,Context 应该在进入函数时创建,并在函数退出时被取消,不应该被传递到下一个函数中。其次,应该避免在 Context 中存储太多的状态信息,因为这样会增加复杂性。最后,Context 应该及时取消,以避免泄露和资源浪费。

4)select 和 Context 有什么关系?

select 语句可以用来监听多个通道的操作,同时 Context 可以用来控制多个 goroutine 的生命周期,两者可以结合起来使用。比如,在一个 HTTP 请求中,我们可以使用 select 来监听多个通道,同时使用 Context 来控制这些 goroutine 的生命周期,从而实现更灵活和精细的控制。

5)你能否举个例子来说明 Context 的使用?

比如,在一个 HTTP 服务器中,我们可以创建一个根 Context,然后在每个请求处理函数中,创建一个子 Context,并把它传递给所有相关的 goroutine。在处理完请求后,我们可以调用子 Context 的 cancel 方法,从而取消所有相关的 goroutine。这样可以避免资源浪费和泄露。同时,我们也可以使用 WithDeadline 或 WithTimeout 方法,设置一个截止时间,当时间到达后,Context 会自动发起取消请求,从而避免等待超时和资源浪费。

5. 竞态、内存逃逸

1.资源竞争,就是在程序中,同一块内存同时被多个 goroutine 访问。我们使用 go build、go run、go test 命令时,添加 -race 标识可以检查代码中是否存在资源竞争。

解决这个问题,我们可以给资源进行加锁,让其在同一时刻只能被一个协程来操作。

延伸:

1)什么是竞争条件?除了资源竞争,还有哪些竞争条件?

竞争条件是指多个 goroutine 在没有同步的情况下访问共享的资源,导致程序出现不可预料的行为。除了资源竞争,还有死锁、饥饿等竞争条件。

2)加锁是如何解决资源竞争问题的?使用锁会带来哪些问题?

加锁可以确保同一时刻只有一个 goroutine 访问共享资源,从而避免了资源竞争问题。使用锁可能会导致死锁、饥饿等问题,因此需要小心地设计加锁策略。

3。在 Go 语言中,有哪些常见的锁类型?它们有什么区别?你在实际项目中使用过哪些锁?

Go 语言中常见的锁类型包括:sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 等。它们的区别在于锁的粒度、并发读写的支持、执行一次性操作的支持等。在实际项目中,我使用过 sync.Mutex 和 sync.RWMutex,前者适合互斥锁的场景,后者适合读写锁的场景。

sync.Mutex
sync.RWMutex

2.逃逸分析就是程序运行时内存的分配位置(栈或堆),是由编译器来确定的。堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。

逃逸场景:

指针逃逸
栈空间不足逃逸
动态类型逃逸
闭包引用对象逃逸

延伸:

1)在什么情况下会发生逃逸?请举例说明。
逃逸发生的条件是变量在函数内部被分配,但是在函数返回后仍然被外部引用或持有。常见的逃逸场景包括指针逃逸、闭包引用对象逃逸、栈空间不足逃逸、动态类型逃逸等。

2)逃逸分析对程序性能有什么影响?如何优化逃逸分析?
逃逸分析的优化是在降低内存分配和回收的性能消耗方面,减少堆分配、减少垃圾回收的压力,提高程序的性能。优化逃逸分析的方法包括减少变量的生命周期、减少对全局变量和函数参数的依赖、使用值类型代替引用类型、使用内存池等。

3)逃逸分析与垃圾回收有什么关系?如何提高垃圾回收的效率?
逃逸分析可以影响垃圾回收的效率,因为逃逸变量会分配在堆上,而堆上的对象需要经过垃圾回收才能被释放。逃逸分析优化的方法也可以提高垃圾回收的效率,例如减少堆分配、减少引用类型、使用内存池等。

4)Go语言中的逃逸分析和Java、C++等其他语言的逃逸分析有什么不同之处?
Go语言的逃逸分析比Java和C++更加重要,因为Go语言的并发模型依赖于协程和通道,而协程的创建和销毁、通道的发送和接收等操作都需要内存分配和回收。Go语言的逃逸分析和Java、C++等其他语言的逃逸分析相比,更加强调对协程和通道等并发原语的优化。

5)除了逃逸分析之外,还有哪些技术可以优化Go语言程序的性能?请谈谈你的看法。
除了逃逸分析之外,还有一些常用的优化技术,例如使用 sync.Pool 进行对象复用、使用 sync.WaitGroup 等待协程完成、使用无锁数据结构等。此外,还可以通过性能测试和性能剖析工具来发现和优化程序的性能瓶颈,例如使用 go test 的 -benchmarks 标识进行性能测试、使用 pprof 工具进行性能剖析等。

6)对于逃逸分析中的指针逃逸,你能解释一下其背后的原理和影响吗?

指针逃逸指的是一个局部变量在函数返回后仍然被外部引用,因此在函数调用时,需要将该变量存储在堆上,而非栈上。这会导致额外的内存分配和垃圾回收负担,降低程序性能。

7)逃逸分析会对程序的性能产生什么影响?如何优化代码以减少逃逸分析的影响?

逃逸分析的开销包括对函数调用栈、变量分配和内存回收的额外开销。为了减少逃逸分析的影响,可以尝试使用栈分配内存、避免使用大的局部变量或函数参数、尽可能减少对接口类型的使用、避免频繁的内存分配和垃圾回收等。

8)在使用逃逸分析时,有哪些注意事项和限制?你能列举出来吗?
给出答案

在使用逃逸分析时,需要注意以下事项和限制:

  • 逃逸分析只在编译时进行,无法在运行时动态调整。
  • 逃逸分析并不总是准确的,可能存在误判。
  • 逃逸分析可能会增加编译时间和内存占用。
  • 逃逸分析对于代码重构和调试会产生影响,因为可能会改变内存分配和回收的行为。

6. golang中new和make的区别?

1.make 仅用来分配及初始化类型为 slice、map、chan 的数据。
2.new 可分配任意类型的数据,根据传入的类型申请一块内存,返回指向这块内存的指针,即类型 *Type。
3.make 返回引用,即 Type,new 分配的空间被清零, make 分配空间后,会进行初始。

延伸:

1)请问在使用 make 初始化 slice 时,第二个参数的作用是什么?

在使用 make 初始化 slice 时,第二个参数指定的是 slice 的长度。这个长度代表 slice 中元素的个数,它的默认值为 0。例如,make([]int, 5) 就会创建一个包含 5 个 int 类型元素的 slice。

2)除了 slice、map、chan,还有哪些类型适合使用 make 进行分配和初始化?

除了 slice、map、chan,还可以使用 make 初始化内置类型数组。例如,make([]int, 5) 可以创建一个包含 5 个 int 类型元素的 slice,make([5]int) 可以创建一个包含 5 个 int 类型元素的数组。

3)new 分配的空间被清零,这会对程序的性能产生什么影响?如何避免这种影响?

new 分配的空间被清零会带来一定的性能开销。为了避免这种影响,可以使用类似 sync.Pool 这样的对象池技术,将已经分配的内存缓存起来,待下次使用时再从对象池中取出。

4)请谈谈你对 new 和 make 的最佳实践和使用场景的理解。

new 用于分配空间并返回指向新分配的空间的指针,适用于实例化结构体、数组和基本类型变量等。make 用于分配和初始化 slice、map 和 chan 等引用类型数据,适用于这些类型的创建和初始化。在实际开发中,应根据具体的需求来选择使用 new 还是 make。

5)在 Go 语言中,有没有其他方式可以分配内存并初始化数据,与 new 和 make 不同?如果有,你知道它们的区别和应用场景吗?

除了 new 和 make,还可以使用 var 或 & 等方式来分配内存并初始化数据。使用 var 可以直接声明变量并初始化,如 var a int = 1。使用 & 可以获取一个变量的地址,并返回指向该地址的指针。这些方法不同于 new 和 make 的地方在于,它们通常用于创建非引用类型变量或在创建引用类型变量时进行初始化。

7.Go中对nil的Slice和空Slice的处理是一致的吗?

首先Go的JSON 标准库对 nil slice 和 空 slice 的处理是不一致。
1.slice := make([]int,0):slice不为nil,但是slice没有值,slice的底层的空间是空的。
2.slice := []int{} :slice的值是nil,可用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。

8.Golang的内存模型中为什么小对象多了会造成GC压力?

通常小对象过多会导致GC三色法消耗过多的GPU。优化思路是,减少对象分配。

9.channel为什么能做到线程安全?

channel可以理解是一个先进先出的循环队列,通过管道进行通信,发送一个数据到Channel和从Channel接收一个数据都是原子性的。不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。设计Channel的主要目的就是在多任务间传递数据的,本身就是安全的。

10.GC的触发条件

1.主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
2.被动触发,分为两种方式:

2.1.使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC。
2.2.使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。

11.怎么查看Goroutine的数量?怎么限制Goroutine的数量?

1.在Golang中,GOMAXPROCS中控制的是未被阻塞的所有Goroutine,可以被 Multiplex 到多少个线程上运行,通过GOMAXPROCS可以查看Goroutine的数量。
2.使用通道。每次执行的go之前向通道写入值,直到通道满的时候就阻塞了

12. Channel是同步的还是异步的?

Channel是异步进行的, channel存在3种状态

1.nil,未初始化的状态,只进行了声明,或者手动赋值为nil
2.active,正常的channel,可读或者可写
3.closed,已关闭,千万不要误认为关闭channel后,channel的值是nil
在这里插入图片描述

13.Goroutine和线程的区别?

1.一个线程可以有多个协程
2.线程、进程都是同步机制,而协程是异步
3.协程可以保留上一次调用时的状态,当过程重入时,相当于进入了上一次的调用状态
4.协程是需要线程来承载运行的,所以协程并不能取代线程,「线程是被分割的CPU资源,协程是组织好的代码流程」

14.Go的Struct能不能比较?

1.相同struct类型的可以比较
2.不同struct类型的不可以比较,编译都不过,类型不匹配

15.Go的Slice如何扩容?

1.首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)。
2.否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍。
3.否则判断,如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的1.25倍。
4.如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)。

16.在Go函数中为什么会发生内存泄露?发生了泄漏如何检测?

Goroutine 需要维护执行用户代码的上下文信息,在运行过程中需要消耗一定的内存来保存这类信息,如果一个程序持续不断地产生新的 goroutine,且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。
可以通过Go自带的工具pprof或者使用Gops去检测诊断当前在系统上运行的Go进程的占用的资源。

17. Go中两个Nil可能不相等吗?

Go中两个Nil可能不相等

接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset)。

两个接口值比较时,会先比较 T,再比较 V。接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较

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

18.Go语言中的内存对齐

CPU 并不会以一个一个字节去读取和写入内存。相反 CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度,内存访问粒度跟机器字长有关。

对齐规则:
1.结构体的成员变量,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度或当前成员变量类型的长度,取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
2.结构体本身,对齐值必须为编译器默认对齐长度,或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值
3.结合以上两点,可得知若编译器默认对齐长度,超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的

19.两个 interface 可以比较吗?

1.判断类型是否一样
reflect.TypeOf(a).Kind() == reflect.TypeOf(b).Kind()

2.判断两个interface{}是否相等
reflect.DeepEqual(a, b interface{})

3.将一个interface{}赋值给另一个interface{}
reflect.ValueOf(a).Elem().Set(reflect.ValueOf(b))

20.go 打印时 %v %+v %#v 的区别?

%v 只输出所有的值;
%+v 先输出字段名字,再输出该字段的值;
%#v 先输出结构体名字值,再输出结构体(字段名字+字段的值);

package main
import "fmt"

type student struct {
 id   int32
 name string
}

func main() {
 a := &student{id: 1, name: "微客鸟窝"}

 fmt.Printf("a=%v \n", a) // a=&{1 微客鸟窝} 
 fmt.Printf("a=%+v \n", a) // a=&{id:1 name:微客鸟窝} 
 fmt.Printf("a=%#v \n", a) // a=&main.student{id:1, name:"微客鸟窝"}
}

21.什么是rune类型

Go语言的字符有以下两种:

1.uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。
2.rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。

22.空 struct{} 占用空间么?用途是什么?

空结构体 struct{} 实例不占据任何的内存空间。

用途:
1.将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
2.不发送数据的信道(channel)
使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
3.结构体只包含方法,不包含任何的字段

各地持续搬运,如有雷同,就是一样

参考链接:

https://blog.csdn.net/qq_43716830/article/details/124405506

23.Java、golang内存逃逸对比

Java和Golang是两种不同的编程语言,它们在内存管理和内存逃逸方面有一些不同之处。

Java的内存管理是通过垃圾回收器(Garbage Collector)来实现的。在Java中,内存的分配和释放是由垃圾回收器自动处理的,开发人员不需要手动管理内存。当一个对象不再被引用时,垃圾回收器会自动回收它所占用的内存。Java的垃圾回收器使用了一些高级的算法来检测和回收不再使用的对象,例如标记-清除算法、复制算法、标记-整理算法等。由于垃圾回收器的存在,Java程序员可以专注于业务逻辑而不必过多关注内存管理。

然而,在Java中也存在内存逃逸的情况。当一个对象在方法中被创建并返回后,如果该对象被保存在方法外的引用中,就发生了内存逃逸。这种情况下,垃圾回收器无法自动回收这个对象,因为它仍然存在被外部引用的可能性。内存逃逸会导致对象的生命周期延长,增加垃圾回收的压力,可能会影响程序的性能。

与此不同,Golang是一种静态类型的编程语言,它使用了基于堆栈的内存管理模型。在Golang中,变量的分配和释放是由编译器和运行时系统自动管理的。Golang的编译器会在编译过程中对变量的生命周期进行分析,并决定将变量分配在堆上还是栈上。当一个变量不再被引用时,运行时系统会自动释放它所占用的内存。这种基于堆栈的内存管理模型可以避免一些内存逃逸的情况,减轻垃圾回收的负担。

然而,Golang中仍然存在内存逃逸的可能性。如果一个变量被保存在全局变量、函数间的闭包或者逃逸到堆上的数据结构中,就会发生内存逃逸。在这种情况下,垃圾回收器无法自动释放这些对象,需要开发人员手动管理内存。尽管Golang的内存管理相对于其他语言来说更加高效,但仍然需要开发人员关注内存逃逸问题,以避免潜在的性能问题。

总的来说,Java和Golang在内存管理和内存逃逸方面有一些差异。Java使用垃圾回收器进行自动内存管理,而Golang使用基于

24.channel的底层数据结构

channel是golang中用来实现多个goroutine通信的管道,它的底层是一个叫做hchan的结构体。在go的runtime包下。

type hchan struct {
  //channel分为无缓冲和有缓冲两种。
  //对于有缓冲的channel存储数据,借助的是如下循环数组的结构
	qcount   uint           // 循环数组中的元素数量
	dataqsiz uint           // 循环数组的长度
	buf      unsafe.Pointer // 指向底层循环数组的指针
	elemsize uint16 //能够收发元素的大小
  

	closed   uint32   //channel是否关闭的标志
	elemtype *_type //channel中的元素类型
  
  //有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
  //当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
	sendx    uint   // 下一次发送数据的下标位置
	recvx    uint   // 下一次读取数据的下标位置
  
  //当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
  //当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
	recvq    waitq  // 读等待队列
	sendq    waitq  // 写等待队列


	lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

//G1
func sendTask(taskList []Task) {
	...
  
	ch:=make(chan Task, 4) // 初始化长度为4的channel
	for _,task:=range taskList {
		ch <- task  //发送任务到channel
	}
  
	...
}

//G2
func handleTask(ch chan Task) {
	for {
		task:= <-ch //接收任务
		process(task) //处理任务
	}
}

ch是长度为4的带缓冲的channel,G1是发送者,G2是接收者

  • 初始hchan结构体重的buf为空,sendx和recvx均为0。
  • 当G1向ch里发送数据时,首先会对buf加锁,然后 将数据copy到buf中 ,然后sendx++,然后释放对buf的锁。
  • 当G2消费ch的时候,会首先对buf加锁,然后将buf中的 数据copy到task变量对应的内存里 ,然后recvx++,并释放锁。

可以发现整个过程,G1和G2没有共享的内存, 底层是通过hchan结构体的buf,并使用copy内存的方式进行通信,最后达到了共享内存的目的 ,这里也体现了Go中的CSP并发模型。

Go中的CSP并发模型即是通过goroutine和channel实现的。

CSP并发模型:不要以共享内存的方式来通信,相反,要通过通信的方式来共享内存。

那么当channel中的缓存满了之后会发生什么呢?

首先简单了解下 GMP的概念 。相关的数据模型如下图:

【G】goroutine是Golang实现的用户空间的轻量级的线程

【M】代表操作系统线程

【P】处理器, 它包含了待运行goroutine。

如果线程M想运行goroutine,必须先获取P,从P中获取goroutine执行。

当G1向buf已经满了的ch发送数据的时候,检测到hchan的buf已经满了,会通知调度器,调度器会将G1的状态设置为waiting, 并移除与线程M的联系,然后从P的runqueue中选择一个goroutine在线程M中执行,此时G1就是阻塞状态,但是不是操作系统的线程阻塞,所以这个时候只用消耗少量的资源。

那么G1设置为waiting状态后去哪了?怎们去resume呢?我们再回到hchan结构体,注意到hchan有个sendq的成员,其类型是waitq,查看源码如下:

type hchan struct {
	...
  recvq    waitq  // 读等待队列
	sendq    waitq  // 写等待队列
	...
}

type waitq struct {
	first *sudog
	last *sudog
}

实际上,当G1变为waiting状态后,会创建一个代表自己的sudog的结构,然后放到sendq这个list中,sudog结构中保存了channel相关的变量的指针(如果该Goroutine是sender,那么保存的是待发送数据的变量的地址,如果是receiver则为接收数据的变量的地址,之所以是地址,前面我们提到在传输数据的时候使用的是copy的方式)

当G2从ch中接收一个数据时,会通知调度器,设置G1的状态为runnable,然后将加入P的runqueue里,等待线程执行.

前面我们是假设G1先运行,如果G2先运行会怎么样呢?

如果G2先运行,那么G2会从一个empty的channel里取数据,这个时候G2就会阻塞,和前面介绍的G1阻塞一样,G2也会创建一个sudog结构体,保存接收数据的变量的地址,但是该sudog结构体是放到了recvq列表里。

当G1向ch发送数据的时候,为了提升效率,runtime并不会对hchan结构体题的buf进行加锁,而是直接将G1里的发送到ch的数据copy到了G2 sudog里对应的elem指向的内存地址!【不通过buf】

这时候,张三道友抛出了三个问题:

  • 为什么在第一种情况下,即G1向缓存满的channel中发送数据时被阻塞。在G2后来接收时,不将阻塞的G1发送的数据直接拷贝到G2中呢?
    这是因为channel中的数据是队列的,遵循先进先出的原则,当有消费者G2接收数据时,需要先接收缓存中的数据,即buf中的数据,而不是直接消费阻塞的G1中的数据。
  • 多个goroutine向有缓存的channel接收/发送数据时,可以保证顺序吗?
func main(){
	cache:=make(chan int,3)
	go func() {
		for i:=0;i< 3;i++ {
		cache<-i
	}
	}()
	time.Sleep(time.Second)
  //休眠1秒钟,保证channel中的数据已经写入完整
	go getCache("gorouine1",cache)
	go getCache("gorouine2",cache)
	go getCache("gorouine3",cache)
	time.Sleep(time.Second)
}

func getCache(routine string,cache <-chan int) {
	for  {
		select {
		case i:=<-cache:
			fmt.Printf("%s:%d\n",routine,i)
		}
	}
}

很多道友在工作中应用channel时遇到上述场景都会默认为是有序的,即认为输出结果应该是:

但实则不然,输出结果如下:

  • 这里其实主要需要明确两点:

    • channel中的数据遵循队列先进先出原则。
    • 每一个goroutine抢到处理器的时间点不一致,gorouine的执行本身不能保证顺序。

    即代码中先写的gorouine并不能保证先从channel中获取数据,或者发送数据。但是先执行的gorouine与后执行的goroutine在channel中获取的数据肯定是有序的。

  • Channel为什么是线程安全的?
    在对buf中的数据进行入队和出队操作时,为当前chnnel使用了互斥锁,防止多个线程并发修改数据

使用select处理多个channel

for{
  select {
  case <-ch1:
  	process1()
  	return
  case <-ch2:
  	process2()
  	return
	}
}

  • 场景:需要对多个通道进行同时处理,但只处理最先发生的channel时

  • 原理:select可以同时监控多个通道的情况,只处理未阻塞的case。

    • 当通道为nil时,对应的case永远为阻塞。
    • 如果channel已经关闭,则这个case是非阻塞的,每次select都可能会被执行到。
    • 如果多个channel都处于非阻塞态,则select会随机选择一个执行。

关闭channel的注意事项

  • 一个 channel不能多次关闭,会导致painc
  • 向一个已经关闭了的 channel发送数据会导致panic

面试点总结

  • Go channel的底层数据结构以及工作原理?
  • 向缓存满的channel写数据会发生什么?
  • 向没有数据的channel读数据会发生什么?
  • 简述channel的日常用法?

25.聊一聊Go的协程信息和如何预防泄露

泄露案例

关于协程泄露很多时候我们往往会忽略它,直到机器资源负载异常才引起重视。
之前排除生产环境异常的时候,曾经遇到过go程序内存泄露的场景,内存泄漏和协程泄露有很大关系,本质上都是资源不回收导致的。

这里列举一个典型的泄露案例:

func JumpForSignal() int {
    ch := make(chan int)
    go func() {
        ch <- bizMtx
    }()

    go func() {
        ch <- bizMtx
    }()

    go func() {
        ch <- bizMtx
    }()
    //一有输入立刻返回
    return <-ch
}

func main() {
    // ...
    JumpForSignal()
    // ...
}

事后分析这个demo可以得知,这个函数调用会阻塞两个子协程,预期只有一个协程会正常退出。

获取协程信息

既然存在协程泄露,我们在日常工作怎么避免或者发现它呢?下面我们列举几个思路。

遵守准则

由于Go是自带GC的语言,很多时候写代码不需要关心变量的资源释放,不像C程序员变量申请之后需要在结束处释放。但是Go的chan在使用时候是有一些准则的,当确定chan不再使用时候,可以在输出方进行close,避免其他协程还在等待该chan的输出。

协程数量

找到泄露的协程,第一个能够想到的是协程数量,当你的函数处理逻辑比较简单,除了主协程之外,预期协程应该都在结束前返回,可以在main函数结束处调用runtime包的函数:

func NumGoroutine() int {
    return int(gcount())
}

通过它可以返回当前协程总数量:

func Count()  {
    fmt.Printf("Number of goroutines:%d\n", runtime.NumGoroutine())
}

func main() {
    defer Count()
    Count()
    JumpForSignal()
}

输出:

Number of goroutines:1
Number of goroutines:3

协程函数栈

还有一种比较常见定位协程的形式,在Go里面,可以用于分析协程函数的上下文,常见的比如go自带的pprof也是通过这种方式获取,实际案例中,条件允许的情况可以开启pprof方便分析。

下面来看一个示例,我们在上面的例子加一个http端口监听,用于接入go自带的pprof分析工具。

随后在浏览器输入:
pprof

可以得到整个程序的协程列表:

goroutine profile: total 7
1 @ 0x165eb6 0x126465 0x126235 0x29341e 0x19de01
#	0x29341d	pixelgo/leak.JumpForSignal.func1+0x3d	F:/code/pixelGo/src/pix-demo/leak/leak.go:24

1 @ 0x165eb6 0x126465 0x126235 0x29347e 0x19de01
#	0x29347d	pixelgo/leak.JumpForSignal.func2+0x3d	F:/code/pixelGo/src/pix-demo/leak/leak.go:28

1 @ 0x165eb6 0x15bb3d 0x1975a5 0x228d05 0x229d8d 0x22c40d 0x321765 0x33437c 0x447c89 0x285239 0x285606 0x4493f3 0x450da8 0x19de01
#	0x1975a4	internal/poll.runtime_pollWait+0x64	D:/dev/go1.16/src/runtime/netpoll.go:227
#	0x228d04	internal/poll.(*pollDesc).wait+0xa4	D:/dev/go1.16/src/internal/poll/fd_poll_runtime.go:87
#	0x229d8c	internal/poll.execIO+0x2ac		D:/dev/go1.16/src/internal/poll/fd_windows.go:175
#	0x22c40c	internal/poll.(*FD).Read+0x56c

// ...

结论是:当前程序一共有7个协程,可以看出分别有1个协程分配在F:/code/pixelGo/src/pix-demo/leak/leak.go:24F:/code/pixelGo/src/pix-demo/leak/leak.go:28,正是上文泄露的代码块。

有时候还可以多维度去分析,比如输入:

pprof/debug

可以通过协程后面的标签,看到当前协程的不同状态,running/io wait/chan send

goroutine 9 [running]:
runtime/pprof.writeGoroutineStacks(0x7f7d00, 0xc0000aa000, 0x0, 0x0)
	D:/dev/go1.16/src/runtime/pprof/pprof.go:693 +0xc5
net/http/pprof.handler.ServeHTTP(0xc000094011, 0x9, 0x7fba40, 0xc0000aa000, 0xc000092000)
    //..

goroutine 1 [IO wait]:
internal/poll.runtime_pollWait(0x223debb10d8, 0x72, 0xc000152f48)
	D:/dev/go1.16/src/runtime/netpoll.go:227 +0x65
internal/poll.(*pollDesc).wait(0xc0001530b8, 0x72, 0x93b400, 0x0, 0x0)
    //...

goroutine 6 [chan send]:
pixelgo/rout.JumpForSignal.func1(0xc000053800)
	F:/code/pixelGo/src/pix-demo/rout/leak.go:25 +0x10e
created by pixelgo/rout.JumpForSignal
	F:/code/pixelGo/src/pix-demo/rout/leak.go:23 +0x71

goroutine 7 [chan send]:
pixelgo/rout.JumpForSignal.func2(0xc000053800)
	F:/code/pixelGo/src/pix-demo/rout/leak.go:30 +0x10e
created by pixelgo/rout.JumpForSignal
	F:/code/pixelGo/src/pix-demo/rout/leak.go:28 +0x93

协程id

接下来我们来探索协程标识: 协程id ,在Go中,每个运行的协程都会分配一个协程id,一个常见的方式是从函数运行栈获取,引用之前网上其他同学的写法:

func main() {
    fmt.Println(getGID())
}

func getGID() uint64 {
    b := make([]byte, 64)
    b = b[:runtime.Stack(b, false)]
    b = bytes.TrimPrefix(b, []byte("goroutine "))
    b = b[:bytes.IndexByte(b, ' ')]
    n, _ := strconv.ParseUint(string(b), 10, 64)
    return n
}

我们来看看runtime.stack() 会返回什么呢,其中真实内容是这样的:

goroutine 21 [running]:
leaktest.interestingGoroutines(0xdb9980, 0xc00038e018, 0x0, 0x0, 0x0)
	F:/code/pixelGo/src/leaktest/leaktest.go:81 +0xbf
leaktest.CheckContext(0xdbe398, 0xc000108040, 0xdb9980, 0xc00038e018, 0x0)
	F:/code/pixelGo/src/leaktest/leaktest.go:141 +0x6e
leaktest.CheckTimeout(0xdb9980, 0xc00038e018, 0x3b9aca00, 0x0)
	F:/code/pixelGo/src/leaktest/leaktest.go:127 +0xe5
leaktest.TestCheck.func8(0xc000384780)
	F:/code/pixelGo/src/leaktest/leaktest_test.go:122 +0xaf
testing.tRunner(0xc000384780, 0xc000100050)
	D:/dev/go1.16/src/testing/testing.go:1193 +0x1a3
created by testing.(*T).Run
	D:/dev/go1.16/src/testing/testing.go:1238 +0x63c

goroutine 1 [chan receive]:
testing.(*T).Run(0xc000037080, 0xd8486a, 0x9, 0xd9ebc8, 0x304bd824304bd800)
	D:/dev/go1.16/src/testing/testing.go:1239 +0x66a
testing.runTests.func1(0xc000036f00)
	D:/dev/go1.16/src/testing/testing.go:1511 +0xbd
testing.tRunner(0xc000036f00, 0xc00008fc00)
	D:/dev/go1.16/src/testing/testing.go:1193 +0x1a3
testing.runTests(0xc0000040d8, 0xf40460, 0x5, 0x5, 0x0, 0x0, 0x0, 0x21cbf1c0100)
	D:/dev/go1.16/src/testing/testing.go:1509 +0x448
testing.(*M).Run(0xc0000c0000, 0x0)
	D:/dev/go1.16/src/testing/testing.go:1417 +0x514
main.main()
	_testmain.go:51 +0xc8

可以发现这个栈和我们运行panic抛出的信息非常类似,需要注意的是,通过这种方式获取协程id并不是一个高效的方式。

实际生产使用过程并不提倡,值得一提的是,为了方便我们更好的定位问题上下文,有时候日志框架又需要我们打印出当前协程id。

比如这是一个生产案例日志输出:

// gid-1号协程用于初始化资源
[0224/162532.310:INFO:gid-1:yx_trace.go:66] cfg:&{ false false [] 0xc000295140 0xc0001d4e00 <nil> <nil> <nil>}
[0224/162532.320:INFO:gid-1:main.go:50] GameRoom Startup->
[0224/162532.320:INFO:gid-1:config_manager.go:107] configManager SetHttpListenAddr:8080
[0224/162532.320:INFO:gid-1:room_manager.go:57] roomManager Startup
[0224/162532.323:INFO:gid-1:room_manager.go:72] roomManager initPrx.
[0224/162532.330:INFO:gid-1:bootstrap.go:153] GameRoom START ok.
// gid-60号协程分配用于启动HTTP Server
[0224/162533.277:INFO:gid-60:expose.go:36] Start for HTTP server...
[0224/162533.277:INFO:gid-60:expose.go:39] register for debug server...

往往日志框架是力求对业务性能影响最低的,既然有性能顾虑,那么它是怎么获取协程id的呢?只能曲线救国了。

还有一个解法,其实在Go中,每个协程绑定的系统线程结构中,有一个g指针,拿到g指针的信息之后,根据g指针结构的偏移量(注意不同go版本可能不同),指定获取id。

汇编获取

通过协程绑定的g指针,这里参考《Go高级编程》的做法

// 记录各个版本的偏移量
var offsetDictMap = map[string]int64{
        "go1.12":    152,
        "go1.12.1":  152,
        "go1.12.2":  152,
        "go1.12.3":  152,
        "go1.12.4":  152,
        "go1.12.5":  152,
        "go1.12.6":  152,
        "go1.12.7":  152,
        "go1.13":    152,
        "go1.14":    152,
        "go1.16.12":    152,
}

// offset for go1.12
var goid_offset uintptr = 152
//go:nosplit
func getG() interface{}

func GoId() int64

// 部分汇编代码
// func getGptr() unsafe.Pointer
TEXT ·getGptr(SB), NOSPLIT, $0-8
    MOVQ (TLS), BX
    MOVQ BX, ret+0(FP)
    RET

TEXT ·GoId(SB),NOSPLIT,$0-8
    NO_LOCAL_POINTERS
    MOVQ ·goid_offset(SB),AX
    // get runtime.g
    MOVQ (TLS),BX
    ADDQ BX,AX
    MOVQ (AX),BX
    MOVQ BX,ret+0(FP)
    RET

性能比较:

我们来简单测试下两种获取go协程id方式性能差距:

// BenchmarkGRtId-8   	1000000000	         0.0005081 ns/op
func BenchmarkGRtId(b *testing.B) {
    for n := 0; n < 1000000000; n++ {
        // runtime获取协程id
        getGID()
    }
}

// BenchmarkGoId-8   	1000000000	         0.05731 ns/op
func BenchmarkGoId(b *testing.B) {
    for n := 0; n < 1000000000; n++ {
        // 汇编方式获取
        GoId()
    }
}

可以看到通过汇编方式获取协程id的方式性能更优,相差几个数量级。


限制协程

上面列举了几个定位协程信息的方法,那么在协程泄露之前有没有其他方式对程序的go协程进行管控呢,有个做法是使用强大的channel坐下限制。

抛砖引玉

这里先提供一个简单的思路,即再包装一层channel进行保护,

// 限制数量
var LIMIT_G_NUM = make(chan struct{}, 100)

// 需要自定义的处理逻辑
type HandleFun func()

func AsyncGoForHandle(fn HandleFun)  {
    // 计数加一
    LIMIT_G_NUM <- struct{}{}
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Fatalf("AsyncGoForHandle recover from err: %v", err)
            }
            // 回收计数
            <-LIMIT_G_NUM
        }()

        // 处理逻辑
        fn()
    }()
}

上面的思路比较简单,相信大家能看懂,每次需要异步创建协程只要调用AsyncGoForHandle()函数即可,不足之处可能是处理逻辑HandleFun()不够通用,需要自己定义具体实现。

还有一种方式,就是引入协程池的概念,这里的池子和数据库连接池有点像,即一开始就预创建好,业务层只要负责提交数据,业界已经有不少成熟的封装。

成熟方案:tunny

之前看到社区有一个封装得比较完善的协程池 tunny ,代码行数不多,我们来试着拆解分析一下代码,项目地址:github.com/Jeffail/tun…

相关实体结构如下,配合源码阅读就比较清晰了。

实体模块

这个框架相当于把协程预先创建好做了池化,随后业务层只需要源源不断把"加工数据"输入到workRequest这个chan即可,也就是process()函数,process()模块会把数据输入到内部channel进行处理,池中的worker会进行加工。

这种工厂模式还是值得借鉴的,Go也有很多成熟框架使用了这种写法。

引用原项目README.md的用法示例:

numCPUs := runtime.NumCPU()

pool := tunny.NewFunc(numCPUs, func(payload interface{}) interface{} {
    var result []byte
    // 关心业务层的输入、输出即可
    result = wrapSomething()
    return result
})
defer pool.Close()

http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
    input, err := ioutil.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Internal error", http.StatusInternalServerError)
    }
    defer r.Body.Close()

    // 提交任务给Process
    result := pool.Process(input)

    w.Write(result.([]byte))
})

http.ListenAndServe(":8080", nil)

总结

  • Go协程有几个内置信息,协程id、协程栈、协程状态(running/io wait/chan send),通过这些信息可以帮助我们一定程度的避免或者定位问题
  • Go里面创建协程只需要一个Go关键字,但是要合理回收却很关键,必要时可以用协程池做限制

26.Go 中常见的内存逃逸分析

很多时候为了更快的开发效率,大多数程序员都是在使用抽象层级更高的技术,包括语言,框架,设计模式等。所以导致很多程序员包括我自己在内对于底层和基础的知识都会有些生疏和,但是正是这些底层的东西构建了我们熟知的解决方案,同时决定了一个技术人员的上限。

在写C和C++的时候动态分配内存是让程序员自己手动管理,这样做的好处是,需要申请多少内存空间可以很好的掌握怎么分配,但是如果忘记释放内存,则会导致内存泄漏。

Rust又比👆上面俩门语言分配内存方式显得不同,Rust的内存管理主要特色可以看做是编译器帮你在适当的地方插入delete来释放内存,这样一来你不需要显式指定释放,runtime也不需要任何GC,但是要做到这点,编译器需要能分析出在什么地方delete,这就需要你代码按照其规则来写了。

相比上面几种的内存管理方式的语言,像JavaGolang在语言设计的时候就加入了garbage collection也就runtime中的gc,让程序员不需要自己管理内存,真正解放了程序员的双手,让我们可以专注于编码。

函数栈帧

你的程序怎么跑起来的👆

当一个函数在运行时,需要为它在堆栈中创建一个栈帧(stack frame)用来记录运行时产生的相关信息,因此每个函数在执行前都会创建一个栈帧,在它返回时会销毁该栈帧。

stack frame准确来说应该是call stack

通常用一个叫做栈基址(bp)的寄存器来保存正在运行函数栈帧的开始地址,由于栈指针(sp)始终保存的是栈顶的地址,所以栈指针保存的也就是正在运行函数栈帧的结束地址。

销毁栈帧

销毁时先把栈指针(sp)移动到此时栈基址(bp)的位置,此时栈指针和栈基址都指向同样的位置。

Go内存逃逸

可以简单得理解成一次函数调用内部申请到的内存,它们会随着函数的返回把内存还给系统。下面来看看一个例子:

package main

import "fmt"

func main() {
    f := foo("Ding")
    fmt.Println(f)
}

type bar struct {
    s string
}

func foo(s string) bar {
    f := new(bar) // 这里的new(bar)会不会发生逃逸???
    defer func() {
        f = nil
    }()
    f.s = s
    return *f
}

我想很多人认为发生了逃逸,但是真的是这样的吗?那就用go build -gcflags=-m escape/struct.go看看会输出什么???

escape/struct.go:7:13: f escapes to heap 可以省略

其实没有发生逃逸,而escape/struct.go:7:13: f escapes to heap的逃逸是因为动态类型逃逸fmt.Println(a …interface{})在编译期间很难确定其参数的具体类型,也能产生逃逸。

继续看下面这一个例子:

package main

import "fmt"

func main() {
    f := foo("Ding")
    fmt.Println(f)
}

type bar struct {
    s string
}

func foo(s string) *bar {
    f := new(bar) // 这里的new(bar)会不会发生逃逸???
    defer func() {
        f = nil
    }()
    f.s = s
    return f
}

f := new(bar)会发生逃逸吗?

$: go build -gcflags=-m escape/struct.go
# command-line-arguments
escape/struct.go:16:8: can inline foo.func1
escape/struct.go:7:13: inlining call to fmt.Println
escape/struct.go:14:10: leaking param: s
escape/struct.go:15:10: new(bar) escapes to heap ✅
escape/struct.go:16:8: func literal does not escape
escape/struct.go:7:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

Go可以返回局部变量指针,这其实是一个典型的变量逃逸案例,虽然在函数 foo() 内部 f 为局部变量,其值通过函数返回值返回,f 本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例。

那就继续往下看吧,看看这个例子:

package main

func main() {
    Slice() // ??? 会发生逃逸吗?
}

func Slice() {
    s := make([]int, 10000, 10000)

    for index, _ := range s {
        s[index] = index
    }
}


估计很多人会回答没有,其实这里发生逃逸,实际上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

发生逃逸

最后一个例子:

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    Println(string(*ReverseA("Ding Ding"))) // ???
}


func Println(str string) {
    io.WriteString(os.Stdout,
      str+"\n")
}

func ReverseA(str string) *[]rune {
    result := make([]rune, 0, len(str))
    for _, v := range []rune(str) {
        v := v
        defer func() {
            result = append(result, v)
        }()
    }
    return &result
}

如果一个变量被取地址,通过函数返回指针值返回,还有闭包,编译器不确定你的切片容量时,是否要扩容的时候,放到堆上,以致产生逃逸。

于是我优化了一下代码,再看:

package main

import (
	"io"
	"os"
)

func main() {
    result := []rune("Ding Ding")
    ReverseB(result)
    Println(string(result))
}

func ReverseB(runes []rune) {
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
}

func Println(str string) {
    io.WriteString(os.Stdout,
      str+"\n")
}

str在运行的时候可更改

如何得知变量是怎么分配?

引用(golang.org) FAQ官方说的:

准确地说,你并不需要知道,Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上, 然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。

小 结

  • 逃逸分析的好处是为了减少gc的压力
  • 栈上分配的内存不需要gc处理
  • 同步消除,如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

27.Go内存管理之代码的逃逸分析

逃逸分析:Go编译器会跨越函数和包的边界进行全局的逃逸分析。它会检查是否需要在堆上为一个变量分配内存,还是说可以在栈本身的内存里对其进行管理。

发生情况:

简单总结一下有以下几类情况:

  1. 发送指针的指针或值包含了指针到 channel 中,由于在编译阶段无法确定其作用域与传递的路径,所以一般都会逃逸到堆上分配。
  2. slices 中的值是指针的指针或包含指针字段。一个例子是类似[] *string 的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上,数据的引用也会转移到堆中。
  3. slice 由于 append 操作超出其容量,因此会导致 slice 重新分配。这种情况下,由于在编译时 slice 的初始大小的已知情况下,将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展,则它将分配在堆上。
  4. 调用接口类型的方法。接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。 参考 http://npat-efault.github.io/programming/2016/10/10/escape-analysis-and-interfaces.html
  5. 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆

28.使用互斥锁的时候会不会出现某个 goroutine 一直取不到锁的情况?互斥锁有哪些状态?如何保证公平?

不会,互斥锁有正常状态和饥饿状态两种状态。

正常状态下,所有等待锁的 goroutine 会按 FIFO 的顺序等待,等待队列中被唤醒的 goroutine 不会立刻持有锁,而是会和最新请求锁的 goroutine 竞争,不过因为新请求的 goroutine 正在占用 cpu,而且可能有很多,所以一般都竞争不过,这时候最新请求的 goroutine 会拿到锁,被唤醒的 goroutine 会回到队首继续等待。

如果队首的等待超过 1ms,那么就会进入饥饿模式,饥饿模式下最新来的 goroutine 不会和等待队列里的竞争,总是优先把锁给等待队列里的第一个。

当等待队列里的 goroutine 获取了锁并且它在队列末尾时,或者某个等待 goroutine 等待时间小于 1ms 时,那么互斥锁的状态会回到正常模式,如此往复。

29.sync.map 的实现原理

  1. 通过 read 和 dirty 两个字段来进行读写分离,最新写入数据始终写进 dirty,然后定期同步到 read 上
  2. 读的时候先从 read 读,如果读不到则穿透到 dirty 上读
  3. 读取 read 时不需要加锁,但是读写 dirty 时都要加锁
  4. 通过 misses 字段来统计 read 被穿透的次数,超过一定量时将 dirty 数据同步到 read 上
  5. 删除数据时通过标记来延迟删除

30.GMP(1)

为什么这么快?

为什么go能支持高并发,它和Java的多线程有什么不同?

常规的多线程是由CPU直接调度的,其中大部分时间花在了上下文切换上面,所以后面就了了协程(co-routine),用于减少上下文切换。

协程

GMP模型

go的高并发特性秘密就是GMP模型

gmpall

M0和G0

M0

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。

G0

G0是每次启动一个M都会第一个创建的gourtineG0仅用于负责调度的GG0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0M0G0

gmpfirst

新的协程被创建和执行

当有新的协程G被创建时, 会优先放入被创建的当前P本地队列 ,如果本地P队列满了,则放入全局G队列。然后P通过G0调度到新的G,然后G0退出,P执行新的G。

gmp_create_g

自旋线程

当本地P队列为空,当前线程就会变成自旋线程,此时G0不断的寻找可执行的G(优先从全局G队列查找),然后找到后放入本地P队列,然后P切换到新找到的G继续执行。

gmp_p_empty

work stealing机制

上面的自旋线程是优先从全局G队列里查找可执行的G,当全局G队列也为空时,他就会从其它的P队列里偷取G,然后放入本地P队列。gmp_workst

hand off机制

如果当前线程的G进行系统阻塞调用时,如进行time.sleep,则当前线程就会释放P,然后把P转交给其它空闲的线程执行,如果没有闲置的线程,则创建新的线程

hand_off

本地P队列已满

如果本地P队列满了,将本地P队列前一半打乱顺序,然后和新的G一起放入全局G队列p_m2

31.GMP(2)

Golang的一大特色就是Goroutine。Goroutine是Golang支持高并发的重要保障。Golang可以创建成千上万个Goroutine来处理任务,将这些Goroutine分配、负载、调度到处理器上采用的是G-M-P模型。

什么是Goroutine

Goroutine = Golang + Coroutine。 Goroutine是golang实现的协程,是用户级线程 。Goroutine具有以下特点:

  • 相比线程,其启动的代价很小,以很小栈空间启动(2Kb左右)
  • 能够动态地伸缩栈的大小,最大可以支持到Gb级别
  • 工作在用户态,切换成很小
  • 与线程关系是n:m,即可以在n个系统线程上多工调度m个Goroutine

进程、线程、Goroutine

在仅支持进程的操作系统中,进程是拥有资源和独立调度的基本单位。在引入线程的操作系统中, 线程是独立调度的基本单位,进程是资源拥有的基本单位 。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换

线程创建、管理、调度等采用的方式称为线程模型。线程模型一般分为以下三种:

  • 内核级线程(Kernel Level Thread)模型
  • 用户级线程(User Level Thread)模型
  • 两级线程模型,也称混合型线程模型

三大线程模型最大差异就在于用户级线程与内核调度实体KSE(KSE,Kernel Scheduling Entity)之间的对应关系。 KSE是Kernel Scheduling Entity的缩写,其是可被操作系统内核调度器调度的对象实体,是操作系统内核的最小调度单元,可以简单理解为内核级线程

用户级线程即协程 ,由应用程序创建与管理,协程必须与内核级线程绑定之后才能执行。 线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程

用户级线程(ULT)与内核级线程(KLT)比较:

特性用户级线程内核级线程
创建者应用程序内核
操作系统是否感知存在
开销成本创建成本低,上下文切换成本低 ,上下文切换不需要硬件支持创建成本高,上下文切换成本高 ,上下文切换需要硬件支持
如果线程阻塞整个进程将被阻塞。即不能利用多处理来发挥并发优势其他线程可以继续执行,进程不会阻塞
案例Java thread, POSIX threadsWindow Solaris

内核级线程模型

内核级线程模型中用户线程与内核线程是一对一关系(1 : 1)线程的创建、销毁、切换工作都是有内核完成的 。应用程序不参与线程的管理工作,只能调用内核级线程编程接口(应用程序创建一个新线程或撤销一个已有线程时,都会进行一个系统调用)。每个用户线程都会被绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。

操作系统调度器管理、调度并分派这些线程。运行时库为每个用户级线程请求一个内核级线程。操作系统的内存管理和调度子系统必须要考虑到数量巨大的用户级线程。操作系统为每个线程创建上下文。进程的每个线程在资源可用时都可以被指派到处理器内核。

内核级线程模型有如下优点:

  • 在多处理器系统中,内核能够并行执行同一进程内的多个线程
  • 如果进程中的一个线程被阻塞,不会阻塞其他线程,是能够切换同一进程内的其他线程继续执行
  • 当一个线程阻塞时,内核根据选择可以运行另一个进程的线程,而用户空间实现的线程中,运行时系统始终运行自己进程中的线程

缺点:

  • 线程的创建与删除都需要CPU参与,成本大

用户级线程模型

用户线程模型中的用户线程与内核线程KSE是多对一关系(N : 1)线程的创建、销毁以及线程之间的协调、同步等工作都是在用户态完成 ,具体来说就是由应用程序的线程库来完成。 内核对这些是无感知的,内核此时的调度都是基于进程的 。线程的并发处理从宏观来看,任意时刻每个进程只能够有一个线程在运行,且只有一个处理器内核会被分配给该进程。

从上图中可以看出来:库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种”多对一”的线程映射

用户级线程有如下优点:

  • 创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多, 因为保存线程状态的过程和调用程序都只是本地过程
  • 线程能够利用的表空间和堆栈空间比内核级线程多

缺点:

  • 线程发生I/O或页面故障引起的阻塞时,如果调用阻塞系统调用则内核由于不知道有多线程的存在,而会阻塞整个进程从而阻塞所有线程, 因此同一进程中只能同时有一个线程在运行
  • 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用

两级线程模型

两级线程模型中用户线程与内核线程是一对一关系(N : M) 。两级线程模型充分吸收上面两种模型的优点,尽量规避缺点。其线程创建在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被绑定到一些(小于或等于用户级线程的数目)内核级线程上。

Golang的线程模型

Golang在底层实现了混合型线程模型 。M即系统线程,由系统调用产生,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用及线程。M与G的关系是N:M。

G-M-P模型概览

G-M-P模型概览图

G-M-P分别代表:

  • G - Goroutine,Go协程,是参与调度与执行的最小单位
  • M - Machine,指的是系统级线程
  • P - Processor,指的是逻辑处理器,P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。

GMP调度流程大致如下:

  • 线程M想运行任务就需得获取 P,即与P关联。
  • 然从 P 的本地队列(LRQ)获取 G
  • 若LRQ中没有可运行的G,M 会尝试从全局队列(GRQ)拿一批G放到P的本地队列,
  • 若全局队列也未找到可运行的G时候,M会随机从其他 P 的本地队列偷一半放到自己 P 的本地队列。
  • 拿到可运行的G之后,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

调度的生命周期

golang调度器生命周期

  • 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 退出程序。

G-M-P的数量

G 的数量:

理论上没有数量上限限制的。查看当前G的数量可以使用runtime. NumGoroutine()

P 的数量:

由启动时环境变量 $GOMAXPROCS 或者是由runtime.GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。

M 的数量:

go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
一个 M 阻塞了,会创建新的 M。M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

调度的流程状态

从上图我们可以看出来:

  • 每个P有个局部队列,局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
  • 每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3),M从绑定的P中的局部队列获取G来执行
  • 当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为work stealing
  • 当G因系统调用(syscall)阻塞时会阻塞M,此时P会和M解绑即 hand off ,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)。
  • 当G因channel或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)

调度过程中阻塞

GMP模型的阻塞可能发生在下面几种情况:

  • I/O,select
  • block on syscall
  • channel
  • 等待锁
  • runtime.Gosched()

用户态阻塞

当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G),对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。

系统调用阻塞

当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall 状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。

G-M-P内部结构

G的内部结构

G的内部结构中重要字段如下,完全结构参见源码

type g struct {
    stack       stack   // g自己的栈
    m            *m      // 隶属于哪个M
    sched        gobuf   // 保存了g的现场,goroutine切换时通过它来恢复
    atomicstatus uint32  // G的运行状态
    goid         int64
    schedlink    guintptr // 下一个g, g链表
    preempt      bool //抢占标记
    lockedm      muintptr // 锁定的M,g中断恢复指定M执行
    gopc          uintptr  // 创建该goroutine的指令地址
    startpc       uintptr  // goroutine 函数的指令地址
}

G的状态有以下9种,可以参见代码

状态含义
_Gidle0刚刚被分配,还没有进行初始化。
_Grunnable1已经在运行队列中,还没有执行用户代码。
_Grunning2不在运行队列里中,已经可以执行用户代码,此时已经分配了 M 和 P。
_Gsyscall3正在执行系统调用,此时分配了 M。
_Gwaiting4在运行时被阻止,没有执行用户代码,也不在运行队列中,此时它正在某处阻塞等待中。Groutine wait的原因有哪些参加代码
_Gmoribund_unused5尚未使用,但是在 gdb 中进行了硬编码。
_Gdead6尚未使用,这个状态可能是刚退出或是刚被初始化,此时它并没有执行用户代码,有可能有也有可能没有分配堆栈。
_Genqueue_unused7尚未使用。
_Gcopystack8正在复制堆栈,并没有执行用户代码,也不在运行队列中。

M的结构

M的内部结构,完整结构参见源码

type m struct {
    g0      *g     // g0, 每个M都有自己独有的g0

    curg          *g       // 当前正在运行的g
    p             puintptr // 隶属于哪个P
    nextp         puintptr // 当m被唤醒时,首先拥有这个p
    id            int64
    spinning      bool // 是否处于自旋

    park          note
    alllink       *m // on allm
    schedlink     muintptr // 下一个m, m链表
    mcache        *mcache  // 内存分配
    lockedg       guintptr // 和 G 的lockedm对应
    freelink      *m // on sched.freem
}

P的内部结构

P的内部结构,完全结构参见源码

type p struct {
    id          int32
    status      uint32 // P的状态
    link        puintptr // 下一个P, P链表
    m           muintptr // 拥有这个P的M
    mcache      *mcache  

    // P本地runnable状态的G队列,无锁访问
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
  
    runnext guintptr // 一个比runq优先级更高的runnable G

    // 状态为dead的G链表,在获取G时会从这里面获取
    gFree struct {
        gList
        n int32
    }

    gcBgMarkWorker       guintptr // (atomic)
    gcw gcWork

}

P有以下几种状态,参加源码

状态含义
_Pidle0刚刚被分配,还没有进行进行初始化。
_Prunning1当 M 与 P 绑定调用 acquirep 时,P 的状态会改变为 _Prunning。
_Psyscall2正在执行系统调用。
_Pgcstop3暂停运行,此时系统正在进行 GC,直至 GC 结束后才会转变到下一个状态阶段。
_Pdead4废弃,不再使用。

调度器的内部结构

调度器内部结构,完全结构参见源码

type schedt struct {

    lock mutex

    midle        muintptr // 空闲M链表
    nmidle       int32    // 空闲M数量
    nmidlelocked int32    // 被锁住的M的数量
    mnext        int64    // 已创建M的数量,以及下一个M ID
    maxmcount    int32    // 允许创建最大的M数量
    nmsys        int32    // 不计入死锁的M数量
    nmfreed      int64    // 累计释放M的数量

    pidle      puintptr // 空闲的P链表
    npidle     uint32   // 空闲的P数量

    runq     gQueue // 全局runnable的G队列
    runqsize int32  // 全局runnable的G数量

    // Global cache of dead G's.
    gFree struct {
        lock    mutex
        stack   gList // Gs with stacks
        noStack gList // Gs without stacks
        n       int32
    }

    // freem is the list of m's waiting to be freed when their
    // m.exited is set. Linked through m.freelink.
    freem *m
}

观察调度流程

GODEBUG trace方式

GODEBUG 变量可以控制运行时内的调试变量,参数以逗号分隔,格式为:name=val。观察GMP可以使用下面两个参数:

  • schedtrace:设置 schedtrace=X 参数可以使运行时在每 X 毫秒输出一行调度器的摘要信息到标准 err 输出中。
  • scheddetail:设置 schedtrace=X 和 scheddetail=1 可以使运行时在每 X 毫秒输出一次详细的多行信息,信息内容主要包括调度程序、处理器、OS 线程 和 Goroutine 的状态。
package main

import (
    "sync"
    "time"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 2000; i++ {
		wg.Add(1)
		go func() {
			a := 0

			for i := 0; i < 1e6; i++ {
				a += 1
			}

			wg.Done()
        }()
        time.Sleep(100 * time.Millisecond)
	}

	wg.Wait()
}

执行一下命令:

GODEBUG=schedtrace=1000 go run ./test.go

输出内容如下:

SCHED 0ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [0]
SCHED 1001ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]
SCHED 2002ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]
SCHED 3002ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]
SCHED 4003ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]

输出内容解释说明:

  • SCHED XXms: SCHED是调度日志输出标志符。XXms是自程序启动之后到输出当前行时间
  • gomaxprocs: P的数量,等于当前的 CPU 核心数,或者GOMAXPROCS环境变量的值
  • idleprocs: 空闲P的数量,与gomaxprocs的差值即运行中P的数量
  • threads: 线程数量,即M的数量
  • spinningthreads:自旋状态线程的数量。当M没有找到可供其调度执行的 Goroutine 时,该线程并不会销毁,而是出于自旋状态
  • idlethreads:空闲线程的数量
  • runqueue:全局队列中G的数量
  • [0]:表示P本地队列下G的数量,有几个P中括号里面就会有几个数字

Go tool trace方式

func main() {
	// 创建trace文件
	f, err := os.Create("trace.out")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	// 启动trace goroutine
	err = trace.Start(f)
	if err != nil {
		panic(err)
	}
	defer trace.Stop()

	// main
	fmt.Println("Hello trace")
}

执行下面命令产生trace文件trace.out:

go run test.go

执行下面命令,打开浏览器,打开控制台查看。

go tool trace trace.out

总结

  1. Golang的线程模型采用的是混合型线程模型,线程与协程关系是N:M。
  2. Golang混合型线程模型实现采用GMP模型进行调度,G是goroutine,是golang实现的协程,M是OS线程,P是逻辑处理器。
  3. 每一个M都需要与一个P绑定,P拥有本地可运行G队列,M是执行G的单元,M获取可运行G流程是先从P的本地队列获取,若未获取到,则从其他P偷取过来(即work steal),若其他的P也没有则从全局G队列获取,若都未获取到,则M将处于自旋状态,并不会销毁。
  4. 当执行G时候,发生通道阻塞等用户级别阻塞时候,此时M不会阻塞,M会继续寻找其他可运行的G,当阻塞的G恢复之后,重新进入P的队列等待执行,若G进行系统调用时候,会阻塞M,此时P会和M解绑(即hand off),并寻找新的空闲的M。若没有空闲的就会创建一个新的M。
  5. Work Steal和Hand Off保证了线程的高效利用。

G-M-P高效的保证策略有:

  • M是可以复用的,不需要反复创建与销毁,当没有可执行的Goroutine时候就处于自旋状态,等待唤醒
  • Work Stealing和Hand Off策略保证了M的高效利用
  • 内存分配状态(mcache)位于P,G可以跨M调度,不再存在跨M调度局部性差的问题
  • M从关联的P中获取G,不需要使用锁,是lock free的

32.slice扩容方式

  • 当容量足够时,进行append操作直接修改其len并在a数组后进行追加即可;
  • 当容量不足时,进行append操作,会发生扩容,此时的数组地址发生变化,cap变为原来的2倍(不完全是2倍,可能会因为字节对齐的原因在2倍的基础上略有偏差);
  • cap不足时追加多个数据扩容2倍后仍然cap不足时,会继续扩容直到cap>len;(此时不是整倍的扩容了);

33.golang 的 GC 如何处理 unsafe.Pointer

问题 1:如果一个对象只被 unsafe.Pointer 所指向,那么这个对象会被回收么?

回答 1:不会。如果 unsafe.Pointer 指向了一个对象,那么 go 的 GC 会知道有这个对象,并且不会释放这个对象的内存。

但是注意,有一个例外:如果这个对象的内存是在 go 外被分配的(比如 C.malloc),那么以上的规则不生效。


问题 2:如果这个对象内部也有一些指针,那么 GC 会如何处理这些指针?

回答 2:如果这个对象是在 go 内部分配的,那么 GC 也会遍历这些指针(也就是不会被释放)。


问题 3:如果在以上两个问题中,对象都不会被释放,那么 GC 是怎么处理的?unsafe.Pointer 会存对象的类型信息么?

回答 3:不会存类型信息,但是如果对象是在 go 中申请的,那么在对应的内存中是会存有类型信息的;如果没有类型信息,那么 GC 会采用非常保守的策略:遍历整个对象,只要其中有 8bit 的值是合法的内存地址(在栈范围内,或者在堆上),就认为是指针,不会进行回收。


问题 4:有没有一种情况 unsafe.Pointer 会变成非法的(野指针)?

回答 4:在 go 中,只要 unsafe.Pointer 有一刻是合法的,并且它的值没有修改,那么 go 会保证它在整个程序的生命周期中都是合法的。在 unsafe.Pointerunsafe.Pointer 间的赋值一定是安全的,但是间接的赋值(比如同过 uintptr)可能是非法的,因为 uintptr 不被认为持有了对象。


go 会忽视所有非 go 分配的对象(比如 C.malloc),所以如果在 C 中有一个指针指向的地址包含了 go 的对象,那么必须保证这个指针在 go 中也被一个对象存储下来。

34.Go map 做 GC

在 Golang 中的 map 结构,在删除键值对的时候,并不会真正的删除,而是标记。那么随着键值对越来越多,会不会造成大量内存浪费?

首先答案是会的,很有可能导致 OOM,而且针对这个还有一个讨论:github.com/golang/go/i…。大致的意思就是在很大的 map 中,delete 操作没有真正释放内存而可能导致内存 OOM。

所以一般的做法:就是 重建map 。而 go-zero 中内置了 safemap 的容器组件。safemap 在一定程度上可以避免这种情况发生。

那首先我们看看 go 原生提供的 map 是怎么删除的?

原生map删除

1  package main
2
3  func main() {
4      m := make(map[int]string, 9)
5      m[1] = "hello"
6      m[2] = "world"
7      m[3] = "go"
8
9      v, ok := m[1]
10     _, _ = fn(v, ok)
11
12     delete(m, 1)
13  }
14
15 func fn(v string, ok bool) (string, bool) {
16     return v, ok
17 }

测试代码如上,我们可以通过 go tool compile -S -N -l testmap.go | grep "CALL"

0x0071 00113 (test/testmap.go:4)        CALL    runtime.makemap(SB)
0x0099 00153 (test/testmap.go:5)        CALL    runtime.mapassign_fast64(SB)
0x00ea 00234 (test/testmap.go:6)        CALL    runtime.mapassign_fast64(SB)
0x013b 00315 (test/testmap.go:7)        CALL    runtime.mapassign_fast64(SB)
0x0194 00404 (test/testmap.go:9)        CALL    runtime.mapaccess2_fast64(SB)
0x01f1 00497 (test/testmap.go:10)       CALL    "".fn(SB)
0x0214 00532 (test/testmap.go:12)       CALL    runtime.mapdelete_fast64(SB)
0x0230 00560 (test/testmap.go:7)        CALL    runtime.gcWriteBarrier(SB)
0x0241 00577 (test/testmap.go:6)        CALL    runtime.gcWriteBarrier(SB)
0x0252 00594 (test/testmap.go:5)        CALL    runtime.gcWriteBarrier(SB)
0x025c 00604 (test/testmap.go:3)        CALL    runtime.morestack_noctxt(SB)

执行第12行的 delete,实际执行的是 runtime.mapdelete_fast64

这些函数的参数类型是具体的 int64mapdelete_fast64 跟原始的 delete 操作一样的,所以我们来看看 mapdelete

mapdelete

长图预警!!!

大致代码分析如上,具体代码就留给大家去阅读了。其实大致过程:

  1. 写保护,防止并发写
  2. 查询要删除的 key 是否存在
  3. 存在则对其标志做删除标记
  4. count--

所以你在大面积删除 key ,实际 map 存储的 key 是不会删除的,只是标记当前的key状态为 empty

其实出发点,和 mysql 的标记删除类似,防止后续会有相同的 key 插入,省去了扩缩容的操作。

但是这个对有些场景是不妥的,如果开发者在未来时间内都不会再插入相同的 key ,很可能会导致 OOM

所以针对以上情况,go-zero 开发了 safemap 。下面我们看看 safemap 是如何避免这个问题的?

safemap

直接从操作 safemap 中分析为什么要这么设计:

  1. 预设一个 删除阈值 ,如果触发会放到一个新预设好的 newmap
  2. 两个 map 是一个整体,所以 key 只能留一份

所以为什么要设置两个 map 就很清楚了:

  1. dirtyOld 作为存储主体,如果 delete 操作达到阈值,则会触发迁移。
  2. dirtyNew 作为暂存体,会在到达阈值时,存放部分 key/value

所以在迁移操作时,我们需要做的就是: 将原先的 dirtyOld 清空,存储的 key/value 通过 for-range 重新存储到 dirtyNew,然后将 dirtyNew 指向 dirtyOld

可能会有疑问:不是说 key/value 没有删除吗,只是标记了 tophash=empty

其实在 for-range 过程中,会过滤掉 tophash <= emptyOne 的 key

这样就实现了不需要的 key 不会被加入到 dirtyNew,进而不会影响 dirtyOld

这其实也就是垃圾回收的年老代和新生代的概念。

35.map的底层实现原理

Go中的map是一个指针,占用8个字节,指向hmap结构体; 源码src/runtime/map.go中可以看到map的底层结构

每个map的底层结构是hmap,hmap包含若干个结构为bmap的bucket数组。每个bucket底层都采用链表结构。接下来,我们来详细看下map的结构

hmap结构体:

// A header for a Go map.
type hmap struct {
    count     int 
    // 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
    flags     uint8 
    // 状态标志,下文常量中会解释四种状态位含义。
    B         uint8  
    // buckets(桶)的对数log_2
    // 如果B=5,则buckets数组的长度 = 2^5=32,意味着有32个桶
    noverflow uint16 
    // 溢出桶的大概数量
    hash0     uint32 
    // 哈希种子

    buckets    unsafe.Pointer 
    // 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
    oldbuckets unsafe.Pointer 
    // 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
    nevacuate  uintptr  
    // 表示扩容进度,小于此地址的buckets代表已搬迁完成。

    extra *mapextra 
    // 这个字段是为了优化GC扫描而设计的。当key和value均不包含指针,并且都可以inline时使用。extra是指向mapextra类型的指针。
 }

bmap结构体

bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和插入中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)

// A bucket for a Go map.
type bmap struct {
    tophash [bucketCnt]uint8  
    // len为8的数组
    // 用来快速定位key是否在这个bmap中
    // 桶的槽位数组,一个桶最多8个槽位,如果key所在的槽位在tophash中,则代表该key在这个桶中
}
//底层定义的常量 
const (
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
    // 一个桶最多8个位置
)

但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:

type bmap struct {
  topbits  [8]uint8
  keys     [8]keytype
  values   [8]valuetype
  pad      uintptr
  overflow uintptr
  // 溢出桶
}

bucket内存数据结构可视化如下:

注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding字段,节省内存空间。

mapextra结构体

当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来

// mapextra holds fields that are not present on all maps.
type mapextra struct {
    // 如果 key 和 value 都不包含指针,并且可以被 inline(<=128 字节)
    // 就使用 hmap的extra字段 来存储 overflow buckets,这样可以避免 GC 扫描整个 map
    // 然而 bmap.overflow 也是个指针。这时候我们只能把这些 overflow 的指针
    // 都放在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中了
    // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
    // oldoverflow 包含扩容时的 hmap.oldbuckets 的 overflow 的 bucket
    overflow    *[]*bmap
    oldoverflow *[]*bmap

		nextOverflow *bmap
	// 指向空闲的 overflow bucket 的指针
}

主要特性

引用类型

map是个指针,底层指向hmap,所以是个引用类型

golang 有三个常用的高级类型 slice 、map、channel, 它们都是 引用类型 ,当引用类型作为函数参数时,可能会修改原内容数据。

golang 中没有引用传递,只有值和指针传递。所以 map 作为函数实参传递时本质上也是值传递,只不过因为 map 底层数据结构是通过指针指向实际的元素存储空间,在被调函数中修改 map,对调用者同样可见,所以 map 作为函数实参传递时表现出了引用传递的效果。

因此,传递 map 时,如果想修改map的内容而不是map本身,函数形参无需使用指针。

func TestSliceFn(t *testing.T) {
	m := map[string]int{}
	t.Log(m, len(m))
	// map[a:1]
	mapAppend(m, "b", 2)
	t.Log(m, len(m))
	// map[a:1 b:2] 2
}

func mapAppend(m map[string]int, key string, val int) {
	m[key] = val
}

共享存储空间

map 底层数据结构是通过指针指向实际的元素存储空间 ,这种情况下,对其中一个map的更改,会影响到其他map

func TestMapShareMemory(t *testing.T) {
	m1 := map[string]int{}
	m2 := m1
	m1["a"] = 1
	t.Log(m1, len(m1))
	// map[a:1] 1
	t.Log(m2, len(m2))
	// map[a:1]
}

遍历顺序随机

map 在没有被修改的情况下,使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同。这是 Go 语言的设计者们有意为之,在每次 range 时的顺序被随机化,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序。

map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map

func TestMapRange(t *testing.T) {
	m := map[int]string{1: "a", 2: "b", 3: "c"}
	t.Log("first range:")
	// 默认无序遍历
	for i, v := range m {
		t.Logf("m[%v]=%v ", i, v)
	}
	t.Log("\nsecond range:")
	for i, v := range m {
		t.Logf("m[%v]=%v ", i, v)
	}

	// 实现有序遍历
	var sl []int
	// 把 key 单独取出放到切片
	for k := range m {
		sl = append(sl, k)
	}
	// 排序切片
	sort.Ints(sl)
	// 以切片中的 key 顺序遍历 map 就是有序的了
	for _, k := range sl {
		t.Log(k, m[k])
	}
}

非线程安全

map默认是并发不安全的,原因如下:

Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。

场景: 2个协程同时读和写,以下程序会出现致命错误:fatal error: concurrent map writes

func main() {

m := make(map[int]int)
go func() {
//开一个协程写map
for i := 0; i < 10000; i++ {

m[i] = i
}
}()

go func() {
//开一个协程读map
for i := 0; i < 10000; i++ {

fmt.Println(m[i])
}
}()

//time.Sleep(time.Second * 20)
for {

;
}
}
如果想实现map线程安全,有两种方式:

方式一:使用读写锁 map + sync.RWMutex

func BenchmarkMapConcurrencySafeByMutex(b *testing.B) {
	var lock sync.Mutex //互斥锁
	m := make(map[int]int, 0)
	var wg sync.WaitGroup
	for i := 0; i < b.N; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			lock.Lock()
			defer lock.Unlock()
			m[i] = i
		}(i)
	}
	wg.Wait()
	b.Log(len(m), b.N)
}

方式二:使用golang提供的 sync.Map

sync.map是用读写分离实现的,其思想是空间换时间。和map+RWLock的实现方式相比,它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作write map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。

func BenchmarkMapConcurrencySafeBySyncMap(b *testing.B) 
{
var m sync.Map
var wg sync.WaitGroup
for i := 0; i < b.N; i++ {		wg.Add(1)
go func(i int) {			defer wg.Done()			m.Store(i, i)
}(i)
}
wg.Wait()
b.Log(b.N)}

哈希冲突

golang中map是一个kv对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。

常用操作

创建

map有3钟初始化方式,一般通过make方式创建

func TestMapInit(t *testing.T) {	// 初始化方式1:直接声明	// var m1 map[string]int	// m1["a"] = 1	// t.Log(m1, unsafe.Sizeof(m1))	// panic: assignment to entry in nil map	// 向 map 写入要非常小心,因为向未初始化的 map(值为 nil)写入会引发 panic,所以向 map 写入时需先进行判空操作	// 初始化方式2:使用字面量	m2 := map[string]int{}	m2["a"] = 2	t.Log(m2, unsafe.Sizeof(m2))	// map[a:2] 8	// 初始化方式3:使用make创建	m3 := make(map[string]int)	m3["a"] = 3	t.Log(m3, unsafe.Sizeof(m3))	// map[a:3] 8}

map的创建通过生成汇编码可以知道,make创建map时调用的底层函数是runtime.makemap。如果你的map初始容量小于等于8会发现走的是runtime.fastrand是因为容量小于8时不需要生成多个桶,一个桶的容量就可以满足

创建流程

makemap函数会通过 fastrand 创建一个随机的哈希种子,然后根据传入的 hint 计算出需要的最小需要的桶的数量,最后再使用 makeBucketArray创建用于保存桶的数组,这个方法其实就是根据传入的 B 计算出的需要创建的桶数量在内存中分配一片连续的空间用于存储数据,在创建桶的过程中还会额外创建一些用于保存溢出数据的桶,数量是 2^(B-4) 个。初始化完成返回hmap指针。

计算B的初始值

找到一个 B,使得 map 的装载因子在正常范围内

B := uint8(0)for overLoadFactor(hint, B) {	B++}h.B = B// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.func overLoadFactor(count int, B uint8) bool {	return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)}

查找

Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。

// 不带 comma 用法value := m["name"]fmt.Printf("value:%s", value)// 带 comma 用法value, ok := m["name"]if ok {    fmt.Printf("value:%s", value)}

map的查找通过生成汇编码可以知道,根据 key 的不同类型,编译器会将查找函数用更具体的函数替换,以优化效率:

key 类型查找
uint32mapaccess1_fast32(tmaptype, hhmap, key uint32) unsafe.Pointer
uint32mapaccess2_fast32(tmaptype, hhmap, key uint32) (unsafe.Pointer, bool)
uint64mapaccess1_fast64(tmaptype, hhmap, key uint64) unsafe.Pointer
uint64mapaccess2_fast64(tmaptype, hhmap, key uint64) (unsafe.Pointer, bool)
stringmapaccess1_faststr(tmaptype, hhmap, ky string) unsafe.Pointer
stringmapaccess2_faststr(tmaptype, hhmap, ky string) (unsafe.Pointer, bool)

查找流程

写保护监测

函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序 panic。这也说明了 map 对协程是不安全的

if h.flags&hashWriting != 0 {	throw("concurrent map read and map write")}

计算hash值

hash := t.hasher(noescape(unsafe.Pointer(&ky)), uintptr(h.hash0))

key经过哈希函数计算后,得到的哈希值如下(主流64位机下共 64 个 bit 位)

 10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

找到hash对应的bucket

m: 桶的个数

从buckets 通过 hash & m 得到对应的bucket,如果bucket正在扩容,并且没有扩容完成,则从oldbuckets得到对应的bucket

m := bucketMask(h.B)b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))// m个桶对应B个位if c := h.oldbuckets; c != nil {  if !h.sameSizeGrow() {  	// 扩容前m是之前的一半  	m >>= 1  }  oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))  if !evacuated(oldb) {	  b = oldb	}}

计算hash所在桶编号:

用上一步哈希值最后的 5 个 bit 位,也就是 01010,值为 10,也就是 10 号桶(范围是0~31号桶)

遍历bucket

计算hash所在的槽位:

top := tophash(hash)func tophash(hash uintptr) uint8 {	top := uint8(hash >> (goarch.PtrSize*8 - 8))	if top < minTopHash {		top += minTopHash	}	return top}

用上一步哈希值哈希值的高8个bit 位,也就是10010111,转化为十进制,也就是151,在 10 号 bucket 中寻找** tophash 值(HOB hash)为 151* 的 槽位**,即为key所在位置,找到了 2 号槽位,这样整个查找过程就结束了。

img

如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。

返回key对应的指针

通过上面找到了对应的槽位,这里我们再详细分析下key/value值是如何获取的

// key 定位公式k :=add(unsafe.Pointer(b),dataOffset+i*uintptr(t.keysize))// value 定位公式v:= add(unsafe.Pointer(b),dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))//对于 bmap 起始地址的偏移:dataOffset = unsafe.Offsetof(struct{  b bmap  v int64}{}.v)

bucket 里 key 的起始地址就是 unsafe.Pointer(b)+dataOffset。第 i 个 key 的地址就要在此基础上跨过 i 个 key 的大小;而我们又知道,value 的地址是在所有 key 之后,因此第 i 个 value 的地址还需要加上所有 key 的偏移。

赋值

通过汇编语言可以看到,向 map 中插入或者修改 key,最终调用的是 mapassign 函数。

实际上插入或修改 key 的语法是一样的,只不过前者操作的 key 在 map 中不存在,而后者操作的 key 存在 map 中。

mapassign 有一个系列的函数,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。

key 类型插入
uint32mapassign_fast32(tmaptype, hhmap, key uint32) unsafe.Pointer
uint64mapassign_fast64(tmaptype, hhmap, key uint64) unsafe.Pointer
stringmapassign_faststr(tmaptype, hhmap, ky string) unsafe.Pointer

我们只用研究最一般的赋值函数 mapassign

赋值流程

map的赋值会附带着map的扩容和迁移,map的扩容只是将底层数组扩大了一倍,并没有进行数据的转移,数据的转移是在扩容后逐步进行的,在迁移的过程中每进行一次赋值(access或者delete)会至少做一次迁移工作。

校验和初始化

1.判断map是否为nil

2.判断是否并发读写 map,若是则抛出异常

3.判断 buckets 是否为 nil,若是则调用 newobject 根据当前 bucket 大小进行分配

迁移

每一次进行赋值/删除操作时,只要oldbuckets != nil 则认为正在扩容,会做一次迁移工作,下面会详细说下迁移过程

查找&更新

根据上面查找过程,查找key所在位置,如果找到则更新,没找到则找空位插入即可

扩容

经过前面迭代寻找动作,若没有找到可插入的位置,意味着需要扩容进行插入,下面会详细说下扩容过程

删除

通过汇编语言可以看到,向 map 中删除 key,最终调用的是 mapdelete 函数

func mapdelete(t \*maptype, h _hmap, key unsafe.Pointer)

删除的逻辑相对比较简单,大多函数在赋值操作中已经用到过,核心还是找到 key 的具体位置。寻找过程都是类似的,在 bucket 中挨个 cell 寻找。找到对应位置后,对 key 或者 value 进行“清零”操作,将 count 值减 1,将对应位置的 tophash 值置成 Empty

e := add(unsafe.Pointer(b), dataOffset+bucketCnt*2*goarch.PtrSize+i*uintptr(t.elemsize))if t.elem.ptrdata != 0 {	memclrHasPointers(e, t.elem.size)} else {	memclrNoHeapPointers(e, t.elem.size)}b.tophash[i] = emptyOne

扩容

扩容时机

再来说触发 map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {		hashGrow(t, h)		goto again // Growing the table invalidates everything, so try again	}

1、装载因子超过阈值

源码里定义的阈值是 6.5 (loadFactorNum/loadFactorDen),是经过测试后取出的一个比较合理的因子

我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。

对于条件 1,元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。新 bucket 只是最大数量变为原来最大数量的 2 倍(2^B * 2) 。

2、overflow 的 bucket 数量过多

在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)

不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触发第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难

对于条件 2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。

扩容函数

func hashGrow(t *maptype, h *hmap) {	bigger := uint8(1)	if !overLoadFactor(h.count+1, h.B) {		bigger = 0		h.flags |= sameSizeGrow	}	oldbuckets := h.buckets	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)	flags := h.flags &^ (iterator | oldIterator)	if h.flags&iterator != 0 {		flags |= oldIterator	}	// commit the grow (atomic wrt gc)	h.B += bigger	h.flags = flags	h.oldbuckets = oldbuckets	h.buckets = newbuckets	h.nevacuate = 0	h.noverflow = 0	if h.extra != nil && h.extra.overflow != nil {		// Promote current overflow buckets to the old generation.		if h.extra.oldoverflow != nil {			throw("oldoverflow is not nil")		}		h.extra.oldoverflow = h.extra.overflow		h.extra.overflow = nil	}	if nextOverflow != nil {		if h.extra == nil {			h.extra = new(mapextra)		}		h.extra.nextOverflow = nextOverflow	}	// the actual copying of the hash table data is done incrementally	// by growWork() and evacuate().}

由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。

上面说的 hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。

迁移

迁移时机

如果未迁移完毕,赋值/删除的时候,扩容完毕后(预分配内存),不会马上就进行迁移。而是采取增量扩容的方式,当有访问到具体 bukcet 时,才会逐渐的进行迁移(将 oldbucket 迁移到 bucket)

if h.growing() {		growWork(t, h, bucket)}

迁移函数

func growWork(t *maptype, h *hmap, bucket uintptr) {	// 首先把需要操作的bucket 搬迁	evacuate(t, h, bucket&h.oldbucketmask())	 // 再顺带搬迁一个bucket	if h.growing() {		evacuate(t, h, h.nevacuate)	}}

nevacuate 标识的是当前的进度,如果都搬迁完,应该和2^B的长度是一样的

在evacuate 方法实现是把这个位置对应的bucket,以及其冲突链上的数据都转移到新的buckets上。

  1. 先要判断当前bucket是不是已经转移。 (oldbucket 标识需要搬迁的bucket 对应的位置)
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))// 判断if !evacuated(b) {  // 做转移操作}

转移的判断直接通过tophash 就可以,判断tophash中第一个hash值即可

func evacuated(b *bmap) bool {  h := b.tophash[0]  // 这个区间的flag 均是已被转移  return h > emptyOne && h < minTopHash // 1 ~ 5}
  1. 如果没有被转移,那就要迁移数据了。数据迁移时,可能是迁移到大小相同的buckets上,也可能迁移到2倍大的buckets上。这里xy 都是标记目标迁移位置的标记:x 标识的是迁移到相同的位置,y 标识的是迁移到2倍大的位置上。我们先看下目标位置的确定

    var xy [2]evacDstx := &xy[0]x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))x.k = add(unsafe.Pointer(x.b), dataOffset)x.v = add(x.k, bucketCnt*uintptr(t.keysize))if !h.sameSizeGrow() {  // 如果是2倍的大小,就得算一次 y 的值  y := &xy[1]  y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))  y.k = add(unsafe.Pointer(y.b), dataOffset)  y.v = add(y.k, bucketCnt*uintptr(t.keysize))}
    
    
    
    
    
  2. 确定bucket位置后,需要按照kv 一条一条做迁移。

4.如果当前搬迁的bucket 和 总体搬迁的bucket的位置是一样的,我们需要更新总体进度的标记 nevacuate

// newbit 是oldbuckets 的长度,也是nevacuate 的重点func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {  // 首先更新标记  h.nevacuate++  // 最多查看2^10 个bucket  stop := h.nevacuate + 1024  if stop > newbit {    stop = newbit  }  // 如果没有搬迁就停止了,等下次搬迁  for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {    h.nevacuate++  }  // 如果都已经搬迁完了,oldbukets 完全搬迁成功,清空oldbuckets  if h.nevacuate == newbit {    h.oldbuckets = nil    if h.extra != nil {      h.extra.oldoverflow = nil    }    h.flags &^= sameSizeGrow  }

遍历

遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。

map遍历是无序的,如果想实现有序遍历,可以先对key进行排序

为什么遍历 map 是无序的?

如果发生过迁移,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。

如果就一个写死的 map,不会向 map 进行插入删除的操作,按理说每次遍历这样的 map 都会返回一个固定顺序的 key/value 序列吧。但是 Go 杜绝了这种做法,因为这样会给新手程序员带来误解,以为这是一定会发生的事情,在某些情况下,可能会酿成大错。

Go 做得更绝,当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个**随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell **开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了

//runtime.mapiterinit 遍历时选用初始桶的函数func mapiterinit(t *maptype, h *hmap, it *hiter) {  ...  it.t = t  it.h = h  it.B = h.B  it.buckets = h.buckets  if t.bucket.kind&kindNoPointers != 0 {    h.createOverflow()    it.overflow = h.extra.overflow    it.oldoverflow = h.extra.oldoverflow  }  r := uintptr(fastrand())  if h.B > 31-bucketCntBits {    r += uintptr(fastrand()) << 31  }  it.startBucket = r & bucketMask(h.B)  it.offset = uint8(r >> h.B & (bucketCnt - 1))  it.bucket = it.startBucket    ...  mapiternext(it)}

总结

  1. map是引用类型
  2. map遍历是无序的
  3. map是非线程安全的
  4. map的哈希冲突解决方式是链表法
  5. map的扩容不是一定会新增空间,也有可能是只是做了内存整理
  6. map的迁移是逐步进行的,在每次赋值时,会做至少一次迁移工作
  7. map中删除key,有可能导致出现很多空的kv,这会导致迁移操作,如果可以避免,尽量避免

36.Mutex

https://juejin.cn/post/7032457568433733639

37.RWMutex

https://juejin.cn/post/7035183181393297422

38.defer

https://juejin.cn/post/7027394017688027172

39.golang数组和切片区别

在Go(也称为Golang)编程语言中,数组和切片是两种不同的数据结构,它们之间有一些重要的区别:

  1. 大小和可变性:
    • 数组(Array):在Go中,数组是一种固定大小的数据结构,一旦创建,其大小就不能更改。数组的大小是其类型的一部分,因此不同大小的数组被认为是不同的类型。例如,[5]int[10]int是两种不同的数组类型。
    • 切片(Slice):切片是一种动态大小的数据结构,可以根据需要动态增加或减小大小。切片是对数组的引用,它们不包含自己的数据,而是指向底层数组的一部分。切片的大小可以在运行时更改,因此它们通常更灵活。
  2. 值传递和引用传递:
    • 数组是值类型(value type),当将数组传递给函数或赋值给其他变量时,会复制整个数组的内容。这意味着在函数内部对数组的修改不会影响原始数组。
    • 切片是引用类型(reference type),当将切片传递给函数或赋值给其他变量时,实际上是传递了一个指向底层数组的引用。因此,对切片的修改会影响原始切片和底层数组。
  3. 使用场景:
    • 数组通常用于固定大小的数据集合,例如存储一组特定数量的元素,这些元素类型相同且在运行时不会更改。
    • 切片通常用于处理可变大小的数据集合,例如在需要动态添加或删除元素的情况下。切片还常用于对数组的某个子集进行操作,或者将多个切片连接成一个新的切片。

Go面试题new.pdf.zip


标题:golang
作者:hanmoonhan
地址:https://www.hanmoonhan.club/articles/2023/04/21/1682044199070.html