cgo 机制 - 从 c 调用 go

article/2025/8/30 1:28:57

fa986ce4f1004dc8e7d21c90ef82f9be.gif

文|朱德江(GitHub ID:doujiang24)

MOSN 项目核心开发者

蚂蚁集团技术专家

dc06faa49a9b4dae37608c4c39ce1e5c.png

专注于云原生网关研发的相关工作

本文 4656 字 阅读 12 分钟

一、前言

去年刚学 go 语言的时候,写了这篇 cgo 实现机制[1] ,介绍了 cgo 的基本情况。主要介绍的是 go=>c 这个调用方式,属于比较浅的层次。随着了解的深入,发现 c=>go 的复杂度又高了一级,所以有了这篇文章。

二、两个方向

首先,cgo 包含了两个方向, c=>go ,go=>c 。

相对来说,go=>c 是更简单的,是在 go runtime 创建的线程中,调用执行 c 函数。对 go 调度器而言,调用 c 函数,就相当于系统调用。执行环境还是在本线程,只是调用栈有切换,还多了一个函数调用的 ABI 对齐,对于 go runtime 依赖的 GMP 环境,都是现有的,并没有太大的区别。

而 c=>go 则复杂很多,是在一个 c 宿主创建的线程上,调用执行 go 函数。这意味着,需要在 c 线程中,准备好 go runtime 所需要的 GMP 环境,才能运行 go 函数。以及,go 和 c 对于线程掌控的不同,主要是信号这块。所以,复杂度又高了一级。

三、GMP 从哪里来

首先简单解释一下,为什么需要 GMP ,因为在 go 函数运行的时候,总是假设是运行在一个 goroutine 环境中,以及绑定有对应的 M 和 P 。比如,要申请内存的时候,则会先从 P 这一层 cache 的 span 中的获取,如果这些没有的话,go runtime 就没法运行了。

虽然 M 是线程,但是具体实现上,其实就是一个 M 的数据结构来表示,对于 c 创建的协程,获取的是 extra M ,也就是单独的表示线程的 M 数据结构。

简单来说,c 线程需要获取的 GMP ,就是三个数据对象。在具体的实现过程中,是分为两步来的:

1. needm 获取一个 extra M

开启了 cgo 的情况下,go runtime 会预先创建好额外的 M ,同时还会创建一个 goroutine,跟这个 M 绑定。所以,获取到 M,也就同时得到了 G。

而且,go runtime 对于 M 并没有限制,可以认为是无限的,也就不存在获取不到 M 的情况。

2.exitsyscall 获取 P

是的,这个就是 go=>c 的反向过程。只是 P 资源是有限的,可能会出现抢不到 P 的情况,此时就得看调度机制了。

四、调度机制

简单情况下,M 和 P 资源都顺利拿到了,这个 c 线程,就可以在 M 绑定的 goroutine 中运行指定的 go 函数了。更进一步,如果 go 函数很简单,只是简单的做点纯 CPU 计算就结束了,那么这期间则不依赖 go 的调度了。

有两种情况,会发生调度:

1. exitsyscall 获取不到 P

此时没法继续执行了,只能:

1.将当前 extra M 上绑定的 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. P 被释放

在 g 被唤醒的时候,此时肯定不是在原来的 c 线程上了

1.当前线程挂起,让出 P,唤醒等待的 c 线程

2.c 线程被唤醒后,拿到 P,继续执行

直观来说,也就是在 c 线程上执行的 goroutine,并不像普通的 go 线程一样,参与 go runtime 的调度。对于 go runtime 而言,协程中的网络任务,还是以非阻塞的方式在执行,只是对于 c 线程而言,则完全是以阻塞的方式来执行了。

为什么需要这样,还是因为线程的调用栈,只有一个,没有办法并发,需要把线程挂起,保护好调用栈。

PS:这里的执行流程,其实跟上面抢不到 P 的流程,很类似,底层也是同一套函数在跑(核心还是 schedule)。

五、信号处理

另外一大差异是,信号处理。

1. c 语言世界里,把信号处理的权利/责任,完全交给用户了。

2. go 语言,则在 runtime 做了一层处理。

比如,一个具体的问题,当程序运行过程中,发生了 segfault 信号,此时是应该由 go 来处理,还是 c 来响应信号呢?

答案是,看发生 segfault 时的上下文:

1.如果正在运行 go 代码,则交给 go runtime 来处理

2.如果正在运行 c 代码,则还是 c 来响应

那具体是怎么实现的呢?信号处理还是比较复杂的,有比较多的细节,这里我们只介绍几个核心点。

1. sighandler 注册

首先,对于操作系统而言,同一个信号,只能有一个 handler 。再看 go 和 c 发生 sighandler 注册的时机:

1. go 编译产生的 so 文件,被加载的时候,会注册 sighandler(仅针对 go 需要用的信号),并且会把原始的 sighandler 保存下来。

2. c 可以在任意的时间,注册 sighandler,可以是任意的信号。

所以,推荐的做法是,在加载 go so 之前,c 先完成信号注册,在 go so 加载之后,不要再注册 sighandler 了,避免覆盖 go 注册 sighandler。

2.信号处理

对于最简单的情况,如果一个信号,只有 c 注册了 sighandler,那么还是按照常规 c 信号处理的方式来。

对于 sigfault 这种,go 也注册了 sighandler 的信号,按照这个流程来:

1.操作系统触发信号时,会调用 go 注册的 sighandler(最佳实践中,go 的信号注册在后面);

2.go sighandler 先判断是否在 c 上下文中(简单的理解,也就是没有 g,实际上还是挺复杂的);

3.如果,在 c 上下文中,会调用之前保存的原始 sighandler(没有原始的 sighandler,则会临时恢复 signal 配置,重新触发信号);

4.如果,在 go 上下文中,则会执行普通的信号处理流程。

其中,2 和 3 是最复杂的,因为 cgo 包含了两个方向,以及信号还有 sigmask 等等额外的因素,所以这里细节是非常多的,不过思路方向还是比较清晰的。

六、优化

上篇 cgo 实现机制[1] ,提过优化一些思路,不过主要针对 go => c 这个方向。因为 c => go 的场景中,还有其他更重要的优化点。

1.复用 extra M

通常情况下,最大的性能消耗点在获取/释放 M

1.上面提到,从 c 进入 go,需要通过 needm 来获取 M 。这期间有 5 个信号相关的系统调用。比如:避免死锁用的,临时屏蔽所有信号,以及开启 go 所需要的信号。

2.从 go 返回 c 的时候,通过 dropm 来释放 M 。这期间有 3 个信号相关的系统调用。目的是恢复到 needm 之前的信号状态(因 needm 强制开启了 go 必须的信号)。

这两个操作,在 MOSN 新的 MOE 架构的测试中,可以看到约占整体 2~5% 的 CPU 占用,还是比较可观的。

了解了瓶颈之后,也就成功了一半。

优化思路也很直观,第一次从 go 返回 c 的时候,不释放 extra M ,继续留着使用,下一次从 c 进入 go 也就不需要再获取 extra M 了。因为 extra M 资源是无限的,c 线程一直占用一个 extra M 也无所谓。

不过,在 c 线程退出的时候,还是需要释放 extra M ,避免泄漏。所以,这个优化,在 windows 就不能启用了,因为 windows 的 pthread API 没有线程退出的 callback 机制。

目前实现了一版在 CL 392854[3] 。虽然通过了一个大佬的初步 review,以及跑通了全部测试,不过,估计要合并还要很久...因为这个 PR 已经比较大了,被标记 L size 了,这种 CL 估计大佬们 review 起来也头大...

在简单场景的测试中,单次 c => go 的调用,从 ~1600ns 优化到了 ~140ns,提升 10 倍,达到了接近 go => c 的水平( ~80ns )效果还是挺明显的。

实现上主要有两个较复杂的点:

1.接收到信号时,判断在哪个上下文里,以及是否应该转发给 c。因为 cgo 有两个方向,而且这两个方向又是可以在一个调用栈中同时发生的,以及信号还有 mask ,系统默认 handler 之分。这里面已经不是简单的状态机可以描述的,go runtime 在这块有约 100 + 行的核心判断代码,以应对各式各样的用法。估计没几个人可以全部记住,只有碰到具体场景临时去分析。或者在跑测试用例失败的时候,才具体去分析。

2.在 c 线程退出,callback 到 go 的时候,涉及到 c 和 go function call ABI 对齐。这里主要的复杂度在于,需要处理好不同的 CPU 体系结构,以及操作系统上的差异。所以工作量还是比较大的。比如 arm ,arm64 , 期间有一个有意思的坑,Aarch64 的 stack pointer 必须是 16 byte 对齐的,否则会触发 bus error 信号。(也因此 arm64 的压栈/出栈指令,都是两个两个操作的)

2.获取不到 P

从 c 进入 go,获取 GMP 的过程中,只有 P 资源是受限的,在负载较高时,获取不到 P 也是比较容易碰到的。

当获取不到 P 时,c 线程会挂起,等待进入全局队列的 g 被唤醒。这个过程对于 go runtime 而言是比较合理的,但是对于 c 线程则比较危险,尤其当 c 线程中跑的是多路复用的逻辑,则影响更大了。

此时有两个优化思路:

1.类似 extra M ,再给 c 线程绑一个 extra P ,或者预先绑定一个 P 。这样 c 线程就不需要被挂起了。这个思路,最大的挑战在于 extra P ,是不受常规 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 会等 20 us => 10 ms,然后将 P 抢走释放掉。等待时间跨度还是挺大的,具体多久就看命了,主要看 sysmon 是否之前已经长时间空闲了。

对于 go => c 这方向,一个 syscall 的等待时间,通常是比较小的,所以这套机制是合适的。但是对于 c => go 这个方向,这种伪 syscall 的等待时间,取决于两个 c => go 调用的间隔时间,其实不太有规律的。所以,可能会造成 P 资源被浪费 20us => 10ms。

所以,又有一个优化方向,两个思路:

1.从 go 返回 c 的时候,立即释放 P ,这样不会浪费 P 资源。

2.调整下 sysmon,针对这种场景,有一种机制,能尽量在 20 us 就把 P 抢走。

其中,思路 1 ,这个 CL 411034 里顺便实现了。这个本来是为了修复 go trace 在 cgo 场景下不能用的 bug ,改到这个点,是因为跟 Michael 大佬讨论,引发的一个改动(一开始还没有意识到是一个优化)。

七、总结

不知道看到这里,你是否一样觉得,c => go 比 go => c 的复杂度又高了一级。反正我是有的。

首先,c 线程得拿到 GMP 才能运行 go 函数,然后,c 线程上的 g 发生了协程调度事件的时候,调度策略又跟普通的 go 线程不一样。另外一个大坑则是信号处理,在 go runtime 接管了 sighandler 之后,我们还需要让 c 线程之前注册的 sighandler 一样有效,使 c 线程感觉不到被 go runtime 接管了一道。

优化这块,相对来说,比较好理解一些,主要是涉及到 go 目前的实现方式,并没有太多底层原理上的改进。复用 extra M 属于降低 CPU 开销;P 相关的获取和释放,则更多涉及到延时类的优化(如果搞了 extra P,则也会有 CPU 的优化效果)。

八、最后

最后吐个槽,其实目前的实现方案中,从 c 调用 go 的场景,go runtime 的调度策略,更多是考虑 go 这一侧,比如 goroutine 和 P 不能被阻塞。但是,对 c 线程其实是很不友好的,只要涉及到等待,就会把 c 线程挂起...

因为 go 的并发模型中,线程挂起通常是可以接受的,但是对于宿主 c 线程而言,有时候被阻塞挂起则是很敏感的。比如,在 MOSN 的 MOE 架构中,对于这类可能导致 c 线程被挂起的行为,需要很小心的处理。

那有没有办法改变,也是有的,只是改动相对要大一点,大体思路是,将 c 调用 go 的 API 异步化:

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 runtime 的调度,c 线程也不必挂起等待了。

这样的设计,对于 c 线程是最友好的,当然也还得有一些配套的改动,比如缺少 P 的时候,得有个 extra P 更好一些,等其他的细节。

不过,这样子的改动还是比较大的,让 go 官方接受这种设计,应该还是比较难的,以后没准可以试试,万一接受了呢~

九、相关链接

[1] cgo 实现机制:

https://uncledou.site/2021/go-cgo/

[2] Goroutine 调度 - 网络调用:

https://uncledou.site/2021/goroutine-schedule-network/

[3] CL 392854 :

https://go-review.googlesource.com/c/go/+/392854

   本周推荐阅读  

81e598f6c6aa30ee772818e3a6e7c3cc.jpeg

MOSN 反向通道详解

e4f09c16ca2fcbf03be0ef01f404c4e0.png

Go 原生插件使用问题全解析

9e5a55976be6e89a765a6442ee216c2f.png

Go 内存泄漏,pprof 够用了么?

87fd6b4a518606458062214428754377.png

从规模化平台工程实践,我们学到了什么?

77f52f4e7d2847e0e3b1d7d120bca6d8.jpeg


http://chatgpt.dhexx.cn/article/d5gfj7AV.shtml

相关文章

深入学习CGO

深入学习CGO 快速入门基础知识import "C" 语句#cgo语句 GO与C的类型转换CGO函数调用CGO内部机制CGO内存模型C类封装成C APICGO调用在go runtime 层面的处理CGO的静态/动态库封装以及编译链接参数CGO定位内存泄露CGO性能CGO最佳使用场景总结参考文献: 很多…

快速上手 CGO,掌握在 Go 里写 C!

大家好,最近因为各种奇怪的原因,接触到了 Go 特色之一 CGO。这方面的相关内容也相对少一些,给大家抛砖引玉,有经验的大佬欢迎补充。 图片来源于 marlin 毕竟很多跨语言调用,还是会依赖 CGO 这个特性。希望大家在真正要…

DevOps学习心得总结

流程步骤: 1、PLAN 制定计划 (牢记交付给用户的目标) 2、CODE 开始编码 (使用相同的代码,不同版本的代码存储到仓库中,借助Git等工具在需要时合并【版本控制】) 3、BUILD 构建阶段…

DevOps工具链

DevOps是敏捷研发中持续构建(Continuous Build,CB)、持续集成(Continuous Integration,CI)、持续交付(Continuous Delivery,CD)的自然延伸,从研发周期向右扩展…

DevOps及DevOps常用的工具介绍

目录 1. 什么是 DevOps2. DevOps 概念的起源2.1. 单体架构 瀑布模式2.2. 分布式架构 敏捷开发模式2.2.1. 多人协同开发问题2.2.2. 多机器问题2.2.3. 开发和运维角色的天生对立问题 2.3. 微服务架构 DevOps 3. DevOps 到底是什么4. DevOps 常用的工具4.1. Jenkins4.2. Kubern…

DevOps 简史

【注】本文节译自:https://www.bmc.com/blogs/devops-history/   IT 行业的当前状态受技术进步在整个历史中所产生的连锁效应所影响。不时出现的新技术极大地改变了世界运转的方式。最近,技术进步似乎开始以惊人的速度出现。自从互联网出现以来&#…

DevOps 学习

目录 一、概述 1、CI/CD简介 二、Git简介 三、Jenkins简介 一、概述 DevOps是Development和Operations的组合,也就是开发和运维的简写。 DevOps集文化理念、实践与工具于一身,可以提高组织高速交付应用程序和服务的能力,与使用传统软件…

DevOps实践

数字化时代,技术的交付速度和质量,直接关系业务的发展和创新。IT 技术交付和运行的效率,成为决定数字化转型成败的关键,而 DevOps 要解决的问题正在于此,DevOps 成为数字化转型的重要一环。 能力构建 随着云原生技术的…

DevOps的前世今生

导语 DevOps诞生已经13年了,你理解他吗? 为什么相伴了13年,你仍然对他不甚了了呢? 你真的以为DevOps是一个筐,什么东西都可以往里装吗? 你以为DevOps落地就是找一个JIRA(敏捷管理工具&#…

Learning DevOps

什么是 DevOps DevOps(Development & Operations)/de’vps/ 是一组过程、方法与系统的统称,用于促进开发 (Dev)、技术运营 (Ops)和质量保障(QA)部门之间的沟通、协作与整合。 DevOps 的开发流程 软件从零开始到…

DevOps思想

什么是DevOps? DevOps是一种思想或方法论,它涵盖了开发、测试、运维的整个过程!DevOps强调开发、测试、运维、质检(QA)部门之间的有效沟通与协作。强调通过自动化的方法管理软件变更、软件集成。使软件从构建到测试、发布更加快捷、可靠&…

DevOps的发展史

公众号关注 「奇妙的 Linux 世界」 设为「星标」,每天带你玩转 Linux ! — 1 — 可操作的概述 多亏了云计算和开源,软件开发的速度从几年缩短到几个月。每家公司都在向一个软件公司转变。DevOps 已迅速成为公司大规模开发和部署软件的最有效方…

DevOps——简析

节选自百度等资料 知乎解析连接 一、DevOps的目的 只有一个:提高开发到运维发布版本的效率。 1.初级应用:开发运维一体化 2.最高阶的应用:端到端的概念。 DevOps 的三大支柱之中,即人(People)、流程&…

DevOps推广实践总结

中大型团队在敏捷DevOps转型过程中常见的实践总结 目录 1、聘用外部DevOps顾问 2、建立DevOps共识 3、采用“DevOps改进”而非“DevOps转型” 4、构建“比学赶超”的组织氛围 5、规范化DevOps实践 1、聘用外部DevOps顾问 小型团队可以不用聘用昂贵的外部教练,因…

DevOps

DevOps 一、DevOps的由来和概念1. 由来2. DevOps概念解析(1)来自不同渠道和来源的定义:(2)其他摘录 二、DevOps 工作流程1. DevOps的好处与价值2. DevOps能力环 三、devops流程工具四、DevOps发展现状哪些互联网公司采…

Devops的概念

1、什么是DevOps? 答:DevOps是产品开发过程中开发(Dev)和运营(Ops)团队之间的灰色区域。DevOps是一种在产品开发周期中强调沟通,集成和协作的文化。因此,它消除了软件开发团队和运营…

DevOps—基本概念

DevOps—基本概念 1. DevOps2. CI/CD 1. DevOps 维基百科定义: DevOps是一组过程、方法与系统的统称,用于促进 开发、技术运营 和 质量保障(QA) 部门之间的沟通、协作与整合。我理解DevOps是一种软件管理思维模式。 为什么会有D…

DevOps简介

一、DevOps定义:Development和Operations的组合,突出重视软件开发人员与运维人员的沟通合作,通过自动化流程使得软件构建、测试、发布更加快捷、频繁和可靠。 它是一个完整的面向IT运维的工作流,以 IT 自动化以及持续集成&#xf…

什么是 DevOps?看这一篇就够了!

文章目录 一、前因二、记忆三、他们说……3.1、Atlassian 回答“什么是 DevOps?”3.2、微软回答“什么是 DevOps?”3.3、AWS 回答“什么是 DevOps?” 四、DevOps 文化4.1、什么是文化?4.2、什么是 DevOps 文化?4.3、领…

Devops基本概念和原理

一、什么是DevOps 1、 DevOps概述 DevOps,即Development and Operations,是一组过程、方法与系统的统称,用于促进软件开发、运维和质量保障部门之间的沟通、协作与整合。DevOps的出现是由于软件行业日益清晰的认识到:为了按时交…