Go语言核心知识回顾(接口、Context、协程)

2023-05-29,,

温故而知新

接口

接口是一种共享边界,计算机系统的各个独立组件可以在这个共享边界上交换信息,在面向对象的编程语言,接口指的是相互独立的两个对象之间的交流方式,接口有如下好处:

    隐藏细节: 对对象进行必要的抽象,外接设备只要满足相应标准就可以和主设备对接,应用程序只要满足操作系统规定的系统调用方式,就可以使用操作系统提供强大的功能,而不必关注对方具体的实现细节
    解耦: 通过接口,能够模块化构建复杂而庞大的系统,将复杂的功能拆分成彼此独立的模块,有助于提升效率和排查问题
    权限控制: 接口是与外界交流的唯一途径,如果外界不满足这种协议就无法和外界交流

Go使用了一种不寻常的方法实现面向对象编程,面向组合的设计模式,在Go语言中的接口就是一种调用契约,是多个方法声明的集合,或者说是受编译器限制的结构类型,限制如下:

    不能有字段
    不能定义自己的方法
    只能声明方法,不能实现

example:

type tester interface {
test()
string() string
} type data struct {} func (d *data) test() {} func (d *data) string() string {
return "data"
} func main() {
var d data
var t tester = &d
t.test()
fmt.Println(t.string())
}

tester是一个接口,data结构实现了其所有方法

接口也会带来一些额外的开销,由于动态数据类型对应的数据大小难以预料,接口中使用指针来存储数据,为了方便数据被寻址,平时分配在栈中的值一旦赋值给接口后,Go runtime会在堆区为接口开辟内存,发生内存逃逸,意味着堆内存分配时的时间消耗

Context

如果想要正确并优雅地退出协程,首先必须正确理解和使用Context标准库。协程在Go中是非常轻量级的资源,它可以被动态地创建和销毁,例如在典型的HTTP服务器中,每个新建立的连接都会创建一个协程,当请求完成后,协程也随之销毁,但是请求连接临时终止也可能超时,此时应该安全并及时地停止协程关联的子协程,避免白白消耗资源

如果不用Context,可能要接住通道的close机制,这个机制会唤醒所有监听该通道的协程,并触发退出逻辑:

select {
case <- c:
// .....
case <-done:
fmt.Println("exit")
}

为了对超时进行规范处理,Go引入了Context来实现协程的退出,并且能够跨协程和跨服务

context本身的含义是上下文,可以理解为它内部携带了超时信息,退出信号,以及其他一些上下文相关的值

Context源码:

// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

Deadline方法用于返回Context的过期时间,Deadline第一个返回值表示Context的过期时间,第二个返回值表示是否设置了过期时间,如果多次调用Deadline方法会返回相同的值

Done是使用最频繁的方法,会返回一个通道,一般的做法是调用者在select中监听该通道的信号,如果该通道关闭则表示服务超时或异常,要执行后续退出逻辑,多次调用Done方法会返回相同的通道

通道关闭后,Err方法会返回退出的原因

Value方法返回指定key对应的value,这是Context携带的值,key必须是可比较的,一般的用法key是一个全局变量,通过context.WithValue将key存储到context中,并通过Context.Value方法取出

context.Value一般在RPC中使用,存储分布式链路追踪的traceId或者鉴权相关的信息,并且该值的作用域在请求结束时终结,同时key必须是访问安全的,因为可能有多个协程同时访问它

调用context.Background函数或context.TODO函数时,会返回最简单的Context实现,context.Background返回的Context一般作为根对象存在,不具有任何功能,不可退出,不可携带值。要具体地使用Context的功能,必须派生出新得Context,配套函数有WithCancel、WithTimeout、WithDeadline和WithValue,前3个函数都用于派生出有退出功能的Context

WithCancel: 返回一个子context和cancel方法,子context会在两种情况下触发退出: 一种情况是调用者主动调用了返回的cancel方法,另一种情况是当参数中父context退出时,子context将级联退出

WithTimeOut: 指定超时时间,当超时发生后,子Context将退出,因此,子Context的退出有三种时机,一种是父Context退出,一种是超时退出,最后一种是主动调用Cancel函数退出

WithDeadline和WithTimeOut函数的处理方法相似,不过它们的参数指定的是最后到期时间

WithValue函数会返回带key-value的子context

协程

协程被认为是轻量级的线程,线程是操作系统资源调度的基本单位,但操作系统却感知不到协程的存在,协程的管理依赖Go runtime自身提供的调度器,Go语言的协程是从属于某一个线程,只有协程和实际线程绑定,才有执行的机会

调度方式 : goroutine是从属于某一个线程,协程与线程的关系为多对多的对应关系,Go语言的调度器可以将多个协程调度到同一个线程中执行,一个协程也可能切换到多个线程中去执,协程上下文切换的速度要快于线程,因为切换线程不必同时切换用户态与操作系统内核态,而且Go中切换协程只需要保留极少的状态和寄存器值(SP/BP/PC),而切换线程则会保留额外的寄存器值(例如浮点寄存器)

调度策略 : 线程调度在多数时间里是抢占式的,操作系统调度器为了均衡每个线程的执行周期,会定时发出中断信号强制切换线程上下文,而Go语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程,这意味着协程可以更好地在规定时间内完成自己的工作,而不会轻易抢占。只有当一个协程运行了太长时间时,Go调度器才会强制抢占其任务的执行

栈的大小 : 线程的栈一般是在创建时指定的,为了避免出现栈溢出(Stack Overflow)的情况,默认的栈会相对较大,这意味着每创建1000个线程就要消耗2GB的虚拟内存,大大限制了可以创建的线程的数量,而Go语言的协程栈默认为2KB,所以在实践中,经常会看到成千上万的协程存在,协程栈在Go运行时的帮助下会动态检测栈的大小,并动态扩容

协程调度依赖于线程,Go runtime将协程与线程绑定在一起,在Go源码中结构体m代表了操作系统线程,结构体m中包含了特殊的调度协程g0,绑定的逻辑处理器P,绑定的用户协程g等重要结构

type m struct {
g0 *g // 特殊的调度协程g0
p puintptr // 当前对应的逻辑处理器P
curg *g // 当前m绑定的用户协程g
tls [6]uintptr // 线程局部存储
}

结构体m要与真实的操作系统线程绑定在一起,这要借助线程本地存储技术,线程本地存储中的变量只对当前线程可见,因此,这种类型的变量可以看作是线程私有的,一般情况下,操作系统会使用FS/GS段寄存器存储线程本地变量

在Go中,并未直接暴露线程本地存储的编程方式,但是Go语言运行时使用线程本地存储,将具体操作系统的线程与运行时代表线程的m结构体绑定,线程本地存储的数据实际是结构体m中m.tls的地址,同时,m.tls[0]会存储当前线程正在运行的协程g的地址,因此在任意一个线程内部,通过线程本地存储,都可以在任意时刻获取绑定在当前线程上的协程g、结构体m、逻辑处理器P和特殊协程g0等信息

此外还要实现结构体m与某一个协程的绑定,这要基于调度器。在GPM中,逻辑处理器器P和唯一的线程M绑定,逻辑处理器P可以在本地存储协程的运行队列,同时又保留了全局运行队列,逻辑处理器P与M绑定的特性决定了,正常情况下有多少个P就有多少个线程

每一个M结构都存储了一个特殊的协程g0,协程g0运行在操作系统的线程栈上,它的主要作用是执行协程调度的一系列运行时代码,一般的协程则负责无差别地执行用户代码。执行用户代码的任何协程都不适合全局调度,当用户协程退出或者抢占时,意味着要重新执行协程调度,这时,要从用户协程g切换到g0,完成协程的调度,协程经历从g->g0->g就完成了一次调度循环,和线程类似,协程切换的过程叫作协程上下文切换

协程的执行现场存储在g.gobuf结构体中,g.gobuf结构体主要保存CPU中的几个重要的寄存器值,分别是rsp、rip和rbp

type gobuf struct {
sp uintptr // 保存CPU的rsp寄存器值
pc uintptr // 保存CPU的rip寄存器值
g guintptr // 记录当前gobuf对象属于哪个goroutine
res sys.Uintreg // 保存系统调用的返回值
bp uintptr // 保存CPU的rbp寄存器值
}

等待被调度的协程序被存储在运行队列中, Go语言调度器将运行队列分为局部运行队列和全局运行队列,局部运行队列是每个P特有的长度为256的数组,该数组模拟了一个循环队列,先查找每个P局部的运行队列,当获取不到局部运行队列时,再从全局队列中获取,但如果只是循环往复地执行局部运行队列中的G,那么全局队列中的G可能一直无法执行,为了避免这种情况,调度器制定了一种策略: P每61次调度,就要从全局运行队列中查找一批协程序

如果本地运行队列已满,那么调度器会将本地运行队列的一半放入全局运行队列,确保程序中有很多协程时,每个协程都有执行的机会,如果局部运行队列和全局运行队列都找不到可用的协程,这时调度器会寻找当前是否有已经准备好运行的网络协程。runtime.netpoll函数会获取当前可运行的协程列表,返回第一个可运行的协程,并通过injectglist函数将其余协程放入全局运行队列等待被调度,当三个队列都空时,调度器就从其他P的本地队列窃取可用的协程

因此顺序是:

    获取要执行垃圾回收的后台标记协程
    获取P.runtext中待运行的协程
    获取P的本地运行队列中待运行的协程
    获取全局运行队列中待运行的协程
    获取已经准备好要运行的网络协程
    窃取其他P中待运行的协程

调度时机分为主动调度、被动调度和抢占调度

主动调度: 协程主动让出自己的执行权利,编译器会插入检查代码,判断这个协程是否要被抢占

被动调度:协程因为休眠、通道阻塞、网络IO阻塞和执行垃圾回收而暂停时,被动让出自己执行权利的过程

Go语言核心知识回顾(接口、Context、协程)的相关教程结束。

《Go语言核心知识回顾(接口、Context、协程).doc》

下载本文的Word格式文档,以方便收藏与打印。