Golang

442 阅读11分钟

一、并发

1 GMP模型——goroutine调度——scheduler调度器

1.1 GMP优点

GMP模型可以在用户空间实现任务的切换,上下文切换成本更小,可以达到使用较少的线程数量实现较大并发的能力

1.2 GMP含义

G是goroutine,是golang协程,是用户态轻量级线程。
M是machine,是内核级线程,是实质上实现业务逻辑的载体。
P是processor本地队列

1.3 M必须拥有P,才能执行G中的代码,P负责G的调度。

1.4 如何新增G

M1新增G会被保存在M1所绑定的本地队列P1中,P1队列超过256个满了以后,会把新增的G放进全局队列中

1.5 M如何从P中获取G

从本地队列P中获取G(无锁)
如果本地队列为空,从全局队列中获取G(加锁)
如果全局队列也为空,再去另一个本地队列P中偷一半的G

1.6 当M1在执行G1的时候被阻塞了,如何继续执行

当M1在执行G1的时候阻塞了,M1与绑定的本地队列P1解绑,接着M2绑定P1,然后执行P1的下一个协程G2。

1.7 G的生命周期

  1. 创建G:go func()可以创建协程G
  2. 保存G:创建的G优先保存到本地队列P,如果本地队列P满了,则会放到全局队列P中
  3. M获取G:M1首先从本地队列P1获取G,如果P1为空,则从全局队列中获取G,如果全局队列也为空,则从另一个本地队列偷一半的G
  4. M调度和执行G:M1调用G.func()函数执行协程G,如果M1在执行G的过程被阻塞了,则本地队列P1与M1解绑。其他M绑定P1,继续执行P1的其他G。

1.8 GM和GMP的区别

GMP能减少大量的全局队列锁竞争。
M 绑定本地队列 P 后,直接在 P 中获取、添加和执行 G,是无锁操作。

1.9 M和P的数量

  1. P的个数在程序启动时决定,默认情况下等同于CPU的核数。
  2. M的数量一般大于P的数量
  3. M创建的条件
    没有足够的M来绑定P。
    比如所有的M此时都阻塞了,但是 P 中还有很多就绪任务,就要去寻找空闲 M,没找到空闲的 M 就会去创建新的 M。

2 Golang协程切换时机

  1. 主动调用
  2. 协程执行完成
  3. 管道读写阻塞
  4. 垃圾回收之后
  5. time系列定时操作
  6. 会阻塞的系统调用,比如文件io,网络io

3 如何在Golang中对性能优化

  1. 使用sync包中的锁时,优先考虑用读写锁
  2. 使用 goroutine 和 channel 实现并发
  3. 使用原子操作

4 Golang中线程同步的方法

  1. channel:用于在goroutine之间传递数据和同步操作。
  2. WaitGroup:用于等待一组 goroutine 执行完成后再进行下一步操作。
  3. Mutex 和 RWMutex:用于保护共享资源,避免多个 goroutine 同时访问。
  4. Atomic:用于对共享资源进行原子操作。

5 为什么goroutine支持高并发

  • 协程比线程更小,只有2k,内存里可以有更多的协程处理并发任务
  • 协程的上下文切换不用从用户态切换到内核态,而且保存的寄存器更少,所以切换开销更小,效率更高
  • go内置了协程调度器,通过gmp模型高效的调度goroutine的运行

二、内存

1 GC垃圾回收:三色标记法和混合写屏障

1.1 三色标记法

  1. 在最开始,所有对象的颜色设置成白色
  2. 从根节点开始遍历所有对象,将遍历到的对象放进灰色集合
  3. 遍历灰色集合,将灰色对象引用的对象变为灰色,遍历之后将本对象标记为黑色
  4. 重复第三步,直到灰色中无任何对象
  5. 回收所有白色对象。

1.2 混合写屏障

  1. GC开始将栈上的对象全部扫描并标记为黑色,之后不再进行第二次重复扫描
  2. GC期间,任何在栈上创建的新对象均为黑色
  3. 被删除的对象标记为灰色
  4. 被添加的对象标记为灰色

1.8版本后为了不造成stop the world,提高回收精度混合写屏障满足弱三色不变式,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,不需要STW。因为栈在扫描后始终是黑色的,也不需要进行re-scan操作。

强三色不变式

不存在黑色对象引用到白色对象的指针。

弱三色不变式

黑色对象可以引用白色对象,但前提是白色对象存在其他灰色对象对它的引用,或链路上游存在灰色对象。

1.3 GC触发时机

  • 主动触发
    调用runtime.GC,阻塞式地等待当前 GC 运行完毕
  • 被动触发
    • 距上一次GC的最长时间,默认两分钟
    • 分配的堆大小达到阈值

2 golang是如何做内存管理的

  • Golang的内存分配主要基于TC Malloc机制,会缓存一些可分配内存,避免多个线程同时分配和释放锁。
  • 垃圾回收机制是:并发标记清除算法,可以在不阻塞线程的的情况下进行垃圾回收
  • 通过逃逸分析,编译器在编译时,分析变量是分配在栈上还是堆上,尽量避免不必要的堆分配,优化性能。
    • 较大或复杂对象分配在堆上,由垃圾回收器管理生命周期
    • 局部变量和短生命周期对象分配在栈上,分配和回收效率高。
  • 用了内存池技术:提供sync.Pool来复用对象,减少频繁分配和垃圾回收的开销。
  • 支持超过1MB的大对象

三、数据结构和关键字

1 mutex

mutex很像操作系统中的PV操作,通过信号量来处理线程中同步与互斥的问题。
S代表剩余资源数。
P代表申请资源,S原子减一,如果减一后S小于0则将自己阻塞起来。
V表示释放资源,S原子++,如果S++后S<=0,表示等待队列上有等待线程,需要将第一个等待的线程唤醒。

  1. mutex是一个结构体,提供lock()和unlock()方法。
  2. state代表互斥锁的状态,例如是否被锁定。内部实现分成四部分
  3. sema表示信号量,等待信号量的协程会阻塞,解锁的协程释放信号量从而唤醒等待信号量的协程

2 channel

  • channel是一个用于通信的管道,遵循先进先出。
  • 需要用make来初始化channel,可以选择是否有缓冲,无缓冲是同步的。

channel的底层实现

  1. 缓冲区是个循环队列,保存队列当前的大小,和队列最大大小
  2. 发送者队列和接收者队列,用于存储等待写入或读取数据的 goroutine 的信息;
  3. 互斥锁

如果往一个关闭的channel读、写会怎么样

  1. 如果写的话,会直接panic。所以永远不要在读端关闭channel,多个写端可以通过context来解决。
  2. 如果里边有数据,会拿到数据
  3. 如果里边没有数据,会读到零值,可以通过判断ok为false来解决。

使用channel的注意事项

  1. channel关闭后的读写问题
  2. 无缓冲的channel是同步的,避免阻塞
  3. 有缓冲的channel的容量如果满了,发送方会阻塞

3 对map的理解,map有哪些注意事项

  1. map是引用类型,将map赋值、传递的时候,用的是引用,而不是副本(内容),多个变量指向的是同一个底层数据结构,对其中一个变量的修改会影响其他变量。
  2. map不是线程安全的,可以用互斥锁或者sync.Map
  3. map的底层实现使用哈希表来存储元素,迭代顺序不一定
  4. 当bucket中平均存储的键值对数量超过6.5(默认是8),或者溢出桶过多。会触发扩容,容量会翻倍。创建新哈希表进行计算、拷贝等,释放旧哈希表内存。
  5. 需要使用ok来判断

向map中存储一个kv

通过 k 的 hash 值与 buckets 长度取余,hash值的低八位定位到key在哪一个bucket(桶)中,
hash 值的高8位存储在 bucket 的 tophash[i] 中,用来快速判断key是否存在。
当一个 bucket 满时,通过 overflow 指针链接到下一个 bucket。

sync.Map比加锁的方案好在哪里,底层数据结构是什么样的

  • sync.Map比互斥锁快的原理是用空间换时间
  • 底层有两个map,read map并发读安全,读操作优先read map,所有写操作直接在dirty map中,read map和dirty map在需要时会进行数据同步。
  • 通过读写分离,降低锁时间来提高效率,适用于读多写少的场景

4 对slice的理解

slice和数组的区别

  1. 数组(array)的长度是固定的,切片(slice)是可变的
  2. 数组是值类型,切片是引用类型。拷贝数组会复制整个数组的内容,复制切片只能复制指针
  3. 对slice切片会指向原内存空间,可能出现并发或者内存泄漏问题。通过申请新的内存空间做拷贝来解决

slice扩容规则是什么

  1. 如果当前切片长度小于1024,新容量为原来的2倍
  2. 当前切片长度大于等于1024,新容量为原来的1.25倍
  3. 如果一次扩容后仍无法容纳新增元素,会继续扩容(加法和对齐)
  4. 扩容时会将原来的元素复制到新的内存空间,旧的被释放。

5 context

1 context的作用

  • 在gorountine之间传递上下文信息。一般用来:超时控制,并发控制。
  • context的树形结构可以在不同层级的goroutine之间有效的传递信号。

2 context树的结构

  • 树的根是一个空的context(context.Background()或者context.TODO())
  • 需要某个节点是子操作,只需要在声明ctx的时候把父ctx传进去
  • 每个节点代表一个新创建的context,可能包含值、取消信号或截止时间
  • 树的边代表从父context到子context的继承关系

3 取消操作

  • 当需要取消一个操作和所有子操作的时候,通过调用树中某个节点的cancel()函数实现。
  • 触发沿树向下传递的取消信号,使所有子节点都能收到取消信号。

4 context树的构建

  • context.WithValue,包含键值对,在整个请求范围内传递数据
  • context.WithCancle、context.WithDeadline、context.WithTimeout,附加取消信号或截止时间,信号用于在请求被取消或超时时通知子goroutine

6 new和make的区别

  • new用于基本数据类型和结构体类型的内存分配,置为初始值
  • make适用于引用类型的内存分配,如切片、map和channel等

其他

1 如何在Golang中对性能优化

  1. 使用 Golang 的内置工具进行性能分析
  2. 减少内存分配。尽可能地重用变量和对象。
  3. 减少垃圾收集,减少内存分配也有助于减少垃圾收集。
  4. 使用并发处理。
  5. 避免使用过多的锁,可以使用读写锁来减少锁的使用
  6. 使用适当的数据结构
  7. 使用编译器优化。

2 golang中常见的引发panic的情况

  1. 往被关闭的channel写数据
  2. 关闭已经被关闭的channel
  3. 类型断言失败,可以通过判断是否断言成功来避免
  4. 数组下标越界
  5. 空指针调用
  6. 除数是0

3 go defer

  • go defer用于注册延迟调用,直到return前才会被执行,
  • 主要用来做资源清理、等候全部子协程退出的并发同步控制、释放锁
  • 如果有多个defer,按照先进后出的方式执行

4 golang引包为什么用_

如果引入一个包,但是不使用包内部的变量或函数的时候,go编译器会报错,下划线可以避免这种报错。
一般用于实现了init函数的包,或者隐式的调用包的init函数

5 init函数的作用和特点

  • init函数用于在启动时做一些初始化操作,没有参数和返回值,可以用来初始化所需的变量、配置或资源,确保程序能够正常运行。
  • 特点:
    • 自动调用:程序启动时候会自动调用所有导入包的init函数,不需要显式调用
    • 顺序执行:按照导入包的顺序执行init
    • 可有可无:如果某个包没有init,不会做任何初始化操作
    • 不能被显式调用:只会在程序启动时自动执行
    • 不能有参数和返回值:只执行一些初始化操作