从 Golang 调用 C 函数

Call C function from Golang

我想在 Golang 中编写控制器逻辑并处理 json 和数据库,同时在 C 中使用我的数学处理模型。在我看来,调用 C 函数的开销必须尽可能低,就像设置寄存器 rcx 一样, rdx, rsi, rdi, 做一些快速调用并得到 rax 值。但是我听说过bigoverhead in cgo

说吧,我有通用的 fastcall x64 c 函数 int64 f(int64 a,b,c,d){return a+b+c+d} 我如何从 go 中调用它,以获得最高的潜在基准分数 testing.B 基准?

PS 没有指针传递,没有技巧,只是对如何以最健壮的方式访问 C 接口感兴趣

In my opinion overhead calling C function have to be as low, as as setting registers rcx, rdx, rsi, rdi, doin some fastcall and getting out rax value. But i've heard of big overhead in cgo <…>

你的意见是没有根据的。
从 Go 到 C 的调用有明显开销的原因是由于以下原因。

我们先考虑C

虽然语言没有任何要求,但由典型编译器和 运行 在典型 OS 上作为常规进程编译的典型 C 程序在很大程度上依赖于 OS 执行其运行时环境的某些方面。
最明显和最重要的方面应该是堆栈:内核负责在加载和初始化程序映像之后以及将执行转移到新生进程代码的入口点之前对其进行设置。

另一个关键点是,虽然没有严格要求,但大多数 C 程序依赖 OS- 本机线程来通过程序代码实现多个同时执行的流程。

在 C 代码中执行的函数调用通常使用相同的 ABI OS 和硬件实现的目标组合进行编译(当然,除非程序员明确设法告诉编译器不这样做——比如,将特定函数标记为具有不同的调用约定)。

C 没有自动管理非堆栈内存(“堆”)的方法。
这种管理通常是通过 malloc(3) 系列的 C 标准库函数完成的。 这些函数管理堆并将通过它们分配的任何内存视为“它们的”(这是很合乎逻辑的)。
C 不提供自动垃圾收集。

让我们回顾一下:从 C: 编译的典型程序使用 OS-提供的线程并在这些线程中使用 OS-提供的堆栈;大部分时间函数调用都遵循平台的 ABI;堆内存由特殊的库代码管理;没有GC。

现在让我们考虑一下 Go

  • 任何一点 Go 代码(包括您的程序和运行时的代码)都在所谓的 goroutines 中运行,它就像超轻量级线程。
  • Go 运行时提供的 goroutine 调度程序(compiled/linked 进入任何用 Go 编写的程序)实现了所谓的 Goroutine 的 M×N 调度——其中 M 个 goroutine 被多路复用到 N OS-提供的线程,其中 M 通常远高于 N。
  • Go 中的函数调用不遵循目标平台的 ABI。
    具体来说,AFAIK 当代版本的 Go 将所有调用参数传递到堆栈上¹。
  • 一个 goroutine 总是 运行 on 一个 OS 提供的线程。
    等待 Go 运行时管理的某些资源(例如通道上的操作、定时器、网络套接字等)的 goroutine 不占用 OS 线程。
    当调度程序选择要执行的 goroutine 时,它​​必须将其分配给 Go 运行时拥有的空闲 OS 线程; 虽然调度程序努力将 goroutine 放在它在挂起之前正在执行的同一线程上,但这并不总是成功,因此 goroutine 可以在不同的 OS 线程之间自由迁移。

以上几点自然会导致 goroutines 拥有自己的堆栈,这些堆栈完全独立于 OS 为其线程提供的堆栈。
与 C 不同,这些堆栈是可增长和可重新分配的。

堆内存由 Go 运行时自动管理,直接完成,没有使用 C stdlib。
Go 有 GC,这个 GC 是 并发的,因为它与执行程序代码的 goroutines 完全并发运行。

让我们回顾一下:goroutines 有自己的堆栈,使用的调用约定与平台的 ABI 和 C 的调用约定都不兼容,并且可能在不同的 OS 线程上的不同执行点上执行。
Go 运行时直接管理堆内存,并具有完全并发的 GC。

现在让我们考虑从 Go 到 C 的调用

正如你现在应该看到的那样,Go 和 C 代码运行的运行时环境的“世界”差异足以产生很大的“阻抗不匹配”,这需要某些 网关 进行 FFI 时——成本非零。

特别是当Go代码即将调用C语言时,必须做到以下几点:

  1. goroutine 必须锁定到当前 运行 上的 OS 线程(“固定”)。
  2. 由于目标 C 调用必须根据平台的 ABI 完成,因此必须保存当前执行上下文——至少是那些将被调用破坏的寄存器。
  3. cgo 机器必须验证要传递给目标 C 调用的任何内存不包含指向由 Go 管理的其他内存块的指针,递归地——这是为了允许 Go 的 GC 继续工作同时
  4. 执行必须从 goroutine 栈切换到线程的栈:必须在后者上创建一个新的栈帧,并且目标 C 调用的参数必须放置在那里(和寄存器中)根据平台的 ABI。
  5. 电话已拨通。
  6. 在 return 之后,必须将执行切换回 goroutine 的堆栈——再次通过网关将任何 returned 结果返回到正在执行的 goroutine 的堆栈帧。

正如您可能看到的那样,成本是不可避免的,在某些 CPU 寄存器中放置值是这些成本中最微不足道的。

对此可以做些什么

一般来说,有两个向量来攻击这个问题:

  • 减少对 C 的调用。

    也就是说,如果对 C 的每次调用都执行冗长的 CPU 密集型计算,则可以推测执行这些调用的开销与使这些调用执行的计算更快所带来的收益相形见绌。

  • 在汇编中编写关键函数。

    Go 允许直接在目标 H/W 平台的程序集中编写代码。

一个可以让您两全其美的“技巧”是利用大多数工业编译器的能力来输出他们编译的函数的汇编语言形式。因此,您可以使用 C 编译器提供的核心功能,例如自动矢量化(针对 SSE)和积极优化,然后获取它生成的任何内容并将其包装在一个薄层的程序集中,该程序集基本上使生成的代码适应本机Go 的 ABI.

有许多第 3 方 Go 包可以执行此操作(例如 this and that),显然 Go 运行时也可以执行此操作。


¹ 自从 1.17 Go 逐渐转向使用基于寄存器的调用约定。
我没有关于这是否使 Go 代码为特定 GOOS/GOARCH 组合编译以遵循其本机 ABI 的信息。