在我看来,调用 C 函数的开销必须尽可能低,就像设置寄存器 rcx、rdx、rsi、rdi、执行一些快速调用和获取 rax 值一样。但我听说cgo 的开销很大<...>

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

我们首先考虑 C

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

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

在 C 代码中执行的函数调用通常使用与操作系统和硬件实现的目标组合相同的ABI进行编译(当然,除非程序员明确设法告诉编译器以其他方式执行 - 例如,标记特定的函数具有不同的调用约定)。

malloc(3)

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

现在让我们考虑 Go

  • 任何一点 Go 代码(包括您的程序和运行时的代码)都在所谓的 goroutine 中运行,这些 goroutine 就像超轻量级线程。

  • Go 运行时提供的 goroutine 调度程序(编译/链接到任何用 Go 编写的程序)实现了所谓的 M×N 的 goroutine 调度——其中 M 个 goroutine 被多路复用到 N 个 OS 提供的线程上,其中 M 通常是远高于N。

  • Go 中的函数调用不遵循目标平台的 ABI。
    具体来说,AFAIK 当代版本的 Go 传递堆栈上的所有调用参数¹。

  • goroutine 总是操作系统提供的线程上运行。
    等待 Go 运行时管理的某些资源(例如通道上的操作、计时器、网络套接字等)的 goroutine 不占用 OS 线程。
    当调度程序选择一个 goroutine 执行时,它必须将它分配给一个空闲的 OS 线程,该线程由 Go 运行时拥有;虽然调度器会努力将 goroutine 放在它在挂起之前正在执行的同一线程上,但这并不总是成功,因此 goroutine 可以在不同的 OS 线程之间自由迁移。

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

堆内存由 Go 运行时自动管理,并且直接完成,不使用 C 标准库。
Go 有 GC,并且这个 GC 是并发的,因为它与执行程序代码的 goroutine 完全同时运行。

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

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

正如您现在应该看到的那样,Go 和 C 代码运行的运行时环境的“世界”大不相同,以至于在执行FFI时需要一定的网关——成本不为零。

特别是,当 Go 代码即将调用 C 时,必须执行以下操作:

cgo

正如您可能看到的,存在不可避免的成本,将值放入某些 CPU 寄存器是这些成本中最可以忽略不计的。

对此可以做些什么

一般来说,有两个向量来解决这个问题:

  • 使对 C 的调用不频繁。

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

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

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

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

有许多 3rd-party Go 包可以做到这一点(比如this和that),显然 Go 运行时也可以做到这一点。