文章目录
- Interface相关
- 1. Go语言中,interface的底层原理是怎样的?
-
- 回答:
- 2. iface和eface的区别是什么?
- 3. 类型转换和断言的区别是什么?
- 4. Go语言interface有哪些应用场景
- 5. 接口之间可以相互比较吗?
-
- 回答:
- GMP 相关:
- 1. 什么是 GMP?(必问)调度过程是什么样的?(对流程熟悉,要求更高,问的较少)
-
- 什么是GMP?
-
- 回答
- 调度过程是怎样的?
-
- RSP、RIP、RBP
- 调度策略
- 调度模式
- 发生调度的时机
- 回答
- 2. GMP能不能去掉P层?会怎么样?
-
- 回答
- 3. G、M 和 P 的数量问题?
- 4. 进程、线程、协程有什么区别?
-
- 回答
- 5. 抢占式调度是如何抢占的?
-
- Go1.14 之前
- Go1.14 之后
- 回答
Interface相关
1. Go语言中,interface的底层原理是怎样的?
回答:
Go 的 interface 底层有两种数据结构: eface 和 iface。
eface 是空的 interface{} 的实现,只包含两个指针:_type 指向类型信息,data 指向实际数据。通过 type 指针来识别具体类型,通过 data 指针来访问实际值。
iface 是带方法的 interface 实现,包含 itab 和 data 两部分。itab 是核心,它存储了接口类型、具体类型,以及方法表。方法表是一个函数指针数组,保存了该类型实现的所有接口方法的地址。
分析 eface 定义:
type eface struct {
_type *_type
data unsafe.Pointer
}

iface 定义:
type iface struct {
tab *itab
data unsafe.Pointer
}
itab 的结构定义如下:
type ITab struct {
Inter *InterfaceType
Type *Type
Hash uint32 // copy of Type.Hash. Used for type switches.
Fun [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}

2. iface和eface的区别是什么?
iface和eface的核心区别在于是否包含方法信息。
eface是空接口interface{}的底层实现,结构非常简单,只有两个字段:_type 指向类型信息,data 指向实际数据。因为空接口没有方法约束,所以不需要存储方法相关信息。
iface是非空接口的底层实现,结构相对复杂,包含 itab 和 data。关键是这个 itab,它不仅包含类型信息,还包含了一个方法表,存储着该类型实现的所有接口方法的函数指针。
3. 类型转换和断言的区别是什么?
类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。对于类型转换而言,类型转换是在编译期确定的强制转换,转换前后的两个类型要相互兼容才行,语法是 T(value)。而类型断言是运行期的动态检查,专门用于从接口类型中提取具体类型,语法是 value.(T)
安全性差别很大: 类型转换在编译期保证安全性,而类型断言可能在运行时失败,所以实际开发中更常用安全版本的类型断言 value, ok := x.(string) 通过ok判断是否成功。
使用场景不同: 类型转换主要解决数值类型、字符串、切片等之间的转换问题;类型断言主要用于接口编程,当你拿到一个interface{}需要还原成具体类型时使用。
底层实现也不同: 类型转换通常是简单的内存重新解释或者数据格式调整;类型断言需要检查接口的底层类型信息,涉及到runtime的类型系统。
4. Go语言interface有哪些应用场景
依赖注入和解耦。 通过定义接口抽象,让高层模块不依赖具体实现,比如定义一个 UserRepo 接口,具体可以是MySQL、Redis或者Mock实现。这样代码更容易测试和维护,也符合SOLID原则。
多态实现。 比如定义一个 Shape 接口包含 Area() 方法,不同的图形结构体实现这个接口,就能用统一的方式处理各种图形。这让代码更加灵活和可扩展。
标准库中大量使用interface来提供统一API。 像 io.Reader、io.Writer 让文件、网络连接、字符串等都能用统一的方式操作; sort.Interface 让任意类型都能使用标准库的排序算法。
还有类型断言和反射的配合使用, 比如JSON解析、ORM映射等场景,先用 interface{} 接收任意类型,再通过类型断言或反射处理具体逻辑。
插件化架构也heavily依赖interface。 比如Web框架中的中间件、数据库驱动、日志组件等,都通过接口定义规范,让第三方能够轻松扩展功能。
5. 接口之间可以相互比较吗?
回答:
接口值之间可以使用 == 和 != 来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic。
接口值在与非接口值比较时,Go会先将非接口值尝试转换为接口值,再比对。
接口值很特别,其它类型要么是可比较类型(如基本类型和指针)要么是不可比较类型(如切片、映射类型和函数),但是接口值视具体的类型和值,可能会抛出潜在的panic。
分析: 接口类型和 nil 作比较
接口值的零值是指 动态类型 和 动态值 都为 nil。当仅且仅当这两部分的值都为 nil 的情况下,这个接口值才会被认为 接口值 == nil。
package main
import "fmt"
type Coder interface {
code()
}
type Gopher struct {
name string
}
func (g Gopher) code() {
fmt.Printf("%s is coding\\n", g.name)
}
func main() {
var c Coder
fmt.Println(c == nil)
fmt.Printf("c: %T, %v\\n", c, c)
var g *Gopher
fmt.Println(g == nil)
c = g
fmt.Println(c == nil)
fmt.Printf("c: %T, %v\\n", c, c)
}
程序输出:
true
c: <nil>, <nil>
true
false
c: *main.Gopher, <nil>
一开始,c 的动态类型和动态值都为 nil,g 也为 nil。当把 g 赋值给 c 后,c 的动态类型变成了 *main.Gopher,仅管 c 的动态值仍为 nil,但是当 c 和 nil 作比较的时候,结果就是 false 了。
GMP 相关:
1. 什么是 GMP?(必问)调度过程是什么样的?(对流程熟悉,要求更高,问的较少)
什么是GMP?
分析 gmp模型是go语言中的协程调度模型
G,M,P简单介绍
G:Goroutine M:Machine内核线程,每个m都有1个特殊的协程g0,这个g0主要负责协程调度和切换,goroutine只有绑定到m上才能够正常运行 P:逻辑处理器Processor,包含goroutine本地队列,队列长度为256,当有 goroutine 要创建时,会被添加到 P 上的 goroutine 本地队列上,如果 P 的本地队列已满,则会维护到全局队列里
P和M的创建时机 P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。 M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。
可以用一个简单的比喻来理解 GMP 调度模型:GMP三者可以看作是一个物流中心,目的是为了将快递尽快地安排快递员送货。G 可以理解为一份快递包裹 📦,P 则是传送带 ⚙️,M 则是快递员 👷。在不扩建的情况下,传送带的数量(P 的数量由 GOMAXPROCS 决定,每个 P 代表一个并行执行的“配额/资格”)和长度(256)是固定的,但是快递员的数量是可以按照需求增减的(M 的数量)。物流中心希望每个快递员都有活干,规定如果快递员没活了就从总部(全局队列global runq拿),如果总部也没有就去netpoll看看有没有因为网络IO就绪而可运行的G,还没有的话去偷别的P的本地队列的一部分包裹,再没有就进入自旋等待新活。如果有人因为生病请假了(M被阻塞了),那就让其他人代班。总部规定每个人每单要限时送达,不然就扣工资让别人送(抢占式调度)
无非是按这个顺序获取 G: local -> global -> netpoll -> steal -> local -> global -> netpoll
回答
gmp是go语言协程调度模型,g代表goroutine,m代表内核线程,p代表逻辑处理器,p中包含本地g队列,g通过p绑定到m才能真正运行
调度过程是怎样的?
分析
上面回答了gmp是go语言的协程调度模型,这个问题是对上一个问题的补充提问,进一步回答协程是怎样调度的。 协程的调度是一个很复杂的过程,尽然是调度,肯定涉及到协程的上下文切换,调度策略以及调度时机还有调度过程,下面分为这几个场景来简单回顾一下,在回答这个问题的时候不用这么详细,主要介绍协程的调度策略和调度时机即可。但是对于调度过程细问,比如问协程会给你上下文切换保存了哪些寄存器,发生调度的时机等问题要做到心中有数
RSP、RIP、RBP

协程上下文切换过程
协程的调度主要是发生在用户的goroutine和g0之间,

协程经过g——>g0——>g的过程就完成了一次调度循环,一次协程调度过程跟线程的调度一样,也会发生协程的上下文切换,同样需要保存协程的执行现场,这样才能够切回g接着上次继续执行,协程的执行现场主要是几个寄存器的值,分别是rsp,rip,rbp。
rsp:指向函数调用的栈顶 rip:指向程序要执行的下一条指令地址 rbp:存储函数栈帧的起始地址

这些寄存器主要保存在goroutine的sched这个字段结构中,goroutine的结构如下:
structG
{
uintptr stackguard; // 分段栈的可用空间下界
uintptr stackbase; // 分段栈的栈基址
Gobuf sched; // 协程切换时,利用sched域来保存上下文
uintptr stack0;
FuncVal* fnstart; // goroutine运行的函数void* param; // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取
int16 status; // 状态 Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
int64 goid; // goroutine的id号
G* schedlink;
M* m; // for debuggers,but offset not hard-coded
M* lockedm; // G被锁定只能在这个M上运行
uintptr gopc; // 创建这个goroutine的go表达式的pc…
};
调度策略
协程的调度过程可以认为是m寻找一个可以运行的g来运行的过程,优先从 P 的本地队列获取 goroutine 来执行; 如果本地队列没有,从全局队列获取,如果全局队列也没有,从netpoll 网络轮询器中查找是否有 Goroutine 等待运行【netpoll 网络轮询器是Go运行时系统中用于高效处理异步网络I/O的一个组件,负责监控被阻塞的Goroutine(例如等待socket读写),并在I/O事件就绪时将其重新激活,确保这些Goroutine能被调度器及时恢复执行,避免线程空转或阻塞】; 还是没有获取到,则会从其他的 P 上偷取 goroutine。
但是这种调度策略存在一个问题,如果本地p队列一直有g的话,那么全局队列的g可能完全没有机会执行?
所以,go的调度器在每执行61次调度,就会优先从全局队列获取一个g放到当前p队列。
如果本地运行队列已经满了,那么无法从全局运行队列调用并放入怎么办?
如果本地运行队列满了,那么调度器会将本地运行队列的一半放入全局队列。这保证了当程序中有很多协程时,每个协程都有执行的机会
调度模式
调度模式一般有两种,抢占式和协作式,协作式调度依靠被调度方主动弃权;抢占式调度则依靠调度器强制将被调度方被动中断
发生调度的时机
-
等待读取或写入未缓冲的通道
-
由于 time.Sleep() 而等待
-
等待互斥量释放
-
发生系统调用
回答
G在刚创建的时候,会优先加入到当前P的本地runq队列中,等待被调度,当这个本地runq队列满了时,会将本地队列前一半的 G 和新创建的 G (也可能是runnext 替换出的G)打乱顺序一起放入全局队列。每个m都有一个特殊的协程g0负责调度工作,每一轮调度过程是这样的,M 优先执行其所绑定的 P 的本地运行队列中的 G,如果本地队列没有 G,则会从全局队列获取,为了提高效率和负载均衡,会从全局队列获取多个 G,而不是只取一个,同样,当全局队列没有时,会从 netpoll 网络轮询器中尝试获取 G,当网络轮询也没有就绪的G,就会把进入自旋状态从其他的 P 上偷取 G 来运行,偷取的个数通常是其他 P 运行队列的一半;如果还没有获取到g,归还释放当前的 P,则m就处于休眠状态。【自旋通常指的是线程在等待某个条件时不进入睡眠状态,而是循环检查条件是否满足。这样做的好处是响应速度快,因为一旦条件满足,线程可以立即继续执行,而不需要被操作系统唤醒,但缺点是会消耗CPU资源,因为循环检查本身需要占用CPU时间】
2. GMP能不能去掉P层?会怎么样?
分析
主要考察对p的作用的理解,因为在期初的时候,是单纯的gm模型,是没有p的,为什么会被弃用呢?假设没有p的话,也就没有本地p的g队列,则所有的m都将去同一个全局队列获取可用g,这样势必会有锁竞争问题,所以回答可以抓住这个点,从性能加以分析

回答
-
每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
-
每个 P 相对的平衡上,在 GMP 模型中实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
3. G、M 和 P 的数量问题?
分析
其实是上一个问题的补充问题,考察对gmp模型的理解深不深入,
P的数量: 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定,GOMAXPROCS 一般为 CPU 核数
M的数量: go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000,但是内核很难支持这么多的线程数(schedinit() 函数会设置 M 最大数量为10000) runtime/debug中的SetMaxThreads函数,设置M的最大数量
M 阻塞并不会必然新建 M;当 M 进入 syscall/block 时,runtime 会先把 P 解绑并 handoff 给其它 M 来继续执行;在需要启动 M 来跑这个 P 时,startm() 会先通过 mget() 复用 idle M,只有拿不到可用 M 且未超线程上限时才会 newm() 创建新的 M。
G的数量: 理论上没有限制,受限于内存,但是goroutine过多会影响程序性能
4. 进程、线程、协程有什么区别?
分析
进程,线程,还有协程都是并发单元,但是具体又有不同,在分析三者区别的时候可以从大小,调度,资源分配还有用户态或者是内核态等几个方面进行分析
回答
进程可以理解为一个动态的程序,进程是操作系统资源分配的基本单位,而线程是操作系统调度的基本单位,进程独占一个虚拟内存空间,而进程里的线程共享一个进程虚拟内存空间。线程的粒度更小,一个进程可以有多个线程
协程可以理解为用户态线程,跟线程的区别主要有三个方面
-
大小,协程大小为2K,可以动态扩容,而线程大小为2M,协程更轻量
-
线程切换需要用户态到内核态的切换,而协程的切换不用,只在用户态完成,线程切换需要保存各种寄存器,而协程切换只需要保存rsp,rip,rbp三个寄存器,协程切换消耗更小
-
线程的调度由操作系统完成,而协程的调度由运行时的调度器完成
5. 抢占式调度是如何抢占的?
分析
本题其实是考察对go语言的协程调度方式的了解,一般的调度方式有两种,协作式和抢占式,协作式就是会主动让渡使用权,抢占式就是在一定情况下,使用权会被抢占。go语言的调度方式都是抢占式的,但是在Go1.14之前和Go1.14之后具体的抢占策略实现又有所不同,本题在回答的时候要注意区分go的版本,对Go1.14之前和之后的抢占策略熟悉,并且分析出Go1.14之后的抢占策略的优势
go语言调度方式
go语言的调度模式在Go1.14 之前是基于协作的抢占式调度,在Go1.14以后实现了基于信号的抢占式调度(异步抢占)
Go1.14 之前
协作式调度就是m会主动让渡出p,让p可以与其他的m绑定,以下情况会发生这种主动让渡(协作调度):

而在下面情况下会发生抢占:
- 同一个goroutine运行超过10ms
抢占的实现原理: Go 会启动一个线程,一直运行着“sysmon”函数,该函数实现了抢占式调度(以及其他诸如使网络处理的等待状态变为非阻塞状态)的功能。sysmon 运行在 M(Machine,实际上是一个系统线程),且不需要 P(Processor)
当 sysmon 发现 M 已运行同一个 G(Goroutine)10ms 以上时,它会将该 G 的内部参数 preempt 设置为 true,当 G 进行函数调用时,G 会检查自己的 preempt 标志,如果它为 true,则调度器在 g0 上把当前 G 切走(从 running 变 runnable)并推入goroutine的局部队列,局部队列满了,再放入全局队列。抢占完成。
但是通过上述过程可以看到,要发生抢占,有1个前提,那就是发生函数调用,如果没有函数调用,即使设置了抢占标志,也不会进行该标志的检查,自然也就不会执行抢占过程。所以如下述代码:
func main() {
go fmt.Println("hi")
for {
}
}
设置单核情况下,在go1.14之前这个代码将正常运行,被阻塞住,因为不会发生调度,for循环这个死循环不是函数调用,所以 preempt 标志检查这个阶段,不会发生抢占调度,这个goroutine不会被抢占,一直阻塞。
Go1.14 之后
sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向正在运行该 G 的 M(OS thread)发送信号SIGURG。Go 的信号处理会在该线程对应的 M 的 gsignal 上执行相关逻辑(或使用它的栈/上下文),将其映射到 M 而不是 G,并使其检查该信号。触发异步抢占,让 G 尽快在安全点被切走(进入 runtime 的 asyncPreempt),随后由调度器把该 G 切走并重新调度。
由于此机制会显式发出信号,因此无需调用函数,就能将正在运行死循环的 goroutine 切换到另一个 goroutine 通过使用信号的异步抢占机制,上面的代码现在就可以按预期工作。GODEBUG=asyncpreemptoff=1 可用于禁用异步抢占。
sysmon 是 Go runtime 里一个后台监控线程(更准确:一个运行在独立 M 上的系统监控循环),用来做一些“全局性的调度/维护工作”。它不跑你的业务 goroutine,主要负责“看场子、纠偏、催调度”。
回答
Go1.14 之前是协作式抢占,Go 会启动一个线程,一直运行着“sysmon”函数,该函数实现了抢占式调度(以及其他诸如使网络处理的等待状态变为非阻塞状态)的功能。sysmon 运行在 M(Machine,实际上是一个系统线程),且不需要 P(Processor)
sysmon 发现某个 G 在一个 P 上连续运行超过约 10ms,会对该 G 标记需要抢占(通常也会把 stackguard0 设为 stackPreempt)。当该 G 之后到达安全点(常见是函数入口的栈检查)进入 runtime 时,调度器在 g0 上把它切走:将其置为 runnable 并放回 runq(通常先回到当前 P 的本地队列,必要时才进入全局队列),完成抢占。
Go1.14 之后是异步式抢占【就是你发起抢占请求的那个goroutine 本身不需要去处理抢占这件事 而是交给gsignal 协程来处理】,基于信号。sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M 发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。异步抢占后,G 放入全局队列。
基于信号量的抢占可以防止类似于死循环这种没有发生函数调用的goroutine一直占用cpu导致程序阻塞,提高了程序的合理性

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!
网硕互联帮助中心






评论前必须登录!
注册