目录


1.go有哪些数据类型?

  1. 布尔型 bool
  2. 数字类型 uint int float32 float64 byte rune
  3. 字符串类型  string
  4. 复合类型 数组类型 (array) 切片类型(slice) 字典类型(map) 管道类型(channel) 结构化类型(structure)
  5. 指针类型 pointer
  6. 接口类型 interface{}
  7. 函数类型 func
  8. 方法类型 method

2.方法与函数有什么区别?

  1. 函数是值不属于任何结构体,类型的方法,也就是说函数是没有接受者的,方法是有指定的接收者

3.方法中值接收者与指针接收者的区别是什么?

  1. 如果方法的接收者是指针,无论调用者是对象还是对象指针,修改的都是对象本身,惠永祥调用者,
  2. 如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者本身
  3. (指针接收者会造成变量逃逸现象,并且会将变量分配到堆中.需要GC才能进行内存回收.)

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

  1. 一般来说局部变量会在函数返回后直接被销毁,所以在函数返回后该变量就变成了无所知的引用.程序会进入未知状态.但是这在Go中是安全的.Go编译器会对每个局部变量进行逃逸分析.如果发现局部变量的作用域超过该函数.则不会将内存分配到栈上,而是分配到堆上,因为他们不在栈区,即使释放函数其内容本身也不会受影响.

5.函数参数传递值是值传递还是引用传递?

  1. Go语言中所有的传参都是值传递,都是一个副本,一个拷贝.
  2. 参数如果是非引用类型(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的区别是什么?

数据长度不同,

  1. 数组初始化必须指定长度,并且长度固定不可变.
  2. 切片长度不是固定的,可以追加元素,再追加时可能使切片的容量增大.

函数传参不同

  1. 数组是值类型,将一个数组赋值给另一个数组的时候传递的是一份深拷贝,函数传参操作会复制整个数组的数据,会占用额外的内存,函数对数组元素值的修改,不会修改原数组内容.(因为是值拷贝,所以原数组和函数内部的数组可以理解成是两个不同的数组.但是内部的值是相同的.所以函数内外看似是对同一个数组操作,实际上是值拷贝操作.)
  2. 切片是引用类型.将一个切片赋值给另一个切片时,传递的是一份浅拷贝.函数传参操作不会拷贝整个切片.只会赋值len和cap.底层共用一个数组.不会占用额外的内存.函数内对数组元素值的修改,会将函数外的数组的值一同修改.

计算数组长度方式不同

  1. 数组需要遍历计算数组长度,时间复杂度为O(n),
  2. 切片底层包含一个len字段.可以直接通过该字段的值来知道切片的长度.时间复杂度为O(1).

10.slice深拷贝和浅拷贝

  1. 深拷贝:拷贝的是数据本身,创造一个新对象,分配一个新内存地址,新对象与原有的对象不共享内存,修改新对象的值不会影响原对象的数据.同理修改原对象的值不会影响新对象的数据.
  2. 浅拷贝:拷贝的是数据地址,只复制指向数据对象的指针.此时新对象和老对象指向内存的位置是一样的.新对象值修改时老对象也会变化.

11.slice扩容机制是什么?

扩容会发生在slice append的时候.当slice的cap不足以容纳新增成员的时候.那么slice 就会进行扩容.扩容规则如下:

  1. 如果新申请容量比两倍的原容量大,那么扩容后容量为新申请容量.
  2. 如果原有slice长度小于1024,那么每次扩容是原来容量的2倍.
  3. 如果原slice长度大于1024,那么每次扩容是原让那个了的1.25倍.
  4. 如果最终容量计算值溢出,则最终容量就是新申请荣联.(超出系统可分配内存的情况下.)
  5. .每次的扩容伴随着内存地址变更.

12.slice为什么不是线程安全的.

  1. slice底层结构并没有使用加锁等方式,并不支持并发读写.所以并不是线程安全的.使用多个goroutine对类型为slice进行操作的时候,每次输出的值大概率都不会是一样的.因为slice底层是没有加锁的.所以会导致多个不同的goroutine读到相同下标进行操作.

13.map的底层实现原理

  1. Go中map是一个指针,占用8个字节,指向hmap结构体
  2. 源码包中src/runtime/map.go定义了hmap的数据结构:
  3. hmap包含若干个结构为bmap的数组.每个bmap底层都采用连表结构,bmap通常叫期bucket.

14.为什么map遍历是无序的.

主要原因有2点:

  • Go 语言中,当我们对 map进行遍历 时,并不是固定地从第一个数开始遍历,每次都是从随机的一个位置开始遍历。即使是一个不会改变的的 map,仅仅只是遍历它,也不太可能会返回一个固定顺序了
  • Go的map遍历结果无需,本质上收到两个方面的影响:"无需写入和扩容的影响"
  • 无序写入可以分成两种情况.
  1. 1.正常写入,(非哈希冲突写入):虽然buckets是一块连续的内存,但是每次写入都会通过hash到某一个bucket上,而不是按照buckets顺序写入,
  2. 2.哈希冲突写入,如果存在hash冲突的情况.那么数据就会写在同一个bucket上,因为每个bucket只能存放8对键值对.所以当超过这个数量的时候就会在创建一个bucket.然后对应的key会指向新的bucket.map的扩容会将原有所有的key都迁移到新的地址上.所以在map遇到扩容之后对应的内存地址会发生变化.所以无法做到有序.
  3. map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。

15.map为什么是非线程安全的

  • 在数据插入的时候
  1. 加入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冲突解决方案有链地址法和开放寻址法:

  • 链地址法
  1. 当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。hash表存储所有想用记录所在连表的头指针.意思就是根据key找到对应的value,如果value是对应的头指针,那么表示该map是冲突的.然后根据指针找到对应的连表,连表中存储的是key,和value,比对查找的key就可以将对应的value拿到.
  • 开放寻址法

18.什么是负载因子?map的负载因子为什么是6.5?

  1. 负载因子是用于衡量当前哈希表空间占用率的核心指标,也就是每个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有什么特点

  1. 给一个nil channel发送数据会导致永远阻塞
  2. 从一个nil channel中接收数据.会造成永远阻塞.
  3. 给一个已经关闭的channel发送数据,会引起panic
  4. 从一个已经关闭的channel接收数据,如果缓冲区为空,则返回一个零值
  5. 无缓冲区的channel是同步的.有缓冲区的channel是异步的.

22.channel的底层实现原理是什么?

  1. 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无缓冲的区别

  1. 不带缓冲的channel是同步的,带缓冲的channel是异步的,不带缓冲的channel中每一个发送者与接受者都会祖泽当前现场,只有当接受者与发送者都准备就绪了,channel才能正常使用.带缓冲的channel并不能无线接受数据而不造成阻塞,能够接受的个数取决于channel定义是,设定的缓冲的大小.只有在这个缓冲范围内,向channel发送的数据才不会造成channel阻塞.

24.channel为什么是线程安全的?

  1. 不同协程之间的通信本身的使用场景就是多线程的,为了保证数据一致性,必须实现线程安全.因此channel的底层实现中,hchan的结构体中采用了Mutex锁来保证数据读写安全.在对循环数组buf中的数据进行入队和出队操作是,必须先获取到互斥锁才可以操作channel中的数据.

25.channel如何控制goroutine并发执行程序

  1. 使用channel进行通信通知.用channel去传递信息.从而控制并发执行顺序.
  2. 使用channel的通信机制进行goroutine执行顺序的控制.
  3. 首先是默认情况下读goroutine在没接收到channel中信号的时候是处于阻塞状态.
  4. 那么可以利用这个阻塞状态对需要优先执行的goroutine A进行先执行,然后A执行过程结束后将B需要的channel写入到B  channel中.
  5. 此时正在阻塞的B goroutine收到 Bchannel发送的一个收信号,然后执行B goroutine操作.同理.在B goroutine执行完成之后,将C需要的执行信号发送到C对应的channel中.
  6. 这样C就可以从channel中获取执行信号.来执行C的操作.那么最终结果就是虽然是在主函数中是同时开启的goroutine操作.但是.因为channel和gorountine的通信机制导致需要串行执行.

channe共享内存有什么优劣势

  •  Go引入了channel和Goroutine实现CSp模型将生产者消费者进行了解耦,Channel其实和消息队列很相似.
  1. 优点:使用channel可以帮助我们解耦生产者消费者,可以降低并发中的耦合.
  2. (高内聚低耦合:一个完整的系统,模块与模块之间,尽可能的使其独立存在,也就是说让每个模块尽可能的独立完成某个特定的子功能,模块与模块之间的接口尽量的少而简单.
  3. 代码内聚就是一个模块内各个元素彼此结合的紧密程度,高内聚就是一个模块内各个元素彼此结合的紧密程度高,所谓高内聚市值一个软件模块是有相关性很强的代码租车.只负责一项任务.也就是常说的单一职责原则.
  4. 高内聚低耦合的好处:短期看没有很明显额好处.甚至短期内会影响系统的开发进度.因为高内聚低耦合的系统对开发设计人员提出来更高的要求.长期看低耦合的模块便于进行单元测试,.且易于维护.)
  • 缺点:容易死锁.

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的四种状态.

在这里插入图片描述

  1.  Locked: 表示该Mutex是否已经被锁定.0:表示没有被锁定. 1: 表示已经被所动.
  2. worked: 表示是否有协程已被唤醒,0:表示没有,1:表示有协程唤醒,正在加锁过程中.
  3. starving: 表示该Mutex是否处于饥饿状态,0表示没有饥饿,1表示饥饿.说明有协程阻塞超过了1ms.

Waiter: 表示阻塞等待锁的协程个数.协程解锁时会根据这个值来判断是否需要释放信号量.

协程之间枪锁实际上是抢着给locked赋值的权利.能给locked置为1,说明枪锁成功.抢不到的话就要阻塞等待mutex.sema信号量.一旦持有锁的协程解锁.等待的协程会依次被唤醒.

worked和starving主要用于控制协程之间的枪锁过程.

28.什么是自旋?

自旋对应CPU的"PAUSE"指令,CPU对应该指令什么都不做.相当于CPU空转,对程序来说相当于sleep了一小段时间.时间非常短.当前实现是30个CPU时钟周期.(主频不一样周期也不一样.)

加锁时程序会自动判断是否可以自旋,无限制的自旋将会给CPU带来巨大的压力.所以判断是否可以自旋很重要.

自旋必须满足以下所有条件:

  1. 锁已被占用,并且锁不处于饥饿模式。
  2. 积累的自旋次数小于最大自旋次数(active_spin=4)。
  3. cpu 核数大于 1。
  4. 有空闲的 P。
  5. 当前 goroutine 所挂载的 P 下,本地待运行队列为空。

自旋的优势是充分利用cpu,尽量避免协程切换,因为当前申请加锁的协程拥有cpu,如果经过短时间的自旋可以获得锁,那么协程可以继续执行.不比进入阻塞状态和切换cpu线程.(自旋是指正在执行的程序自己为了获取更多的执行时间而产生的.如果短时间内无法执行完成该程序,那么就会自己再次获取锁权限,接着进行执行.如果执行时间超过4次自旋时间,那么就需要进行排队.)

29.互斥锁正常模式和饥饿模式的区别

正常模式(非公平锁): 

  1.         解释一: 所有等待锁的goroutine按照FiFO(先进先出顺序等待.),唤醒的goroutine不会直接拥有锁,而是会和心情求的Goroutine竞争锁,新请求的goroutine更容易获抢占锁,,因为他正在CPU上执行,会有自旋锁与心唤醒的goroutine抢占锁,在这种情况下新唤醒的goroutine会更大程度上抢占到锁.这种情况下新唤醒的groutine会被加入到等待队列的前面,等到锁被释放后,队列前面的goroutine才会再次被唤醒,进行优先抢占锁使用权.\
  2.         解释二: 正常模式下协程如果加锁不成功不会立即转入等待队列.而是判断是否满足自旋条件.如果满足自旋条件.那么当蚩尤蓑的协程释放锁的时候,会释放一个信号量来唤醒等待队列中的协程.如果有协程处在自旋过程中,锁往往会被该自旋锁获得.被唤醒的协程只能再次阻塞,不过阻塞钱会判断自上次阻塞到本次阻塞经过了多少时间.如果超过1msmutex将会进入饥饿模式.
  3. 上面的是查到的资料
  4. 下面是自己的总结.(自旋锁是一个新创建的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读写优先策略:

  1. 队列 A 最多只允许有 一个writer,如果有其他 writer,需要在 队列B 等待;
  2. 当一个 writer 到了 队列A 后,只允许它 之前的reader 执行读操作,新来的 reader 需要在 队列A 后面排队;
  3. 当前面的 reader 执行完读操作之后,writer 执行写操作;
  4. writer 执行完写操作后,让 后面的reader 执行读操作,再唤醒队列B 的一个 writer 到 队列A 后面排队。
     

互斥锁与读写锁的区别:

  1. 读写锁区分读操作和写操作,而互斥锁是不区分读写的.只要申请锁那么在锁释放前,其他的任何一种锁都是不会申请成功的.只能等待.
  2. 互斥锁同一时间只允许一个线程访问该对象,无论读写操作,读写锁同一时间只允许一个写操作,但是可以有多个读操作可以同时执行.

31.Go原子操作有哪些?

  • 原子操作仅会由一个独立的CPU指令代表和完成.原子操作是无锁的.尝尝直接通过CPU指令直接实现.其他同步技术的实现尝尝依赖原子操作.
  • 当我们想要对某个变量并发安全的操作的时候,除了可以使用官方提供的MuTex之外,还可以使用sync.atomic包的原子操作.能够保证变量在读取或者修改的时候不被其他协程锁影响.
  • atomic包提供的原子操作能够保证任意时刻都只有一个goroutine对变量进行操作.善用atomic能够避免程序中出现大量的锁操作.
  • 常见操作:
    • 增减Add
    • 载入Load
    • 比较并交换CompareAndSwap
    • 交换Swap
    • 存储Store

32.原子操作和锁的区别是什么?

  1. 原子操作是由底层硬件支持,而锁是基于原子操作信号+信号量完成的.若实现相同的功能,前者通常会更有效率,
  2. 原子操作是单个指令的互斥操作;互斥锁/读写锁都是一种数据结构,可以完成临界区(多个指令)的互斥操作.扩大原子操作的范围.
  3. 原子操作是无锁状态.属于乐观锁;一般使用的属于悲观锁.
  4. 原子操作存在与各个指令/语言层级,比如机器指令层级的原子操作.汇编层级的原子操作.Go语言级别的原子操作等.
  5. 锁也存在于各个指令/语言中.比如机器指令层级,汇编指令层级的锁."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对应关系

  1. 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之间的关系都是动态可变的.
  2. 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:

  1. Processor感知M0正在处理的协程G0处于系统调用的阻塞状态
  2. Processor将G0从自己的G队列中移除
  3. Processor重新申请新的M1,来继续执行G队列
    1. 如果有空闲的M,则直接复用空闲M
    2. 如果无空间的M,则新建一个线程M
  4. M0执行完G0的系统调用后,G0将存放在全局G队列中,等待某个Processor唤起
  5. 同时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泄露的场景有哪些?

泄露原因

  1. Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。

  2. Goroutine 内的业务逻辑进入死循环,资源一直无法释放。

  3. 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进行检查.

泄露场景:

  1. 如果输出的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模型

  1. G(Goroutine) : 代表Go协程Goroutine,存储了Goroutine的执行栈信息,Goroutine状态以及Goroutine的任务函数等,G的数量无限制,理论上只收内存大小影响.创建一个G的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个Goroutine,而且Go在G退出的时候会吧G清理之后放到P本地或者全局闲置列表,gFree中以便下次复用,
  2. 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),就是两者并不是绝对的数量绑定关系.
  3. P虚拟处理器,执行G所需要的资源和上下文,只有将P和M绑定,才能让P中的runq真正的运行起来,P的数量决定了系统内最大可并行的G的数量.  P的数量受本地CPU核心数量影响,可以通过改变环境变量 $GOMAXPROCS来设置CPU核心数.
  4. 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能一次操作就能完成读写。还有跨平台的问题,有的平台不支持访问任意地址上的任意数据,必须按照顺序依次按块读取。