Golang协程调度二:协程切换原理

丁凯 · · 45 次点击 · · 开始浏览    

概述

协程是Golang中的轻量级线程,麻雀虽小五脏俱全,Golang管理协程时也必然会涉及到协程之间的切换:阻塞的协程被切换出去,可运行的协程被切换进来。我们在本章节就来仔细分析下协程如何切换。

TLS

thread local storage:

getg()

goget()用来获取当前线程正在执行的协程g。该协程g被存储在TLS中。

mcall()

mcall在golang需要进行协程切换时被调用,用来保存被切换出去协程的信息,并在当前线程的g0协程堆栈上执行新的函数。一般情况下,会在新函数中执行一次schedule()来挑选新的协程来运行。接下来我们就看看mcall的实现。

调用时机

系统调用返回

当执行系统调用的线程从系统调用中返回后,有可能需要执行一次新的schedule,此时可能会调用mcall来完成该工作,如下:

func exitsyscall(dummy int32) {
    ......
    // Call the scheduler. 
    mcall(exitsyscall0)
    ......
}

在exitsyscall0中如果可能会放弃当前协程并执行一次schedule,挑选新的协程来占有m。

由于阻塞放弃执行

由于某些原因,当前执行的协程可能会被阻塞,如管道读写时条件无法满足,则当前协程会被阻塞直到条件满足。

在gopark()函数中,便会调用该mcall放弃当前协程并执行一次协程调度。

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason string, traceEv byte, traceskip int) {
    mp := acquirem()
    gp := mp.curg 
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = *(*unsafe.Pointer)(unsafe.Pointer(&unlockf))
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here. 
    mcall(park_m)
}

而park_m函数我们在后面会分析,它放弃之前执行的协程并调用一次schedule()挑选新的协程来执行。

执行原理

前面我们主要描述了mcall被调用的时机,现在我们要来看看mcall的实现原理。

mcall的函数原型是:

func mcall(fn func(*g))

这里fn的参数指的是在调用mcall之前正在运行的协程。

我们前面说到,mcall的主要作用是协程切换,它将当前正在执行的协程状态保存起来,然后在m->g0的堆栈上调用新的函数。 在新的函数内会将之前运行的协程放弃,然后调用一次schedule()来挑选新的协程运行。

// func mcall(fn func(*g)) 
// Switch to m->g0's stack, call fn(g). 
// Fn must never return.  It should gogo(&g->sched) 
// to keep running g. 
TEXT runtime·mcall(SB), NOSPLIT, $0-8
    // DI中存储参数fn 
    MOVQ    fn+0(FP), DI 

    get_tls(CX)
    // 获取当前正在运行的协程g信息 
    // 将其状态保存在g.sched变量 
    MOVQ    g(CX), AX   // save state in g->sched 
    MOVQ    0(SP), BX   // caller's PC 
    MOVQ    BX, (g_sched+gobuf_pc)(AX)

    LEAQ    fn+0(FP), BX    // caller's SP 
    MOVQ    BX, (g_sched+gobuf_sp)(AX)

    MOVQ    AX, (g_sched+gobuf_g)(AX)

    MOVQ    BP, (g_sched+gobuf_bp)(AX)

    // switch to m->g0 & its stack, call fn 
    MOVQ    g(CX), BX

    MOVQ    g_m(BX), BX

    MOVQ    m_g0(BX), SI
    CMPQ    SI, AX  // if g == m->g0 call badmcall 
    JNE 3(PC)
    MOVQ    $runtime·badmcall(SB), AX
    JMP AX
    MOVQ    SI, g(CX)   // g = m->g0 
    // 切换到m->g0堆栈 
    MOVQ    (g_sched+gobuf_sp)(SI), SP  // sp = m->g0->sched.sp 
    // 参数AX为之前运行的协程g 
    PUSHQ   AX

    MOVQ    DI, DX

    MOVQ    0(DI), DI 
    // 在m->g0堆栈上执行函数fn 
    CALL    DI 
    POPQ    AX

    MOVQ    $runtime·badmcall2(SB), AX
    JMP AX
RET 

如何获取当前协程执行信息

前两句理解起来可能比较晦涩:

buf+0(FP) 其实就是获取gosave的第一个参数(gobuf地址),参考 A Quick Guide to Go’s Assembler

The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP).

LEAQ buf+0(FP), BX则是获取到第一个参数的存储地址,而根据golang的堆栈布局,这个地址其实是调用者的sp,如下:

接下来的几句比较容易理解,在第一句获取了gobuf的地址后,接下来将一些相关成员设置成合适的value。 最关键的是以下几句

get_tls(CX)

MOVQ    g(CX), BX 
MOVQ BX, gobuf_g(AX)

这几句的作用是从TLS中获取当前线程运行的g,然后将其存储在gobuf的成员g。

gosave()

gosave在golang协程切换时被调用,用来保存被切换出去协程的信息,以便在下次该协程被重新调度执行时可以快速恢复出协程的执行上下文。

与协程调度相关的数据结构如下:

type g struct {
    stack       stack  

    stackguard0 uintptr 
    stackguard1 uintptr 
    ......

    sched       gobuf

    ......
}

// gobuf记录与协程切换相关信息 
type gobuf struct {
    sp   uintptr 
    pc   uintptr 
    g    guintptr

    ctxt unsafe.Pointer 

    ret  uintreg

    lr   uintptr 
    bp   uintptr 
}

gosave是用汇编语言写的,性能比较高,但理解起来就没那么容易。

TODO: gosave()的调用路径是什么呢?

// void gosave(Gobuf*)

// save state in Gobuf; setjmp 
TEXT runtime·gosave(SB), NOSPLIT, $0-8 
    MOVQ    buf+0(FP), AX           // gobuf
    LEAQ    buf+0(FP), BX           // caller's SP

    MOVQ    BX, gobuf_sp(AX)

    MOVQ    0(SP), BX               // caller's PC

    MOVQ BX, gobuf_pc(AX)

    MOVQ $0, gobuf_ret(AX)

    MOVQ $0, gobuf_ctxt(AX)

    MOVQ BP, gobuf_bp(AX)

    get_tls(CX)

    MOVQ    g(CX), BX 
    MOVQ BX, gobuf_g(AX)

RET 

前两句理解起来可能比较晦涩:

buf+0(FP) 其实就是获取gosave的第一个参数(gobuf地址),参考 A Quick Guide to Go’s Assembler

The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP).

LEAQ buf+0(FP), BX则是获取到第一个参数的存储地址,而根据golang的堆栈布局,这个地址其实是调用者的sp,如下:

接下来的几句比较容易理解,在第一句获取了gobuf的地址后,接下来将一些相关成员设置成合适的value。 最关键的是以下几句

get_tls(CX)
MOVQ    g(CX), BX 
MOVQ BX, gobuf_g(AX)

这几句的作用是从TLS中获取当前线程运行的g,然后将其存储在gobuf的成员g。

gogo()

gogo的作用正好相反,用来从gobuf中恢复出协程执行状态并跳转到上一次指令处继续执行。因此,其代码也相对比较容易理解,我们就不过多赘述,如下:

gogo()主要的调用路径:schedule()–>execute()–>googo()

// void gogo(Gobuf*)
// restore state from Gobuf; longjmp 
TEXT runtime·gogo(SB), NOSPLIT, $0-8 
MOVQ    buf+0(FP), BX           // gobuf
MOVQ    gobuf_g(BX), DX 
MOVQ 0(DX), CX 
get_tls(CX)

MOVQ DX, g(CX)

MOVQ    gobuf_sp(BX), SP        // restore SP 
MOVQ    gobuf_ret(BX), AX 
MOVQ    gobuf_ctxt(BX), DX 
MOVQ    gobuf_bp(BX), BP 
MOVQ $0, gobuf_sp(BX)

MOVQ $0, gobuf_ret(BX)

MOVQ $0, gobuf_ctxt(BX)

MOVQ $0, gobuf_bp(BX)

// 恢复出上一次执行指令,并跳转至该指令处

MOVQ    gobuf_pc(BX), BX 
JMP BX 

这里最后一句跳转至该协程被调度出的那条语句继续执行,需要注意的是该函数不再返回调用者。

本文来自:知乎专栏

感谢作者:丁凯

查看原文:Golang协程调度二:协程切换原理

45 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传