参考:
Go 汇编函数 - Go 语言高级编程
Go 嵌套汇编 - 掘金 (juejin.cn)
前言:
Golang 适用 Go-Runtime(Go 运行时,嵌入在被编译的PE可执行文件之中)来管理调度协同程式的运行。
Go 语言没有多线程(MT)的概念,在 Go 语言之中,每个 Go 协程就类似开辟了一个新的线程,效率上,肯定是比分配线程好的。
但也仅限于分配协程,及单个进程可以跑几万个乃至几十万个协同程序,这是线程无法比拟的,因为在操作系统之中,最小执行单元的单位就是线程了,但是线程相对协同程序来说,过重,无论是内存还是CPU。
但不意味着 Go 协程执行的效率比线程要好,别太自信与盲目,协程是比不了线程代码CPU执行效率的。
上面也提到了,只是可以同时开辟几万个乃至几十万个协程,并且启动协程速度比线程快非常多,这是它的优势,但是缺点也很明显,在物理线程上执行 Go 协同程式的代码效率不高。
目前世界上最快的协同程序切换,应该是 C/C++ 之中的:
State Threads Library (sourceforge.net)
boost::context
两个库各有千秋,但相对来说 boost 更好用一些,在这里需要提醒大家一点,应用程序之中运行协同程序,它是依托于进程之中的物理线程上执行的。
来到正题,我们先来探讨 Golang 到底是 “Stackless” 无栈轻量协程,还是 “Stackful” 有栈重量协程呢?
那么就有必要分析清楚,有栈协程跟无栈协程之间到底有什么区别。
首先:
1、有栈协程
1.1、栈协程是一种基于线程或进程的协程实现方式。
1.2、栈协程拥有自己的执行栈,可以独立地管理栈帧、局部变量和函数调用。
1.3、栈协程的切换需要保存和恢复整个执行上下文,包括栈指针、寄存器等。
1.4、由于栈协程具有独立的执行栈,因此它们可以支持递归调用和深度嵌套。
1.5、由于栈协程需要额外的资源来维护栈,因此在创建和销毁方面可能会有一些开销。
2、无栈协程
2.1、无栈协同是一种基于用户空间的协程实现方式。
2.2、无栈协同没有独立的执行栈,它们共享相同的调用栈。【重点】
2.3、无栈协同使用状态机来管理协程的执行,并通过保存和恢复状态来实现协程的切换。
2.4、由于无栈协同共享调用栈,因此它们不能支持递归调用和深度嵌套。
2.5、无栈协同通常比栈协程更轻量级,创建和销毁开销较小。
似乎从上述定义的概念来说,Golang 是有栈协议?但真的是这样吗?显然不是的,首先真正意义上的有栈协程,是无法被运行时代管的。
有栈协程存在以下几个限制:
1、如果开发人员切换协程处理不当的情况下,会导致协程栈内存泄漏问题。
2、如果开发人员在多个线程之中执行
3、有栈协程无法动态扩展计算栈空间,所以有栈协程需要在分配时,明确指定栈空间大小。
一个协同程序可以在多个线程上按保证顺序性(时序)进行处理,无论是有栈协同程序、或者是无栈协同程序,均可以。
Go 协同程序是属于 “Stackless” 无栈协程的类型,但 Go 为了实现协同程序能像 Stackful 有栈协程一样,拥有属于自己的外挂栈空间,并且支持动态栈空间扩容。
但要注意一点:
1、Go 协程可能在不同的线程上面被执行,虽然 Go 语言运行时保证了,单一协同程序执行的时序性,但开发人员需要在其中注意协同程序之间的同步问题,类似多线程并发编程。
2、若要实现同步锁的情况,人们需要考虑多线程问题,否则这可能造成很严重的后果,即 Go 运行时附着的工作线程被阻塞,同时最好的实现方式伪同步锁,如利用管道来实现类似效果。
相对传统的 TTASLock/CAS自选锁实现,可能不太适合Go 这种结构的程序,这是因为:Go 协同程序在没有执行异步的情况下是不会让出线程CPU的,你可以理解为,你需要执行类似文件IO、网络IO、或者调用 Go 运行时库之中的同步库,例如:sync.Mutex 产生了阻塞行为
鉴于 Go 运行时是多线程执行,在不阻塞 Go 运行时最大工作线程的情况下,其它协程,仍旧是可以正常就绪的工作的,这取决于运行时调度。
所以严格意义上来说,Go 协程属于 “Stackless” + “Stackful” 的变种协程,它属于 “Stackless” 无栈协同程序的一种,但 Go 编译器实现对其用户代码进行展开,并分配一个 “Go 外挂计算栈内存空间单元”,而非真正意义上的函数栈,如同C#、C++、C#、ASM、IL函数的调用堆栈。
有栈协程无法放大执行堆栈的根本原因是寄存器,EIP、RIP,及地址链之间存在上下依赖问题等等,Go 并非是真的有栈协程,自然不会存在这个问题,它本来就是由编译器支持的黑魔法,实现的协同程序(“重点:最终会被展开编译为状态机切换的”),但这类编译器不能编译过度复杂协同应用程序,虽然我个人是相信 Google 的技术水平的,但并不代表,不对 Stackless 协程先天存在的对于编译器的复杂性,感到一丝忧虑,这个世界上不存在完美的技术,这类编译器完全内部实现的纯纯黑盒,对开发人员来说不太容易掌控到更多的细节。
Go 通过外挂计算栈空间的解决方案,在该 Go 栈空间内不保存任何寄存器之类的值,仅存储调用函数栈帧的元RID、参数、变量等(值或引用),所以在栈空间不足时,进行扩大外挂栈时。
即:分配新的栈空间内存,并把原栈内存复制过来,在释放原栈内存空间的内存,并把新的栈内存首地址(指针)挂载到当前 Go 协同程序的栈顶指针、栈底指针。
在复制并放大 Go 协程栈内存空间的时候,会导致该协同被同步阻塞,恢复取决于这个步骤在何时完成。
Go 栈空间虽然不会保存寄存器的值,但并不意味着 Go 程序不会适用目标平台汇编指令集
下述是一个很简单的 Go 加法函数,返回参数 x+y 的值:
package main
func Add(x int, y int) int {
return x + y
}
func main() {}
那么 Go 编译器会输出以下的汇编指令
TEXT main.Add(SB), NOSPLIT|NOFRAME|ABIInternal, $0-16
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $5, main.Add.arginfo1(SB)
FUNCDATA $6, main.Add.argliveinfo(SB)
PCDATA $3, $1
ADDQ BX, AX
RET
TEXT main.main(SB), NOSPLIT|NOFRAME|ABIInternal, $0-0
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
RET
从上述的代码中,我们可以清晰的看到,出现了并非X86/X64汇编语法的,FUNCDATA 、PCDATA 两个指令。
它们是 GO 汇编之中的伪指令,注意它是伪指令,意思就是说这东西不能用,除了GO的编译器能理解它之外,其它的汇编器,无论 GCC、VC++ 都是不认识这个东西。
人们可以理解,Go 存在两个编译过程,一个前端编译器,一个后端编译器,前端编译器就是把我们写的 .go 源文件的程序代码编译为 Go 后端编译器认识的 Go 汇编指令集代码。
这的确很类似于 JAVA/JVM 编译的字节码、C# 编译器的 MSIL 中间指令代码,但又存在明显的区别,人们可以显著的参考下述在ARM平台输出的 Go 汇编代码
TEXT main.Add(SB), LEAF|NOFRAME|ABIInternal, $-4-12
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $5, main.Add.arginfo1(SB)
MOVW main.x(FP), R0
MOVW main.y+4(FP), R1
ADD R1, R0, R0
MOVW R0, main.~r0+8(FP)
JMP (R14)
TEXT main.main(SB), LEAF|NOFRAME|ABIInternal, $-4-0
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
JMP (R14)
人们可以明显的看到,除了几个伪指令是相同他的,但是内部实现所使用的指令发生了变化,这是因为,Go 每个平台编译器生成的 Go 汇编代码会根据CPU指令集平台的不同而不同,这是因为 Go 虽然编译的是只能给 Go 后端编译器看的汇编代码。
但不意味着它会完全按照先编译为字节码、中间代码的形式,Go 前端编译器输出的 Go 汇编,在编译的过程中,就已经按照目的平台的指令集进行了一部分的翻译(不完全是真汇编,但汇编已很接近了。)
剩下那部分伪指令是让 Go 汇编器,在构建目的程序时,所需处理的东西,就是GC、外挂栈空间内存上面的参数、局部变量读取这些实现,最后生成的目的汇编代码,才是用来编译为目的PE、ELF可执行文件的。
OK:这里简单的描述下上面X86汇编的意义,ARM我不怎么看得懂,所以不在此处献丑了
第一句 Go 汇编指令:
TEXT main.Add(SB), NOSPLIT|NOFRAME|ABIInternal, $0-16
1、TEXT: 这是一个伪指令,用于指示下面的代码是函数代码(类似于其他汇编语言中的函数标签)。
2、main.Add(SB): main.Add 是函数的名称,SB 表示 Static Base(静态基址),它是一个汇编符号,指示函数相对于全局数据区的偏移量。
3、NOSPLIT|NOFRAME|ABIInternal: 这是函数的属性标志。NOSPLIT 指示编译器不应在函数内插入栈分裂代码,NOFRAME 指示编译器不应创建函数堆栈帧,ABIInternal 表示该函数的调用约定为 Go 内部使用。
4、$0-16: 这是函数的栈帧大小指令。$0 表示该函数不会在栈上分配任何局部变量的空间,-16 表示函数会从参数中读取16字节的数据。
注意:这个栈空间指的是 Go 程序外挂的栈哈,不是进程线程的栈空间。(或为虚拟栈空间)
第二句 Go 汇编指令:
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
1、这是一个 FUNCDATA 伪指令,用于插入与垃圾回收(garbage collection)相关的元数据。
2、$0 表示这段元数据的索引值为 0(参数位:0 = X)
3、gclocals·g2BeySu+wFnoycgXfElmcg==(SB) 是一个符号名,它引用了一个包含局部变量和参数信息的数据结构。
第三句 Go 汇编指令:
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
跟第二句没区别,元数据索引值为 1(参数位:1 = Y)
第四句 Go 汇编指令:
FUNCDATA $5, main.Add.arginfo1(SB)
main.Add.arginfo1(SB) 是获取 “描述函数参数类型和数量的数据结构的引用地址”。
Go 语言没有显示的函数签名声明,所以编译器需要这个函数的参数信息,以便于可以正确的传递参数值给该函数。
第五句 Go 汇编指令:
FUNCDATA $6, main.Add.argliveinfo(SB)
main.Add.argliveinfo(SB) 是获取 “描述函数参数活跃性的数据结构的引用地址”
参数的活跃性指的是在函数执行期间哪些参数被使用了。这些信息对于优化代码的执行效率非常重要,GO GC在用。
第六句 Go 汇编指令
PCDATA $3, $1
把 $1 的值复制到 $3,AT&T汇编风格是:
操作数 原操作数, 目标操作数
加法实现 GO 汇编指令
ADDQ BX, AX
RET
1、AX 和 BX 寄存器用于存储 x 和 y 的值。
2、之后,通过 ADDQ BX, AX 指令将 y 的值加到 x 上,并将结果保存在 AX 寄存器中。
3、最后,使用 RET 指令将结果返回。
总结:
1、Golang 协程不会保存CPU寄存器的值。
2、Golang 协程属于 Stackless 协程的一种变种。
3、Golang 通过为外挂计算栈内存空间,来实现类似有栈协程的效果。
4、Golang 两个协程可能在不同的物理线程上面工作,所以公用数据访问时,须注意同步问题。
5、Golang 协程在处理异步操作的时,让出了当前协程占用的线程CPU,协程处于WAIT状态时, 当前协程依赖的外部数据,可能在外部发生了改变或者释放。
所以,该协程被唤醒之后(resume\awake)理应检查当前依赖数据的状态,如:在该协程处于 Yield 等待状态之中时,其它协程调用了 Dispose 函数,释放了 “它(公用数据)” 持有的全部被托管及非托管资源。
6、Golang 也会适用寄存器优化,但这有一些前提,就是简单的算术运算,可以被编译为寄存器优化的代码,这不冲突,只是最终会把值存储到 “Go” 为每个协程分配的外挂栈内存空间上面。
就像在 MSIL 之中,人们执行 stloc.s、ldloc.s、ldarg.s、starg.s 这些指令集一样,只不过它不像微软的 .NET CLR 会把这些代码编译为近似 C/C++ 编译器输出的目标平台汇编代码,当然不管怎么做,这类由GC系统标记的语言,都会在最终编译输出的汇编代码之中插入引用技术管理的实现,区别是在什么地方插入,当然这的看GC系统是怎么设计的,比如链式遍历的GC,就不需要在每个函数引用资源的地方去做 AddRef、到结尾做 ReleaseRef 这样的行为,但缺点就是GC在处理终结的时候,CPU开销比较大。
7、Golang 之中托管资源是通过RID间接引用的,即托管资源并非是直接使用指针,这是因为资源或会被GC压缩或移动碎片整理,当然这个时候会导致阻塞问题,即:GC Pinned 问题。