目录
1.go有哪些数据类型?
- 布尔型 bool
- 数字类型 uint int float32 float64 byte rune
- 字符串类型 string
- 复合类型 数组类型 (array) 切片类型(slice) 字典类型(map) 管道类型(channel) 结构化类型(structure)
- 指针类型 pointer
- 接口类型 interface{}
- 函数类型 func
- 方法类型 method
2.方法与函数有什么区别?
- 函数是值不属于任何结构体,类型的方法,也就是说函数是没有接受者的,方法是有指定的接收者
3.方法中值接收者与指针接收者的区别是什么?
- 如果方法的接收者是指针,无论调用者是对象还是对象指针,修改的都是对象本身,惠永祥调用者,
- 如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者本身
- (指针接收者会造成变量逃逸现象,并且会将变量分配到堆中.需要GC才能进行内存回收.)
4.函数返回局部变量的指针是否安全?
- 一般来说局部变量会在函数返回后直接被销毁,所以在函数返回后该变量就变成了无所知的引用.程序会进入未知状态.但是这在Go中是安全的.Go编译器会对每个局部变量进行逃逸分析.如果发现局部变量的作用域超过该函数.则不会将内存分配到栈上,而是分配到堆上,因为他们不在栈区,即使释放函数其内容本身也不会受影响.
5.函数参数传递值是值传递还是引用传递?
- Go语言中所有的传参都是值传递,都是一个副本,一个拷贝.
- 参数如果是非引用类型(int string struct等类型的话),这样的话在函数中就无法修改原内容数据,如果是引用类型的话(指针,map,channel,slice等) 这样的就可以修改原内容数据.(个人理解,值传递可以理解成一个函数内部的局部变量.这个变量无法跳出函数本身.所以当该类型的参数传递进函数后就是拷贝了原本的值.只能是函数内部使用.引用类型需要指定一个唯一的内存地址.这个地址上存储的是对应的值.在使用参数的时候需要将这个内存地址传递到函数中.因为内存地址是唯一的.所以无论在函数内部还是函数外部只要是修改,那么都会将原来的值进行修改.类似于全局变量,局部引用的效果.)
6.defer关键字的实现原理?
返回值=xxx
调用defer 函数
空return
普通的函数返回时, 汇编代码类似:
add xx SP
return
包含defer的语句则汇编代码是:
call runtime.deferreturn,
add xx SP
return
7.内置函数make和new的区别?
变量初始化一般分为两步, 变量声明+变量内存分配,var 关键字就是用来声明变量的,new和make主要用来分配内存的.
make只能用来分配初始化类型为slice.map.chan类型的数据.并且返回类型为类型本身.
new可以分配任意类型的数据,并且置零,返回一个指向该类型内存地址的指针.
8.slice底层实现原理?
切片是基于数组实现的.他的底层是数组,他自己本身非常小.可以理解为对底层数组的抽象,因为是基于底层数组的实现,所以他的底层内存结构是连续的.效率非常高.还可以通过索引获取数据,
切片本身并不是动态数组或者数组指针,它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写限定在指定的区域内,且本身只是一个只读对象,其工作机制类似于数组指针的一种封装(切片扩容,低于1024的情况下翻倍扩容,高于1024的情况下1.25倍原数组数量扩容.直到扩容的到所需求的大小为止,在扩容期间底层的内存地址是发生变化的.每扩容一次内存地址就会相应的改变一次.go数组扩容机制)
9.array与slice的区别是什么?
数据长度不同,
- 数组初始化必须指定长度,并且长度固定不可变.
- 切片长度不是固定的,可以追加元素,再追加时可能使切片的容量增大.
函数传参不同
- 数组是值类型,将一个数组赋值给另一个数组的时候传递的是一份深拷贝,函数传参操作会复制整个数组的数据,会占用额外的内存,函数对数组元素值的修改,不会修改原数组内容.(因为是值拷贝,所以原数组和函数内部的数组可以理解成是两个不同的数组.但是内部的值是相同的.所以函数内外看似是对同一个数组操作,实际上是值拷贝操作.)
- 切片是引用类型.将一个切片赋值给另一个切片时,传递的是一份浅拷贝.函数传参操作不会拷贝整个切片.只会赋值len和cap.底层共用一个数组.不会占用额外的内存.函数内对数组元素值的修改,会将函数外的数组的值一同修改.
计算数组长度方式不同
- 数组需要遍历计算数组长度,时间复杂度为O(n),
- 切片底层包含一个len字段.可以直接通过该字段的值来知道切片的长度.时间复杂度为O(1).
10.slice深拷贝和浅拷贝
- 深拷贝:拷贝的是数据本身,创造一个新对象,分配一个新内存地址,新对象与原有的对象不共享内存,修改新对象的值不会影响原对象的数据.同理修改原对象的值不会影响新对象的数据.
- 浅拷贝:拷贝的是数据地址,只复制指向数据对象的指针.此时新对象和老对象指向内存的位置是一样的.新对象值修改时老对象也会变化.
11.slice扩容机制是什么?
扩容会发生在slice append的时候.当slice的cap不足以容纳新增成员的时候.那么slice 就会进行扩容.扩容规则如下:
- 如果新申请容量比两倍的原容量大,那么扩容后容量为新申请容量.
- 如果原有slice长度小于1024,那么每次扩容是原来容量的2倍.
- 如果原slice长度大于1024,那么每次扩容是原让那个了的1.25倍.
- 如果最终容量计算值溢出,则最终容量就是新申请荣联.(超出系统可分配内存的情况下.)
- .每次的扩容伴随着内存地址变更.
12.slice为什么不是线程安全的.
- slice底层结构并没有使用加锁等方式,并不支持并发读写.所以并不是线程安全的.使用多个goroutine对类型为slice进行操作的时候,每次输出的值大概率都不会是一样的.因为slice底层是没有加锁的.所以会导致多个不同的goroutine读到相同下标进行操作.
13.map的底层实现原理
- Go中map是一个指针,占用8个字节,指向hmap结构体
- 源码包中src/runtime/map.go定义了hmap的数据结构:
- hmap包含若干个结构为bmap的数组.每个bmap底层都采用连表结构,bmap通常叫期bucket.
14.为什么map遍历是无序的.
主要原因有2点:
- Go 语言中,当我们对 map进行遍历 时,并不是固定地从第一个数开始遍历,每次都是从随机的一个位置开始遍历。即使是一个不会改变的的 map,仅仅只是遍历它,也不太可能会返回一个固定顺序了
- Go的map遍历结果无需,本质上收到两个方面的影响:"无需写入和扩容的影响"
- 无序写入可以分成两种情况.
- 1.正常写入,(非哈希冲突写入):虽然buckets是一块连续的内存,但是每次写入都会通过hash到某一个bucket上,而不是按照buckets顺序写入,
- 2.哈希冲突写入,如果存在hash冲突的情况.那么数据就会写在同一个bucket上,因为每个bucket只能存放8对键值对.所以当超过这个数量的时候就会在创建一个bucket.然后对应的key会指向新的bucket.map的扩容会将原有所有的key都迁移到新的地址上.所以在map遇到扩容之后对应的内存地址会发生变化.所以无法做到有序.
- map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。
15.map为什么是非线程安全的
- 在数据插入的时候
- 加入AB两个协程同事对同一个map进行操作.然后计算出了相同的哈希值对应想用的bucket数组位置,因为此时应该位置还没有数据,所以两个协成对同一个数组的头部位置进行数据写入,当A写入完成后,B再次进行写入到同一个位置.那么就会导致A的数据被B覆盖.从而导致A的数据丢失.
- 在map扩容的时候,
16.map如何查找
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)
}
17.map冲突的解决方式
比较常用的Hash冲突解决方案有链地址法和开放寻址法:
- 链地址法
- 当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。hash表存储所有想用记录所在连表的头指针.意思就是根据key找到对应的value,如果value是对应的头指针,那么表示该map是冲突的.然后根据指针找到对应的连表,连表中存储的是key,和value,比对查找的key就可以将对应的value拿到.
- 开放寻址法
18.什么是负载因子?map的负载因子为什么是6.5?
- 负载因子是用于衡量当前哈希表空间占用率的核心指标,也就是每个bucket桶存储的平均元素个数.Go官方发现,装载因子越大,填入的元素越多.空间利用率越高.但是发生hash冲突的几率就会变大.反之装载因子越小,填入的数据就越少,冲突发生的几率就越小.但是空间浪费就会变的更严重.而且还会提高扩容操作的次数.根据这个测试结果和讨论,官方综合给出了一个较为适中的值.把Go中的map负载因子硬编码置为6.5,这就是map中负载因子是6.5的由来.这就意味着Go语言中当map存储的元素个数大于或者等于6.5*桶的个数的时候就会出发扩容行为.
19.map如何扩容
双倍扩容:数据太多,增加桶的数量.扩容采取了一种称为渐进式的方式,原有的key并不会一次性搬迁完成,每次最多只会搬迁2个bucket.
等量扩容:重新排列,(是一个整理的过程.)极端情况下,重新排列也解决不了,map存储就会蜕变成链表,性能大大降低,此时哈希因子hash0的设置,可以降低此类极端情况发生.
- 装载系数或者溢出桶的增加,会触发map扩容
- “扩容”可能并不是增加桶的数量,而是整理数据,使数据更加紧凑
- map扩容采用渐进式,桶被操作时才会重新分配
20.map和sync.Map谁的性能最好,为什么?
这是sync.map底层的数据结构.
type Map struct {
mu Mutex
read atomic.Value // readOnly缓存字段.读取map的时候不加锁.
//写入的时候要顺带更新这个字段的缓存.空间占用率会更高.
//使用空间换读取操作的时间.
dirty map[interface{}]*entry //写入时候需要写入的真实map.操作.
misses int
}
// Map.read 属性实际存储的是 readOnly。
type readOnly struct {
m map[interface{}]*entry
amended bool
}
21.channel有什么特点
- 给一个nil channel发送数据会导致永远阻塞
- 从一个nil channel中接收数据.会造成永远阻塞.
- 给一个已经关闭的channel发送数据,会引起panic
- 从一个已经关闭的channel接收数据,如果缓冲区为空,则返回一个零值
- 无缓冲区的channel是同步的.有缓冲区的channel是异步的.
22.channel的底层实现原理是什么?
- Go中的channel是一个队列,遵循先进先出的原则.负责协程之间的通讯,(Go语言提倡不要通过共享内存来实现通信,而是要通过通信来实现内存的共享,CSP并发模型就是通过goroutine和channel来实现的.)通过var声明或者make函数创建的channel变量是一个存储在函数栈上的指针.占用8个字节,指向堆上的hchan结构体.
type hchan struct {
closed uint32 // channel是否关闭的标志
elemtype *_type // channel中的元素类型
// channel分为无缓冲和有缓冲两种。
// 对于有缓冲的channel存储数据,使用了 ring buffer(环形缓冲区) 来缓存写入的数据,本质是循环数组
// 为啥是循环数组?普通数组不行吗,普通数组容量固定更适合指定的空间,弹出元素时,普通数组需要全部都前移
// 当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
buf unsafe.Pointer // 指向底层循环数组的指针(环形缓冲区)
qcount uint // 循环数组中的元素数量
dataqsiz uint // 循环数组的长度
elemsize uint16 // 元素的大小
sendx uint // 下一次写下标的位置
recvx uint // 下一次读下标的位置
// 尝试读取channel或向channel写入数据而被阻塞的goroutine
recvq waitq // 读等待队列
sendq waitq // 写等待队列
lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}
等待队列:双向链表,包含一个头结点和一个尾结点
每个节点是一个sudog结构体变量,记录哪个协程在等待,等待的是哪个channel,等待发送/接收的数据在哪里
type waitq struct {
first *sudog
last *sudog
}
type sudog struct {
g *g
next *sudog
prev *sudog
elem unsafe.Pointer
c *hchan
...
}
23.channelyou无缓冲的区别
- 不带缓冲的channel是同步的,带缓冲的channel是异步的,不带缓冲的channel中每一个发送者与接受者都会祖泽当前现场,只有当接受者与发送者都准备就绪了,channel才能正常使用.带缓冲的channel并不能无线接受数据而不造成阻塞,能够接受的个数取决于channel定义是,设定的缓冲的大小.只有在这个缓冲范围内,向channel发送的数据才不会造成channel阻塞.
24.channel为什么是线程安全的?
- 不同协程之间的通信本身的使用场景就是多线程的,为了保证数据一致性,必须实现线程安全.因此channel的底层实现中,hchan的结构体中采用了Mutex锁来保证数据读写安全.在对循环数组buf中的数据进行入队和出队操作是,必须先获取到互斥锁才可以操作channel中的数据.
25.channel如何控制goroutine并发执行程序
- 使用channel进行通信通知.用channel去传递信息.从而控制并发执行顺序.
- 使用channel的通信机制进行goroutine执行顺序的控制.
- 首先是默认情况下读goroutine在没接收到channel中信号的时候是处于阻塞状态.
- 那么可以利用这个阻塞状态对需要优先执行的goroutine A进行先执行,然后A执行过程结束后将B需要的channel写入到B channel中.
- 此时正在阻塞的B goroutine收到 Bchannel发送的一个收信号,然后执行B goroutine操作.同理.在B goroutine执行完成之后,将C需要的执行信号发送到C对应的channel中.
- 这样C就可以从channel中获取执行信号.来执行C的操作.那么最终结果就是虽然是在主函数中是同时开启的goroutine操作.但是.因为channel和gorountine的通信机制导致需要串行执行.
channe共享内存有什么优劣势
- Go引入了channel和Goroutine实现CSp模型将生产者消费者进行了解耦,Channel其实和消息队列很相似.
- 优点:使用channel可以帮助我们解耦生产者消费者,可以降低并发中的耦合.
- (高内聚低耦合:一个完整的系统,模块与模块之间,尽可能的使其独立存在,也就是说让每个模块尽可能的独立完成某个特定的子功能,模块与模块之间的接口尽量的少而简单.
- 代码内聚就是一个模块内各个元素彼此结合的紧密程度,高内聚就是一个模块内各个元素彼此结合的紧密程度高,所谓高内聚市值一个软件模块是有相关性很强的代码租车.只负责一项任务.也就是常说的单一职责原则.
- 高内聚低耦合的好处:短期看没有很明显额好处.甚至短期内会影响系统的开发进度.因为高内聚低耦合的系统对开发设计人员提出来更高的要求.长期看低耦合的模块便于进行单元测试,.且易于维护.)
- 缺点:容易死锁.
26.如何那种情况下channel会出现死锁现象?应该怎么解决?或者预防死锁出现?
死锁:
单个协程永久阻塞
两个或两个以上的协程的执行过程中,由于竞争资源或由于彼此通信而造成的一种阻塞的现象。
channel死锁场景:
非缓存channel只写不读
非缓存channel读在写后面
缓存channel写入超过缓冲区数量
空读
多个协程互相等待
总结:空读满写
- 举例.当一个channel中没有数据而直接读取的时候,会发生死锁.
q := make(chan int,2)
<-q
解决方案是采用select语句,再加上default默认处理方式:
q := make(chan int,2)
select{
case val:=<-q:
default:
...
}
当channel中写满后再进行数据写入的时候会造成死锁(有缓冲去的情况下)
q := make(chan int,2)
q<-1
q<-2
q<-3
无缓冲区的情况下,只有写操作没有读操作,只要向channel写入数据就会造成死锁.
q := make(chan int)
q<-1
解决方法:防止过度写入,添加select方法.让过渡写入的数据走select中的default选项.
func main() {
q := make(chan int, 2)
q <- 1
q <- 2
select {
case q <- 3:
fmt.Println("ok")
default:
fmt.Println("wrong")
}
}
注意:向已经关闭的channel中再次写入数据,此时造成的错误不是死锁.而是panic.解决方法只有不像channel中写入数据.但是可以从已经关闭的channel中读取数据.
上述提到的死锁,是指在程序的主线程中发生的情况,如果上述的情况发生在非主线程中,读取或者写入的情况是发生堵塞的,而不是死锁。实际上,阻塞情况省去了我们加锁的步骤,反而是更加有利于代码编写,要合理的利用阻塞。。
27.Go互斥锁的实现原理
type Mutex struct {
state int32
sema uint32
}
Mutex.state表示互斥锁状态.比如是否被锁定.
Mutex.sema表示心好累.协程阻塞等心好累.解锁的协程释放信号量从而环形等待信号的协程.
state内部实现时把该变量分成了四份.并用来记录Mutex的四种状态.
- Locked: 表示该Mutex是否已经被锁定.0:表示没有被锁定. 1: 表示已经被所动.
- worked: 表示是否有协程已被唤醒,0:表示没有,1:表示有协程唤醒,正在加锁过程中.
- starving: 表示该Mutex是否处于饥饿状态,0表示没有饥饿,1表示饥饿.说明有协程阻塞超过了1ms.
Waiter: 表示阻塞等待锁的协程个数.协程解锁时会根据这个值来判断是否需要释放信号量.
协程之间枪锁实际上是抢着给locked赋值的权利.能给locked置为1,说明枪锁成功.抢不到的话就要阻塞等待mutex.sema信号量.一旦持有锁的协程解锁.等待的协程会依次被唤醒.
worked和starving主要用于控制协程之间的枪锁过程.
28.什么是自旋?
自旋对应CPU的"PAUSE"指令,CPU对应该指令什么都不做.相当于CPU空转,对程序来说相当于sleep了一小段时间.时间非常短.当前实现是30个CPU时钟周期.(主频不一样周期也不一样.)
加锁时程序会自动判断是否可以自旋,无限制的自旋将会给CPU带来巨大的压力.所以判断是否可以自旋很重要.
自旋必须满足以下所有条件:
- 锁已被占用,并且锁不处于饥饿模式。
- 积累的自旋次数小于最大自旋次数(active_spin=4)。
- cpu 核数大于 1。
- 有空闲的 P。
- 当前 goroutine 所挂载的 P 下,本地待运行队列为空。
自旋的优势是充分利用cpu,尽量避免协程切换,因为当前申请加锁的协程拥有cpu,如果经过短时间的自旋可以获得锁,那么协程可以继续执行.不比进入阻塞状态和切换cpu线程.(自旋是指正在执行的程序自己为了获取更多的执行时间而产生的.如果短时间内无法执行完成该程序,那么就会自己再次获取锁权限,接着进行执行.如果执行时间超过4次自旋时间,那么就需要进行排队.)
29.互斥锁正常模式和饥饿模式的区别
正常模式(非公平锁):
- 解释一: 所有等待锁的goroutine按照FiFO(先进先出顺序等待.),唤醒的goroutine不会直接拥有锁,而是会和心情求的Goroutine竞争锁,新请求的goroutine更容易获抢占锁,,因为他正在CPU上执行,会有自旋锁与心唤醒的goroutine抢占锁,在这种情况下新唤醒的goroutine会更大程度上抢占到锁.这种情况下新唤醒的groutine会被加入到等待队列的前面,等到锁被释放后,队列前面的goroutine才会再次被唤醒,进行优先抢占锁使用权.\
- 解释二: 正常模式下协程如果加锁不成功不会立即转入等待队列.而是判断是否满足自旋条件.如果满足自旋条件.那么当蚩尤蓑的协程释放锁的时候,会释放一个信号量来唤醒等待队列中的协程.如果有协程处在自旋过程中,锁往往会被该自旋锁获得.被唤醒的协程只能再次阻塞,不过阻塞钱会判断自上次阻塞到本次阻塞经过了多少时间.如果超过1msmutex将会进入饥饿模式.
- 上面的是查到的资料
- 下面是自己的总结.(自旋锁是一个新创建的goroutine在cpu上运行的时产生的尝试给自己加锁的一个称呼.)
正常模式下会有goroutine尝试自旋加锁.
(抢占模式.如果多次之后未抢到锁.
就到队列尾部排队.),如果1ms之内抢到了锁,那么就执行加锁.
然后其他的goroutine中有一部分就继续排队等待另一部分尝试自旋加锁..
当队列中的goroutine被阻塞后在队列中的时间超过1ms的时候
这个goroutine就处于饥饿状态.那么前面无论是否有自旋锁在
抢占加锁.都会将锁分配给队列中首个饥饿状态中的goroutine,
如果占有锁的goroutine占有市场地域1ms就释放锁了.那么就
不会出现饥饿模式,如果队列中没有goroutine在排队,那么也
不会出现饥饿模式.
30.Go 读写锁的实现原理
读写锁RWMutex,是对Mutex的一个扩展,当一个Goroutine获得了读锁之后.其他goroutine
可以获取读锁,但是不能获取写锁.当一个Goroutine获取写锁后其他goroutine既不能获取读锁也不能获取写锁.(只能存在一个写或者 多个读.)
底层实现结构:
type RWMutex struct {
w Mutex // 控制 writer 在 队列B 排队
writerSem uint32 // 写信号量,用于等待前面的 reader 完成读操作
readerSem uint32 // 读信号量,用于等待前面的 writer 完成写操作
readerCount int32 // reader 的总数量,同时也指示是否有 writer 在队列A 中等待
readerWait int32 // 队列A 中 writer 前面 reader 的数量
}
// 允许最大的 reader 数量
const rwmutexMaxReaders = 1 << 30
举例:假设当前有两个 reader,readerCount = 2;允许最大的reader 数量为 10
- 当 writer 进入队列A 时,readerCount = readerCount - rwmutexMaxReaders = -8,readerWait = readerCount = 2
- 如果再来 3 个reader,readerCount = readerCount + 3 = -5
- 获得读锁的两个reader 执行完后,readerCount = readerCount - 2 = -7,readerWait = readerWait-2 =0,writer 获得锁
- writer 执行完后,readerCount = readerCount + rwmutexMaxReaders = 3,当前有 3个 reader
实现方法:
func (rw *RWMutex) RLock() // 加读锁
func (rw *RWMutex) RUnlock() // 释放读锁
func (rw *RWMutex) Lock() // 加写锁
func (rw *RWMutex) Unlock() // 释放写锁
RWMutex读写优先策略:
- 队列 A 最多只允许有 一个writer,如果有其他 writer,需要在 队列B 等待;
- 当一个 writer 到了 队列A 后,只允许它 之前的reader 执行读操作,新来的 reader 需要在 队列A 后面排队;
- 当前面的 reader 执行完读操作之后,writer 执行写操作;
- writer 执行完写操作后,让 后面的reader 执行读操作,再唤醒队列B 的一个 writer 到 队列A 后面排队。
互斥锁与读写锁的区别:
- 读写锁区分读操作和写操作,而互斥锁是不区分读写的.只要申请锁那么在锁释放前,其他的任何一种锁都是不会申请成功的.只能等待.
- 互斥锁同一时间只允许一个线程访问该对象,无论读写操作,读写锁同一时间只允许一个写操作,但是可以有多个读操作可以同时执行.
31.Go原子操作有哪些?
- 原子操作仅会由一个独立的CPU指令代表和完成.原子操作是无锁的.尝尝直接通过CPU指令直接实现.其他同步技术的实现尝尝依赖原子操作.
- 当我们想要对某个变量并发安全的操作的时候,除了可以使用官方提供的MuTex之外,还可以使用sync.atomic包的原子操作.能够保证变量在读取或者修改的时候不被其他协程锁影响.
- atomic包提供的原子操作能够保证任意时刻都只有一个goroutine对变量进行操作.善用atomic能够避免程序中出现大量的锁操作.
- 常见操作:
- 增减Add
- 载入Load
- 比较并交换CompareAndSwap
- 交换Swap
- 存储Store
32.原子操作和锁的区别是什么?
- 原子操作是由底层硬件支持,而锁是基于原子操作信号+信号量完成的.若实现相同的功能,前者通常会更有效率,
- 原子操作是单个指令的互斥操作;互斥锁/读写锁都是一种数据结构,可以完成临界区(多个指令)的互斥操作.扩大原子操作的范围.
- 原子操作是无锁状态.属于乐观锁;一般使用的属于悲观锁.
- 原子操作存在与各个指令/语言层级,比如机器指令层级的原子操作.汇编层级的原子操作.Go语言级别的原子操作等.
- 锁也存在于各个指令/语言中.比如机器指令层级,汇编指令层级的锁."go语言层级的锁"
33.Goroutine的底层实现原理?
Goroutine可以理解成Go语言的协程(轻量级的线程),是Go支持高并发的基础,属于用户态的线程.由GoRuntime管理而不是操作系统.他由语言本身和框架层调度.Golang在语言层面实现了调度器,同时对网络,IO,进行了封装处理.屏蔽了操作系统层面的复杂细节,在语言层面提供统一的关键字支持.
GMP 调度模型
G=Goroutine 协程,P=Processor 处理器, M=Thread 线程
- 全局队列(Global Queue):存放等待运行的 G。
- P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
- M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去.
GMP对应关系
- Goroutine调度器和OS调度器是通过M结合起来的.每个M都会与1个内核线程进行绑定,OS调度器负责吧内核线程分配到CPU的核心上执行,在运行是一个M同时只能绑定一个P,M和P是1对1 绑定的.M和P的组合构成了G的有效运行环境.但是M和P会实时的组合和断开,以保证待执行的G队列能够得到及时的执行,而P和G的关系是1对多的,多个可执行G会顺序排查一个队列挂载某个P上,在运行过程中,M和内核线程之间的对应关系是不变的.在M的声明周期内他只会和一个内核线程绑定,二M和P以及P和G之间的关系都是动态可变的.
- M与O的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以即使P的数量是1 ,也可能会创建多个M出来.但是因为P的存在,G和M可以呈现多对多的关系,当一个正在与某个M对接并运行这都的G,需要因某个事件,比如等待IO或者等待锁的解除.而暂停运行的时候,调度器总会即使的发现,并吧这个G与那个M分离开,用来释放计算资源供那些等待运行的G使用.
M和P何时会被创建?
P何时会被创建?
- 在确定了P的最大数量n后,运行时系统会根据这个数量来创建n个P,
M何时会被创建?
- 没有足够的M来关联P并运行其中的可运行的G的时候(意思就是G的可执行数量比较多的时候,并且有足够的P,这个时候如果M不足的话就会创建M),
- 比如当前可用的所有的M此时都阻塞住了,而P中还要很多就绪任务,就会去寻找空闲的M,而没有空闲的M就会去创建M.
34. go func调度流程是什么?
当Processor中的G产生系统调用/IO时,处理流程如下,假设此时运行线程为M0:
- Processor感知M0正在处理的协程G0处于系统调用的阻塞状态
- Processor将G0从自己的G队列中移除
- Processor重新申请新的M1,来继续执行G队列
- 如果有空闲的M,则直接复用空闲M
- 如果无空间的M,则新建一个线程M
- M0执行完G0的系统调用后,G0将存放在全局G队列中,等待某个Processor唤起
- 同时M0也进入空闲状态,等待其他P复用,或者被销毁
- 所以,系统中M的个数通常会略多于P的个数,但同时执行的M个数和P数量一样
注:Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上
35.goroutine和线程的区别是什么?
goroutine | 线程 | |
内存占用 | 创建一个Goroutine的栈内存消耗为几KB,实际运行过程中如果开辟的栈内存空间不够用,那么会自行扩容. | 创建一个栈内存消耗为几百KB-一两兆的空间 |
创建和销毁 | goroutine是由runtime负责管理的,创建和销毁都是用户级别,创建和销毁资源都是runtime包进行管理.不需要和操作系统进行直接交互, | 需要跟操作系统直接交互申请创建资源和归还资源.所以增加了系统资源的开销.相比goroutine来说更重一些. |
切换 | goroutine实现高并发的主要原因是由于切换开销更小,这也是和线程之间最主要的区别.goroutine的调度是协同式的,不会直接与操作系统内核打交道.当goroutine进行切换时,只有少量的寄存器需要保存和恢复,更多的系统级的应用状态和信息不需要进行保存和恢复.所以切换速度更快.代价更小 | 线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给他的时间片,就会被其他可执行的线程抢占,线程切换过程中需要保存和恢复所有计算器中的信息,包含的更多系统界别的应用,所以恢复的数据也会更多.时间更长. |
36.Goroutine泄露的场景有哪些?
泄露原因
Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。
channel使用不当:
channel发送没有接收者,
func main() {
for i := 0; i < 4; i++ {
queryAll()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
func queryAll() int {
ch := make(chan int)
for i := 0; i < 3; i++ {
go func() { ch <- query() }()
}
return <-ch
}
func query() int {
n := rand.Intn(100)
time.Sleep(time.Duration(n) * time.Millisecond)
return n
}
输出结果:
goroutines: 3
goroutines: 5
goroutines: 7
goroutines: 9
这里的原因是每次每次调用queryAll都会启动3个Goroutine,但是每次return只会接受一个ch中的值,也就是会有两个ch的值是没有被接受的.这样就会导致每次都会有2个goroutine会处于等待数据接受状态,从而导致goroutine只增不减,出现泄露的情况.
goroutine启动后内部channel只接受数据.不进行数据发送
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
ch := make(chan struct{})
go func() {
ch <- struct{}{}
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
这里初始化完成channel后向channel中写入了一个空struct,但是并没有消费者对这个channel进行消费.(正常情况下也写不进去.因为没有缓存,)所以
nil channel
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan int
go func() {
<-ch
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
这里出现goroutine泄露的原因是,启动goroutine消费channel中的信息,但是chanel中没有写入信息.,导致goroutine阻塞住,从而这个goroutine就不会被释放,一直监听channel消息的到来,(但是这里的channel并没有初始化,并没有分配内存空间,是无法将消息放到channel中的.所以这个goroutine每次运行后都无法释放.从而导致goroutine泄露.)
goroutine中的请求等待时间过长并且没有超时时间
func main() {
for {
go func() {
_, err := http.Get("https://www.xxx.com/")
if err != nil {
fmt.Printf("http.Get err: %v\n", err)
}
// do something...
}()
time.Sleep(time.Second * 1)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}
}
输出结果
goroutines: 5
goroutines: 9
goroutines: 13
goroutines: 17
goroutines: 21
goroutines: 25
...
这个例子中,展示了一个Go语言中经典的事故场景,也就是一般我们会在应用程序中取调用第三方服务接口,但是第三方接口有时候会很慢,久久没有返回响应结果,恰好 Go语言中默认的http.client是没有设置超时时间的.因此就会导致一直阻塞.多次请求就会导致一直上涨,(这里使用了for循环模拟多次请求.)
所以我们在用goroutine进行第三方接口调用的时候,需要加上超时时间.超过这个时间的话就发送一个channel信号将当前goroutine停止.
互斥锁忘记解锁
func main() {
total := 0
defer func() {
time.Sleep(time.Second)
fmt.Println("total: ", total)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
total += 1
}()
}
}
输出结果:
total: 1
goroutines: 10
因为goroutine中加锁.后并没有进行解锁.所以total只能被加锁的goroutine进行操作.导致goroutine创建后无法释放.释放方法就是同一个goroutine中解锁即可.
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
defer mutex.Unlock()
total += 1
}()
}
同步锁使用不同步
func handle(v int) {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < v; i++ {
fmt.Println("脑子进煎鱼了")
wg.Done()
}
wg.Wait()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
go handle(3)
time.Sleep(time.Second)
}
这里的结果是调用函数的时候预先添加过量的wg.add数量.导致创建的数量与结束的数量不匹配.所以wg.wait就会一直不清零,导致一直阻塞.
正常使用是每次wg.add(1), 代码结束之前 defer wg.done(-1), 来使用.这样的话创建的数量和结束的数量是成对存在的.就不会存在goroutine阻塞泄露的情况了.
排查方法
调用runtime.NumGoroutine方法来获取运行数量.进行前后比较,就可以知道有没有泄露了.
但是线上业务不适应这个命令.所以大多数生产,测试,环境使用pprof进行检查.
泄露场景:
- 如果输出的Goroutines数量在不断增加,说明存在泄露的情况.
37.如何查看正在执行的goroutine数量
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
for i := 0; i < 100; i++ {
go func() {
select {}
}()
}
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
select {}
}
终端执行就可以看到(也可以通过页面查看) 引用(go性能分析工具pprof)
go tool pprof -http=:1248 http://127.0.0.1:6060/debug/pprof/goroutine
38.如何控制并发额goroutine数量?
可以使用Wait.Group启动指定数量的goroutine,监听channel的通知.发送者推送信息到channel,信息处理完了关闭channel,等待goroutine依次退出.
var (
// channel长度
poolCount = 5
// 复用的goroutine数量
goroutineCount = 10
)
func pool() {
jobsChan := make(chan int, poolCount)
// workers
var wg sync.WaitGroup
for i := 0; i < goroutineCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobsChan {
// ...
fmt.Println(item)
}
}()
}
// senders
for i := 0; i < 1000; i++ {
jobsChan <- i
}
// 关闭channel,上游的goroutine在读完channel的内容,就会通过wg的done退出
close(jobsChan)
wg.Wait()
}
39.GMP和GM模型
- G(Goroutine) : 代表Go协程Goroutine,存储了Goroutine的执行栈信息,Goroutine状态以及Goroutine的任务函数等,G的数量无限制,理论上只收内存大小影响.创建一个G的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个Goroutine,而且Go在G退出的时候会吧G清理之后放到P本地或者全局闲置列表,gFree中以便下次复用,
- M(Machin : Go对操作系统线程(OS Thread) 的封装,可以看做操作系统的内核线程,想要在CPU上执行代码必须有线程,通通过系统调用clone创建,M在绑定有效P之后,进入一个调度循环,二调度循环的机制大致是从P的本地运行队列以及全局队列中获取G,切换到G的执行栈上执行G的函数,调用goexit做清理工作并回到M上,如此反复,M并不保留G的状态,这是G 可以跨M调度的基础,M的数量是有限的,默认最大是10000,可以通过debug.SetMaxThreads()方法进行设置,如果他有空闲M那么就会回收或者睡眠,如果M不够用那么就会自动创建,M对应P的关系是(M:N),就是两者并不是绝对的数量绑定关系.
- P虚拟处理器,执行G所需要的资源和上下文,只有将P和M绑定,才能让P中的runq真正的运行起来,P的数量决定了系统内最大可并行的G的数量. P的数量受本地CPU核心数量影响,可以通过改变环境变量 $GOMAXPROCS来设置CPU核心数.
- Sched 调度器结构,他维护有存储M和G的全局队列.以及调度器本身的状态信息.
40.Go调度原理(参考33,34条)
CPU是无法直接感知到Goroutine的,只知道内核线程,所以需要Go调度器将协程调度到内核线程上去,然后操作系统调度器将内核线程放到CPU上去执行.
M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M上去,
41.Go hand off机制
也称为P分离机制,当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的 M 执行,也提高了线程利用率
42.Go work stealing 机制
Work-stealing
43.Go抢占式调度
真正的抢占式是基于信号完成的,所以也成为了"异步抢占",不管协程有没有意愿让出CPU执行权限,只要某个协程执行时间过长,就会发送信号强行夺取cpu使用权限.
- M注册一个sigurg信号的处理函数,sighandler
- sysmon启动后会间隔性的进行监控.最长间隔10ms,最短间隔20us,如果发现某协程独占P的时间超过10ms,会给M发送抢占信号.
- M收到抢占信号后,内核执行sighandeler函数把当前协程的状态从_Gorunning正在执行改成Grunnable可执行,把抢占的协程放到全队队列里.M继续寻找其他Goroutine来执行.
- 被抢占的G再次调度过来执行时,会继续原来的执行流.
翻译一下就是,goroutine每10ms就会切换一次,这个切换执行的goroutine要么是处在饥饿状态下的goroutine,要么是处在自旋锁状态的goroutine,要么就是P本地队列或者全局队列中的goroutine,
goroutine执行的优先级别是,饥饿>自旋锁状态goroutine>本地队列>全局队列
44.Go如何查看运行时调度信息
有两种方式可以查看一个程序的调度GMP信息,分别是go tool trance 和GODEBUG.
45.Go内存逃逸机制
概念
在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少 GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。
逃逸机制
- 编译器会根据变量是否被外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
- 如果栈上放不下,则必定放到堆上;
总结
栈上分配内存比在堆中分配内存效率更高
栈上分配的内存不需要 GC 处理,而堆需要
逃逸分析目的是决定内分配地址是栈还是堆
逃逸分析在编译阶段完成
因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点。
46.内存对齐机制
因为不同类型的变量占用内存的大小是不一样的,但是cpu每次读取的内存长度是固定的,为了cpu能高效的读写数据(cpu读取数据不是一个字节一个字节读取的,一次读取的一块内存),所以编译器在编译的时候会通过填充空数据(数据不是连续的),让一个变量,使cpu能一次操作就能完成读写。还有跨平台的问题,有的平台不支持访问任意地址上的任意数据,必须按照顺序依次按块读取。