Гипотеза, с которой я начинала, звучала максимально просто: Go компилируется в машинный код, питон интерпретируется, значит Go быстрее примерно везде, и заменить им питон можно в любой момент, было бы желание. Проверять ее я решила замерами и заодно вести счет, как на ринге: за каждый раунд, в котором язык выигрывает по заранее выбранной метрике, ему достается очко. Раундов в итоге набралось восемь, и трещать по швам гипотеза начала уже на втором.

Стенд

Все замеры я делала на виртуалке с одним ядром Xeon на 2,1 ГГц и четырьмя гигабайтами памяти. Питон 3.12.3, numpy 2.4.4, который для линейной алгебры ходит в OpenBLAS 0.3.31, и Go 1.22.2 из репозитория убунты. Версии не самые свежие, но на порядок цифр это, я думаю, не влияет. Счетные задачи я прогоняла по шесть раз скриптом bench.sh, время меряется изнутри процесса без учета старта интерпретатора, в тексте среднее и σ.

Раунд первый: арифметика в цикле

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

def main(N):
    s = 0
    for i in range(N):
        s += i * i % 7
    return s
var s int64
for i := int64(0); i < N; i++ {
	s += i * i % 7
}

Первый прогон дал питону 1,85 секунды, и я уже собиралась записать результат, когда заметила, что написала скрипт без единой функции, прямо на уровне модуля. Переменные на уровне модуля в питоне глобальные, каждое обращение к ним превращается в поиск по словарю, а локальные переменные функции лежат в массиве и достаются по индексу. Я завернула цикл в функцию, и время упало до 1,12 ± 0,02 с бесплатно за одну строчку def. Так что если вы когда-нибудь мерили питон скриптом без функций, то мерили вы, похоже, совсем не то, что хотели.

Go на той же задаче показал 23,5 ± 0,7 мс, почти в 48 раз быстрее. Откуда такая пропасть, видно по байткоду: если попросить его у питона через dis, то на одну итерацию s += i * i % 7 приходится одиннадцать инструкций, от загрузки s и двух загрузок i до сохранения результата и прыжка обратно в начало цикла. На двадцать миллионов итераций это 220 миллионов инструкций, и 1,12 с / 220·10⁶ дает около 5 нс на каждую. За эти пять наносекунд интерпретатор достает объекты со стека, смотрит на их типы, находит нужную функцию умножения, создает в куче новый объект-число со счетчиком ссылок и кладет его обратно. Go компилирует тот же цикл в машинный код, где i и s сидят в регистрах, а остаток от деления на семерку компилятор заменяет умножением на магическую константу и сдвигом, так что ни одного IDIV в ассемблере нет. Итого 1,2 нс на целую итерацию против 56 у питона, и первое очко уходит Go.

Раунд второй: матрицы

Следующая гипотеза вытекала из первой: если на простой арифметике Go быстрее в 48 раз, то на умножении матриц, где этой арифметики миллиарды операций, отрыв должен остаться примерно таким же. Для Go я написала самое прямолинейное умножение, которое не стыдно показать людям:

// порядок циклов i-k-j, чтобы внутренний цикл шел по памяти подряд
for i := 0; i < n; i++ {
	for k := 0; k < n; k++ {
		a := A[i*n+k]
		bk := B[k*n : k*n+n]
		ci := C[i*n : i*n+n]
		for j := range ci {
			ci[j] += a * bk[j]
		}
	}
}

Питону я разрешила пользоваться numpy, потому что матрицы на питоне в реальной жизни считают именно так, и его версия уместилась в одну строчку C = A @ B. Смотрите: на матрицах 300×300 numpy справился за 1,0 ± 0,1 мс против 17,6 ± 0,3 мс у Go, на 1000×1000 за 25,1 ± 0,7 мс против 0,59 ± 0,01 с, а на 2000×2000 за 0,205 ± 0,010 с против 4,56 ± 0,07 с, обогнав Go в 22 раза. Для полноты картины я прогнала на 300×300 и чистый питон на списках списков, он думал 0,88 ± 0,05 с. Больший размер я ему давать не стала, потому что время растет как куб размера, для 1000 выходит 0,88 × (1000/300)³ ≈ 33 секунды, для 2000 еще в восемь раз больше, около четырех с половиной минут, и ждать их ради пары клеточек в таблице мне было лень.

Гипотеза провалилась с треском, и разбираться пришлось на уровне инструкций процессора. Если попросить компилятор Go показать ассемблер через go build -gcflags=-S, внутренний цикл превращается в пару MULSD и ADDSD, скалярных инструкций, каждая из которых обрабатывает одно число double за раз. Автовекторизации в компиляторе Go 1.22 нет, и на процессоре, у которого во флагах записан AVX-512, это выглядит особенно обидно. numpy на A @ B сразу уходит в OpenBLAS, и np.show_runtime() показывает, что тот выбрал ядро SkylakeX, то есть код на AVX-512 с FMA, где одна инструкция перемалывает восемь чисел, а сами матрицы режутся на блоки под размер кеша.

Проверим это арифметикой: умножение двух матриц 2000×2000 требует 2 × 2000³ = 1,6·10¹⁰ операций с плавающей точкой. Делим на 4,56 с и получаем 3,5 GFLOPS у Go, делим на 0,205 с и получаем 78 GFLOPS у OpenBLAS. Отношение сходится с замером, и для связки из восьми чисел на инструкцию, FMA и нормальной работы с кешем против скалярного цикла такой разрыв выглядит вполне правдоподобно.

Самое важное в строчке C = A @ B то, что питон работает в ней ровно столько, сколько нужно, чтобы найти метод matmul и передать в библиотеку указатели на два буфера. Все остальное время процессор исполняет код людей, которые всю жизнь занимаются BLAS. Скорость питона в таких задачах равна скорости того, кто написал под ним нативную библиотеку, и на этом держится весь машинлернинг: у PyTorch, TensorFlow и JAX снаружи питоновский интерфейс, а внутри все считается на C++ и CUDA. Переписывать питоновскую часть такого пайплайна на Go бессмысленно, горячий код там давно живет в другом месте.

Второе очко уходит питону, и счет после двух раундов выглядит так:

Раунд

Что меряла

Go

Python

Счет Go : Python

1

Арифметика в цикле

23,5 мс

1,12 с

1 : 0

2

Матрицы 2000×2000

4,56 с

0,205 с (numpy)

1 : 1

Раунд третий, или Тайна третьего массива

Раз numpy так хорош, логично было отдать ему и первую задачу. Гипотеза была такая: numpy крутит циклы на C, значит разница с Go должна почти исчезнуть.

def main(N):
    a = np.arange(N, dtype=np.int64)
    return int((a * a % 7).sum())  

Результат вышел 0,18 ± 0,02 с, быстрее чистого питона в шесть раз и медленнее Go почти в восемь, и первые ряды, я вижу, уже догадались, куда делось время. Выражение a * a % 7 numpy считает по кусочкам: сначала создает массив из двадцати миллионов int64, это 160 МБ, потом второй такой же под квадраты, потом третий под остатки, и только после этого суммирует. Получается ТРИ временных массива по 160 МБ, и камень большую часть времени гоняет данные через память, пока Go делает все за один проход, не вылезая из регистров. Это обычная цена numpy: он хорош, пока операция крупная и целиком векторная, а на цепочке мелких операций над большим массивом он упирается в память, так что очко снова уходит Go.

Раунд

Что меряла

Go

Python

Счет Go : Python

1

Арифметика в цикле

23,5 мс

1,12 с

1 : 0

2

Матрицы 2000×2000

4,56 с

0,205 с (numpy)

1 : 1

3

Арифметика на numpy

23,5 мс

0,18 с (numpy)

2 : 1

Почему у Go нет своего numpy

После матриц у меня остался вопрос, который на счет не влияет, но без него картина не складывается: почему у Go с 2009 года так и не появилось своего numpy, вокруг которого собралась бы такая же толпа ученых и аналитиков. У Go есть gonum со своей линейной алгеброй, но его я в этот раз не мерила, поэтому врать про его цифры не буду. Моя гипотеза была в том, что Go слишком дорого ходить в библиотеки на C, и без них повторять numpy пришлось бы с нуля. Намек на это есть в одной из поговорок Роба Пайка, которая так и звучит: «Cgo is not Go», и проверку я начала с цены вызова тривиальной функции на C из обоих языков.

В Go обычный вызов функции, которой я запретила инлайниться, чтобы компилятор не схитрил, стоит от 1,3 до 1,5 нс на итерацию цикла. Тот же вызов сишной функции add через cgo стоит от 56 до 66 нс, то есть примерно в сорок раз дороже. На каждом таком вызове рантайм переключается на системный стек и сообщает планировщику, что горутина ушла в чужой код, и все это ради сложения двух чисел.

В питоне вызов обычной питоновской функции в цикле обходится примерно в 50 нс, а вызов функции abs из libc через ctypes в 216 нс, причем ctypes считается самым медленным способом дернуть C, модули на C API работают быстрее. Выходит разница в четыре раза против сорока у Go, и объясняется она просто: для питона сишный код практически родной, он и так платит за каждый свой шаг столько, что поход в C на этом фоне почти незаметен, а Go быстр сам по себе, поэтому переход границы с C для него выглядит как заметный налог.

Цена вызова, впрочем, оказалась меньшей из проблем, потому что cgo ломает то, за что Go любят больше всего. Я попробовала собрать эту программу под arm64 так же, как собирается любая программа на Go, через GOOS=linux GOARCH=arm64, и получила вот это:

# runtime/cgo
gcc_arm64.S: Assembler messages:
gcc_arm64.S:30: Error: no such instruction: `stp x29,x30,[sp,'

Системный gcc про arm64 ничего не знает, для cgo нужен кросс-тулчейн под каждую целевую платформу, и магия «собрал под что угодно одной командой» на этом заканчивается. Так что гипотеза подтвердилась даже с запасом, и культура в Go сложилась соответствующая: библиотеку на C принято переписать на чистом Go, даже если это долго и получится медленнее. В питоне культура строго обратная, сишную или фортрановскую библиотеку заворачивают в модуль, собирают бинарные wheel-пакеты под все платформы и выкладывают на PyPI, и пользователь даже не узнает, что внутри у него лежит фортран. Для научных вычислений, где все самое ценное написано на C и Fortran за последние полвека, второй подход выигрывает с огромным отрывом.

В чем сила, брат? В одном бинарнике

Уж в сборке и доставке, думала я, Go выиграет всухую, и начала с минимального HTTP-сервера на net/http на десять строчек. Бинарник вышел на 7 МБ, и первая же проверка дала осечку: file сказал про него dynamically linked, хотя вся слава Go держится как раз на статических бинарниках. Оказалось, что пакет net при наличии в системе gcc по умолчанию собирается с cgo, чтобы уметь ходить в системный резолвер DNS, и бинарник начинает зависеть от libc. С CGO_ENABLED=0 получился честный statically linked файл того же размера, который можно положить в пустой контейнер FROM scratch.

Кросс-компиляция после этого делается переменными окружения: GOOS=linux GOARCH=arm64, GOOS=windows GOARCH=amd64 и GOOS=darwin GOARCH=arm64 дали мне ELF под ARM, exe под винду и Mach-O под мак, и file подтвердил все три. Первая сборка под новую платформу на моем единственном ядре заняла около 18 секунд, потому что Go заодно компилирует стандартную библиотеку под эту платформу, а повторная после правки кода заняла 0,33 секунды. Ставить для этого ничего не пришлось, тулчейн у Go один на все платформы.

Четвертый раунд я посвятила времени старта, среднему по двадцати запускам из баша вместе с fork и exec. Бинарник на Go с hello world стартует за 0,7 мс, голый python3 -c pass за 10 мс, с import numpy за 76 мс, а с import pandas за 270 мс. Для сервиса, который стартует раз в неделю, разница между миллисекундой и четвертью секунды ничего не значит, но для CLI-утилиты, которую скрипт дергает в цикле тысячу раз, это уже 0,7 секунды против четырех с половиной минут на одном только импорте pandas, если утилита его тянет, так что очко достается Go без вопросов.

А дальше начинается доставка, где у питона вопросов заметно больше. Какой версии питон стоит на целевой машине, и совпадает ли она с той, под которую собраны зависимости? Есть ли у нужной библиотеки wheel под эту архитектуру, или pip сейчас полезет собирать ее из исходников компилятором, которого на сервере нет? Контейнеры эту боль лечат, но образ с интерпретатором и зависимостями все равно приходится собирать, хранить и обновлять, а бинарник на Go просто копируется. Docker, Kubernetes, Terraform, Prometheus и etcd написаны на Go, и мне кажется, что главную роль в этом выборе сыграла именно доставка на тысячи разных машин, скорость счета для таких программ вторична.

Раунды пятый и шестой: сто тысяч задач ждут сеть

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

Вокруг GIL при этом кое-что меняется, потому что в Python 3.13 появилась экспериментальная сборка без GIL, в 3.14 ее перевели в официально поддерживаемые, но основной сборкой по умолчанию остается версия с GIL. Вдобавок интерпретатор без GIL включает его обратно, если импортировать расширение на C, которое явно не помечено как совместимое, и выводит об этом предупреждение. Как быстро до этого доберутся все библиотеки, которыми люди реально пользуются, хз.

Зато ожиданию сети одно ядро никак не мешает, и тут у меня была гипотеза, в которой я была почти уверена: горутины разнесут asyncio и по времени, и по памяти. Тест простой, сто тысяч конкурентных задач, каждая спит секунду, изображая ожидание ответа от базы или соседнего сервиса. На Go я запустила горутины:

var wg sync.WaitGroup
for i := 0; i < 100_000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(time.Second) // делаем вид, что ждем сеть
	}()
}
wg.Wait()

На питоне то же самое делают корутины в asyncio:

async def worker():
    await asyncio.sleep(1)  # делаем вид, что ждем сеть

async def main():
    await asyncio.gather(*(worker() for _ in range(100_000)))

Для полноты картины я добавила обычные потоки через threading. Пиковую память каждый процесс сообщает о себе сам: Go читает VmHWM из /proc/self/status, питон берет ru_maxrss из модуля resource. Прогонов тут меньше, по два-три, а для ста тысяч потоков один, потому что второй раз ждать полминуты мне не захотелось.

Вариант

Время

Пиковая память

Go, 100 000 горутин

1,26 с

256 МБ

Python asyncio, 100 000 задач

2,02 с

164 МБ

Python threading, 10 000 потоков

2,5 с

146 МБ

Python threading, 100 000 потоков

26 с

336 МБ

По времени гипотеза подтвердилась: накладные расходы сверх той самой секунды сна у горутин 0,26 секунды против целой секунды у asyncio, которому на одном ядре надо создать сто тысяч объектов Task и прогнать их через event loop, и пятое очко уходит Go. Запускаю замер памяти, и asyncio оказывается экономнее, 164 МБ против 256 у Go. Сначала я решила, что где-то накосячила с замером, потом посчитала: 256 МБ на сто тысяч горутин дают около 2,6 КБ на штуку, что хорошо объясняется стартовым стеком горутины в 2 КБ плюс служебная структура и таймер. Корутине в питоне отдельный стек не нужен, она хранит только свой фрейм, отсюда около 1,7 КБ на задачу, и шестое очко достается питону. Потоки питона тоже справились, система переварила даже сто тысяч штук, но за 26 секунд, так что для такой нагрузки они годятся разве что как наглядная демонстрация того, зачем вообще придумали asyncio.

Главное преимущество Go в этой части в очки не переводится, оно видно в самом коде. В питоне асинхронность заразная: чтобы написать await, функция сама должна быть async, и эта раскраска ползет вверх по всему стеку вызовов. Стоит где-то в глубине вызвать синхронную библиотеку, которая лезет в сеть, например requests, и весь event loop встает колом вместе со всеми ста тысячами задач. В Go функция, которая ждет сеть, выглядит точно так же, как функция, которая ничего не ждет, блокирует она только свою горутину, а планировщик тем временем крутит остальные. Сетевой сервис с отдельной горутиной на каждый запрос и обычным линейным кодом внутри пишется на Go заметно проще, чем проект на питоне, где async приходится протаскивать через все слои.

Раунд

Что меряла

Go

Python

Счет Go : Python

1

Арифметика в цикле

23,5 мс

1,12 с

1 : 0

2

Матрицы 2000×2000

4,56 с

0,205 с (numpy)

1 : 1

3

Арифметика на numpy

23,5 мс

0,18 с (numpy)

2 : 1

4

Старт процесса

0,7 мс

10 мс

3 : 1

5

100 000 задач, время

1,26 с

2,02 с (asyncio)

4 : 1

6

100 000 задач, память

256 МБ

164 МБ (asyncio)

4 : 2

if err != nil, или День сурка

Последние два раунда я провела на задаче из жизни: есть лог в формате JSON Lines на миллион строк и 76 МБ, и надо посчитать, сколько там каких HTTP-статусов. Гипотеза была в том, что на разборе JSON компилируемый Go оторвется от интерпретатора хотя бы на порядок. На питоне решение выглядит так:

import json, sys
from collections import Counter

c = Counter(json.loads(line)["status"] for line in open(sys.argv[1]))
print(dict(c))

Та же задача на Go выглядит вот так:

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"log"
	"os"
)

type Entry struct {
	Status int `json:"status"` // из всей записи нам нужен только статус
}

func main() {
	f, err := os.Open(os.Args[1])
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	counts := map[int]int{}
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		var e Entry
		if err := json.Unmarshal(sc.Bytes(), &e); err != nil {
			log.Fatal(err) // и снова здравствуйте
		}
		counts[e.Status]++
	}
	if err := sc.Err(); err != nil {
		log.Fatal(err) // сканер тоже умеет ломаться, да
	}
	fmt.Println(counts)
}

Седьмой раунд Go выиграл, но отрыв оказался скромнее ожидаемого раз в шесть: 1,05 секунды против 1,69 у питона по трем прогонам, разница в 1,6 раза. Модуль json в питоне написан на C, а encoding/json в Go разбирает структуры через рефлексию, которая быстротой не славится, так что тут встретились быстрый парсер из медленного языка и медленный парсер из быстрого. Восьмой раунд, по объему кода, питон забирает с разгромным счетом, четыре строчки против тридцати одной, из которых три блока отведены под if err != nil. Кстати, у bufio.Scanner по умолчанию лимит на длину строки в 64 КБ, и на логе с длинным стектрейсом внутри одной записи он честно вернет ошибку, так что последний if err != nil в листинге стоит там совсем не для красоты.

Для скрипта, который я запущу пару раз и выкину, питон выигрывает без вариантов, но у многословности Go есть обратная сторона, ради которой ее и терпят. Каждая ошибка в Go обработана там, где она возникла, и по коду сразу видно, что произойдет, если файл не открылся или строка оказалась битой. Питоновский однострочник на битой строке упадет с трейсбеком и потеряет все, что успел насчитать, и чтобы довести его до поведения сервиса, который пишет ошибку в лог и едет дальше, придется написать ровно столько же примерно столько же кода, сколько в Go. При этом типы там опциональные, и проверит их mypy, если его кто-нибудь не забыл прикрутить к CI. Плюс gofmt, который форматирует весь код на Go одинаково, и довольно бедный язык, в котором почти любую задачу решают примерно одним способом, и в команде на сто человек, где код читают намного чаще, чем пишут, эта бедность экономит дофига времени на ревью.

Раунд

Что меряла

Go

Python

Счет Go : Python

1

Арифметика в цикле

23,5 мс

1,12 с

1 : 0

2

Матрицы 2000×2000

4,56 с

0,205 с (numpy)

1 : 1

3

Арифметика на numpy

23,5 мс

0,18 с (numpy)

2 : 1

4

Старт процесса

0,7 мс

10 мс

3 : 1

5

100 000 задач, время

1,26 с

2,02 с (asyncio)

4 : 1

6

100 000 задач, память

256 МБ

164 МБ (asyncio)

4 : 2

7

Разбор JSON-лога, время

1,05 с

1,69 с

5 : 2

8

Разбор JSON-лога, строк кода

31

4

5 : 3

Где набраны очки

По очкам Go ведет 5 : 3, но исходная гипотеза про замену питона в любой момент при этом не выжила, потому что стоит посмотреть, где именно набраны очки, и картина получается совсем другая. Go взял свои очки там, где программа исполняет собственный код и где ее надо быстро запустить: арифметика в цикле, старт процесса, переключение между ожидающими задачами, разбор JSON. Питон взял свои там, где работает чужой код на C или где дороже всего время программиста: матрицы через BLAS, корутины без собственного стека и скрипт на четыре строчки.

Эта же граница отлично видна на карте, где какой язык живет. Python сидит там, где горячий код давно написан на C, C++, Fortran или CUDA, и человеку нужен удобный пульт управления этим кодом: машинное обучение, анализ данных, научные расчеты, автоматизация, скрипты на один раз. Go сидит там, где программу надо собрать, развезти на тысячу серверов и запускать миллион раз: сетевые сервисы, CLI-утилиты, все, что крутится в Kubernetes, и сам Kubernetes.

Переезд между этими территориями не случается по вполне приземленным причинам. Переписать обучение модели с питона на Go значит потерять PyTorch, Jupyter и двадцать лет чужих библиотек, а выиграть почти ничего, потому что в нормально написанном обучении на питон приходится малая доля времени, остальное считает видеокарта. Переписать оператор для Kubernetes с Go на питон значит тащить интерпретатор нужной версии в каждый контейнер, получить старт в десятки и сотни миллисекунд и заново разбираться с GIL. Обе миграции приносят выигрыш там, где его никто не просил, и проигрыш там, где у проекта реально болит. Поэтому вполне обычная конструкция выглядит как оба языка сразу: сервис на Go принимает запросы, держит соединения и ходит в базы, а за ним стоит модель на PyTorch, завернутая в питоновский сервер.

Сдвинуть эту границу может разве что питон без GIL, который откусит у Go часть счетных задач на нескольких ядрах. Одного бинарника и старта за миллисекунду он питону при этом не даст, и без них в инфраструктуру его, мне кажется, звать не станут. С другой стороны, чтобы Go пришел в машинное обучение, ему нужен свой numpy с армией пользователей, а культура «Cgo is not Go» толкает к тому, чтобы переписать полвека фортрана на чистом Go, и браться за это, похоже, никто не спешит.

И да, bench.sh, который прогонял каждую программу по шесть раз и считал среднее с σ, я написала на bash с awk, потому что ради восемнадцати строчек запуска чужих бинарников не захотелось доставать ни Go, ни Python, и в этой нише оба они, похоже, давно проиграли языку из восьмидесятых.

Комментарии (2)


  1. Mox
    23.09.2026 07:35

    Мне кажется когда пишут про переезд - то заменяют Python + Django и go + свои ORM

    Интересно было бы cравнивать какой-то REST+JSON API CRUD - отдать список сущностей, создать, удалить, апдейтить, и это все под нагрузкой и в параллель.

    Второй момент - интересно еще сравнивать загрузку машины на одинаковой нагрузке. Потому что в реале то количество пользователей не вырастает от переписывания.


  1. saag
    23.09.2026 07:35

    пишем numpy подразумеваем Fortran, это он обгоняет Go