Golang基础知识与常见问题

数据结构

Slice

slice结构

GO切片是在数组之上的抽象数据结构类型,数组类型定义了长度和元素类型。

例如,[4]int类型标识一个四个整数的数组数组的长度是固定的,长度是数组类型的一部分。数组以常规的索引方式访问,不需要显示初始化。数组的零值为0

切片的写法是[]T,T是切片元素的类型。与数组不同,切片没有给固定长度。而切片slice的长度与容量不固定,指针指向底层数组

切片可以使用内置函数make创建。函数make接受一个类型,一个长度,一个可选的容量参数。调用make时,内部会分配一个数组,然后返回数组对应的切片,大概容量被忽略时,它默认为指定的长度。切片的零值为nil。对于切片的零值,len和cap都将返回0。下图为切片的数据结构

在这里插入图片描述
在这里插入图片描述

slice扩容

在Go语言中使用append()函数向Slice添加元素,扩容也是发生在append的调用中,当切片内部的容量,不足以容纳新增元素时就会触发Slice的扩容。

1.18前的slice扩容操作

  1. 计算新容量,将旧容量(old cap)扩大为原来的二倍,得出double cap
  2. 将double cap与容纳切片所需要的容量比较,若double cap小于所需容量,新容量·new cap = 所需容量
  3. 若double cap大于所需容量,比较 old cap 长度是否大于1024,若小于1024,则new cap=double cap
  4. 若 old cap 长度大于1024,则循环求出大于切片所需要的容量的1.25倍容量new cap
  5. 判断new cap是否溢出,若溢出 new cap=切片所需要的容量
    在这里插入图片描述

1.18后的slice扩容操作

golang 1.18版本前的slice扩容,当old cap长度大于1024,扩容系数直接由原来的2倍变为1.25,不够平滑。

所以新版本的扩容修改点主要集中在old cap长度大于1024 后的扩容逻辑

  • 修改原来1024的阈值为threshold常量,threshold定义为256
  • 大于阈值后new cap的计算逻辑由原来的old cap +old cap/4 变为old cap+ (old cap+3*threshold)/4

官方源码如下在这里插入图片描述

slice与数组操作

1.数组长度固定,若通过参数传递的方式讲数组传给函数,在函数内对数组的修改不会映射到函数外

func test(a [2]int) {
	a[0] = 200
}

func main() {
	a := [2]int{1, 2}
	test(a)
	fmt.Println(a)
}

上述程序正确的输出是 [1 2],数组 a 没有发生改变。
在 Go 语言中,数组是一种值类型,而且不同长度的数组属于不同的类型。例如 [2]int 和 [20]int 属于不同的类型。
当值类型作为参数传递时,参数是该值的一个拷贝,因此更改拷贝的值并不会影响原值,即不会映射到此修改函数之外

2.将切片slice通过参数传递的方式讲数组传给函数,在函数内向切片append一个元素,无法映射到函数外

func test(a []int) {
	a = append(a, 1, 2, 3, 4, 5, 6, 7, 8)
	a[0] = 200
}

func main() {
	a := []int{1, 2}
	test(a)
	fmt.Println(a)
}

上述程序输出仍是 [1 2],切片 a 没有发生改变。

表示长度的len和容量的cap是int类型,那么在传递到函数内部就是一个副本而已,即无法在函数内部通过append修改len的值影响到函数外部len的值,传参时拷贝了新的切片,因此当新切片的长度发生改变时,原切片并不会发生改变。

在函数 test中,新切片 a 增加了 8 个元素,原切片对应的底层数组不够放置这 8 个元素,因此申请了新的空间来放置扩充后的底层数组。这个时候新切片和原切片指向的底层数组就不是同一个了。因此,对新切片第 0 个元素的修改,并不会影响原切片的第 0 个元素。

3.将切片slice通过参数传递的方式讲数组传给函数,在函数内修改一个切片的已有元素,可以映射到函数外

func TestSlice(t *testing.T) {
    s := []int{1, 2, 3, 4}
	changeSlice(s)
	fmt.Println(s)
}

func changeSlice(s []int) {
   s[0] = 4}

上述程序输出仍是 [4 2,3,4],切片 s发生改变。在这里插入图片描述
slice结构中包含数组的数据,数据部分是指针类型,即在函数内对slice数据的修改是可以生效的,因为值传递进去的就是指向数据的指针,所以在函数内修改一个切片的已有元素,可以映射到函数外

map

map数据结构

在golang中,通过make关键字初始化map

make(map[int]int)

map的底层结构是一个指向hamp的指针,hmap结构定义在golang runtime包下,其结构如下
在这里插入图片描述

  • count :代表键值对数量
  • hash0 :hash种子
  • noverflow:溢出桶的近似数量
  • buckets:桶,bamp结构
  • oldbuckets:旧桶
  • neacuate:即将迁移的旧桶编号

bamp桶结构

bmap是我们说的“桶”,bmap包含一个类型为uint8的数组。bmap结构定义如下,
在这里插入图片描述
buckCnt为常量定义,其值为8,具体定义如下
在这里插入图片描述
从结构定义中可以看出,一个桶只能存储8个键值对。为保证内存的分布紧凑,key都放一起,v都放一起。每个桶的keys前,都有8个tophash对应hash值的高8位。

每个桶还有一个overflow指针,指向一个溢出桶。溢出桶和常规桶在内存上是连续的,数据结构也完全相同。主要用来减少扩容操作。

当map的key和value都不是指针,并且size都小于128字节的情况下,会把bmap标记为不含指针,这样可以避免gc时扫描整个hmap
在这里插入图片描述

map扩容机制

map的扩容采用渐进式扩容。扩容的新桶和旧桶分别以指针的形式保存在hmap中,分别对应buckets和old buckets。

在扩容时在内存开辟新的地址。然后把新老地址记录到hmap。在插入或修改、删除 key 的时候,尝试分批分量进行桶的迁移

首先要明确转装载因子的概念

  • 装载因子=填入表中的元素格数/散列表长度,装载因子越大,说明空闲位置越少,冲突越多,散列表性能下降(表明很多bucket都很快要装满了,查找效率和插入效率都变低了,所以扩容是有必要的)

在了解装载因子概念后,有两种情况需要扩容,分别为

  1. 装载因子超过阈值,源码定义阈值为6.5 翻倍扩容(会创建新的桶数组,规模是之前数组的二倍,旧桶会逐渐被复制到新桶)
  2. noverflow的bucket数量过多 等量扩容

对于上述两种情况,其扩容策略不同,对应的扩容策略:

  • 对于条件1来说,元素过多,bucket太少。将B加1,bucket的数量(2^B)直接变为原来的二倍。于是就有了新老bucket。这是元素都在旧的bucket中,还没有迁移到新的bucket中,新bucket只是最大数量变为原来最大数量的2倍
  • 对于条件2来说,元素不多,只是noverflow bucket特别多,说明bucket都没填满。办法是开辟新的bucket空间,将老bucket中的元素移到新的bucket中,使得同一个bucket中的key排列更紧密。这样,在overflowbucket中的key可以移动到bucket中来,结果是节省空间,提高bucket利用率,map的查找和插入效率自然提升

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

hashGrow()函数实际上并没有真正的“搬迁”,他只是分配好了新的buckets,并将老buckets挂到了oldbuckets字段。
在这里插入图片描述

真正搬迁buckets的动作在growWork()函数中
在这里插入图片描述

调用growWork()函数的动作在mapassign和mapdelete函数中。也就是插入或修改,删除key的时候,都会进行尝试buckets的工作。先检查oldbuckets是否搬迁完毕,具体就是检查oldbuckets是否是nil
在这里插入图片描述

遍历map

遍历map的每次结果顺序不固定

当遍历map时,并不是固定从0号bucket开始遍历,每次都是从一个随机值序号的bucket开始遍历的,并且是从这个bucket的一个随机序号的cell开始遍历,这样,当遍历时不会返回一个固定序列的key/value

channel

channel结构

goroutine之间通信通常通过channel通信,数据模型如下:
在这里插入图片描述

在这里插入图片描述

  • 用来保存goroutine之间传递数据的循环链表。=====> buf
  • 用来记录此循环链表当前发送或接收数据的下标值。=====> sendx和recvx
  • 用于保存向该chan发送和从chan接收数据的goroutine的队列。=====> sendq 和 recvq
  • 保证channel写入和读取数据时线程安全的锁。 =====> lock

等待队列

从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞,保存在recvq队列中。因读阻塞的goroutine会被向channel写入数据的goroutine唤醒

向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞,保存在sendq队列中。因写阻塞的goroutine会被从channel读数据的goroutine唤醒

从channel读数据流程

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出goroutine,读取goroutine中的数据,把此goroutine唤醒,结束读取流程
  2. 如果等待发送队列sendq不为空,并且存在缓冲区,那么说明此时缓冲区已经满了,则直接从缓冲区首部读取数据,将等待发送队列sendq中的goroutine取出,将goroutine中的数据写到缓冲区尾部,把goroutine唤醒,结束读取流程
  3. 如果等待发送队列sendq为空,并且缓存区有数据,那么直接从缓冲区读取数据即可,结束读取流程
  4. 如果等待发送队列sendq为空,并且缓存区没有数据,那么当前读数据的goroutine加入到等待队列recvq中,进入休眠,等待被写goroutine唤醒

向channel写数据流程

  1. 如果等待接受队列recvq不为空,说明无缓冲区或者缓冲区无数据,那么直接从等待队列recvq中取出goroutine,并且将数据写给goroutine,将该goroutine唤醒,结束发送流程
  2. 如果缓冲区存在缓冲区并且缓冲区有空余,那么将数据写入到缓冲区,结束写入流程
  3. 如果缓冲区没有空余或者不存在缓冲区,将待写入数据写入到goroutine,并将goroutine加入到等待队列sendq中,进入休眠,等待读goroutine唤醒

sync.map

golang中map的线程是不安全的。同时并发读写会报错。所以官方在1.9版本之后,引入了sync.map。它自带一把互斥锁,支持并发读写

结构定义

sync包下的map结构定义如下
在这里插入图片描述
read map & dirty map

read map 和 dirty map 的存储方式是不一致的——前者使用 atomic.Value,后者只是单纯的使用 map。其实,这样的原因是 read map 是一个给 lock free 操作使用的数据结构,必须保证 load/store 的原子性,而 dirty map 的 load+store 操作是由 lock (就是 mu)来保护的

两个结构里面存储的是entry结构,存储entry结构的原因

  • 删除操作不是线程安全的,所以为entry增加两个状态,nil和expunged
  • read map 和 dirty map 中含有相同的一部分 entry,我们称作是 normal entries,是双方共享的

dirty map 是用来在无法进行 lock free 操作的情况下,需要 lock 来做一些更新工作的对象。read map 是用来进行 lock free 操作的

读写流程

  • 通过 read 和 dirty 两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上
  • 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty 读取 read 并不需要加锁,而读或写 dirty 都需要加锁
  • misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty数据同步到 read 上
  • 对于删除数据则直接通过标记来延迟删除

在这里插入图片描述

不适用的场景

不适用于大量写的场景,这样会导致read map读不到数据而进一步加锁读取,同时dirty map也会一直晋升为read map,整体性能较差。

sync.once

数据结构定义

type Once struct {
    done uint32  // 保证变量仅被初始化一次,需要有个标志来判断变量是否已初始化过,若没有则需要初始化
    m    Mutex  // 线程安全,支持并发,无疑需要互斥锁来实现
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 { 
        o.doSlow(f)  // 原子获取 done 的值,判断 done 的值是否为 0,如果为 0 就调用 doSlow 方法,进行二次检查。
    }
}

func (o *Once) doSlow(f func()) {
    // 二次检查时,持有互斥锁,保证只有一个 goroutine 执行。
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
      // 二次检查,如果 done 的值仍为 0,则认为是第一次执行,执行参数 f,并将 done 的值设置为 1。
      defer atomic.StoreUint32(&o.done, 1)
      f()
    }
}

defer计数

if o.done == 0 {
    defer atomic.StoreUint32(&o.done, 1)
    f()
}

如果不用 defer ,当 f() 执行的时候出现 panic 的时候(被外层 recover,进程没挂),会导致没有 o.done 加计数,但其实 f() 已经执行过了,这就违反语义了。

为什么需要将done 放在第一个字段?

// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.

热路径(hot path)是程序非常频繁执行的一系列指令,sync.Once 绝大部分场景都会访问o.done,在热路径上是比较好理解的,如果 hot path 编译后的机器码指令更少,更直接,必然是能够提升性能的。

为什么放在第一个字段就能够减少指令呢?因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。如果是其他的字段,除了结构体指针外,还需要计算与第一个值的偏移(calculate offset)。在机器码中,偏移量是随指令传递的附加值,CPU 需要做一次偏移值与指针的加法运算,才能获取要访问的值的地址。因为,访问第一个字段的机器代码更紧凑,速度更快。

为什么不用cas 操作检查计数?

官方源码中的解释如下

// Note: Here is an incorrect implementation of Do:
//
// if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the atomic.StoreUint32 must be delayed until after f returns.

sync.cond

在这里插入图片描述

  • Locker ,锁保证数据一致性
  • copyCheck,赋值检查器,检查用户是否发生拷贝行为
  • notifyList,通知列表,本质上是个队列

notifyList

在这里插入图片描述

  • wait为下一个waiter的ticket编号,在没有lock情况下原子自增
  • notify 是下一个被通知的waiter的ticket编号。它可以在没有lock的情况下进行读取,只有在持有lock情况下才能写。
  • head,tail 为waiter列表

当Cond调用Wait方法时,向wait字段加1,并返回一个ticket编号

而后使用这个 ticket 编号来等待通知,这个过程会将等待通知的 goroutine 进行停,进入等待状态, 并将其 M 与 P 解绑,从而将 G 从 M 身上剥离,放入等待队列 中

当调用 Signal 时,会有一个在等待的 goroutine 被通知到,具体过程就是从 列表中找到 要通知的 goroutine,而后将其 goready 来等待调度循环将其调度

WaitGroup

WaitGroup能够一直等到所有的goroutine执行完成,并且阻塞主线程的执行,直到所有的goroutine执行完成

三个方法

  • Add(delta int),添加或者减少等待goroutine的数量
  • Done(),相当于Add(-1)
  • Wait(),执行阻塞,直到所有的WaitGroup数量变成 0

WaitGroup用于线程同步,WaitGroup等待一组线程集合完成,才会继续向下执行。 主线程(goroutine)调用Add来设置等待的线程(goroutine)数量。 然后每个线程(goroutine)运行,并在完成后调用Done。 同时,Wait用来阻塞,直到所有线程(goroutine)完成才会向下执行

GMP

GMP是golang内置的模型,在多goroutine工作上扮演着重要的角色

M结构

一个M代表着一个内核线程,M结构体包含如下字段

  • go,代表一个特殊的goroutine,这个goroutine是GO运行时系统在启动之初创建的,用于执行一些特殊任务

  • curg,存放当前M正在运行的那个G指针

  • p,则指向了当前M相关联的那个P

  • nextp,而字段P的值则会指向当前M相关联的那个

M创建之初,会被加入到全局M队列,此时,它的起始函数和nextp会着设置。调度器为M创建一个KSE并与之关联

P结构

一个P代表着执行一个GO代码片段所需要的的资源(上下文),一个处理器

P是G能够在M中运行的关键。GO中的调度器会适时让P与不同的M建立关联或者断开关联

当M因为调度器调用而阻塞时,调度器把M与关联的P断开。如果这个P的可运行G队列还存在未被运行的G,那么调度器会寻找一个空闲的M,或者创建一个新的M,并与之关联。

p是有状态的,p的部分状态如下

  • pidle:表明当前P未与M关联
  • prunning:此状态表明当前p正在与某个M关联
  • psyscall:此状态表明当前p中的运行的G正在进行调度
  • pdead:此状态表明p不会被使用

G结构

一个G代表着一个GO代码片段。前者是对后者的一个封装

一个G代表一个goroutine,也与go函数对应,使用go语句就是向go的调度器提交了一个并发任务

GO编译器会把go语句编程对内部函数newproc的调用,并把go函数以及参数作为此函数的参数传递给这个函数

  • 调度器在接到这样的调用之后,会先检查go函数以及参数的合法性
  • 试图从本地p的自由G列表和调度器的自由G列表中获取一个可用的G,没有获取到,新建一个G

无论用于封装的go函数的G是否是新建的,调度器都会对他进行初始化,包括关联go函数,设置状态等等

在初始化完成之后,这个G会存储到P的runnext字段,该字段用于存放新的G。如果runnext字段已经有一个G了,那么这个G会被放到可运行G队列的队尾,如果队尾满了,则加入到调度器的可运行G队列

部分状态如下

  • Gidle:表示当前G刚被分配,但还未初始化
  • Grunnable:表示当前G正在可运行队列中等待
  • Grunning:表示当前G正在运行
  • Gsyscall:表示当前G正在习性某个系统调用
  • Gwaiting:表示当前G正在阻塞
  • Gdead:表示当前G的正在闲置

GMP模型架构

在这里插入图片描述

  • 全局队列(Global Queue):存放等待运行的 G。
  • P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  • P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  • M线程想运行任务就得获取 P,从 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

P和M的数量

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 和 M 何时会被创建

P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

GO调度器

在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。

GO调度器的调度流程是基于上述的MPG模型的,基本流程如下

  • 在M与P绑定后,M会不断从P的Local队列(runq)中取出G(无锁操作),切换到G的堆栈并执行
  • 当P的Local队列中没有G时,再从Global队列中返回一个G(有锁操作,因此实际还会从Global队列批量转移一批G到P
    Local队列)
  • 当Global队列中也没有待运行的G时,则尝试从其它的P窃取(steal)部分G来执行
  • 当没有G可被执行时,M会与P解绑,然后进入休眠(idle)状态。

举例说明通过go关键字开启goroutine到执行的一个完整流程

  1. 通过go关键字创建一个goroutine,go语句编程对内部函数newproc的调用,并把go函数封装成G
  2. 将G放到P的本地G队列中
  3. 若P的本地队列已满,则放到全局G队列中
  4. M与P绑定,M获取与之绑定的P本地G队列中的G
  5. 若P的本地G队列为空,则从全局G队列中获取G,若全局G队列为空,则从其他P的本地G队列中窃取G
  6. M获取G之后进行调度,执行go func
  7. 若M在执行G期间陷入syscall,P与M进行解绑,P创建或者寻找一个空闲的M进行绑定,执行本地G队列中剩下的G

用户态阻塞/唤醒

当Goroutine因为Channel操作而阻塞(通过gopark)时,对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G。

当阻塞的G被G2唤醒(通过goready)时(比如channel可读/写),G会尝试加入G2所在P的runnext,然后再是P Local队列和Global队列。

syscall

当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于block on syscall状态,此时仍然可被抢占调度: 执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但队列中仍然有G需要执行,则创建一个新的M。

当系统调用完成后,G会重新尝试获取一个idle的P,并恢复执行,如果没有idle的P,G将加入到Global队列。

系统调用能被调度的关键有两点:

  • runtime/syscall包中,将系统调用分为SysCall和RawSysCall,前者和后者的区别是前者会在系统调用前后分别调用entersyscall和exitsyscall(位于src/runtime/proc.go),做一些现场保存和恢复操作,这样才能使P安全地与M解绑,并在其它M上继续执行其它G。某些系统调用本身可以确定会长时间阻塞(比如锁),会调用entersyscallblock在发起系统调用前直接让P和M解绑(handoffp)。

  • 另一个关键点是sysmon,它负责检查所有系统调用的执行时间,判断是否需要handoffp。

sysmon

  • sysmon是一个由runtime启动的M,也叫监控线程,它无需P也可以运行,它每20us~10ms唤醒一次,主要执行:

  • 释放闲置超过5分钟的span物理内存;

  • 如果超过2分钟没有垃圾回收,强制执行;

  • 将长时间未处理的netpoll结果添加到任务队列;

  • 向长时间运行的G任务发出抢占调度;

  • 收回因syscall长时间阻塞的P;

  • 入口在src/runtime/proc.go:sysmon函数,它通过retake实现对syscall和长时间运行的G进行调度

抢占式调度

当某个goroutine执行超过10ms,sysmon会向其发起抢占调度请求

由于Go调度不像OS调度那样有时间片的概念,因此实际抢占机制要弱很多:Go中的抢占实际上是为G设置抢占标记(g.stackguard0),当G调用某函数时(更确切说,在通过newstack分配函数栈时),被编译器安插的指令会检查这个标记,并且将当前G以runtime.Goched的方式暂停,并加入到全局队列。

G的几种暂停方式

  • gosched:将当前的G暂停,保存堆栈状态,以_GRunnable状态放入Global队列中,让当前M继续执行其它任务。无需对G进行唤醒操作,因为总会有M从Global队列取得并执行该G。抢占调度即使用该方式。

  • gopark: 与goched的最大区别在于gopark没有将G放回执行队列,而是位于某个等待队列中(如channel的waitq,此时G状态为_Gwaitting),因此G必须被手动唤醒(通过goready),否则会丢失任务。应用层阻塞通常使用这种方式。

  • notesleep: 既不让出M,也不让G和P重新调度,直接让线程休眠直到被唤醒(notewakeup),该方式更快,通常用于gcMark,stopm这类自旋场景

  • notesleepg: 阻塞G和M,放飞P,P可以和其它M绑定继续执行,比如可能阻塞的系统调用会主动调用entersyscallblock,则会触发 notesleepg

  • goexit: 立即终止G任务,不管其处于调用堆栈的哪个层次,在终止前,确保所有defer正确执行。

总结

Go 为什么要使用GPM?而不是像大多数调度器一样只有两层关系GM,直接用M(OS线程)的数量来限制并发能力。

我粗浅的理解是为了更好地处理syscall,当某个M陷入系统调用时,P则”抛妻弃子”,与M解绑,让阻塞的M和G等待被OS唤醒,而P则带着local queue剩下的G去找一个(或新建一个)idle的M,当阻塞的M被唤醒时,它会尝试给G找一个新的归宿(idle的P,或扔到global queue,等待被领养)

GC

标记清除

阶段步骤

标记-清除Mark-Sweep算法是最基础的追踪式算法,分为“标记”和“清除”两个步骤:

  • 标记:记录需要回收的垃圾对象
  • 清除:在标记完成后回收垃圾对象的内存空间

优点

  • 简单直接,速度快,适合可回收对象不多的场景
  • 算法吞吐量较高,即运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)较高
  • 空间利用率高:同标记-复制相比不需要额外空间复制对象,也不需要像引用计数算法为每个对象设置引用计数器

缺点

  • 会造成不连续的内存空间(内存碎片),导致有大的对象创建的时候,明明内存中总内存是够的,但是空间不是连续的造成对象无法分配;
  • 清除后会产生大量的内存碎片空间,导致程序在运行时可能没法为较大的对象分配内存空间,导致提前进行下一次垃圾回收

标记整理

标记-整理Mark-Compact算法综合了标记-清除法和标记-复制法的优势,既不会产生内存碎片化的问题,也不会有一半内存空间浪费的问题。该方法首先标记出所有“可达”的对象,然后将存活的对象移动到内存空间的一端,最后清理掉端边界以外的内存。

优点

  • 避免了内存碎片化的问题
  • 在对象存活率较高的情况下,标记-整理算法由于不需要复制对象效率更高,因此更加适合老年代算法

缺点

  • 整理过程较为复杂,需要多次遍历内存导致STW时间比标记-清除算法更长

三色指针

前面提到的“标记”类算法都有一个共同的瑕疵,即在进行垃圾回收的时候会暂停整个程序(STW问题)。三色标记法是对“标记”阶段的改进,在不暂停程序的情况下即可完成对象的可达性分析

GC线程将所有对象分为三类:

  • 白色:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时所有的白色都是垃圾对象

  • 灰色:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描

  • 黑色:已搜索完的对象,所有的引用已经被扫描完

三色标记法属于增量式GC算法,回收器首先将所有的对象着色成白色,然后从GC Root出发,逐步把所有“可达”的对象变成灰色再到黑色,最终所有的白色对象即是“不可达”对象。

步骤阶段

  • 初始时所有对象都是白色对象
  • 从GC Root对象出发,扫描所有可达对象并标记为灰色,放入待处理队列
  • 从队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色放入队列
  • 重复上一步骤,直到灰色对象队列为空
  • 此时所有剩下的白色对象就是垃圾对象

优点

  • 不需要暂停整个程序进行垃圾回收

缺点

  • 如果程序垃圾对象的产生速度大于垃圾对象的回收速度时,可能导致程序中的垃圾对象越来越多而无法及时收集
  • 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量

内存逃逸

堆内存与栈内存

Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间

Go 语言实现垃圾回收(Garbage Collector)机制,因此呢,Go 语言的内存管理是自动的,通常开发者并不需要关心内存分配在栈上,还是堆上

在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。

在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSH 和 POP,一个是将数据 push 到栈空间以完成分配,pop 则是释放空间,也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。

在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。

逃逸分析

Go 语言中,堆内存是通过垃圾回收机制自动管理的,无需开发者指定。那么,Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。

如果变量的内存发生逃逸,它的生命周期就是不可知的,其会被分配到堆上,而堆上分配内存不能像栈一样会自动释放

指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

在这里插入图片描述
函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。

编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况:

在这里插入图片描述
new(Demo) escapes to heap 即表示 new(Demo) 逃逸到堆上了。

interface逃逸

在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

例如上面例子中的局部变量 demo:
在这里插入图片描述
demo 是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println(),但是因为 fmt.Println() 的参数类型定义为 interface{},因此也发生了逃逸。

fmt 包中的 Println 函数的定义如下:
在这里插入图片描述
我们将上面的例子修改为:
在这里插入图片描述
这种情况下,局部变量 demo 不会发生逃逸,但是 demo.name 仍旧会逃逸。

栈空间不足逃逸

操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。
在这里插入图片描述
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。

对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。

对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。我们来做一个实验:
在这里插入图片描述
generate8191() 创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。

generate8192() 创建了大小为 8192 的 int 型切片,恰好占用 64 KB。

generate(n),切片大小不确定,调用时传入。

在这里插入图片描述
make([]int, 8191) 没有发生逃逸,make([]int, 8192) 和make([]int, n) 逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

闭包逃逸

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

在这里插入图片描述
Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

在这里插入图片描述

性能调优

利用逃逸分析提升性能

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

利用死码消除提升性能

死码消除(dead code elimination, DCE)是一种编译器优化技术,用处是在编译阶段去掉对程序运行结果没有任何影响的代码

死码消除有很多好处:减小程序体积,程序运行过程中避免执行无用的指令,缩短运行时间。

使用常量提升性能

在某些场景下,将变量替换为常量,性能会有很大的提升。

在这里插入图片描述
max 是一个非常简单的函数,返回两个值中的较大值。
a 和 b 是两个全局变量,赋值为 10 和 20。
如果 a 大于 b,那么将会调用 time.Sleep() 休眠 3 秒。
拷贝 maxvar.go 为 maxconst.go,并将 var a, b 修改为 const a, b。

在这里插入图片描述
编译 maxvar.go 和 maxconst.go,并比较编译后的二进制大小:

在这里插入图片描述
我们可以看到 maxconst 比 maxvar 体积小了约 10% = 0.22 MB。

我们使用 -gcflags=-m 参数看一下编译器做了哪些优化:
在这里插入图片描述
max 函数被内联了,即被展开了,手动展开后如下:

在这里插入图片描述

那如果 a 和 b 均为常量(const)呢?那在编译阶段就可以直接进行计算:

在这里插入图片描述
计算之后,10 > 20 永远为假,那么分支消除后:

在这里插入图片描述
进一步,20 == 10 也永远为假,再次分支消除

但是如果全局变量 a、b 不为常量,即 maxvar 中声明的一样,编译器并不知道运行过程中 a、b 会不会发生改变,因此不能够进行死码消除,这部分代码被编译到最终的二进制程序中。因此 maxvar 比 maxconst 二进制体积大了约 10%。

如果在 if 语句中,调用了更多的库,死码消除之后,体积差距会更大。

因此,在声明全局变量时,如果能够确定为常量,尽量使用 const 而非 var,这样很多运算在编译器即可执行。死码消除后,既减小了二进制的体积,又可以提高运行时的效率,如果这部分代码是 hot path,那么对性能的提升会更加明显。

sync.pool提升性能

sync.pool主要是用来保存和复用临时对象,减少内存分配,降低 GC 压力。

例如json 的反序列化在文本解析和网络通信过程中非常常见,当程序并发度非常高的情况下,短时间内需要创建大量的临时对象。而这些对象是都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。

sync.Pool 是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。sync.Pool 用于存储那些被分配了但是没有被使用,而未来可能会使用的值。这样就可以不用再次经过内存分配,可直接复用已有对象,减轻 GC 的压力,从而提升系统的性能。

sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。

使用

声明对象池

只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。
在这里插入图片描述
Get & Put
在这里插入图片描述
Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。
Put() 则是在对象使用完毕后,返回对象池。

测试

在这里插入图片描述

测试结果如下:

在这里插入图片描述
这个例子创建了一个 bytes.Buffer 对象池,而且每次只执行一个简单的 Write 操作,存粹的内存搬运工,耗时几乎可以忽略。而内存分配和回收的耗时占比较多,因此对程序整体的性能影响更大。

性能分析工具

golang主要通过pprof来分析程序的性能,pprof 采样数据主要有三种获取方式

  • runtime/pprof: 手动调用runtime.StartCPUProfile或runtime.StopCPUProfile等 API来生成和写入采样文件,灵活性高
  • net/http/pprof: 通过 http 服务获取Profile采样文件,简单易用,适用于对应用程序的整体监控。通过 runtime/pprof 实现
  • go test: 通过 go test -bench . -cpuprofile prof.cpu生成采样文件 适用对函数进行针对性测试

net/http/pprof

在应用程序中导入import _ “net/http/pprof”,并启动 http server即可

在这里插入图片描述
之后可通过 http://localhost:6060/debug/pprof/CMD 获取对应的采样数据。支持的 CMD 有:

  • goroutine: 获取程序当前所有 goroutine 的堆栈信息。
  • heap: 包含每个 goroutine 分配大小,分配堆栈等。每分配 runtime.MemProfileRate(默认为512K) 个字节进行一次数据采样。
  • threadcreate: 获取导致创建 OS 线程的 goroutine 堆栈
  • block: 获取导致阻塞的 goroutine 堆栈(如 channel, mutex 等),使用前需要先调用 runtime.SetBlockProfileRate
  • mutex: 获取导致 mutex 争用的 goroutine 堆栈,使用前需要先调用 runtime.SetMutexProfileFraction

go tool pprof 常用命令:

  • topN: 输入 top 命令,默认显示 flat 前10的函数调用,可使用 -cum 以 cum 排序
  • list Func: 显示函数名以及每行代码的采样分析
  • web: 生成 svg 热点图片,可在浏览器中打开,可使用 web Func 来过滤指定函数相关调用树

通过top5命令可以看到,mapaccess1_fast64函数占用的CPU 采样时间最多,通过 web mapaccess1_fast64 命令打开调用图谱,查看该函数调用关系,可以看到主要在DFS 和 FindLoops 中调用的,然后再通过 list DFS查看函数代码和关键调用,得到 map 结构是瓶颈点,尝试转换为 slice 优化,整个过程参考Profiling Go Programs。总的思路就是通过top 和web 找出关键函数,再通过list Func 查看函数代码,找到关键代码行并确认优化方案(辅以 go test Benchmark)。

golang面试常见问题

在此列举出一些golang面试中的一些问题,全部面试问题无法列举,请见谅

什么是协程(Goroutine)

Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutines 可以被认为是轻量级的线程。 与线程相比,创建 Goroutine 的开销很小。 Go应用程序同时运行数千个 Goroutine 是非常常见的做法。

Go 有异常类型吗?

Go 没有异常类型,只有错误类型(Error),通常使用返回值来表示异常状态。

f, err := os.Open("test.txt")
if err != nil {
    log.Fatal(err)
}

如何高效地拼接字符串

Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder,最小化内存拷贝次数。

var str strings.Builder
for i := 0; i < 1000; i++ {
    str.WriteString("a")
}
fmt.Println(str.String())

什么是 rune 类型

ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。

Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。例如下面的例子中 语 和 言 使用 UTF-8 编码后各占 3 个 byte,因此 len(“Go语言”) 等于 8,当然我们也可以将字符串转换为 rune 序列。

fmt.Println(len("Go语言")) // 8
fmt.Println(len([]rune("Go语言"))) // 4

defer 的执行顺序

多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。

defer 在 return 语句之后执行,但在函数退出之前,defer 可以修改返回值。

func test() int {
	i := 0
	defer func() {
		fmt.Println("defer1")
	}()
	defer func() {
		i += 1
		fmt.Println("defer2")
	}()
	return i
}

func main() {
	fmt.Println("return", test())
}
// defer2
// defer1
// return 0

这个例子中,可以看到 defer 的执行顺序:后进先出。但是返回值并没有被修改,这是由于 Go 的返回机制决定的,执行 return 语句后,Go 会创建一个临时变量保存返回值,因此,defer 语句修改了局部变量 i,并没有修改返回值。那如果是有名的返回值呢?

func test() (i int) {
	i = 0
	defer func() {
		i += 1
		fmt.Println("defer2")
	}()
	return i
}

func main() {
	fmt.Println("return", test())
}
// defer2
// return 1

这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。

如何判断 2 个字符串切片(slice) 是相等的?

go 语言中可以使用反射 reflect.DeepEqual(a, b) 判断 a、b 两个切片是否相等,但是通常不推荐这么做,使用反射非常影响性能。

通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。

func StringSliceEqualBCE(a, b []string) bool {
    if len(a) != len(b) {
        return false
    }

    if (a == nil) != (b == nil) {
        return false
    }

    b = b[:len(a)]
    for i, v := range a {
        if v != b[i] {
            return false
        }
    }

    return true
}

字符串打印时,%v 和 %+v 的区别

%v 和 %+v 都可以用来打印 struct 的值,区别在于 %v 仅打印各个字段的值,%+v 还会打印各个字段的名称。

但如果结构体定义了 String() 方法,%v 和 %+v 都会调用 String() 覆盖默认值。

init函数执行时机

init() 函数是 Go 程序初始化的一部分。Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

一句话总结: import –> const –> var –> init() –> main()

package main

import "fmt"

func init()  {
	fmt.Println("init1:", a)
}

func init()  {
	fmt.Println("init2:", a)
}

var a = 10
const b = 100

func main() {
	fmt.Println("main:", a)
}
// 执行结果
// init1: 10
// init2: 10
// main: 10

Go 语言的局部变量分配在栈上还是堆上

由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

func foo() *int {
	v := 11
	return &v
}

func main() {
	m := foo()
	println(*m) // 11
}

foo() 函数中,如果 v 分配在栈上,foo 函数返回时,&v 就不存在了,但是这段函数是能够正常运行的。Go 编译器发现 v 的引用脱离了 foo 的作用域,会将其分配在堆上。因此,main 函数中仍能够正常访问该值。

2 个 interface 可以比较吗?

Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况

两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
类型 T 相同,且对应的值 V 相等。

type Stu struct {
	Name string
}

type StuInt interface{}

func main() {
	var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
	var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
	fmt.Println(stu1 == stu2) // false
	fmt.Println(stu3 == stu4) // true
}

stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。

stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true

两个 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
}

上面这个例子中,将一个 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。

函数返回局部变量的指针是否安全?

这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

无缓冲的 channel 和 有缓冲的 channel 的区别?

对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。

对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

func main() {
	st := time.Now()
	ch := make(chan bool)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true  // 无缓冲,发送方阻塞直到接收方接收到数据。
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds())
	time.Sleep(time.Second * 5)
}
func main() {
	st := time.Now()
	ch := make(chan bool, 2)
	go func ()  {
		time.Sleep(time.Second * 2)
		<-ch
	}()
	ch <- true
	ch <- true // 缓冲区为 2,发送方不阻塞,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 0.0 s
	ch <- true // 缓冲区使用完,发送方阻塞,2s 后接收方接收到数据,释放一个插槽,继续往下执行
	fmt.Printf("cost %.1f s\n", time.Now().Sub(st).Seconds()) // cost 2.0 s
	time.Sleep(time.Second * 5)
}

什么是协程泄露(Goroutine Leak)?

协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:

  • 缺少接收器,导致发送阻塞
  • 缺少发送器,导致接收阻塞
  • 死锁(dead lock)
  • 无限循环(infinite loops)

缺少接收器,导致发送阻塞

这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。

func query() int {
	ch := make(chan int)
	for i := 0; i < 1000; i++ {
		go func() { ch <- 0 }()
	}
	return <-ch
}

func main() {
	for i := 0; i < 4; i++ {
		query()
		fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
	}
}
// goroutines: 1001
// goroutines: 2000
// goroutines: 2999
// goroutines: 3998

缺少发送器,导致接收阻塞

那同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出。

死锁(dead lock)

两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

无限循环(infinite loops)

这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

func request(url string, wg *sync.WaitGroup) {
	i := 0
	for {
		if _, err := http.Get(url); err == nil {
			// write to db
			break
		}
		i++
		if i >= 3 {
			break
		}
		time.Sleep(time.Second)
	}
	wg.Done()
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go request(fmt.Sprintf("https://127.0.0.1:8080/%d", i), &wg)
	}
	wg.Wait()
}

Go 可以限制运行时操作系统线程的数量吗?

可以使用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(num int) 设置,例如:

runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值