关于 Go 协同程序(Coroutines 协程)、Go 汇编及一些注意事项。

参考:

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 的值:

Go 复制代码
package main

func Add(x int, y int) int {
    return x + y
}

func main() {}

那么 Go 编译器会输出以下的汇编指令

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 汇编代码

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 汇编指令:

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 汇编指令:

Go 复制代码
FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)

1、这是一个 FUNCDATA 伪指令,用于插入与垃圾回收(garbage collection)相关的元数据。

2、$0 表示这段元数据的索引值为 0(参数位:0 = X)

3、gclocals·g2BeySu+wFnoycgXfElmcg==(SB) 是一个符号名,它引用了一个包含局部变量和参数信息的数据结构。

第三句 Go 汇编指令:

Go 复制代码
FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)

跟第二句没区别,元数据索引值为 1(参数位:1 = Y)

第四句 Go 汇编指令:

Go 复制代码
FUNCDATA $5, main.Add.arginfo1(SB)

main.Add.arginfo1(SB) 是获取 "描述函数参数类型和数量的数据结构的引用地址"。

Go 语言没有显示的函数签名声明,所以编译器需要这个函数的参数信息,以便于可以正确的传递参数值给该函数。

第五句 Go 汇编指令:

Go 复制代码
FUNCDATA $6, main.Add.argliveinfo(SB)

main.Add.argliveinfo(SB) 是获取 "描述函数参数活跃性的数据结构的引用地址"

参数的活跃性指的是在函数执行期间哪些参数被使用了。这些信息对于优化代码的执行效率非常重要,GO GC在用。

第六句 Go 汇编指令

Go 复制代码
PCDATA  $3, $1

把 $1 的值复制到 $3,AT&T汇编风格是:

操作数 原操作数, 目标操作数

加法实现 GO 汇编指令

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 问题。

相关推荐
Abladol-aj1 小时前
并发和并行的基础知识
java·linux·windows
清水白石0081 小时前
从一个“支付状态不一致“的bug,看大型分布式系统的“隐藏杀机“
java·数据库·bug
吾日三省吾码6 小时前
JVM 性能调优
java
弗拉唐7 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi778 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
少说多做3438 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀8 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20208 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深8 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++