golang

FastCGo: как мы ускорили вызов C-кода в Go в 16,5 раза

  • пятница, 4 июля 2025 г. в 00:00:09
https://habr.com/ru/companies/flant/articles/923912/

Всем привет! Меня зовут Владимир Пустовалов, я C++ разработчик в команде Deckhouse компании «Флант». Мои коллеги — DevOps-инженеры — на данный момент обслуживают более 600 кластеров, и, естественно, в каждом из них развёрнута система мониторинга.

Изначально мы использовали Prometheus — опенсорсную систему мониторинга, написанную на Go. По нашей статистике, она занимала около 20 % ресурсов каждого кластера. Мы не могли с этим мириться и поэтому разработали проект под названием Prom++, в котором многократно сократили потребление оперативной памяти и снизили нагрузку на центральный процессор.

Кратко о том, что мы сделали: переписали на С++ ядро хранения и обработки горячих данных, при этом вся оркестрация и периферия остались в Prometheus на Go, что позволило сохранить полную совместимость с Prometheus. Для частых вызовов кода C++ мы использовали механизм CGo, однако первые тесты показали, что производительность CPU практически не улучшилась из-за медлительности CGo. В итоге мы переписали CGo, создав собственный механизм вызова.

Что будет в этой статье:

  • Разберём, что такое CGo и почему он медленный.

  • Создадим простой собственный механизм CGo-вызова.

  • Доведём этот механизм до полноценного решения.

На каждом этапе будем проводить бенчмаркинг, чтобы отслеживать изменения в производительности.

Содержание для удобной навигации по статье:

Насколько CGo медленный 

CGo-вызов — это встроенный в язык Go механизм вызова C-кода. Это не стороннее решение, а часть исходного кода Go. Давайте проведём бенчмаркинг и замерим время работы CGo-вызова и время вызова Go-функции.

Все бенчмаркинги мы проводили на специально арендованном виртуальном сервере на базе процессора с архитектурой ARM64 со следующими параметрами:

  • Ampere Altra Neoverse-N1 @ 2GHz, Ubuntu 24.04.1;

  • Go1.24.2 linux/arm64;

  • используем бенчмаркинг из пакета testing;

  • тестируем с параметром -count=10 и анализируем результаты через benchstat.

Полная команда проведения бенчмаркинга состоит из его запуска и передачи результатов его работы на вход утилите benchstat:

nice -n -20 \
go test. -bench=. -count=10 | tee bench. txt && \
benchstat bench.txt

Обязательно запускаем бенчмаркинг через утилиту nice для повышения приоритета процесса в системе, чтобы он получил максимальное количество ресурсов CPU и выдал наиболее достоверные результаты.

Далее сравним скорость вызова пустой Go-функции и вызова CGo, в котором вызывается пустая C-функция. Для этого реализуем необходимые функции и создадим две функции бенчмаркинга:

/* void empty_function() {} */
import "C"

func CGoEmptyFunctionCall() { C.empty_function() }

//go:noinline
func GoEmptyFunctionCall() {}

func BenchmarkCGoEmptyFunctionCall(b *testing.B) {
   for i := 0; i < b.N; i++ {
      CGoEmptyFunctionCall()
   }
}
func BenchmarkGoEmptyFunctionCall(b *testing.B) {
   for i := 0; i < b.N; i++ {
      GoEmptyFunctionCall()
   }
}

Обратите внимание, что к пустой Go-функции добавлена аннотация //go:noinline, чтобы компилятор не заинлайнил вызов.

Перед запуском бенчмаркинга изучим ассемблерный код, сгенерированный компилятором:

go test -c -o fastcgo.test && \
go tool objdump -S ./fastcgo.test

В коде бенчмаркинга пустой Go-функции присутствует инструкция CALL, что подтверждает отсутствие инлайнинга:

func BenchmarkGoEmptyFunctionCall(b *testing.B) {
   ...
   // for i := 0; i < b.N; i++ {
   MOVD R0, 40(RSP)
   MOVD ZR, R1
   JMP 6(PC)
   MOVD R1, 16(RSP)
   // GoEmptyFunctionCall()
   CALL fastcgo/fastcgo.GoEmptyFunctionCall(SB)
   // for i := 0; i < b.N; i++ {
   MOVD 16(RSP), R0
   ADD $1, R0, R1
   MOVD 40(RSP), R0
   MOVD 424(R0), R2
   CMP R2, R1
   BLT -7(PC)
   ...

Также в коде бенчмаркинга CGo-вызова есть инструкция CALL, но вызов Go-функции, вызывающей CGo, заинлайнен:

func BenchmarkCGoEmptyFunctionCall(b *testing.B) {
   ...
   // for i := 0; i < b.N; i++ {
   MOVD R0, 40(RSP)
   MOVD ZR, R1
   JMP 7(PC)
   ADD $1, R1, R0
   MOVD R0, 16(RSP)
   // CGoEmptyFunctionCall()
   NOOP
   // C.empty_function()
   CALL fastcgo/fastcgo._Cfunc_empty_function.abi0(SB)
   // for i := 0; i < b.N; i++ {
   MOVD 40(RSP), R0
   MOVD 16(RSP), R1
   MOVD 424(R0), R2
   CMP R2, R1
   BLT -8(PC)
   ...

Компилятор сгенерировал ожидаемый код. Запускаем бенчмаркинг и получаем следующие результаты: вызов CGo занимает примерно 71 наносекунду (71,08н ± 1 %), тогда как вызов пустой Go-функции — около 1,8 наносекунды (1,798н ± 1%). Таким образом, вызов CGo примерно в 40 раз медленнее обычного Go-вызова.

Особенно критично это на фоне того, что у нас есть функция на C++, которая добавляет данные в хранилище и работает примерно за 40 наносекунд. То есть переход из Go в C++ занимает больше времени, чем работа функции с тяжёлой логикой. Давайте разберёмся, почему CGo работает так медленно и что можно с этим сделать.

Почему CGo такой медленный

Я представил иерархию вызовов в момент, когда мы уходим из Go и переходим в C++:

_Cfunc_empty_function
    runtime.cgocall
        runtime.entersyscall
        runtime.osPreemptExtEnter

        asmcgocall
            runtime.gosave_systemstack_switch
            runtime.save_g

            _cgo_1aa62ab265ba_Cfunc_empty_function
                empty_function

            runtime.save_g

            runtime.osPreemptExtExit
            runtime.exitsyscall

Go выполняет цепочку вызовов, ключевыми из которых являются перевод горутины в режим системного вызова и переключение со стека горутины на системный стек. Сейчас важно запомнить факт переключения стека — далее мы подробно разберём, зачем это необходимо.

Пишем свой CGo

В целом вся эта цепочка вызовов нам не нужна. Поэтому я не вижу препятствий для создания собственного оптимизированного CGo — FastCGo. Но прежде чем приступить к реализации, давайте разберёмся, какие четыре ключевых компонента необходимы для вызова C-кода из Go:

  • thread;

  • ABI (application binary interface) / calling convention (конвенция вызова);

  • стек;

  • параметры / возвращаемое значение.

thread

По сути, это физический поток операционной системы, который будет выполнять наши инструкции. Когда выполняется код горутины, thread уже существует, остаётся лишь заставить его выполнить код вызова C-функции. Сделать это стандартными средствами Go невозможно, однако в Go есть абстрактный портируемый псевдоассемблер, который не привязан к какому-либо железу.

Функция на таком ассемблере состоит из двух частей:

  • Прототип функции, который реализуется на языке Go:

// fastcgo.go
package fastcgo

func EmptyAsmFunction()
  • Тело функции, которая реализуется на языке псевдоассемблера и описывается в файлах с расширением '.s':

// fastcgo.s
#include "textflag.h"

TEXT ·EmptyAsmFunction(SB), NOSPLIT, $0-0
  RET

Мы знаем, как вызывать сторонний код, но сам вызов требует соблюдения определённых правил. Для этого разберёмся с понятием application binary interface, или calling convention.

ABI / calling convention

Начиная с версии 1.17 в Go реализовано два ABI:

  • ABI0 — stack-based calling convention;

  • ABIInternal — register-based calling convention.

Главное различие между ними заключается в том, что ABI0 передаёт параметры и возвращаемые значения через стек, а ABIInternal — через регистры. Соответственно, работа через регистры значительно быстрее по сравнению с работой через стек. Однако ABIInternal применяется только для внутреннего использования Go: все функции, написанные на Go, используют ABIInternal. Когда же требуется вызвать сторонний код, например через CGo или ассемблерные функции, используется ABI0.

Итак, реализуем простейший FastCGo-вызов:

// fastcgo.go
package fastcgo

func SimpleCall0(function unsafe.Pointer)

Здесь мы описываем прототип на Go: реализовываем функцию, которая будет принимать на вход unsafe.Pointer — указатель на функцию, которую мы будем вызывать на C++.

И реализация на ассемблере достаточно тривиальна. Так как Go-ассемблер — это псевдоассемблер, у него есть препроцессор, знакомый всем разработчикам на C/C++. Поэтому делаем следующее:

// fastcgo.s

// Подключаем заголовочный файл textflag.h, 
// в котором содержатся константы и атрибуты для описания функции.
#include "textflag.h" 

// Объявляем функцию SimpleCall0. 
// NOSPLIT — отключаем генерацию механизма роста стека. 
// $0-0 — обозначаем, что функция не добавляет на стек параметры.
TEXT ·SimpleCall0(SB), NOSPLIT, $0-0
  // Забираем со стека входной параметр (функцию для вызова) 
  // и сохраняем его в регистр R0.
  MOVD fn+0(FP), R0

  // Делаем вызов этой функции.
  CALL R0

  // Выходим из нашей ассемблерной функции.
  RET

Вызов такой ассемблерной функции ничем не отличается от вызова обычной функции на языке Go:

/*
void empty_function() {}
*/
import "C"
import "golang.conf/fastcgo"

func FastCGoEmptyFunctionCall() {
   fastcgo.SimpleCall0(C.empty_function)
}

В итоге FastCGo-вызов выполняется примерно за 2,2 наносекунды, что в 33 раза быстрее обычного CGo-вызова. Однако наша реализация уступает по производительности обычному Go-вызову, потому что «под капотом» мы делаем два вызова:

  1. Вызываем ассемблерную функцию.

  2. Вызываем C-код из ассемблерной функции.

Таким образом, мы создали простейший механизм FastCGo, но он пока абсолютно пуст — C-функция на данном этапе ничего не выполняет. Чтобы она могла выполнять полезную нагрузку, ей необходим стек.

Стек

В Go есть несколько видов стека:

  • Стек потока (системный стек) — выделяется системой при создании потока (pthread_create). Его размер статичен и определяется параметрами системы (ulimit -a | grep "stack size"), при этом он достаточно большой, чтобы любое приложение в системе могло комфортно работать и выполнять свою полезную задачу.

  • Стек горутины — выделяется самим Go при создании горутины. У него динамический размер, который при необходимости может увеличиваться. Минимальный размер такого стека — 2 КБ.

  • Стек горутины обработки сигналов (по сути ничем не отличается от стека горутины).

Вспоминаем, что CGo работает на стеке потока. Это правильно, потому что стек потока имеет фиксированный размер, которого гарантированно хватит для выполнения необходимых задач. Поэтому и в FastCGo нам также необходимо работать именно на системном стеке.

Как добраться до системного стека? Ответ простой — через регистры.

Код Go реализован в соответствии с требованиями архитектуры ARM64. При этом в Go существуют регистры специального назначения, например регистр R28. В нём хранится указатель на описание горутины, на которой выполняется код. Этот указатель ссылается на структуру g, которую можно найти в исходном коде Go:

// src/runtime/runtime2.go
type g struct {
   stack stack
   ...
   m *m
   sched gobuf
   ...
}

Эта структура содержит большое количество полей, но мы разберём только те, которые непосредственно связаны со стеком.

Первое поле называется stack и описывается одноимённой структурой stack, которая содержит верхнюю и нижнюю границы стека:

type g struct {
   ...
   stack stack
   ...
}
type stack struct {
   lo uintptr
   hi uintptr
}

Если из верхней границы вычесть нижнюю, мы получим размер стека.

Второе поле — shed. Оно описывается структурой gobuf, содержащей значения регистров и дополнительные параметры, которые заполняются в момент переключения горутины:

type g struct {
   ...
   sched gobuf
   ...
}
type gobuf struct {
  sp uintptr
  pc uintptr
  g guintptr
  ctxt unsafe.Pointer
  ret uintptr
  lr uintptr
  bp uintptr
}

В этой структуре нас интересует только поле sp. Это значение регистра RSP, то есть указатель на вершину стека.

И последнее поле, которое нам потребуется для реализации FastCGo с переключением стека, — это поле m. Оно описывается одноимённой структурой, содержащей описание потока, на котором выполняется горутина.

type g struct {
   ...
   m *m
   ...
}
type m struct {
   g0 *g
   morebuf gobuf
   divmod uint32
   procid uint64
   ...
}

Эта структура, как и структура g, содержит множество полей, но нас интересует поле g0. Оно хранит указатель на системную горутину — ту, которая формируется при создании потока. И именно у этой системной горутины хранится системный стек.

Итак, алгоритм получения системного стека выглядит следующим образом:

  1. Из регистра R28 мы получаем указатель на структуру g, у которой обращаемся к полю m.

  2. У поля m обращаемся к полю g0, то есть к системной горутине.

  3. У системной горутины в поле shed обращаемся к полю sp, где лежит вершина стека.

Теперь, чтобы реализовать FastCGo с переключением стека, мы должны сделать следующие шаги:

  1. Сохраняем текущий sp: запоминаем вершину стека, на котором работает горутина.

  2. Устанавливаем g.m.g0.sched.sp в sp, тем самым переключаем стек на системный.

  3. Вызываем C-функцию.

  4. Восстанавливаем исходный sp.

Попробуем реализовать это на ассемблере. Прототип функции на Go остаётся неизменным: она принимает всего один параметр — указатель на функцию, которую необходимо вызвать:

func Call0(function unsafe.Pointer)

Начнём реализацию с описания структур в отдельном Go-файле. Код этих структур я взял напрямую из исходных файлов Go:

// runtime_go1.24.go
type stack struct {
   lo, hi uintptr
}
type gobuf struct {
   sp, pc, g, ctxt, ret, lr, bp uintptr
}
type m struct {
   g0 *g
}
type g struct {
   stack stack
   stackguard0, stackguard1 uintptr
   _panic, _defer uintptr
   m *m
   sched gobuf
}

Типы полей этой структуры и их последовательность нужно оставить такими же, как и в исходном коде Go. Когда будем компилировать наше решение, Go создаст нам специальный автогенерируемый файл go_asm.h:

// go_asm.h
#define m__size 8
#define m_g0 0
#define g__size 112
#define g_stack 0
#define g_stackguard0 16
#define g_stackguard1 24
#define g__panic 32
#define g__defer 40
#define g_m 48
#define g_sched 56
...

Здесь будут храниться константы, которые описывают размеры структур, реализованных нами на предыдущем шаге, и смещения полей в этих структурах. Это позволит использовать в ассемблерном коде не магические цифры (например, 112), а именованные константы, что повысит читабельность ассемблерного кода.

Реализация функции на ассемблере содержит чуть более десяти строк:

// fastcgo.s

// Подключаем заголовочный файл go.asm 
// и вводим в видимость компилятора наши константы.
#include "go_asm.h"
#include "textflag.h"

TEXT ·Call0(SB), NOSPLIT, $0-0
  // Забираем со стека входной параметр (функцию для вызова)
  // и сохраняем его в регистр R0.
  MOVD fn+0(FP), R0

  // Сохраняем значение регистра RSP в регистр R19 — callee-saved регистр. 
  // Если C-функция в процессе работы изменит значение этого регистра, 
  // то, согласно архитектуре ARM64, она должна будет восстановить 
  // его исходное значение при выходе. 
  // Поэтому мы можем использовать этот регистр как временное хранилище.
  MOVD RSP, R19

  // Получаем вершину системного стека и сохраняем в регистр R9.
  MOVD g_m(g), R9
  MOVD m_g0(R9), R10
  MOVD (g_sched+gobuf_sp)(R10), R9
  AND $~15, R9

  // Изменяем стек на системный.
  MOVD R9, RSP

  // Вызываем C++-функцию.
  CALL R0

  // Возвращаем исходный стек (стек горутины).
  MOVD R19, RSP
  RET

Функция реализована, и теперь нужно убедиться в корректности её работы. Для этого проверим, что мы действительно работаем на тех же адресах стека, что и CGo. Реализуем C-функцию, которая будет выводить адрес стека, на котором она работает:

#include <stdio.h>

void print_stack() {
 int i;
 printf("%p\n", &i);
 fflush(stdout);
}

CGo и FastCGoOnSystemStack работают примерно на одних и тех же адресах, в отличие от FastCGo, который работает на стеке горутины:

У CGo и FastCGo не должны совпадать адреса стека, так как первый в процессе своей работы располагает на стеке дополнительные параметры.

И теперь мы можем провести бенчмаркинг нашей функции:

Производительность функции просела на 0,5 наносекунды, потому что мы добавили дополнительные инструкции в нашу ассемблерную реализацию.

Параметры / возвращаемое значение

У нас есть практически готовое решение. Осталось реализовать механизм обмена данными между Go и C. Посмотрим, насколько просядет производительность функций при передаче параметров и возвращении значения.

Для этого в Go-функции реализуем функцию, которая принимает и возвращает int:

//go:noinline
func GoFunctionCall(a int) int {
   return a
}

Проводим бенчмаркинг и видим, что скорость работы такой функции отличается от скорости работы обычной функции примерно на десятую долю наносекунды, что практически незаметно:

Это объясняется тем, что Go-функции работают с использованием ABIInternal, то есть параметры и возвращаемые значения передаются через регистры, а работа с регистрами происходит практически мгновенно.

Давайте посмотрим, как реализован механизм передачи параметров и возврата значений в CGo. Описываем C-функцию, которая принимает и возвращает int:

int function(int a) {
 return a;
}

Чтобы понять, как CGo принимает и возвращает значение, мы задампим автогенерируемые файлы, которые создаются в процессе компиляции нашего кода. Для этого запускаем go tool cgo cgo.go и заглянем в файл _cgo_gotypes.go:

// _cgo_gotypes.go

type _Ctype_int int32

func _Cfunc_function(p0 _Ctype_int) (r1 _Ctype_int) {
   _cgo_runtime_cgocall(
        _cgo_396d768b6c6b_Cfunc_function,
        uintptr(unsafe.Pointer(&p0)))
   if _Cgo_always_false {
      _Cgo_use(p0)
   }
   return
}

Функция _Cfunc_function принимает и возвращает int. Но если посмотреть на директиву return, то становится ясно, что функция по факту сама ничего не возвращает. Вместо этого в ней происходит вызов _cgo_runtime_cgocall, куда передаются два параметра: 

  • _cgo_396d768b6c6b_Cfunc_function — функция, которую необходимо вызвать;

  • uintptr(unsafe.Pointer(&p0))) — указатель на входной параметр.

Входным параметром мы принимаем int, но _Cfunc_function работает согласно ABI0, то есть параметры она принимает через стек. Следовательно, взяв указатель на входной параметр int, мы фактически получили указатель на стек (на стек горутины).

А так выглядит реализация кода уже на C:

void _cgo_396d768b6c6b_Cfunc_function(void* v) {
 struct {
   int p0;
   char __pad4[4];
   int r;
   char __pad12[4];
 } __attribute__((__packed__))* _cgo_a = v;
 char *_cgo_stktop = _cgo_topofstack();
 __typeof__(_cgo_a->r) _cgo_r;
 _cgo_r = function(_cgo_a->p0);
 _cgo_a = (void*)((char*)_cgo_a +
         (_cgo_topofstack() - _cgo_stktop));
 _cgo_a->r = _cgo_r;
}

Эта функция работает уже на системном стеке.

В начале функции объявляется структура, которая фактически содержит два поля:

  • поле p0 — входной параметр;

  • поле r — возвращаемое значение.

Между этими полями выставлен паддинг согласно правилам выравнивания переменных в архитектуре ARM64. Входной параметр приводится к типу этой структуры, то есть C считает, что на вход ему передана именно эта структура.

Первое, что происходит в этой функции, — это вызов _cgo_topofstack(), которая возвращает указатель на вершину стека горутины. Таким образом, хотя мы работаем на системном стеке, CGo берёт указатель на вершину стека горутины и сохраняет его в локальной переменной.

Затем CGo вызывает C-функцию и сохраняет её результат на стеке.

Далее CGo снова вызывает _cgo_topofstack(), получает вершину стека горутины, вычитает из него предыдущее значение вершины стека и прибавляет к нему адрес входного параметра. Это называется пересчёт указателя.

Во время работы CGo-функции из C-кода может быть вызван Go Callback. При переходе в Go происходит переключение с системного стека на стек горутины. При этом стек горутины динамический и может увеличиваться по мере необходимости. Если во время выполнения Go Callback стек горутины вырастает (реаллоцируется), то указатель, переданный в C-функцию, станет невалидным — скорее всего, память, на которую он указывал, будет уже разрушена.

Поэтому в реализации CGo происходит пересчёт актуального адреса входного параметра после вызова C-функции и по этому новому адресу записывается значение — результат функции. 

Эта «магия» приводит к снижению производительности примерно на 10 %, что в числовом выражении составляет около 7 наносекунд:

То есть и без того не самый быстрый CGo становится ещё медленнее.

В версии Go 1.24 появилась аннотация #cgo nocallback

int function(int a) {
 return a;
}

// #cgo nocallback function
import "C"

Согласно документации, эта аннотация должна оптимизировать CGo-вызов, отключая генерацию вспомогательного кода, необходимого для вызова Go Callback. Но бенчмаркинг показывает совершенно иной результат: наблюдается просадка производительности в 4 наносекунды:

Посмотрим, что произошло. Автогенерированная Go-функция по вызову C-кода до использования аннотации выглядела так:

// _cgo_gotypes.go

func _Cfunc_function(p0 _Ctype_int) (r1 _Ctype_int) {
   
   _cgo_runtime_cgocall(
_cgo_396d768b6c6b_Cfunc_function, uintptr(unsafe.Pointer(&p0)))
   

   if _Cgo_always_false {
      _Cgo_use(p0)
   }
   return
}

После добавления аннотации мы получили два дополнительных вызова: _Cgo_no_callback(true) и _Cgo_no_callback(false):

// _cgo_gotypes.go

func _Cfunc_function(p0 _Ctype_int) (r1 _Ctype_int) {
   _Cgo_no_callback(true)
   _cgo_runtime_cgocall(
_cgo_396d768b6c6b_Cfunc_function, uintptr(unsafe.Pointer(&p0)))
   _Cgo_no_callback(false)

   if _Cgo_always_false {
      _Cgo_use(p0)
   }
   return
}

То есть на данный момент использование этой аннотации не только не ускоряет CGo-вызов, а вообще замедляет его. Будем надеяться, что в будущих версиях Go механика этой аннотации будет переработана и мы действительно получим прирост в скорости CGo-вызова.

Хорошо, давайте в FastCGo не будем повторять эти излишние операции и реализуем собственную быструю функцию. Начнём с реализации на C++:

void fastcgo_function(void* args, void* result) {
 struct Arguments {
   int a;
 };
 struct Result {
   int value;
 };

 ((struct Result*)result)->value =
        ((struct Arguments*)args)->a;
}

Функция принимает на вход два параметра:

  • void* args — указатель на структуру аргументов;

  • void* result — указатель на структуру результатов.

В структуру результатов я записал входное значение структуры аргументов, тем самым повторив поведение функции, которую мы написали для бенчмаркинга CGo-вызова.

Прототип функции на Go получает два дополнительных параметра (два unsafe.Pointer на структуру аргументов и на структуру результатов):

func Call2(function unsafe.Pointer,
           args unsafe.Pointer,
           result unsafe.Pointer)

Вызов такой функции на Go будет выглядеть следующим образом:

func FastCGoFunctionCallOnSystemStack() {
   var args = struct {
      a int32
   }{5}

   var result = struct {
      value int32
   }{}

   fastcgo.Call2(C.fastcgo_function,
                 unsafe.Pointer(&args),
                 unsafe.Pointer(&result))
}

Здесь мы располагаем две структуры на стеке, инициализируем их и через unsafe.Pointer передаём указатель на эти структуры в наш код на C++.

Теперь внесём изменения в ассемблере. Напомню, что так выглядела FastCGo-функция с переключением стека без передачи входных параметров:

TEXT ·Call0(SB), NOSPLIT, $0-0
   MOVD fn+0(FP), R0



   MOVD RSP, R19

   MOVD g_m(g), R9
   MOVD m_g0(R9), R10
   MOVD (g_sched+gobuf_sp)(R10), R9
   AND $~15, R9

   MOVD R9, RSP
   CALL R0
   MOVD R19, RSP
   RET

А так она будет выглядеть с входными параметрами:

TEXT ·Call2(SB), NOSPLIT, $0-0
   MOVD fn+0(FP), R2
   MOVD fn+8(FP), R0
   MOVD fn+16(FP), R1

   MOVD RSP, R19

   MOVD g_m(g), R9
   MOVD m_g0(R9), R10
   MOVD (g_sched+gobuf_sp)(R10), R9
   AND $~15, R9

   MOVD R9, RSP
   CALL R2
   MOVD R19, RSP
   RET

Теперь мы забираем со стека два дополнительных параметра и делаем вызов C-функции уже по другому регистру.

Запускаем бенчмаркинг и видим, что производительность просела в 24 раза, то есть на 50 наносекунд:

Просчитался, но где?!

Заглянем в ассемблерный код, чтобы разобраться, что произошло. Там мы видим, что по месту объявления структур аргументов и результата выполняются два вызова Go-функции newobject:

func BenchmarkFastCGoFunctionCallOnSystemStack(b *testing.B) {
     // ...
     // var args = struct {
     ADRP 102400(PC), R0
     ADD $2624, R0, R0
     CALL runtime.newobject(SB)
     MOVD R0, 48(RSP)
     ORR $1, ZR, R1
     MOVW R1, (R0)
     // var result = struct {
     ADRP 102400(PC), R0
     ADD $2752, R0, R0
     CALL runtime.newobject(SB)

Компилятор почему-то решил разместить наши структуры не на стеке, а в куче. Аналогичный результат можно получить, запустив escape-анализ Go: go build -gcflags=-m.

Компилятор явно укажет, что эти параметры он располагает в куче:

  • ./cgo.go:45:6: moved to heap: args

  • ./cgo.go:49:6: moved to heap: result

Почему так произошло и что делать?

Когда Go видит, что указатели на объекты передаются в функцию, которую он не может проанализировать (например, в ассемблерный код), он предпочитает разместить эти параметры в куче, а не на стеке. Тем самым он отдаёт управление памятью под эти объекты garbage-коллектору.

Это логичное и безопасное решение, но для наших задач оно неприемлемо, поскольку снижает производительность. Поэтому нужно как-то обмануть компилятор.

Если раньше мы передавали unsafe.Pointer:

func Call2(function unsafe.Pointer,
           args unsafe.Pointer,
           result unsafe.Pointer)

… то теперь мы будем передавать uintptr:

func Call2(function unsafe.Pointer,
           args uintptr,
           result uintptr)

То есть мы говорим компилятору, что принимаем не указатели на объекты, а просто числа. И если раньше вызов FastCGo в Go выглядел следующим образом:

func FastCGoFunctionCallOnSystemStack() {
   var args = struct {
      a int32
   }{5}

   var result = struct {
      value int32
   }{}

   fastcgo.Call2(C.fastcgo_function,
                 unsafe.Pointer(&args),
                 unsafe.Pointer(&result))
}

… то теперь он выглядит так:

func FastCGoFunctionCallOnSystemStack() {
   var args = struct {
      a int32
   }{5}

   var result = struct {
      value int32
   }{}

   fastcgo.Call2(C.fastcgo_function,
                 uintptr(unsafe.Pointer(&args)),
                 uintptr(unsafe.Pointer(&result)))
}

Запускаем бенчмаркинг и получаем уже довольно красивые цифры:

Вызов FastCGo-функции теперь занимает около 4,8 наносекунды. Да, по сравнению с предыдущей реализацией FastCGo производительность снизилась примерно на 80 %, но в абсолютных значениях это всего лишь 2 наносекунды.

Для сравнения: CGo на том же тесте показал просадку производительности в 7 наносекунд. То есть даже в этом аспекте мы остаёмся быстрее.

Ниже представлена полная картина результатов бенчмаркингов:

Конечная реализация FastCGo работает за 4,8 наносекунды против практически 79 наносекунд у CGo. Это в 16,5 раза быстрее.

Теперь у нас есть готовое рабочее решение. Осталось разобраться, как мы обмениваемся данными между C и Go.

Обмен данными между C и Go

Все типы данных, которыми мы обмениваемся, можно разделить на три типа:

  • примитивные (bool, int, float64 и т. д.) — одинаковы как в C++, так и в Go;

  • примитивные в куче (slice, string) — разберём подробнее дальше;

  • сложные (map) — этот тип данных мы не портировали на C++, поэтому не передаём его из Go в C++.

String — это структура, которая содержит два поля: указатель на память и размер этой памяти:

// go

type String struct {
   memory uintptr
   length uint
}

Реализация на C++ довольно простая:

// c++

struct String {
 const char* memory;
 size_t length;
};

Структура slice — чуть более сложная, так как здесь появляется дополнительное поле capacity:

// go

type Slice struct {
   items uintptr
   length uint
   capacity uint
}

Для реализации на C++ используется шаблонный класс:

// c++

template <class Type>
struct Slice {
 Type *items;
 size_t length;
 size_t capacity;
};

Реализовав эти структуры данных, мы можем передавать информацию из Go в C и обратно, эффективно работая с ними. Однако при активном обмене параметрами возникают определённые проблемы.

Проблема 1 — выравнивание

Представим, что в Go есть некая модель под названием Item, которая хранит два числа типа uint64 и uint32:

// go

type Item struct {
   a uint64
   b uint32
   // padding 4 bytes
}

var args = struct {
   item Item
   c uint32
}{Item{}, 1}

// unsafe.Sizeof(args) == 24

Мы хотим передать эту структуру в C++ и ещё дополнительно число типа uint32.

Размер структуры args равен 24 байтам.

На C++ разработчик решил полениться, не создавать модель Item и просто принимает все три числа, используя структуру arguments, размер которой — 16 байт:

// c++

struct Arguments {
 uint64_t a;
 uint32_t b;
 uint32_t c;
};

// sizeof(Arguments) == 16

Что делать в этом случае: 

  • Не лениться и синхронизировать структуры аргументов. 

  • Сортировать члены структуры по убыванию размера, чтобы снизить размер паддинга, генерируемого компилятором. 

  • Писать юнит-тесты.

Проблема 2 — упаковка структур

В разработанном нами ядре хранения данных в C++ мы активно используем упакованные структуры с отключённым паддингом:

// c++

__attribute__((__packed__))
struct PackedStruct {
 uint64_t a;
 uint32_t b;
};

К сожалению, в Go мы не можем напрямую передавать упакованные структуры, так как язык не поддерживает их использование. Поэтому приходится либо отказаться от передачи упакованных структур из C в Go, либо работать с такими структурами в Go как с массивом байтов. Но в последнем случае код становится практически нечитабельным и может привести к дополнительным багам.

Проблема 3 — утечки памяти

При передаче памяти из языка с ручным управлением (C++) в язык с автоматическим управлением (Go) существует риск утечек памяти.

Что делать в этом случае?

Решение 1

Когда в Go мы принимаем память, выделенную в C++, можно сделать подобие деструктора благодаря функции SetFinalizer. Для этого оборачиваем память, аллоцированную в C++, в специальную структуру и размещаем её в куче. И для этой структуры можем реализовать финалайзер:

// go
type CppMemory struct {
   Memory []byte
}

var memory = &CppMemory{
   Memory: /*C++ allocated memory */
}
runtime.SetFinalizer(memory, func(c *CppMemory) {
   // clear memory in C++
})

Это решение несёт за собой две потенциальные проблемы:

  • Расположение структуры в куче — это просадка производительности.

  • SetFinalizer может не вызываться для объектов размером менее 16 байт. Это указано в официальной документации по Go, поэтому нужно это учитывать.

Решение 2

Можно в C++ использовать аллокатор памяти из Go — mallocgc. Когда мы в C++ аллоцируем память, Go будет всё про неё знать и в нужный момент сможет вызвать для неё деструктор, чтобы очистить. Но реализация этого механизма довольно тяжёлая, и это уже отдельная тема.

Проблема 4 — use after free

Когда мы передаём память из Go в C++, необходимо всегда помнить, что в Go работает garbage-коллектор, который может в любой момент освободить неиспользуемую память. Если в этот момент C++-код попытается обратиться к такой памяти, это приведёт к segfault:

// go
{
   obj := &Object{...}
   fastcgo.Call1(C.set_obj,
      uintptr(unsafe.Pointer(obj)))
}
runtime.GC()
{
   // error here
   fastcgo.Call1(C.modify_obj,
      uintptr(unsafe.Pointer(&args)))
}

Что делать в этом случае?

Необходимо продлевать время жизни объекта — использовать функцию KeepAlive из пакета runtime. Также рекомендуем при обмене памятью между различными средами использовать ASan для проверки корректности работы с памятью.

Вместо заключения

FastCGo практически не отличается от CGo, за исключением того, что первый работает в неотслеживаемом состоянии. Это значит, что когда мы делаем CGo-вызов, то Go прекрасно знает, что в данный момент выполняется CGo-вызов. Когда мы делаем FastCGo-вызов, Go абсолютно ничего не знает, что происходит в данный момент.

Кроме того, у FastCGo пока нет поддержки Go-коллбэков, но мы уже работаем над их реализацией. 

Но самое главное отличие — это скорость работы: FastCGo работает в 16,5 раза быстрее обычного CGo-вызова. Это лишь одна из множества оптимизаций, реализованных в Deckhouse Prom++.

Из этого следует вывод: CGo — универсальное «коробочное» решение для редких вызовов или вызовов с поддержкой коллбэков, а FastCGo — специализированное решение для частых вызовов.

Репозиторий с бенчмаркингом FastCGo.

Кстати, мы ищем Go-техлида в новую команду Deckhouse Platform Security, которая разрабатывает безопасный слой поверх K8s для нашей платформы. Если вы пишете на Go, работали с Kubernetes и у вас есть опыт управления командой, посмотрите вакансию

P. S.

Читайте также в нашем блоге: