当前位置: 首页 > Web前端 > HTML

cgo机制-从c调用go

时间:2023-03-27 23:58:59 HTML

|朱德江(GitHubID:doujiang24)Mosn项目核心开发者蚂蚁集团技术专家,专注云原生网关研发相关工作。本文12分钟阅读4656字。我在使用go语言的时候,写过这篇cgo实现机制[1],里面介绍了cgo的基本情况。主要介绍的是调用方法go=>c,属于比较浅的层次。随着理解的深入,发现c=>go的复杂度高了一个层次,于是就有了这篇文章。2.两个方向首先,cgo包括两个方向,c=>go,go=>c。相对来说go=>c更简单,它在goruntime创建的线程中调用并执行c函数。对于go调度器来说,调用一个c函数就相当于一个系统调用。执行环境还是在这个线程,只是调用栈切换了,函数调用多了一个ABI对齐。go运行时依赖的GMP环境是有的,区别不大。但是c=>go要复杂得多。它在c主机创建的线程上调用并执行go函数。这意味着在c线程中,需要先准备好go运行时所需的GMP环境,然后才能运行go函数。并且,go和c在线程控制上的区别主要在于信号。所以,复杂度高了一个层次。3、GMP从何而来首先简单说明一下为什么需要GMP,因为go函数在运行时,总是假设运行在一个goroutine环境中,绑定了对应的M和P。比如申请内存时,会先从P级缓存的span中获取。如果这些不可用,goruntime将无法运行。M虽然是一个线程,但是在实际实现中,它实际上是用M的一个数据结构来表示的。对于c创建的协程来说,额外的得到了M,是一个单独的M数据结构,代表一个线程。简单来说,c线程需要获取的GMP就是三个数据对象。在具体实现过程中,分为两步:1.当needm获取到一个额外的M,启动cgo时,goruntime会提前创建一个额外的M,同时创建一个goroutine,绑定到这个当然可以。因此,当你得到M的时候,你同时得到了G。而且goruntime对M没有限制,可以认为是无限制的,所以不存在M取不到的情况。2.exitsyscall得到P是的,这就是go=>c的逆过程。只是P资源有限,可能会出现抢不到P的情况。这个时候就要看调度机制了。4.当调度机制简单,并且M和P资源都成功获取到时,这个c线程就可以在M绑定的goroutine中运行指定的go函数。再者,如果go函数很简单,做一些纯CPU的就可以了计算的话,那么这段时间就不依赖于go的调度了。有两种情况会发生调度:1.exitsyscall无法获取到P,此时无法继续执行。它只能:1.把绑定在当前extraM上的g放入全局g等待队列2.把当前c线程挂起,等待g被唤醒执行。当g被唤醒执行时,因为g和M是绑定的:1.执行g的线程会挂掉,放弃P,唤醒等待的c线程2.c线程被唤醒后,获取P继续执行。2.协程在执行go函数的过程中挂掉了。比如go函数发起网络调用,需要等待网络响应。根据之前的文章,Goroutine调度——网络调用[2]。当前的g会挂掉,唤醒下一个g继续执行。但是,因为M和g是绑定的,此时:1.g被放入等待队列2.当前c线程被挂起,等待g被唤醒3.g被唤醒时释放P。不是在原来的c线程上1.当前线程被挂起,放弃P,唤醒等待的c线程2.c线程被唤醒后,拿到P继续执行直观来说就是在c上执行threadGoroutine与普通的go线程不同,参与goruntime的调度。对于goruntime来说,协程中的网络任务还是以非阻塞的方式执行,但是对于c线程来说,则是完全阻塞的执行。为什么需要这样做是因为线程只有一个调用栈,没办法并发,需要挂起线程来保护调用栈。PS:这里的执行过程其实和上面P不能抢到的过程很像。底层也在运行同一套功能(核心还是schedule)。五、信号处理另一个很大的区别是信号处理。在c语言的世界里,信号处理的权利/责任完全交给了用户。在go语言中,一层处理是在运行时完成的。比如一个具体的问题,当程序运行过程中出现segfault信号,此时应该去处理,还是应该c去响应该信号?答案是,看segfault发生时的上下文:1.如果go代码在运行,会交给goruntime去处理。2.如果c代码在运行,还是会响应c。它是如何实施的?信号处理还是比较复杂的,有很多细节。这里只介绍几个核心点。1、Sighhandler注册首先,对于操作系统来说,同一个信号只能有一个handler。查看go和c中的sighandler注册的时机:go编译生成的so文件加载时,会注册sighandler(只针对go需要的signal),保存原来的sighandler。c可以随时注册sighandler,可以是任意信号。因此,推荐的方法是在加载goso之前在c中完成信号注册,goso加载后不要注册sighandler,以免覆盖go注册sighandler。2.信号处理对于最简单的情况,如果一个信号,只有c注册了sighhandler,那么按照正常的c信号处理方法。对于sigfault,go也注册了sighandler的信号。遵循这个流程:1.当操作系统触发信号时,会调用go注册的sighandler(最佳实践是go的signal在后面注册);2.gosighandler首先判断是否在c上下文中(简单理解,就是没有g,其实挺复杂的);3、如果,在c上下文中,会调用之前保存的原来的sighandler(没有原来的sighandler,会暂时恢复信号配置,重新触发信号);4.如果,在go上下文中,会执行正常的信号处理流程。其中2和3是最复杂的,因为cgo包含了两个方向,还有signal和sigmask等附加因素,所以这里细节比较多,但是方向思路比较清晰。六、优化cgo实现机制在之前的文章[1]中,提到了一些优化的思路,但主要针对go=>c的方向。因为在c=>go场景中,还有其他更重要的优化点。1.多路复用extraM通常最大的性能消耗点是获取/释放M。1.上面说了从c到go,需要通过needm获取M。这期间有5次信号相关的系统调用。例如:为了避免死锁,暂时屏蔽所有信号,把go需要的信号打开。2.从go回到c时,通过dropm释放M。这期间有3个信号相关的系统调用。目的是恢复needm之前的信号状态(因为needm强行打开了go所必需的信号)。这两个操作,在MOSN新的MOE架构的测试中,可以看到大约占整体CPU使用率的2~5%,相当可观。了解瓶颈是成功的一半。优化思路也很直观。第一次从go返回c时,多余的M不释放,留着用。下次再输入gofromc,就不需要再额外获取M了。因为多出来的M资源是无限的,c线程一直占用多出来的M也没有关系,但是当c线程退出的时候,多出来的M还是需要释放掉,避免泄露。所以这个优化在windows上是不能开启的,因为windows的pthreadAPI没有线程退出的回调机制。当前在CL392854[3]中实现了一个版本。虽然通过了一个大佬的初审,通过了所有的测试,但是估计要合并很久。。。因为这个PR已经比较大了,标记为L大小。这种CL估计要被大佬审核了。看起来头大了。。。在简单场景的测试中,单个c=>go调用从~1600ns优化到~140ns,提升了10倍,达到了接近go=>c的水平(~80纳秒)。效果还是很明显的。实现上有两个比较复杂的点:1.当收到一个信号时,判断它在哪个上下文中,是否应该转发给c。因为cgo有两个方向,而这两个方向可以同时出现在一个调用栈中,而且signal也有mask,系统默认是一个handler。这不再用简单的状态机来描述了。goruntime在这方面有大约100+行核心判断代码来处理各种用法。估计没几个人能全部记住,只是遇到具体场景临时分析一下。或者在测试用例失败时详细分析。2.c线程退出,回调go到go的时候,涉及到c和go函数调用的ABI对齐。这里主要的复杂性在于需要处理不同的CPU架构和操作系统的差异。所以工作量还是比较大的。比如arm,arm64,期间有个有意思的坑,Aarch64的栈指针必须16字节对齐,否则会触发总线错误信号。(这就是为什么arm64的push/pop指令是两个操作。)2.如果拿不到P,就从c去走,而在拿GMP的过程中,只有P资源有限,负载高。当时,比较容易遇到无法获取P的情况。当得不到P时,c线程就会挂掉,等待进入全局队列的g被唤醒。这个过程对于goruntime来说比较合理,但是对于c线程来说就比较危险了,尤其是当多路复用逻辑运行在c线程中时,影响就更大了。此时有两种优化思路:1、类似extraM,然后给c线程绑定一个extraP,或者提前绑定一个P。这样c线程就不用挂了。这样的思路,最大的挑战在于extraP,它不受常规P数量的限制,对于go中P的定义来说是一个不小的挑战。2、不将g放入全局队列,而是放入优先级更高的P.runnext,这样g可以快速调度,c线程等待的时间可以更短。这个思路最大的挑战在于,给这个g加上优先级的判断可能有点违背g应该相等的原则。不过应该没问题,P.runnext本来就是为了处理某些需要优先处理的场景而设计的,这里只是多了一种场景。这个优化方向没有CL,但是我们有同学在做。3.尽快释放P。当从go返回c时,将调用entersyscall。具体来说,M和P并没有完全解除绑定,而是让P进入syscall的状态。接下来会出现两种情况:1、马上又要调用一个c=>go,直接使用这个P;2.sysmon会强制解除绑定。对于进入syscall的P,sysmon会等待20us=>10ms,然后抢走P并释放。等待的时间跨度还是蛮大的,多长时间看缘分,主要看sysmon之前有没有长时间闲置。对于go=>c的方向,一个syscall的等待时间通常比较小,适合这种机制。但是对于c=>go方向,这个pseudosyscall的等待时间取决于两次c=>go调用的间隔时间,其实不是很规律。因此,可能会导致P资源浪费20us=>10ms。所以又多了一个优化方向和两个思路:1、go返回c的时候,马上释放P,这样就不会浪费P的资源。2.调整sysmon。对于这种场景,有一种机制可以在20us内尽可能多的带走P。其中idea1顺便在这个CL411034中实现。这本来是为了修复gotrace在cgo场景下无法使用的bug。改到这个点是因为和Michael的讨论,一个改动(一开始没意识到是优化)。7.小结不知道大家看到这里是不是也觉得c=>go在复杂度上比go=>c高了一个层次。无论如何,我有。首先,c线程必须获得GMP才能运行go函数。然后,当c线程的g中发生协程调度事件时,其调度策略与普通go线程不同。另一个大坑是信号处理。在goruntime接管了sighandler之后,我们还需要让在c线程之前注册的sighandler生效,让c线程感觉不到自己被goruntime接管了。优化部分比较容易理解。主要涉及到go目前的实现,底层原理上改进不多。重用额外的M属于减少CPU开销;P相关的获取和释放更多的是延迟优化相关(如果使用额外的P,也会有CPU优化的效果)。8.最后,让我说一点。事实上,在目前的实现方案中,从c调用go时,go运行时的调度策略更多的是关注go的这边。比如goroutine和P是不能阻塞的。但是,其实对c线程是很不友好的。只要涉及到等待,c线程就会被挂起...因为在go的并发模型中,线程挂起通常是可以接受的,但是对于宿主c线程来说,有时候被阻塞挂起是非常敏感的。例如,在MOSN的MOE架构中,这种可能导致c线程挂起的行为需要谨慎处理。有什么办法可以改变吗?有,但是变化比较大。大意是从c异步调用goAPI:g=GoFunc(a,b)printf("g.status:%d,g.result:%d\n",g.status,g.result)意思即调用Go函数不再同步返回函数的返回值,而是返回一个带状态的g。这样做的好处是因为API是异步的,所以执行的时候不需要等待g同步返回。如果g被挂起,直接返回status=yield的g,goroutine协程会继续被go运行时调度,c线程不用挂掉等待。这种设计对c线程是最友好的,当然也要有一些配套的改变,比如缺P的时候最好多一个P,等等细节。不过这样的改动还是比较大的,Go要正式接受这个设计应该是比较困难的。说不定以后可以试试,万一你接受了~9.相关链接[1]cgo实现机制:https://uncledou.site/2021/go...[2]Goroutine调度——网络调用:https://uncledou.site/2021/go...[3]CL392854:https://go-review.googlesourc...本周推荐阅读MOSNBackchannel详解GoNative插件使用问题全解析Go内存泄漏,pprof够用吗?我们从大规模平台工程实践中学到了什么?