morestack on g0
在go程序中调用c语言代码的场景中,有时候会出现more stack on g0的错误,这个错误十分常见,比如下面这个程序就可以触发。
/*static int increase(int a) { return goIncrease(a++);}*/import "C"func main() { goIncrease(0)}//export goIncreasefunc goIncrease(a C.int) C.int { a++ if a > 2500 { return a } return C.increase(a)}
该程序中goIncrease调用c语言定义的函数increase,increase又调用了go中的代码goIncrease,这样循环调用下去,直到a大于2500时退出。运行该程序,报出下面的错误。
fatal: morestack on g0SIGTRAP: trace trapPC=0x4054832 m=0 sigcode=1signal arrived during cgo executiongoroutine 1 [running, locked to thread]:runtime.abort() /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/asm_amd64.s:859 +0x2 fp=0x7ffeefb19170 sp=0x7ffeefb19168 pc=0x4054832runtime.morestack() /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/asm_amd64.s:416 +0x25 fp=0x7ffeefb19178 sp=0x7ffeefb19170 pc=0x4052ef5rax 0x17rbx 0x7ffeefb19140rcx 0x40dc720rdx 0x0rdi 0x2rsi 0x7ffeefb190e0rbp 0xc000260388rsp 0x7ffeefb19168......
错误显示主M中的主goroutine也就是G0调用runtime.morestack()申请更多的栈空间,这个函数导致程序崩溃。
在smart chain的测试过程中也遇到了类似的问题,具体情况是这样:
evmone是Ewasm团队用C++写的EVM实现。smart chain使用该项目来执行evm code。由于smart chain本身是go语言编写,这就引入了在go代码中调用c++代码的问题。
evmc项目实现了一个binding层,它对外暴露go语言接口,内部使用cgo来调用c代码,而c代码文件中又wrap了c++实现。
#evmc/bindings/go/evmc/evmc.gopackage evmc/*#cgo CFLAGS: -I${SRCDIR}/.. -Wall -Wextra#include <evmc/evmc.h>#include <evmc/helpers.h>#include <evmc/loader.h>......static struct evmc_result execute_wrapper(struct evmc_vm* vm, uintptr_t context_index, enum evmc_revision rev, enum evmc_call_kind kind, uint32_t flags, int32_t depth, int64_t gas, const evmc_address* destination, const evmc_address* sender, const uint8_t* input_data, size_t input_size, const evmc_uint256be* value, const uint8_t* code, size_t code_size, const evmc_bytes32* create2_salt){ struct evmc_message msg = { kind, flags, depth, gas, *destination, *sender, input_data, input_size, *value, *create2_salt, }; struct evmc_host_context* context = (struct evmc_host_context*)context_index; return evmc_execute(vm, &evmc_go_host, context, rev, &msg, code, code_size);}*/import "C"func (vm *VM) Execute(ctx HostContext, rev Revision,...... result := C.execute_wrapper(vm.handle, C.uintptr_t(ctxId), uint32(rev), C.enum_evmc_call_kind(kind), flags, C.int32_t(depth), C.int64_t(gas), &evmcDestination, &evmcSender, bytesPtr(input), C.size_t(len(input)), &evmcValue, bytesPtr(code), C.size_t(len(code)), &evmcCreate2Salt) removeHostContext(ctxId)......}
VM的Execute方法中调用了C伪包中定义的execute_wrapper函数,execute_wrapper函数中调用的是evmc/bindings/go/evmc/helpers.h中定义的evmc_execute函数。
static inline struct evmc_result evmc_execute(struct evmc_vm* vm, const struct evmc_host_interface* host, struct evmc_host_context* context, enum evmc_revision rev, const struct evmc_message* msg, uint8_t const* code, size_t code_size){ return vm->execute(vm, host, context, rev, msg, code, code_size);}
而evmc_execute函数中wrap了vm.execute函数指针指向的代码段,这个指针在创建虚拟机时被赋值为如下函数。
#lib/evmone/execution.cppevmc_result execute(evmc_vm* /*unused*/, const evmc_host_interface* host, evmc_host_context* ctx, evmc_revision rev, const evmc_message* msg, const uint8_t* code, size_t code_size) noexcept{...... if(op!=OP_BEGINBLOCK) { for(int i = state->stack.size() - 1; i >= 0; i--) { ...... } }......}
通过上面讲述的这样一系列binding,我们最终可以在go代码中调用c++库中的函数。
当我们尝试用smart chain运行以太坊官方项目中的智能合约测试用例时,在运行到stRandom2目录下的randomStatetest458.json时,程序崩溃,报出morestack on g0的错误。
这个用例中合约递归调用该合约自身,直到gas fee不足后再逐层退出调用。错误显示g0上当前栈空间不足,无法继续调用接下来的函数。
这里简单提一下go语言的栈管理。golang的调度器中有三个关键结构体,G,M,P。G代表一个goroutine,M对应一个os thread,P对应一个cpu核。当创建一个goroutine时,go运行时创建一个G,并分配kB量级的用户栈,这个栈是位于内存堆中的,如果G运行过程中需要更多的栈空间,最终会调用src/runtime/asm_amd64.s中的runtime·morestack方法来进行stack split。
TEXT runtime·morestack(SB),NOSPLIT,$0-0 // Cannot grow scheduler stack (m->g0). get_tls(CX) MOVQ g(CX), BX MOVQ g_m(BX), BX MOVQ m_g0(BX), SI CMPQ g(CX), SI JNE 3(PC) CALL runtime·badmorestackg0(SB) CALL runtime·abort(SB) // Cannot grow signal stack (m->gsignal). MOVQ m_gsignal(BX), SI CMPQ g(CX), SI JNE 3(PC) CALL runtime·badmorestackgsignal(SB) CALL runtime·abort(SB)
但是要注意的是g0是不允许stack split的,g0不同于其他的g,它的栈是系统堆栈,当执行系统调用,cgocall,调度等任务时,会从普通g的栈上切换到g0的栈,这时的任务是不可抢占的,也不被垃圾收集器扫描,同时,系统堆栈是不支持split的,它在线程初始化时指定,一般是MB量级的。例如,开启了cgo后,创建一个新的M时会调用_cgo_sys_thread_start启动一个线程,可以看到栈大小已经在创建时指定。
TEXT ·asmcgocall(SB),NOSPLIT,$0-20 // Switch to system stack. MOVQ m_g0(R8), SI CALL gosave<>(SB) MOVQ SI, g(CX) MOVQ (g_sched+gobuf_sp)(SI), SP // Now on a scheduling stack (a pthread-created stack). // Make sure we have enough room for 4 stack-backed fast-call // registers as per windows amd64 calling convention. SUBQ $64, SP ANDQ $~15, SP // alignment for gcc ABI MOVQ DI, 48(SP) // save g MOVQ (g_stack+stack_hi)(DI), DI SUBQ DX, DI MOVQ DI, 40(SP) // save depth in stack (can't just save SP, as stack might be copied during a callback) MOVQ BX, DI // DI = first argument in AMD64 ABI MOVQ BX, CX // CX = first argument in Win64CALLAX
cgo生成的桩代码中使用cgocall调用cgo工具生成的c函数,cgocall最终会调用汇编代码.asmcgocall
f1 := func() { output, gasLeft, err = v.vm.Execute(...) } if v.isNewRound(depth) { var mtx sync.Mutex mtx.Lock() go func() { defer mtx.Unlock() //will call c function f1() }() mtx.Lock() mtx.Unlock() return } f1() return}
CALL AX后在evmone的代码中又回调了smart chain中的go语言函数。这个是通过cgocallbackg实现的,它保证了这些调用都在同一个M上。
到目前为止,我们可以知道当代码不停的在go和c之间互相调用时,c语言的函数执行和go的运行时代码是执行在g0栈上的,栈上的内容除了c函数的调用栈外还有go运行时保存的g上下文信息等。而栈的大小在创建线程时指定,在运行中不会split,所以,如果不加以栈深度等限制的情况下,栈一定会溢出。而go不允许g0扩增栈的大小,这就是报出morestack on g0的原因。
如何解决呢?
一方面,由于evmone中虚拟机执行函数是固定的,它的栈大小可以近似成一个常量,同时go的运行时在进入到c代码执行前保存的信息也基本是常量,所以我们可以通过控制递归调用c代码的深度来防止栈溢出。
另一方面,当M被锁定在某个G上时,如果该G处于不可执行状态,那么M会释放掉P,runtime寻找新的M绑定P来运行P中的G。在cgocallbackg中runtime会将当前G与M锁定,我们只需要找到一种方式使得当前的G不可执行,就能释放P让新的M运行接下来的代码。例如下面的代码所示:
f1 := func() { output, gasLeft, err = v.vm.Execute(...) } if v.isNewRound(depth) { var mtx sync.Mutex mtx.Lock() go func() { defer mtx.Unlock() //will call c function f1() }() mtx.Lock() mtx.Unlock() return } f1() return}
我们通过在go回调函数中创建新的goroutine,在新建的goroutine中执行这部分被c代码调用的go代码。当前G在第二次尝试获取锁时失败,导致当前M释放掉P,新建的goroutine被绑定到新的M上运行。由于每个M都有一个独立的g0栈,这就相当于变相的动态扩充了系统栈的大小。这里的互斥锁同时也保证了最初的cgocall的调用顺序,程序还是顺序执行的。
结论:利用cgo调用c代码时要格外注意栈溢出问题,这点对于区块链应用更加重要。如果不加以合适的栈保护,一些恶意或有bug的合约代码在执行时会击穿g0栈,导致节点崩溃,链的活性就会受到影响。