【Go面试】Go slice深拷贝和浅拷贝_哔哩哔哩_bilibili
基础篇
1.Go方法值接收者和指针接收者的区别
简单言之就是是否会影响调用的结构体,方法接收者是指针会影响
2.返回局部变量的指针
一般来说,局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指""的引用,程序会进入未知状态。
但这在Go中是安全的,Go编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上,因为他们不在栈区,即使释放函数,其内容也不会受影响。
3.值传递
形参和实际参数内存地址不一样,证明是指传递﹔参数是值类型,所以函数内对形参的修改,不会修改原内容数据
4.defer原理(编译器把defer放到函数尾部)
特点:多个defer顺序类似栈。
pannic之后的defer不会执行,之前的会。
定义:
defer能够让我们推迟执行某些函数调用,推迟到当前函数返回前才实际执行。defer与panic和recover结合,形成了Go语言风格的异常与捕获机制。
使用场景∶
defer语习经常被用于处理成对的操作,如文件句柄关闭、连接关闭、释放锁
优点:
方便开发者使用
缺点:
有性能损耗
实现原理:
Go14中编译器会将deler函数直接插入到函致的尾部,无需链表和栈上参数拷贝,性能大幅提升。把deler函数在当前函数内展开并直接调用,这种方式被称为open codeddefer
5.make和new
首先纠正下make和new是内置函数,不是关键字
变量初始化,一般包括2步,变量声明+变量内存分配,var关键字就是用来声明变量的,new和make函数主要是用来分配内存的
var声明值类型的变量时,系统会默认为他分配内存空间,并赋该类型的零值
比如布尔、数字、字符串,j结构体
如果指针类型或者引用类型的变量,系统不会为它分配内存,默认就是nil。此时如果你想直接使用,那么系统会抛异常,必须进行内存分配后,才能使用。
new和 make两个内置函数,主要用来分配内存空间,有了内存,变量就能使用了,主要有以下2点区别:
使用场景区别:
make 只能用来分配及初始化类型为slice、map、chan 的数据。new可以分配任意类型的数据,并且置零。
返回值区别;
make函数原型如下,返回的是slice、map、chan类型本身
切片篇
1.切片底层
切片是基于数组实现的,它的底层是数组,可以理解为对底层数组的抽象。
2.数组和切片不一样
传参区别
数组
是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作都会复制整个数组数据,会占用额外的内存,函致内对数组元素值的修改,不会修改原数组内容。
切片
是引用类型,将一个切片赋值给另一个切片时,传递的是一份浅拷贝,函数传参操作不会拷贝整个切片,只会复制len和cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。直到发生扩容转移内存之后,就不会影响。
3.深浅拷贝
区别:前后是否共享一片内存空间
深拷贝:copy或者append函数
4.slice扩容
旧内存被gc获取释放
5.slice不是线程安全
先看下线程安全的定义:
多个线程访问同一个对象时,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
Map篇
1.底层
Go中的map是一个指针,占用8个字节,指向hmap结构体源码包中 src/runtime/map.go定义了hmap的数据结构:
hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket(桶)
一个桶放8个key-val
2.为什么随机遍历
想要有序遍历,要先对key进行排序,然后根据key遍历
3.Go map线程不安全
保证线程安全的方式:
4.map查找
Go语言中读取 map有两种语法︰
带comma和不带comma。当要查询的key不在 map里,带comma 的用法会返回一个bool型变量提示key 是否在 map中; 而不带comma的语句则会返回一个value类型的零值。如果value 是int型就会返回0,如果value 是string 类型,就会返回空字符串。
【Go面试】Go map冲突的解决方式?_哔哩哔哩_bilibili
5.map冲突解决(就是hash的冲突解决)
6.map负载因子6.5
负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个bucket桶存储的平均元素个数。
就是官方统计决定的
Go官方发现:装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数
根据这份测试结果和讨论,Go官方取了一个相对适中的值,把Go中的map的负载因子硬编码为6.5,这就是6.5的选择缘由。这意味着在Go语言中,当map存储的元素个数大于或等于6.5*桶个数时,就会触发扩容行为。
7.map什么时候扩容
条件1:超过负载
条件2:溢出桶太多
当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多。
当桶总数>=2^15时,直接与2^15比较,当溢出桶总数>=2^15时,即认为溢出桶太多了。
双倍扩容︰针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为双倍扩容(类似slice)
等量扩容∶针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket中的key排列地更紧密,节省空间,提高bucket利用率,进而保证更快的存取。该方法我们称之为等量扩容。
8.sync.map和map性能对比
Chan管道篇
1.chan原理
概念:
Go中的channel是一个队列,遵循先进先出的原则,负责协程之间的通信(Go语言提倡不要通过共享内存来通信,而要通过通信来实现内存共享,CSP(CommunicatingSequential Process)并发模型,就是通过goroutine和channel来实现的)
发送策略
接收策略
2.channel一些特点
3有无缓冲
有缓冲先放到缓冲里,放满了阻塞。
4.chan线程安全
加互斥锁
5.channel共享内存有什么优劣势?
6.控制并发顺序
7.发生死锁
【Go面试】Go channel发送和接收什么情况下会发生死锁?_哔哩哔哩_bilibili
死锁常见于有多个协程阻塞,而当主协程中如果有channel发生阻塞,则必然死锁
无缓冲只读不写或者只写不读都会阻塞
有缓冲缓冲满了再写和缓冲空了再读会阻塞
资源永远获取不到
8.CSP模型
9.MPG模型(Go并发调度)
CSP(communicating sequential processes)并发模型_csp并发模型_ScarletMeCarzy的博客-CSDN博客
M指的是Machine,一个M直接关联了一个内核线程。由操作系统管理。
P指的是”processor”,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接。
G指的是Goroutine,其实本质上也是一种轻量级的线程。包括了调用栈,重要的调度信息,例如channel等。
P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应,例如在4Core的服务器上回启动4个线程。G会有很多个,每个P会将Goroutine从一个就绪的队列中做Pop操作,为了减小锁的竞争,通常情况下每个P会负责一个队列。
抛弃P(Processor)
你可能会想,为什么一定需要一个上下文,我们能不能直接除去上下文,让Goroutine的runqueues挂到M上呢?答案是不行,需要上下文的目的,是让我们可以直接放开其他线程,当遇到内核线程阻塞的时候。
一个很简单的例子就是系统调用sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。
如上图左图所示,M0中的G0执行了syscall,然后就创建了一个M1(也有可能来自线程缓存),(转向右图)然后M0丢弃了P,等待syscall的返回值,M1接受了P,将·继续执行Goroutine队列中的其他Goroutine。
当系统调用syscall结束后,M0会“偷”一个上下文,如果不成功,M0就把它的Gouroutine G0放到一个全局的runqueue中,将自己置于线程缓存中并进入休眠状态。全局runqueue是各个P在运行完自己的本地的Goroutine runqueue后用来拉取新goroutine的地方。P也会周期性的检查这个全局runqueue上的goroutine,否则,全局runqueue上的goroutines可能得不到执行而饿死。
Groutine协程篇
1.底层
【Go面试】Go goroutine的底层实现原理?_哔哩哔哩_bilibili
创建好的这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息。
每个G在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列中。
2.协程和线程区别
创建消耗小,用户级,函数负责调度,切换快消耗小
3.协程泄露
大概就是协程因为某某原因一直不能释放,类似内存泄漏
4.查看协程数量
调度模型篇
1.Go 线程实现模型?
死磕 java线程系列之线程模型 - 知乎 (zhihu.com)
【Go面试】Go 线程实现模型?_哔哩哔哩_bilibili
Go实现的是两级线程模型(M:N),准确的说是GMP模型,是对两级线程模型的改进实现,使它能够更加灵活地进行线程之间的调度。
也就是说go的协程其实地位相当于其他语言的用户级线程?只是协程因为自身的特性非常轻量级,是轻量级线程。
线程模型有 :内核线程 :用户线程 = M:N 或者 1 : 1 或者 1: N
1:1
N:1
M:N
优点:
- 能够利用多核
- 上下文切换成本低
- 如果进程中的一个线程被阻塞,不会阻塞其他线程,是能够切换同一进程内的其他线程继续执行
缺点:
- 实现起来最复杂
2.Go GMP和GM模型?
Golang-企业题库 | GOLANG ROADMAP · 知识星球
G(Goroutine):代表Go 协程Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。G的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。
M(Machine): Go 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程,通过系统调用 clone 创建。M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M的数量有限制,默认数量限制是 10000,可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。
**P(Processor):虚拟处理器,M执行G所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,**P的数量受本机的CPU核数影响,可通过环境变量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数。
Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息
3.Go 调度原理?
goroutine调度的本质就是将 **Goroutine (G)**按照一定算法放到CPU上去执行。
CPU感知不到Goroutine,只知道内核线程,所以需要Go调度器将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行
M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M
Go 调度器的实现不是一蹴而就的,它的调度模型与算法也是几经演化,从最初的 GM 模型、到 GMP模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,经历了不断地优化与打磨。
设计思想
-
线程复用(work stealing 机制和hand off 机制)
-
利用并行(利用多核CPU)
-
抢占调度(解决公平性问题)
调度对象
Go 调度器
Go 调度器是属于Go runtime中的一部分,Go runtime负责实现Go的并发调度、垃圾回收、内存堆栈管理等关键功能
4.Go work stealing 机制?偷协程
当线程M⽆可运⾏的G时,尝试从其他M绑定的P偷取G,减少空转,提高了线程利用率(避免闲着不干活)。
当从本线程绑定 P 本地 队列、全局G队列、netpoller都找不到可执行的 g,会从别的 P 里窃取G并放到当前P上面。
从netpoller 中拿到的G是_Gwaiting状态( 存放的是因为网络IO被阻塞的G),从其它地方拿到的G是_Grunnable状态
从全局队列取的G数量:N = min(len(GRQ)/GOMAXPROCS + 1, len(GRQ/2)) (根据GOMAXPROCS负载均衡)
从其它P本地队列窃取的G数量:N = len(LRQ)/2(平分)
取G优先级:本地队列->全局队列->网络轮询器netpoller ->偷取其他P本地队列下的G
5.Go hand off 机制?M阻塞,把P给其他M
概念
也称为P分离机制,当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的 M 执行,也提高了线程利用率(避免站着茅坑不拉shi)。
分离流程
当前线程M阻塞时,释放P,给其它空闲的M处理
6.抢占调度
在1.2版本之前,Go的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度,这会引发一些问题,比如:
- 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿
- 垃圾回收器是需要stop the world(咋瓦鲁多全局暂停,等GC收集完再全部开始)的,如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine停下来,这会造成较长时间的等待时间
基于协作的抢占式调度流程:(运行超过10ms打一个标记,函数调用的时候尝试抢占)
-
编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
-
Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记
-
当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里
这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。
比如,死循环等并没有给编译器插入抢占代码的机会
基于信号的抢占式调度
真正的抢占式调度是基于信号完成的,所以也称为“异步抢占”。不管协程有没有意愿主动让出 cpu 运行权,只要某个协程执行时间过长,就会发送信号强行夺取 cpu 运行权。
- M 注册一个 SIGURG 信号的处理函数:sighandler
- sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占P超过10ms,会给M发送抢占信号
- M 收到信号后,内核执行 sighandler 函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他 goroutine 来运行
- 被抢占的 G 再次调度过来执行时,会继续原来的执行流
抢占分为_Prunning
和_Psyscall
,_Psyscall
抢占通常是由于阻塞性系统调用引起的,比如磁盘io、cgo。_Prunning
抢占通常是由于一些类似死循环的计算逻辑引起的。
内存管理篇
1.内存分配
Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
设计思想
- 内存分配算法采用Google的
TCMalloc算法
,每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争(先本地再全局) - 把内存切分的非常的细小,分为多级管理,以降低锁的粒度
- 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销
分配组件
Go的内存管理组件主要有:mspan
、mcache
、mcentral
和mheap
2.Go 内存逃逸机制?
概念(函数返回局部变量引用导致某些内存无法销毁,从栈逃逸到堆上)
在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。
在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少 GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
逃逸机制
编译器会根据变量是否被外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
- 如果栈上放不下,则必定放到堆上;(栈比较小)
逃逸分析也就是由编译器决定哪些变量放在栈,哪些放在堆中,通过编译参数-gcflag=-m
可以查看编译过程中的逃逸分析,发生逃逸的几种场景如下:
指针逃逸
package main
func escape1() *int {
var a int = 1
return &a
}
func main() {
escape1()
}
当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上
栈空间不足(默认2k)
package main
func escape2() {
s := make([]int, 0, 10000)
for index, _ := range s {
s[index] = index
}
}
func main() {
escape2()
}
当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上
变量大小不确定
func escape3() {
number := 10
s := make([]int, number) // 编译期间无法确定slice的长度
for i := 0; i < len(s); i++ {
s[i] = i
}
}
func main() {
escape3()
}
编译期间无法确定slice的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。直接s := make([]int, 10)
不会发生逃逸
动态类型
动态类型就是编译期间不确定参数的类型、参数的长度也不确定的情况下就会发生逃逸
空接口 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
fmt.Println(a …interface{})函数参数为interface,编译器不确定参数的类型,会将变量分配到堆上
闭包引用对象
package main
func escape5() func() int {
var i int = 1
return func() int {
i++
return i
}
}
func main() {
escape5()
}
闭包函数中局部变量i在后续函数是继续使用的,编译器将其分配到堆上
总结
-
栈上分配内存比在堆中分配内存效率更高
-
栈上分配的内存不需要 GC 处理,而堆需要
-
逃逸分析目的是决定内分配地址是栈还是堆
-
逃逸分析在编译阶段完成
因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点
3.Go 内存对齐机制?
什么是内存对齐
为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。
对齐系数
不同硬件平台占用的大小和对齐值都可能是不一样的,每个特定平台上的编译器都有自己的默认"对齐系数",32位系统对齐系数是4,64位系统对齐系数是8
不同类型的对齐系数也可能不一样,使用Go
语言中的unsafe.Alignof
函数可以返回相应类型的对齐系数,对齐系数都符合2^n
这个规律,最大也不会超过8
优点
-
提高可移植性,有些
CPU
可以访问任意地址上的任意数据,而有些CPU
只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了 -
提高内存的访问效率,32位CPU下一次可以从内存中读取32位(4个字节)的数据,64位CPU下一次可以从内存中读取64位(8个字节)的数据,这个长度也称为CPU的字长。CPU一次可以读取1个字长的数据到内存中,如果所需要读取的数据正好跨了1个字长,那就得花两个CPU周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。
缺点
- 存在内存空间的浪费,实际上是空间换时间
4.Go GC实现原理?
【Go面试】Go GC实现原理?_哔哩哔哩_bilibili
什么是GC?
垃圾回收也称为GC (Garbage dollection),是一种自动内存管理机制
现代高级编程语言管理内存的方式分为两种:自动和手动,像C、C++等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;而PHP、Java和Go等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。
在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack) ,GC负责回收堆内存,而不负责回收栈中的内存:
栈是线程的专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈,函数执行完后,编译器可以将栈上分配的内存可以直接释放,不需要通过GC来回收。
堆是程序共享的内存,需要GC进行回收在堆上分配的内存。
几种主流GC算法
常见三种:
STW:Stop the world全局暂停
Gc算法三色标记法
此算法是在Go 1.5版本开始使用,Go语言采用的是标记清除算法,并在此基础上使用了三色标记法和混合写屏障技术,CC过程和其他用户goroutine可并发运行,但需要一定时间的STW
三色标记法只是为了叙述方便而抽象出来的一种说法,实际上的对象是没有三色之分的。这里的三色,对应了垃圾回收过程中对象的三种状态∶
step 1:创建:白、灰、黑三个集合
step 2:将所有对象放入白色集合中
step 3:遍历所有root对象,把遍历到的对象从白色集合放入灰色集合(这里放入灰色集合的都是根节点的对象)
step 4:遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,自身标记为黑色
step 5:重复步骤4,直到灰色中无任何对象,其中用到2个机制:
step 6:收集所有白色对象(垃圾)
GC流程
一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记开始、标记终止、清理:
1.标记准备(Mark Setup)︰打开写屏障((Write Barrier),需STW (stop the world)
2.标记开始(Marking)︰使用三色标记法并发标记,与用户程序并发执行
3.标记终止(Mark Termination)∶对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需STW (stop the word)
4.清理(Sweeping)︰将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行
触发时机
5.Go GC如何调优?
并发编程
1.Go 常用的并发模型?CSP模型
并发模型说的是系统中的线程如何协作完成并发任务,不同的并发模型,线程以不同的方式进行通信和协作。
线程间通信方式
线程间通信方式有两种:共享内存和消息传递,无论是哪种通信模型,线程或者协程最终都会从内存中获取数据,所以更为准确的说法是直接共享内存、发送消息的方式来同步信息
共享内存
抽象层级:抽象层级低,当我们遇到对资源进行更细粒度的控制或者对性能有极高要求的场景才应该考虑抽象层级更低的方法
耦合:高,线程需要在读取或者写入数据时先获取保护该资源的互斥锁
线程竞争:需要加锁,才能避免线程竞争和数据冲突
发送消息
抽象层级:抽象层级高,提供了更良好的封装和与领域更相关和契合的设计,比如Go 语言中的Channel
就提供了 Goroutine 之间用于传递信息的方式,它在内部实现时就广泛用到了共享内存和锁,通过对两者进行的组合提供了更高级的同步机制
耦合:低,生产消费者模型
线程竞争:保证同一时间只有一个活跃的线程能够访问数据,channel维护所有被该chanel阻塞的协程,保证有资源的时候只唤醒一个协程,从而避免竞争
Go语言中实现了两种并发模型,一种是共享内存并发模型,另一种则是CSP模型。
共享内存并发模型
通过直接共享内存 + 锁的方式同步信息,传统多线程并发
通过发送消息的方式来同步信息,Go语言推荐使用的通信顺序进程(communicating sequential processes)并发模型,通过goroutine和channel来实现
goroutine
是Go语言中并发的执行单位,可以理解为”线程“channel
是Go语言中各个并发结构体(goroutine
)之前的通信机制。 通俗的讲,就是各个goroutine
之间通信的”管道“,类似于Linux中的管道
因为协程和管道打交道不用管其他协程,不需要加锁保证,所以相对耦合度更低
2.Go 有哪些并发同步原语?
原子操作
Mutex、RWMutex 等并发原语的底层实现是通过 atomic 包中的一些原子操作来实现的,原子操作是最基础的并发原语
Channel
channel
管道,高级同步原语,goroutine之间通信的桥梁
使用场景:消息队列、数据传递、信号通知、任务编排、锁
基本并发原语
Go 语言在 sync
包中提供了用于同步的一些基本原语,这些基本原语提供了较为基础的同步功能,但是它们是一种相对原始的同步机制,在多数情况下,我们都应该使用抽象层级更高的 Channel 实现同步。
常见的并发原语如下:sync.Mutex互斥锁
、sync.RWMutex读写锁
、sync.WaitGroup
等待一组 Goroutine 的返回、sync.Cond
可以让一组的 Goroutine 都在满足特定条件时被唤醒、sync.Once
可以让一组的 Goroutine 都在满足特定条件时被唤醒、sync.Pool池化
、sync.Context
可以进行上下文信息传递、提供超时和取消机制、控制子 goroutine 的执行
3.Go WaitGroup实现原理?
概念
Go
标准库提供了WaitGroup
原语, 可以用它来等待一批 Goroutine 结束
使用方法
在WaitGroup里主要有3个方法:
WaitGroup.Add()
:可以添加或减少请求的goroutine数量,Add(n)
将会导致counter += n
WaitGroup.Done()
:相当于Add(-1),Done()
将导致counter -=1
,请求计数器counter为0 时通过信号量调用runtime_Semrelease
唤醒waiter线程WaitGroup.Wait()
:会将waiter++
,同时通过信号量调用runtime_Semacquire(semap)
阻塞当前 goroutine
主协程调用wait()方法,通过add(x)增加或减少需要等待的协程数,每当完成一个协程调用Done()相当于自动add(-1),当计数器降为0,调用wait的主协程开始工作
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)//计数器加一
go func() {
defer wg.Done() //5个子协程,完成自动计数器减一
println("hello")
}()
}
wg.Wait() //计数器为0继续运行
}
4.Go 有哪些方式安全读写共享变量?
5.排查数据竞争
概念
只要有两个以上的goroutine并发访问同一变量,且至少其中的一个是写操作的时候就会发生数据竞争;全是读的情况下是不存在数据竞争的。
下面一个读一个写
package main
import "fmt"
func main() {
i := 0
go func() {
i++ // write i
}()
fmt.Println(i) // read i
}