Go中常见面试题
前言
本章给大家带来一些Go中常见面试题,常见面试也是我们工作中容易错误一些知识点。 例如:golang里的数组和切片的区别。
golang里的数组和切片有了解过吗?
数组长度是固定的,而切片是可变长的。可以把切片看作是对底层数组的封装,每个切片的底层数据结构中,一定会包含一个数组。数组可以被称为切片的底层数组,切片也可以被看作对数组某一连续片段的引用。因此,Go中切片属于引用类型,而数组属于值类型,通过内建函数len
,可以取得数组和切片的长度。通过内建函数cap
,可以得到数组和切片的容量。但是数组的长度和容量是相等的,并且都不可变,而且切片容量是有变化规律的。
数组和切片的关系:
切片一旦初始化, 切片始终与保存其元素的基础数组相关联。因此,切片会和与其拥有同一基础数组的其他切片共享存储 ; 相比之下,不同的数组总是代表不同的存储。
数组和切片的区别
-
切片的长度可能在执行期间发生变化 ,而数组的长度不能变化,可以把切片看成一个长度可变的数组。
-
数组作为函数参数是进行值传递的,函数内部改变传入的数组元素值不会影响函数外部数组的元素值; 切片作为函数的参数是进行的指针传递,函数内部改变切片的值会影响函数外部的切片元素值。
-
数组可以比较,切片不能比较(对底层数组的引用)。
例题: 说出下面的返回值
package main
import "fmt"
func main() {
s := []int{1, 2}
s1 := []int{1, 2}
s2 := make([]int, 2, 3)
a := [2]int{1, 2}
test(s, s1, s2, a)
fmt.Printf("outter=> s: %v, s1: %v, s2: %v, a: %v\n", s, s1, s2, a)
}
func test(s []int, s1 []int, s2 []int, a [2]int) {
s = append(s, 3, 4, 5, 6, 7)
s[0] = 9
s1[0] = 9
s2 = append(s2, 8)
s2[0] = 9
a[0] = 9
fmt.Printf("inner=> s: %v, s1:%v, s2:%v, a: %v\n", s, s1, s2, a)
}
输出结果:
inner=> s: [9 2 3 4 5 6 7], s1:[9 2], s2:[9 0 8], a: [9 2]
outter=> s: [1 2], s1: [9 2], s2: [9 0], a: [1 2]
介绍一下通道
如果说goroutine
是Go程序并发的执行体,通道就是它们之间的连接。通道可以使一个goroutine
发送特定值到另一个goroutine
的通信机制。每一个通道都是一个具体类型的通过,叫做通道的元素类型。例如一个具有int
类型元素的通道写为chan int
。
通道是一个用map
创建的数据结构的引用。当复制或者作为参数传递到一个函数时,复制的是引用,这样调用者和被调用者都引用同一份数据结构。和其他引用类型一样,通道的零值是nil
。
通道有两个主要操作:发送(send
)和接收(receive
),两者统称为通信。send
语句从一个goroutine
传输一个值到另一个在执行接收表达式的goroutine
。两个操作都使用<-
操作符书写。发送语句中,通道和值分别在<-
的左右两边。在接收表达式中,<-
放在通道操作数前面,在接收表达式中,其结果未被使用也是合法的。
ch <- x //发送语句
x = <-ch //接收语句
<-ch //接收语句,丢弃结果
通道支持第三个操作:关闭 (close
),它设置一个标志位来指示值当前已经发送完毕,这个通道后面没有值了;关闭后的发送操作将导致宕机。在一个已经关闭的通道上进行接收操作,将获取所有已经发送的值,直到通道为空;这时任何接收操作会立即完成,同时获取到一个通道元素对应的零值。通过调用内置的close
函数来关闭通道:
close(ch)
根据通道的容量,可以将通道分为无缓冲通道和缓冲通道
- 无缓冲通道:
ch = make(chan int) 、ch = make(chan int, 0)
- 有缓冲通道:
ch = make(chan int, 3)
根据通道传输方向,还可以通道分为双向通道,只读通道和只写通道
- 只读通道: 只能发送的通道,允许发送但不允许接收
chan<- int
- 只写通道: 只能接收的通道,允许接收但不允许发送
<-chan int
简答版:
通道类型的值本身就是并发安全的。在声明并初始化一个通道时,可以使用内建函数make
,传给这个函数第一个参数为通道具体类型的字面量(如:chan int
),还可以接一个可选的整形参数作为通道的容量,但是这个整形数据不能小于零。
通道相当与一个先进先出(FIFO
)的队列,各个元素严格按照发送顺序排列,先被发送的一定会被先接收。使用操作符表示<-
如果定义通道时未指定通道的长度,那么该通道的长度为0
,没有缓冲,即发送一个数据之后,通道就会阻塞,直到该元素被接收。如果定义的长度为n
(n
为正整数),那么通道的长度即为n
。
channel实现方式
channel实现方式/原理/概念/底层实现
- Go语言提供了一种不同的并发模型–通信顺序进程(
communicating sequential processes,CSP
)。 - 设计模式:通过通信的方式共享内存
channel
收发操作遵循先进先出(FIFO
)的设计
type hchan struct {
qcount uint // channel中的元素个数
dataqsiz uint // channel中循环队列的长度
buf unsafe.Pointer // channel缓冲区数据指针
elemsize uint16 // buffer中每个元素的大小
closed uint32 // channel是否已经关闭,0未关闭
elemtype *_type // channel中的元素的类型
sendx uint // channel发送操作处理到的位置
recvx uint // channel接收操作处理到的位置
recvq waitq // 等待接收的sudog(sudog为封装了goroutine和数据的结构)队列由于缓冲区空间不足而阻塞的Goroutine列表
sendq waitq // 等待发送的sudogo队列,由于缓冲区空间不足而阻塞的Goroutine列表
lock mutex // 一个轻量级锁
}
channel创建: ch := make(chan int, 3)
- 创建
channel
实际上就是在内存中实例化了一个hchan
结构体,并返回一个chan
指针 channel
在函数间传递都是使用的这个指针,这就是为什么函数传递中无需使用channel
的指针,而是直接用channel
就行了,因为channel
本身就是一个指针
channel发送数据: ch <- 1 ch <- 2
- 检查
recvq
是否为空,如果不为空,则从recvq
头部取一个goroutine
,将数据发送过去,并唤醒对应的goroutine
即可。 - 如果
recvq
为空,则将数据放入到buffer
中。 - 如果
buffer
已满,则将要发送的数据和当前goroutine
打包成sudog
对象放入到sendq
中。并将当前goroutine
置为waiting
状态。
channel接收数据:<-ch <-ch
-
检查
sendq
是否为空,如果不为空,且没有缓冲区,则从sendq
头部取一个goroutine
,将数据读取出来,并唤醒对应的goroutine
,结束读取过程。 -
如果
sendq
不为空,且有缓冲区,则说明缓冲区已满,则从缓冲区中首部读出数据,把sendq
头部的goroutine
数据写入缓冲区尾部,并将goroutine
唤醒,结束读取过程。 -
如果
sendq
为空,缓冲区有数据,则直接从缓冲区读取数据,结束读取过程。 -
如果
sendq
为空,且缓冲区没数据,则只能将当前的goroutine
加入到recvq
,并进入waiting
状态,等待被写goroutine
唤醒。
channel规则:
操作 | 空channel | 已关闭channel | 活跃中的channel |
---|---|---|---|
close(ch) | panic | panic | 成功关闭 |
ch<- v | 永远阻塞 | panic | 成功发送或阻塞 |
v,ok = <-ch | 永远阻塞 | 不阻塞 | 成功接收或阻塞 |
channel和锁的对比
并发问题可以用channel
解决也可以用Mutex
解决,但是它们的擅长解决的问题有一些不同。
channel
关注的是并发问题的数据流动,适用于数据在多个协程中流动的场景。
而mutex
关注的是是数据不动,某段时间只给一个协程访问数据的权限,适用于数据位置固定的场景。
向为nil的channel发送数据会怎么样
空通道即无缓冲通道。无缓冲通道上的发送操作将会阻塞,直到另一个goroutine
在对应的通道上执行接收操作,这时值传送完成,两个goroutine
都可以继续执行。相反,如果接收操作先执行,接收方gorountine
将阻塞,直到另一个goroutine
在同一个通道上发送一个值。
使用无缓冲通道进行的通信导致发送和接收goroutine
同步化。因此,无缓冲通道也称为同步通道。当一个值在无缓冲通道上传递时,接收值后发送方goroutine
才被再次唤醒。
WaitGroup使用点
① Add一个负数
如果计数器的值小于0会直接panic
② Add在Wait之后调用
比如一些子协程开头调用Add
结束调用Wait
,这些 Wait
无法阻塞子协程。正确做法是在开启子协程之前先Add
特定的值。
③ 未置为0就重用
WaitGroup
可以完成一次编排任务,计数值降为0后可以继续被其他任务所用,但是不要在还没使用完的时候就用于其他任务,这样由于带着计数值,很可能出问题。
④ 复制waitgroup
WaitGroup
有nocopy
字段,不能被复制。也意味着WaitGroup
不能作为函数的参数。
go struct 能不能比较
需要具体情况具体分析,如果struct中含有不能被比较的字段类型,就不能被比较,如果struct中所有的字段类型都支持比较,那么就可以被比较。
不可被比较的类型:
① slice,因为slice是引用类型,除非是和nil比较
② map,和slice同理,如果要比较两个map只能通过循环遍历实现
③ 函数类型
其他的类型都可以比较。
还有两点值得注意:
- 结构体之间只能比较它们是否相等,而不能比较它们的大小。
- 只有所有属性都相等而属性顺序都一致的结构体才能进行比较。
读写锁底层是怎么实现的
读写锁的底层是基于互斥锁实现的。
- 为什么有读写锁,它解决了什么问题?(使用场景)
- 它的底层原理是什么?
在这里我会结合 Go 中的读写锁 RWMutex
进行介绍。
我们通过与 Mutex
对比得出答案。Mutex
是不区分 goroutine
对共享资源的操作行为的,在读操作、它会上锁,在写操作,它也会上锁,当一段时间内,读操作居多时,读操作在 Mutex 的保护下也不得不变为串行访问,对性能的影响也就比较大了。
RWMutex
读写锁的诞生为了区分读写操作,在进行读操作时,goroutine
就不必傻傻的等待了,而是可以并发地访问共享资源,将串行读变成了并行读,提高了读操作的性能。
读写锁针对解决一类问题:readers-writes
,同时有多个读或者多个写操作时,只要有一个线程在执行写操作,其他的线程都不能进行读操作。
读写锁其实有三种工作模型:
- Read-perferring 优先读设计,可能会导致写饥饿
- Write-prferring 优先写设计,避免写饥饿
- 不指定优先级 不区分优先级,解决饥饿问题
Go 中的读写锁,工作模型是 Write-prferring 方案。
简洁答案:
- 读写锁解决问题
主要应用于写操作少,读操作多的场景。读写锁满足以下四条规则。
- 写锁需要阻塞写锁:一个协程拥有写锁时,其他协程写锁定需要阻塞;
- 写锁需要阻塞读锁:一个协程拥有写锁时,其他协程读锁定需要阻塞;
- 读锁需要阻塞写锁:一个协程拥有读锁时,其他协程写锁定需要阻塞;
- 读锁不能阻塞读锁:一个协程拥有读锁时,其他协程也可以拥有读锁。
- 读写锁底层实现
读写锁内部仍有一个互斥锁,用于将多个写操作隔离开来,其他几个都用于隔离读操作和写操作。
2个协程交替打印字母和数字
package main
import (
"fmt"
)
func main() {
limit := 26
numChan := make(chan int, 1)
charChan := make(chan int, 1)
mainChan := make(chan int, 1)
charChan <- 1
go func() {
for i := 0; i < limit; i++ {
<-charChan
fmt.Printf("%c\n", 'a'+i)
numChan <- 1
}
}()
go func() {
for i := 0; i < limit; i++ {
<-numChan
fmt.Println(i)
charChan <- 1
}
mainChan <- 1
}()
<-mainChan
close(charChan)
close(numChan)
close(mainChan)
}
goroutine与线程的区别?
一个线程可以有多个协程。
线程、进程都是同步机制;而协程是异步 。
协程可以保留上一次调用时的状态,当过程重入时,相当于进入了上一次的调用状态,协程是需要线程来承载运行的,所以协程并不能取代线程,线程是被分割的CPU资源,协程是组织好的代码流程。
讲一讲 GMP 模型
三个字母的含义
-
G(Goroutine)
:G
就是我们所说的 Go 语言中的协程 Goroutine 的缩写,相当于操作系统中的进程控制块。其中存着goroutine
的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。 -
M(Machine)
:代表一个操作系统的主线程,对内核级线程的封装,数量对应真实的 CPU 数。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。 -
P(Processor
):Processor
代表了M
所需的上下文环境,代表M
运行G
所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当P
有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以P
和M
是相互绑定的。总的来说,P
可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行goroutine
,必须先获取P
,P
中还包含了可运行的G
队列。
首先呢,GMP 这三个字母的含义分别是 Goroutine,Machine,Processor。这个Goroutine,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。Machine就是代表了一个操作系统的主线。M 结构体中,保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。M 直接关联一个 os 内核线程,用于执行 G。(这里思考一个这个模型的图片回答),这个 M 做的事情就是从关联的 P 的本地队列中直接获取待执行的 G。剩下的 Processor 是代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务。在GMP调度模型中,P 的个数就是 GOMAXPROCS,是可以手动设置的,但一般不修改,GOMAXPOCS 默认值是当前电脑的核心数,单核CPU就只能设置为1,如果设置>1,在 GOMAXPOCS 函数中也会被修改为1。总的来说,这个 P 结构体的主要的任务就是可以根据实际情况开启协程去工作。
GO中深拷贝和浅拷贝
深入理解Go语言的深拷贝与浅拷贝:优化你的数据复制策略"
深拷贝︰拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。
实现深拷贝的方式:
copy(slice2, slice1)
- 遍历
slice
进行append
赋值
浅拷贝∶拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。
实现浅拷贝的方式:引用类型的变量,默认赋值操作就是浅拷贝。 如slice2 := slice1
引用类型与值类型
引用类型
变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。
值类型是
基本数据类型,int
,float
,bool
,string
, 以及数组和 struct 特点:变量直接存储值,内存通常在栈中分配,栈在函数调用后会被释放
对于引用类型的变量,我们不光要声明它,还要为它分配内容空间
于值类型的则不需要显示分配内存空间,是因为go会默认帮我们分配好
new()
func new(Type) *Type
, new()
: 对类型进行内存分配,入参为类型,返回为类型的指针,指向分配类型的内存地址。
make()
func make(t Type, size ...IntegerType) Type
make()
也是用于内存分配的,但是和new
不同,它只用于channel
、map
以及切片的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型,因为这三种类型就是引用类型,所以就没有必要返回他们的指针了。
注意,因为这三种类型是引用类型,所以必须得初始化,但是不是置为零值,这个和new是不一样的。
简而言之make()
用于初始化slice
, map
, channel
等内置数据结构
channel的应用场景
channel适用于数据在多个协程中流动的场景,有很多实际应用:
① 任务定时
比如超时处理:
select {
case <-time.After(time.Second):
定时任务:
select {
case <- time.Tick(time.Second)
② 解耦生产者和消费者
可以将生产者和消费者解耦出来,生产者只需要往channel发送数据,而消费者只管从channel中获取数据。
③ 控制并发数
以爬虫为例,比如需要爬取1w条数据,需要并发爬取以提高效率,但并发量又不过过大,可以通过channel来控制并发规模,比如同时支持5个并发任务:
ch := make(chan int, 5)
for _, url := range urls {
go func() {
ch <- 1
worker(url)
<- ch
}
}
go的GC
标记清理 -> 三色标记发 -> 混合写屏障
- 标记清除:
此算法主要有两个主要的步骤:
标记(Mark phase)
清除(Sweep phase)
第一步,找出不可达的对象,然后做上标记。
第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 stop the world。
也就是说,这段时间程序会卡在哪儿。故中文翻译成 卡顿.
标记-清扫(Mark And Sweep)算法存在什么问题?
标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:
STW,stop the world;让程序暂停,程序出现卡顿。
标记需要扫描整个heap
清除数据会产生heap碎片
这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。
-
三色并发标记法:
首先:程序创建的对象都标记为白色。
gc开始:扫描所有可到达的对象,标记为灰色。
从灰色对象中找到其引用对象标记为灰色,把灰色对象本身标记为黑色。
监视对象中的内存修改,并持续上一步的操作,直到灰色标记的对象不存在。
此时,gc回收白色对象。
最后,将所有黑色对象变为白色,并重复以上所有过程。 -
混合写屏障:
注意:
当gc进行中时,新创建一个对象. 按照三色标记法的步骤,对象会被标记为白色,这样新生成的对象最后会被清除掉,这样会影响程序逻辑。
golang引入写屏障机制.可以监控对象的内存修改,并对对象进行重新标记。
gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。
slice用copy和左值进行初始化的区别
copy(slice2, slice1)
实现的是深拷贝。拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。
同样的还有:遍历slice进行append赋值
- 如
slice2 := slice1
实现的是浅拷贝。拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。默认赋值操作就是浅拷贝。
channel是否线程安全
channel为什么设计成线程安全?
不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全。
channel如何实现线程安全的?
channel的底层实现中, hchan结构体中采用Mutex锁来保证数据读写安全。在对循环数组buf中的数据进行入队和出队操作时,必须先获取互斥锁,才能操作channel数据。
go的map是线程安全的吗?
-
线程安全:对
map
进行并发读写时,如果程序能正常运行并能得到预期的结果。Map
默认不是并发安全的,并发读写时程序会panic
。 -
map为什么不支持线程安全?和场景有关,官方认为大部分场景不需要多个协程进行并发访问,如果为小部分场景加锁实现并发访问,大部分场景将付出加锁代价(性能降低)。
-
实现:
1)加读写锁(map+sync.RWMutex
)
2)使用Go提供的sync.Map
(内部加了锁)
Go语言Slice是否线程安全
Go语言实现线程安全常用的几种方式:
1.互斥锁;
2.读写锁;
3.原子操作;
4.sync.once;
5.sync.atomic;
6.channel
slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个goroutine对类型为slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致;
slice在并发执行中不会报错,但是数据会丢失。
make可以初始化哪些结构
通过make创建对象 make只能创建slice 、channel、 map。
new和make对比:
- make 只能用来分配及初始化类型为 slice、map、chan 的数据。new 可以分配任意类型的数据;
- new 分配返回的是指针,即类型
*Type
。make 返回引用,即 Type; - new 分配的空间被清零。make 分配空间后,会进行初始化;
- make 函数只用于 map,slice 和 channel,并且不返回指针
go 深拷贝发生在什么情况下?切片的深拷贝是怎么做的?
深拷贝,浅拷贝概念
- 深拷贝(Deep Copy):
拷贝的是数据本身,创造一个样的新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。既然内存地址不同,释放内存地址时,可分别释放。
- 浅拷贝(Shallow Copy):
拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。释放内存地址时,同时释放内存地址。参考来源
在go语言中值类型赋值都是深拷贝,引用类型一般都是浅拷贝:
值类型的数据,默认全部都是深拷贝:Array、Int、String、Struct、Float,Bool
引用类型的数据,默认全部都是浅拷贝:Slice,Map
对于引用类型,想实现深拷贝,不能直接 := ,而是要先开辟地址空间(new) ,再进行赋值。
怎么进行(切片的)深拷贝?
可以使用 copy()
函数来进行深拷贝,copy 不会进行扩容,当要复制的 slice 比原 slice 要大的时候,只会移除多余的。
func main() {
slice1 := []int{1, 2, 3, 4, 5}
slice2 := []int{6, 7, 8}
copy(slice2, slice1) // 复制slice1的前3个元素到slice2中
fmt.Println(slice1, slice2)
copy(slice1, slice2) // 复制slice2的3个元素到slice1的前3个位置
fmt.Println(slice1, slice2)
}
使用 append()
函数来进行深拷贝,append
会进行扩容(这里涉及到的就是 Slice
的扩容机制 )。
func main() {
a := []int{1, 2, 3}
b := make([]int, 0)
b = append(b, a[:]...)
fmt.Println(a, b)
a[1] = 1000
fmt.Println(a, b)
fmt.Printf("%p,%p", a, b)
}
Go 中切片扩容的策略:
- 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量
- 否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍
- 否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环 增加原来的 1/4, 直到最终容量大于等于新申请的容量
- 如果最终容量计算值溢出,则最终容量就是新申请容量
注意:如果 slice
在 append()
过程中没有发生扩容,那么修改就在原来的内存中,如果发生了扩容,就修改在新的内存中。
空结构体占不占内存空间? 为什么使用空结构体?
空结构体是没有内存大小的结构体。
通过 unsafe.Sizeof()
可以查看空结构体的宽度,代码如下:
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // prints 0
准确的来说,空结构体有一个特殊起点: zerobase
变量。zerobase
是一个占用 8 个字节的uintptr
全局变量。每次定义 struct {}
类型的变量,编译器只是把zerobase变量的地址给出去。也就是说空结构体的变量的内存地址都是一样的。
空结构体的使用场景主要有三种:
-
实现方法接收者:在业务场景下,我们需要将方法组合起来,代表其是一个 ”分组“ 的,便于后续拓展和维护。
-
实现集合类型:在 Go 语言的标准库中并没有提供集合(Set)的相关实现,因此一般在代码中我们图方便,会直接用 map 来替代:
type Set map[string]struct{}
。 -
实现空通道:在 Go channel 的使用场景中,常常会遇到通知型 channel,其不需要发送任何数据,只是用于协调 Goroutine 的运行,用于流转各类状态或是控制并发情况。
如果在两个不同的结构体类型之间进行比较(地址不同),则一定不能进行比较,除非强制转换成相同的结构体类型。
如果在一个结构体中,需要看情况进行比较。在 Go 语言中,当其基本类型包含:slice
、map
、function
时,是不能比较的。
GPM模型
golang gmp模型,全局队列中的G会不会饥饿,为什么?P的数量是多少?能修改吗?M的数量是多少?
-
全局队列中的G不会饥饿。 因为线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。所以全局队列中的G总是能被消费掉.
-
P的数量可以理解为最大为本机可执行的cpu的最大数量。
通过runtime.GOMAXPROCS(runtime.NumCPU())
设置。
runtime.NumCPU()
方法返回当前进程可用的逻辑cpu数量。
服务器能开多少个M由什么决定? 服务器能开多少个P由什么决定?
由于M必须持有一个P才可以运行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。
P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。
Go语⾔本身是限定M的最⼤量是10000,可以在runtime/debug包中的SetMaxThreads函数来修改设置
P的个数在程序启动时决定,默认情况下等同于CPU的核数
程序中可以使用 runtime.GOMAXPROCS() 设置P的个数,在某些IO密集型的场景下可以在一定程度上提高性能。
一般来讲,程序运行时就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。在某些IO密集型的应用里,这个值可能并不意味着性能最好。理论上当某个Goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在IO密集型应用中不妨把GOMAXPROCS设置的大一些,或许会有好的效果。