聊聊Go程序是如何运行的

简介: 本文作者 **sharkChili** 是一名 Java 和 Go 语言开发者,同时也是 CSDN 博客专家和 JavaGuide 维护者。文章探讨了 Go 语言的执行过程,从汇编角度出发,解释了如何从 `main.go` 文件开始,经过入口跳转、参数拷贝、启动协程、运行 `g0` 的 `main` 方法等步骤,最终执行到用户定义的 `main` 函数。文章还展示了相关汇编代码片段,并提供了运行时检查、系统初始化和调度器初始化的细节。结尾提到,有兴趣的读者可以加入作者创建的交流群进行深入讨论。

写在文章开头

Go语言是一门编译语言,其工作过程即直接通过编译生成各大操作系统的机器码即可直接执行,所以这篇文章笔者就从底层汇编码的角度聊一聊Go语言是如何运行的。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

go语言代码执行详解

最终执行代码

我们首先在goland上创建一个名为main.go的文件,代码格式指明未main包下的main方法,当go语言完成编译并启动后,就会执行到这段代码:

package main

import (
    "fmt"
)

func main() {
   
   
    fmt.Println("hello Go")
}

入口跳转

go语言是跨平台的语言,所以底层对各大平台的启动都做了特定的封装,以笔者的windows系统为例,其执行入口为rt0_windows_amd64.s,同理Linux系统则是rt0_linux_amd64.s,可以看到在任何平台它们都会通过汇编指令JMP跳转到_rt0_amd64方法:

//windows的入口代码_rt0_amd64_windows
TEXT _rt0_amd64_windows(SB),NOSPLIT,$-8
    JMP    _rt0_amd64(SB)

//Linux入口代码_rt0_amd64_linux
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP    _rt0_amd64(SB)

拷贝参数,启动协程

通过全局搜索看到这个方法的实现,这段代码会通过MOVQ将参数的数量argc拷贝到目标寄存器DI上,然后再通过LEAQ计算所有参数argv的偏移量地址并存储到SI上,然后再次跳转到runtime·rt0_go方法准备利用上述寄存的参数完成g0协程初始化。


TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ    0(SP), DI    // argc
    LEAQ    8(SP), SI    // argv
    JMP    runtime·rt0_go(SB)

启动g0运行main方法

因为runtime·rt0_go也是汇编方法,所以全局搜索后我们再次定位到该方法的实现,在这里笔者给出几个核心步骤:

  1. 将参数入栈,用于后续的各种初始化和启动操作所用。
  2. 调用check进行程序启动前必要的检查操作。
  3. 拷贝上述入栈的参数进行系统参数初始化。
  4. 初始化全局调度器。
  5. 创建协程g0等待线程绑定并运行main方法。
  6. 启动线程即M绑定协程g0,执行main方法。

对应核心代码如下:


TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
    // 参数入栈
    MOVQ    DI, AX        // argc
    MOVQ    SI, BX        // argv
    SUBQ    $(5*8), SP        // 3args 2auto
    ANDQ    $~15, SP
    MOVQ    AX, 24(SP)
    MOVQ    BX, 32(SP)

    //......

    // 初始化协程g0栈等信息
    MOVQ    $runtime·g0(SB), DI
    LEAQ    (-64*1024+104)(SP), BX
    MOVQ    BX, g_stackguard0(DI)
    MOVQ    BX, g_stackguard1(DI)
    MOVQ    BX, (g_stack+stack_lo)(DI)
    MOVQ    SP, (g_stack+stack_hi)(DI)

    //......

    //调用check进行运行时检查
    CALL    runtime·check(SB)

    //......

    //拷贝argc和argv
    MOVL    24(SP), AX        // copy argc
    MOVL    AX, 0(SP)
    MOVQ    32(SP), AX        // copy argv
    MOVQ    AX, 8(SP)
    CALL    runtime·args(SB)
    //完成系统参数初始化,例如系统字长,CPU核心数等信息初始化
    CALL    runtime·osinit(SB)
    //初始化调度器
    CALL    runtime·schedinit(SB)

    // runtime·mainPC代表我们程序执行的main函数的地址值,下述方法会通过创建一个协程g0来调用这个方法
    MOVQ    $runtime·mainPC(SB), AX        // entry
    PUSHQ    AX
    CALL    runtime·newproc(SB)
    POPQ    AX

    // 启动一个线程绑定上述的协程,自此调度器开始工作直接执行main方法
    CALL    runtime·mstart(SB)

    CALL    runtime·abort(SB)    // mstart should never return
    RET

我们先来看看运行时检查的步骤,这段代码在runtime1.go上,从笔者贴出的代码不难看出,这个方法会在程序运行进行类型长度、CAS、指针、原子类的进行正确性的检查操作。

func check() {
   
   
    //......
    if unsafe.Sizeof(a) != 1 {
   
   
        throw("bad a")
    }

    //......
    if timediv(12345*1000000000+54321, 1000000000, &e) != 12345 || e != 54321 {
   
   
        throw("bad timediv")
    }

    //......
    if !atomic.Cas(&z, 1, 2) {
   
   
        throw("cas1")
    }
    //......

    m = [4]byte{
   
   1, 1, 1, 1}
    atomic.Or8(&m[1], 0xf0)
    if m[0] != 1 || m[1] != 0xf1 || m[2] != 1 || m[3] != 1 {
   
   
        throw("atomicor8")
    }

    //......

    *(*uint64)(unsafe.Pointer(&j)) = ^uint64(0)
    if j == j {
   
   
        throw("float64nan")
    }
    if !(j != j) {
   
   
        throw("float64nan1")
    }

//......

    if _FixedStack != round2(_FixedStack) {
   
   
        throw("FixedStack is not power-of-2")
    }

    if !checkASM() {
   
   
        throw("assembly checks failed")
    }
}

检查之后就是osinit,它会获取当前操作系统核心数、系统字长等基本信息:

func osinit() {
   
   
    asmstdcallAddr = unsafe.Pointer(abi.FuncPCABI0(asmstdcall))

    setBadSignalMsg()

    loadOptionalSyscalls()

    disableWER()

    initExceptionHandler()

    initHighResTimer()
    timeBeginPeriodRetValue = osRelax(false)

    initLongPathSupport()

    ncpu = getproccount()

    physPageSize = getPageSize()

    // Windows dynamic priority boosting assumes that a process has different types
    // of dedicated threads -- GUI, IO, computational, etc. Go processes use
    // equivalent threads that all do a mix of GUI, IO, computations, etc.
    // In such context dynamic priority boosting does nothing but harm, so we turn it off.
    stdcall2(_SetProcessPriorityBoost, currentProcess, 1)
}

完成检查后就调用schedinit进行调度器初始化,从各个函数的语义即可知晓它会进行一次STW然后进行堆栈、cpu、环境变量、垃圾回收器等各个信息的初始化:

func schedinit() {
   
   

    // The world starts stopped.
    worldStopped()

    moduledataverify()
    stackinit()
    mallocinit()
    godebug := getGodebugEarly()
    initPageTrace(godebug) // must run after mallocinit but before anything allocates
    cpuinit(godebug)       // must run before alginit


    goargs()
    goenvs()
    secure()
    parsedebugvars()
    gcinit()


    //......
}

最终就是创建一个线程绑定协程g0,调用$runtime·mainPC(SB)从而拿到g0协程的main方法,最终定位到我们实现的main包下的main方法main_main,这一点我们可以定位main_main的注释知晓(go:linkname main_main main.main),这个main_main指向的是被链接的main包下的main方法,也就是我们编写入口代码:

// The main goroutine.
func main() {
   
   
    //我们实现的main包下的main方法
    fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
    fn()

}

以终为始印证观点

基于文章开头给出的代码断点,通过堆栈调用的信息可以看到,g0的main方法确实通过main_main定位我们编写的main方法并完成执行:

小结

碍于篇幅等原因,笔者对go程序的运行仅做了简单的介绍,总体来说go程序运行大体分为以下几个步骤:

  1. 参考拷贝并入栈
  2. 类型检查
  3. 系统信息初始化
  4. 主调度器初始化
  5. 创建协程g0分配main方法的调用
  6. 创建线程绑定g0
  7. 通过协程g0的main方法调用我们的main方法,程序启动并运行

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

目录
相关文章
|
25天前
|
Go 数据处理 开发者
Go 语言的反射机制允许程序在运行时动态检查和操作类型信息,提供极大的灵活性和扩展性
Go 语言的反射机制允许程序在运行时动态检查和操作类型信息,提供极大的灵活性和扩展性。本文探讨了反射的基本原理、主要操作、应用场景及注意事项,并通过实例展示了反射的实际应用,帮助开发者更好地理解和使用这一强大特性。
29 2
|
3月前
|
Kubernetes Go 持续交付
一个基于Go程序的持续集成/持续部署(CI/CD)
本教程通过一个简单的Go程序示例,展示了如何使用GitHub Actions实现从代码提交到Kubernetes部署的CI/CD流程。首先创建并版本控制Go项目,接着编写Dockerfile构建镜像,再配置CI/CD流程自动化构建、推送Docker镜像及部署应用。此流程基于GitHub仓库,适用于快速迭代开发。
70 3
|
3月前
|
Kubernetes 持续交付 Go
创建一个基于Go程序的持续集成/持续部署(CI/CD)流水线
创建一个基于Go程序的持续集成/持续部署(CI/CD)流水线
|
3月前
|
IDE Go 数据处理
Go to Learn Go之第一个Go程序
Go to Learn Go之第一个Go程序
28 0
|
4月前
|
Linux Shell Go
如何构建和安装 Go 程序
如何构建和安装 Go 程序
53 1
|
4月前
|
Go
在Go中如何停止程序
在Go中如何停止程序
|
4月前
|
Go 数据库 UED
[go 面试] 同步与异步:程序执行方式的不同之处
[go 面试] 同步与异步:程序执行方式的不同之处
|
4月前
|
存储 缓存 安全
|
4月前
|
编译器 Go 开发者
|
4月前
|
设计模式 Java 编译器
Go - 基于逃逸分析来提升程序性能
Go - 基于逃逸分析来提升程序性能
46 2