Производительность систем Производительность конкурентного кода: контеншн, false sharing, закон Амдала
0%

Производительность конкурентного кода: контеншн, false sharing, закон Амдала

Производительность конкурентного кода: контеншн, false sharing, закон Амдала

Есть эксперимент, который стоит проделать один раз в жизни, чтобы навсегда изменить интуицию. Возьмите сервис, который держит 12 000 запросов в секунду на 8 воркерах, поднимите число воркеров до 64 и снимите метрику снова. Довольно часто вы увидите 9 000. Не 96 000, не 12 000 — меньше, чем было. Железо то же, код тот же, работы столько же, а система стала хуже от того, что вы дали ей больше исполнителей.

Это не парадокс и не баг. Это нормальное поведение любой системы с общим состоянием, и у него есть формула, которую можно подогнать по вашим же замерам и использовать как прогноз. Конкурентный код — единственная область производительности, где «сделать больше» напрямую означает «сделать медленнее», и потому здесь особенно опасно чинить по интуиции. Мы договорились в https://courses.digitable.life/post/performance/00-overview/, что сначала измеряем; здесь измерять придётся не одну точку, а кривую.

Ключевая мысль: конкурентный код не ускоряется от добавления потоков — он ускоряется от уменьшения того, что потоки делят между собой. Всё остальное в статье — способы найти это «общее» приборами и убрать его хирургически, а не наугад.

1. Что именно мы масштабируем: три разных «больше потоков»

Слово «конкурентность» покрывает три несводимые задачи, и путать их — источник половины неудачных оптимизаций.

Параллельное ускорение (strong scaling). Одна большая CPU-задача, фиксированный объём работы, цель — сократить время. Метрика: $S(N) = T(1)/T(N)$. Тут и живёт закон Амдала: перекодирование видео, обучение модели, сортировка гигабайта.

Сокрытие задержки (latency hiding). Работа в основном ждёт диск, сеть, базу. Потоки нужны не для вычислений, а чтобы, пока один ждёт, другой работал. Оптимальное число потоков определяется не числом ядер, а законом Литтла: чтобы держать $\lambda$ запросов в секунду при времени пребывания $W$, нужна конкурентность $L = \lambda W$. Разбор I/O — в https://courses.digitable.life/post/performance/06-io-and-syscalls/.

Пропускная способность сервера (throughput scaling). Много независимых запросов, цель — обслужить больше в секунду при удержании p99. Именно здесь кривая заворачивается вниз, и именно эту задачу большинство читателей решает в проде.

Разница между конкурентностью и параллелизмом сформулирована у Роба Пайка (Concurrency Is Not Parallelism): конкурентность — про структуру программы (много независимо продвигающихся дел), параллелизм — про исполнение (много дел одновременно на разном железе). Конкурентная программа может не быть параллельной вовсе. Базовое введение в модели — https://courses.digitable.life/post/computer-science/14-concurrency-and-parallelism/.

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

2. Закон Амдала: потолок, который считается до работы

Пусть доля $p$ работы распараллеливается идеально, а доля $1-p$ строго последовательна. Тогда

$$T(N) = (1-p)\,T(1) + \frac{p\,T(1)}{N} \quad\Longrightarrow\quad S(N) = \frac{1}{(1-p) + \dfrac{p}{N}}, \qquad S_{\infty} = \frac{1}{1-p}$$

Оригинал — Gene Amdahl, «Validity of the single processor approach to achieving large scale computing capabilities», AFIPS 1967, полстраницы текста. Смысл жестокий: если 5% работы последовательны, тысяча ядер даст ускорение максимум в 20 раз, а не в 1000.

Последовательная доля $1-p$ Потолок $S_\infty$ $S(8)$ $S(64)$
1% 100x 7,48 39,3
5% 20x 5,93 15,4
10% 10x 4,71 9,1
25% 4x 3,08 3,9
50% 2x 1,78 2,0

Смотрите на колонку $S(64)$: при 10% последовательного кода 64 ядра дают 9,1x, то есть 86% купленного процессора простаивает. Это не «плохой код», это арифметика.

Как измерить $p$, а не угадать

Главная ошибка — подставлять в формулу выдуманное $p$. Его надо получить из замера. Разверните формулу:

$$\frac{1}{S(N)} = (1-p) + \frac{p}{N} = 1 - p\,\frac{N-1}{N} \quad\Longrightarrow\quad p = \frac{N}{N-1}\left(1 - \frac{1}{S(N)}\right)$$

Измерили: один воркер — 4 200 rps, восемь воркеров — 13 400 rps. Значит $S(8) = 3{,}19$, отсюда $p = \frac{8}{7}(1 - 0{,}313) = 0{,}785$, потолок $1/(1-0{,}785) \approx 4{,}7$. Вывод, полученный за две минуты и один прогон: переезд на 32-ядерный инстанс даст в лучшем случае +47% к текущему, а не x4. Дальше решение принимает не инженер, а калькулятор бюджета.

Чего закон Амдала не знает. Во-первых, $p$ не константа: с ростом $N$ появляются накладные расходы, которых при $N=1$ не было — синхронизация, когерентность кэшей, планировщик; реальная кривая уходит ниже амдаловской. Во-вторых, задача может расти вместе с ресурсами — возражение Густафсона (Reevaluating Amdahl’s Law, CACM 1988): в HPC на большем кластере считают более крупную модель, и тогда $S = N - (1-p)(N-1)$, то есть линейно. Это weak scaling, и для веб-сервисов он релевантнее, чем кажется: обычно вы хотите обслужить больше пользователей, а не тот же трафик быстрее. В-третьих, он ничего не говорит про деградацию: Амдал монотонно растёт и упирается в асимптоту, а реальные системы заворачивают вниз.

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

Нил Гюнтер добавил к последовательной доле второй штраф — когерентность: стоимость согласования состояния между всеми парами участников. Пар $N(N-1)$, отсюда квадратичный член (Neil Gunther, Guerrilla Capacity Planning):

$$X(N) = \frac{\lambda N}{1 + \sigma(N-1) + \kappa N(N-1)}, \qquad N_{max} = \sqrt{\frac{1 - \sigma}{\kappa}}$$

  • $\lambda$ — производительность одного воркера (при нормировке $X(1)=1$);
  • $\sigma$ — контеншн: доля работы, сериализуемая на общем ресурсе (очередь к мьютексу, к пулу соединений, к одному диску). Даёт амдаловский потолок;
  • $\kappa$ — когерентность: цена поддержания согласованного общего состояния (инвалидация кэш-линий, синхронизация реплик). Даёт разворот вниз.

При $\kappa = 0$ формула вырождается в закон Амдала. При $\kappa > 0$ у кривой есть максимум $N_{max}$ — та самая точка, после которой добавление воркеров ухудшает систему. У большинства сервисов она существует, и её положение — конкретное число, которое надо знать и вбить в конфиг пула.

Три модели масштабирования: линейная, Амдал и USL с разворотом вниз

Подгонка USL по вашим замерам

1. для N в {1, 2, 4, 8, 16, 32, ...}:
2.     прогнать нагрузку до стационара, отбросить разогрев
3.     записать X[N] = медиана пропускной способности по k повторам
4. C[N] = X[N] / X[1]                      # нормировка на одного воркера
5. подогнать (sigma, kappa) МНК к модели C[N] = N / (1 + sigma*(N-1) + kappa*N*(N-1))
6. если sigma > 0 и kappa > 0: N_max = sqrt((1 - sigma) / kappa)
7. проверить остатки: систематическая структура => модель не годится, ищите смену режима

Сложность подгонки — $O(m \cdot i)$, где $m$ — число точек (обычно 5–8), $i$ — итерации оптимизатора; на практике микросекунды. Дорого стоит шаг 2: каждая точка — полноценный прогон нагрузки.

# usl_fit.py — подгонка универсального закона масштабируемости по замерам
import numpy as np
from scipy.optimize import curve_fit

# N — число воркеров, X — измеренная пропускная способность (rps), медиана из 5 прогонов
N = np.array([1, 2, 4, 8, 16, 24, 32], dtype=float)
X = np.array([4200, 8010, 14230, 21540, 24010, 22600, 20180], dtype=float)

C = X / X[0]  # нормируем: во сколько раз лучше одного воркера

def usl(n, sigma, kappa):
    return n / (1.0 + sigma * (n - 1.0) + kappa * n * (n - 1.0))

# bounds не дают уйти в отрицательные параметры — они физически бессмысленны
(sigma, kappa), cov = curve_fit(usl, N, C, p0=[0.05, 0.005], bounds=([0, 0], [1, 1]))
err = np.sqrt(np.diag(cov))  # стандартные ошибки параметров — их надо смотреть

n_max = np.sqrt((1 - sigma) / kappa) if kappa > 0 else float("inf")
print(f"sigma = {sigma:.4f} +- {err[0]:.4f}   (контеншн)")
print(f"kappa = {kappa:.5f} +- {err[1]:.5f}   (когерентность)")
print(f"N_max = {n_max:.1f} воркеров, потолок = {usl(n_max, sigma, kappa) * X[0]:,.0f} rps")

# остатки: если они не случайны, модель не описывает вашу систему
print("остатки:", np.round(C - usl(N, sigma, kappa), 3))
sigma = 0.0413 +- 0.0061   (контеншн)
kappa = 0.00498 +- 0.00042 (когерентность)
N_max = 13.9 воркеров, потолок = 24 205 rps
остатки: [ 0.    -0.011  0.028 -0.019  0.006  0.012 -0.016]

Как это читать. $\sigma \approx 0{,}04$ значит: около 4% работы сериализуется, амдаловский потолок ~24x. Но $\kappa \approx 0{,}005$ утаскивает кривую вниз, и реальный максимум — на 14 воркерах. Ставить пул на 32 бессмысленно и вредно: получите меньше пропускной способности и заметно худший p99, потому что запросы будут стоять в очередях, которых при 14 воркерах не было.

Диагностическая ценность параметров важнее прогноза:

Наблюдение Что искать в первую очередь
$\sigma$ велика, $\kappa \approx 0$ одна глобальная блокировка, единственный писатель, пул на N=1, единый лог-файл
$\kappa$ велика общая изменяемая память: атомарные счётчики, false sharing, shared-map, кросс-сокетный NUMA-трафик
оба малы, а кривая всё равно плоха вы упёрлись не в конкурентность: диск, сеть, внешний сервис — смотрите USE-метрики
остатки структурны, не шум режимов два: например, до 8 воркеров всё в L3, после — выход в память

Оговорка, обязательная для честности: USL — эмпирическая модель с двумя параметрами, а не физический закон. Она хорошо описывает широкий класс систем и отвратительно — системы с резкими переключениями режима (кэш перестал влезать, GC перешёл в другой режим, автоскейлер добавил ноду). Проверяйте остатки: если модель не села, это тоже результат — значит, в системе есть порог, и его надо найти.

4. Как снять кривую масштабируемости честно

Кривая — главный артефакт этой статьи, и её легко испортить.

  1. Меняйте ровно одно: число воркеров. Не размер входа, не время прогона, не количество клиентов.
  2. Каждая точка — до стационара. Прогрев (JIT, кэши, пул соединений, page cache) отбрасывается — подробно в https://courses.digitable.life/post/performance/02-benchmarking/.
  3. k повторов на точку, медиана и разброс. Одна точка на N — это не измерение, а анекдот.
  4. Порядок точек рандомизируйте. Иначе дрейф (нагрев железа, рост БД) запишется в тренд по N.
  5. Считайте не только пропускную способность. Если p99 вырос втрое при +8% rps, вы не выиграли.
  6. Следите, чтобы генератор нагрузки не стал узким местом. Классика: кривая заворачивается вниз, потому что задыхается k6, а не сервис.
  7. Фиксируйте частоту CPU. При одном активном ядре процессор в турбо, при 32 — на базовой частоте. Однопоточная база $X(1)$ оказывается завышена, и вы «измеряете» плохое масштабирование, которого нет. Проверьте turbostat или посчитайте реальные ГГц через perf stat -e cycles,task-clock.

Для внутрипроцессной конкурентности в Go штатный инструмент — флаг -cpu:

go test -run=^$ -bench=BenchmarkCounter -cpu=1,2,4,8,16,32 -benchtime=3s -count=5 ./... | tee scale.txt
benchstat scale.txt
BenchmarkCounter/Mutex-1     18.4ns ± 1%
BenchmarkCounter/Mutex-2      112ns ± 4%     # x6 хуже на двух ядрах — контеншн
BenchmarkCounter/Mutex-4      241ns ± 7%
BenchmarkCounter/Mutex-8      495ns ±11%
BenchmarkCounter/Mutex-16    1.02µs ±14%     # линейная деградация: чистая сериализация
BenchmarkCounter/Sharded-1   19.1ns ± 1%
BenchmarkCounter/Sharded-8   20.8ns ± 3%     # плоско — то, что нужно
BenchmarkCounter/Sharded-32  24.6ns ± 6%

В бенчмарке с b.RunParallel время на операцию растёт с числом ядер — это нормальная подача результата для конкурентного кода (ns/op на одну операцию, а не суммарное время). Плоская линия означает идеальное масштабирование, растущая — контеншн. Для внешней нагрузки то же делают ступенчатым профилем в k6, см. https://courses.digitable.life/post/performance/11-load-testing/.

5. Анатомия контеншна: что стоит мьютекс

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

Стоимость по этапам (порядки на типичном x86-сервере середины 2020-х, как иллюстрация метода, а не константы — измеряйте своё железо):

Этап Порядок Что происходит
Успешный CAS без конкуренции 10–25 нс одна атомарная операция, линия уже в L1 в состоянии M/E
CAS при конкуренции 50–500 нс линия уходит к другому ядру, RFO, промах вплоть до LLC/интерконнекта
Спин перед парковкой сотни нс pause/yield, дешевле сисколла, но жжёт такты
futex_wait + futex_wake 1–5 мкс два системных вызова, переключение контекста
Возврат на CPU после сна 1–50 мкс ждём планировщик, кэши и TLB уже холодные

Разница между «блокировка свободна» и «блокировка занята» — это не проценты, а порядки. Поэтому средняя стоимость Lock() бесполезна как метрика: смотреть надо на долю попаданий в медленный путь.

В Linux быстрый путь целиком в user space через futex (Ulrich Drepper, «Futexes Are Tricky»), в ядро уходим только при конкуренции; есть PTHREAD_MUTEX_ADAPTIVE_NP со спином перед сном. В Go sync.Mutex спинит ограниченное число раз, затем паркует горутину, а если ожидание превысило 1 мс — переключается в starvation mode и передаёт владение строго по очереди: пропускная способность ухудшается ради ограничения хвоста, и об этом полезно помнить, когда видите «странный» p99 (модель — https://courses.digitable.life/post/golang/04-concurrency/). В JVM biased locking удалён в JDK 15+ (JEP 374), так что старые советы про него устарели. В .NET спин у Monitor и счётчик contention в dotnet-counters.

Патологии, у которых есть имена

  • Lock convoy. Поток отпустил блокировку и тут же берёт снова; разбуженный конкурент ещё не дошёл до CPU. Система вырождается в цепочку переключений контекста: пропускная способность падает в разы при почти свободном CPU по данным top.
  • Инверсия приоритетов. Низкоприоритетный держит блокировку, нужную высокоприоритетному, и его вытесняет средний. Классика — зависания марсохода Pathfinder в 1997-м (разбор Glenn Reeves). Лечится наследованием приоритета.
  • Thundering herd. Одно событие будит всех ожидающих, побеждает один, остальные снова засыпают: notifyAll вместо notify, а в кэшах — stampede, см. https://courses.digitable.life/post/performance/09-caching/.
  • Блокировка на время I/O. Самая частая и самая дорогая ошибка: под мьютексом делается запрос в сеть или в базу. Критическая секция раздувается с наносекунд до миллисекунд, $\sigma$ в USL взлетает. Ищите в коде Lock() и любой сетевой вызов до Unlock().

6. Инструменты: как увидеть контеншн, а не догадаться о нём

Главная особенность конкурентных проблем: их не видно в CPU-профиле. Поток, стоящий на мьютексе, снят с процессора, сэмплирующий профайлер его не сэмплирует. Симптом «CPU 30%, rps не растёт, p99 плохой» — почти диагноз (см. off-CPU в https://courses.digitable.life/post/performance/03-cpu-profiling/).

package main

import (
    "net/http"
    _ "net/http/pprof"
    "runtime"
)

func main() {
    // 1 из 5 событий блокировки на мьютексе попадает в профиль.
    // 1 = всё (дорого), 0 = выключено. Начните со 100 и снижайте по мере надобности.
    runtime.SetMutexProfileFraction(5)
    // Сэмплировать блокирующие события длиннее ~10 мкс (значение в наносекундах).
    // rate=1 пишет всё — на проде это заметный оверхед.
    runtime.SetBlockProfileRate(10_000)

    go func() { _ = http.ListenAndServe("localhost:6060", nil) }()
    // ... основная работа сервиса
}
# профиль ожидания на мьютексах: сколько времени горутины ждали разблокировки
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex

# профиль блокировок вообще: каналы, WaitGroup, select, sync.Cond
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/block

# быстрый скрининг без профилировщика: где стоят горутины
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | grep -A3 'semacquire'

Типичный вывод top по mutex-профилю:

Showing nodes accounting for 47.31s, 96.4% of 49.08s total
      flat  flat%   sum%        cum   cum%
    38.02s 77.47% 77.47%     38.02s 77.47%  sync.(*Mutex).Unlock
     6.11s 12.45% 89.92%      6.11s 12.45%  sync.(*RWMutex).Unlock
     3.18s  6.48% 96.40%      3.18s  6.48%  sync.(*Mutex).Unlock

Здесь подвох, на котором спотыкаются все: в mutex-профиле Go событие приписывается Unlock, а не Lock — профиль отвечает на вопрос «кто заставил других ждать», а не «кто ждал». Смотрите стек выше Unlock: он покажет владельца критической секции, то есть виновника, а не жертву. Ось значений — суммарное время ожидания других горутин, поэтому 49 секунд ожидания за 10 секунд прогона нормальны: это сумма по всем горутинам.

# контеншн на блокировках (нужны символы; для ядерных — CONFIG_LOCK_STAT)
sudo perf lock record -a -- sleep 10
sudo perf lock contention -a -b -- sleep 10    # режим через BPF, perf 6.x

# где потоки СПЯТ и сколько: off-CPU flame graph
sudo /usr/share/bcc/tools/offcputime -df -p $(pgrep -n myservice) 30 > out.stacks
./flamegraph.pl --color=io --title="Off-CPU time" out.stacks > offcpu.svg

# грубый скрининг: переключения контекста и миграции между ядрами
perf stat -e context-switches,cpu-migrations -p $(pgrep -n myservice) -- sleep 10

Ориентир: если context-switches на порядок больше числа обслуженных запросов, вы почти наверняка смотрите на convoy или на слишком мелкую гранулярность блокировок. Инструменты уровня ОС разобраны в https://courses.digitable.life/post/operating-systems/14-observability-and-performance/.

Платформа Инструмент Что показывает
JVM JFR, событие jdk.JavaMonitorEnter; async-profiler -e lock стек и длительность ожидания монитора
.NET dotnet-counters monitormonitor-lock-contention-count, dotnet-trace частота попаданий в медленный путь
Python GIL: py-spy top (колонка GIL), sys.setswitchinterval доля времени под глобальной блокировкой
Node.js --prof, метрика лага event loop сериализация в единственном потоке
PostgreSQL pg_stat_activity.wait_event_type = 'Lock', pg_locks блокировки строк и таблиц, см. https://courses.digitable.life/post/performance/08-database-performance/

Приложите к этому USE-метод из https://courses.digitable.life/post/performance/01-measuring/: у блокировки есть утилизация (доля времени, когда она занята) и насыщение (сколько потоков стоят в очереди). Утилизация 60% ещё терпима, очередь из 20 потоков — уже катастрофа: время ожидания растёт нелинейно, как в любой системе массового обслуживания.

7. False sharing: общее там, где вы ничего не делили

Самый контринтуитивный механизм. Два потока пишут в разные переменные, между ними нет ни одной блокировки, гонки данных нет, код формально идеален — и он работает в разы медленнее однопоточного.

Причина в гранулярности. Протокол когерентности кэшей (MESI и родня) оперирует не байтами и не переменными, а кэш-линиями: 64 байта на подавляющем большинстве x86 и ARM, 128 на Apple silicon и части серверных ARM. Чтобы записать хотя бы один байт, ядро обязано получить линию в эксклюзивное владение, а значит — отобрать её у всех остальных.

Раскладка счётчиков по кэш-линиям: с false sharing и с padding

Каждая пара «инкремент туда, инкремент сюда» превращается в раунд по интерконнекту. Инкремент, который должен стоить один такт, стоит сотню и больше. При этом блокировок в коде нет, mutex-профиль пуст, CPU загружен на 100% — и всё это время процессор гоняет линию между ядрами.

Как обнаружить: perf c2c

Специализированный инструмент один и очень хороший — perf c2c (cache-to-cache) Джо Марио (разбор автора):

sudo perf c2c record -F 60000 -a -- sleep 5
sudo perf c2c report -NN -g --call-graph=none --stdio
=================================================
           Shared Data Cache Line Table
=================================================
Index  Cacheline        Total  Tot  ---- Store Refs ----  ---- Load Hitm ----
                       records  Hitm  L1 Hit  L1 Miss      Total  LclHitm
    0  0x55f8a41c2100    28941  71.4%   9420    1142        8814     8814
    1  0x55f8a41c2180     1204   2.1%    311      48         259      259

Колонка Load Hitm («hit modified») — это и есть false sharing: чтение попало в линию, которую другое ядро держит в состоянии Modified. Если одна кэш-линия собрала десятки процентов Hitm, вы нашли проблему. Дальше отчёт покажет разбивку по смещениям внутри линии и по функциям: видно, что байты 0–7 трогает один поток, а байты 8–15 — другой. Это подпись false sharing; при true sharing смещение будет одно и то же. Косвенные признаки без perf c2c: рост cache-misses без роста рабочего набора, IPC ниже 0,5 при 100% CPU, счётчик mem_load_l3_hit_retired.xsnp_hitm на Intel.

// Плохо: 8 счётчиков подряд по 8 байт — все в одной 64-байтовой линии.
type badStats struct {
    hits [8]uint64
}

// Хорошо: каждый счётчик на своей линии.
const cacheLine = 64

type paddedCounter struct {
    v uint64
    _ [cacheLine - 8]byte // padding до границы линии
}

type goodStats struct {
    hits [8]paddedCounter
}

Аналоги: в C++17 — alignas(std::hardware_destructive_interference_size), в C — __attribute__((aligned(64))), в Java — @jdk.internal.vm.annotation.Contended (в прикладном коде проще добавить long p1..p7), в Rust — #[repr(align(64))], в .NET — padding-поля или явная раскладка структуры.

Три предупреждения, без которых совет вредит. Не сыпьте padding превентивно: он раздувает структуры, ухудшает локальность (о ней — https://courses.digitable.life/post/performance/05-cache-and-locality/) и увеличивает давление на кэш; массив из миллиона счётчиков, раздутый в 8 раз, проиграет больше, чем выиграет. Выравнивание должно быть настоящим: padding внутри структуры не спасёт, если сам объект не выровнен на границу линии, а аллокатор Go гарантирует 8/16 байт, не 64. Проверьте, что помогло — до/после на кривой масштабируемости, а не на одной точке; это единственный способ отличить настоящий false sharing от совпадения.

Каноничный пример борьбы с false sharing в проде — LMAX Disruptor: указатели головы и хвоста кольцевого буфера разнесены по разным линиям, иначе producer и consumer убивают производительность друг друга (технический документ LMAX).

8. True sharing: атомарный счётчик тоже не масштабируется

Убрали мьютекс, поставили atomic.AddUint64 — и лучше не стало. Так и должно быть: атомарная операция read-modify-write требует эксклюзивного владения линией ровно так же, как запись под мьютексом. Разница в том, что нет парковки потока, — но сериализация на когерентности остаётся. Это true sharing: все действительно пишут в одну переменную, и padding тут бессилен.

Порядки величин (снова иллюстрация, не константы): неконкурентный lock xadd — единицы наносекунд, он же под 16 потоками на одной линии — сотни наносекунд на операцию. Пропускная способность падает примерно линейно с числом участников; это чистый $\kappa$ из USL.

Решение — не синхронизировать, а распределить: каждый воркер пишет в свою ячейку, читатель суммирует.

package metrics

import (
    "runtime"
    "sync/atomic"
)

const cacheLine = 64

type shard struct {
    v uint64
    _ [cacheLine - 8]byte // изоляция от соседей по массиву
}

// Counter — счётчик, масштабирующийся линейно по числу ядер.
// Inc: O(1) без контеншна при равномерном распределении по шардам.
// Value: O(S), где S — число шардов (обычно = GOMAXPROCS).
type Counter struct {
    shards []shard
    mask   uint64
}

func NewCounter() *Counter {
    n := 1
    for n < runtime.GOMAXPROCS(0) { // округляем вверх до степени двойки ради дешёвой маски
        n <<= 1
    }
    return &Counter{shards: make([]shard, n), mask: uint64(n - 1)}
}

// Inc увеличивает счётчик. Индекс шарда — дешёвая функция от идентификатора воркера.
func (c *Counter) Inc(workerID uint64) {
    atomic.AddUint64(&c.shards[workerID&c.mask].v, 1)
}

// Value возвращает сумму. Значение приблизительное: шарды читаются не атомарно
// относительно друг друга. Для метрик это приемлемо, для инвариантов — нет.
func (c *Counter) Value() uint64 {
    var total uint64
    for i := range c.shards {
        total += atomic.LoadUint64(&c.shards[i].v)
    }
    return total
}

Сложность: инкремент $O(1)$ с константой уровня попадания в L1, чтение $O(S)$; память — $S \cdot 64$ байт вместо 8; точность — мгновенное значение слегка размазано во времени. Это и есть размен: точность и память в обмен на масштабируемость, и именно так устроены LongAdder в Java, per-CPU счётчики в ядре Linux, mcache в аллокаторе Go. Родственные приёмы: батчинг обновлений (локальный аккумулятор, сброс раз в 1000 операций — контеншн падает в 1000 раз), приблизительные счётчики (инкремент с вероятностью $1/k$), полное разделение чтения и записи.

9. Полная карта: что ещё ломает масштабирование

Больше потоков, чем ядер. Каждый лишний runnable-поток — это переключение контекста, вымывание L1/L2 и лишние миграции. Для CPU-bound работы оптимум почти всегда близок к числу доступных ядер, а «доступных» в контейнере — не то, что показывает nproc. Классическая авария: JVM или Go в контейнере с лимитом cpu: 2 видят 64 ядра хоста и создают 64 потока, которые cgroup дружно троттлит. Диагностика — cat /sys/fs/cgroup/cpu.stat, поля nr_throttled и throttled_usec; лечение — GOMAXPROCS по лимиту (библиотека automaxprocs) и явные пулы потоков в JVM.

RWMutex не всегда лучше Mutex. Читатели тоже пишут — в счётчик читателей, а это та же атомарная операция на общей линии. На коротких критических секциях RWMutex систематически проигрывает обычному Mutex из-за более дорогого протокола; выигрыш появляется, только когда секция достаточно длинная, а читателей действительно много. Ровно тот случай, когда надо измерить обе версии, а не рассуждать.

Lock-free не значит быстрее. Алгоритм без блокировок гарантирует прогресс системы, а не скорость. Под высоким контеншном CAS-цикл крутится десятки раз, сжигая такты и гоняя ту же линию между ядрами. Lock-free выигрывает там, где нужны предсказуемые хвосты и отсутствие блокирующих зависимостей, а не там, где «хочется быстро»; плюс цена — модель памяти, порядок операций, ABA, то есть ошибки, которые не ловятся тестами.

GC и аллокатор — общий ресурс. Идеально распараллеленный код может не масштабироваться просто потому, что все воркеры одновременно аллоцируют. Симптом: в профиле растёт runtime.mallocgc или паузы, увеличивающиеся с числом воркеров. Разбор — https://courses.digitable.life/post/performance/04-memory/. NUMA: на двухсокетной машине доступ к памяти чужого сокета дороже в полтора-два раза, а трафик когерентности между сокетами принципиально дороже, чем внутри; если кривая ломается ровно на границе числа ядер одного сокета, смотрите numastat -p <pid> и пробуйте numactl --cpunodebind=0 --membind=0.

10. Алгоритм диагностики

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

11. Что делать: стратегии по убыванию эффекта

Порядок неслучаен. Первые пункты дают кратный выигрыш и упрощают код, последние — проценты и усложняют.

  1. Убрать общее состояние. Per-worker данные, иммутабельность, копия вместо ссылки. Самая быстрая синхронизация — та, которой нет; часто это ещё и упрощение архитектуры.
  2. Не держать блокировку дольше нужного. Вынести I/O, логирование, аллокации и сериализацию за пределы критической секции. Обычно правка на десять строк с эффектом в разы.
  3. Шардировать. Один мьютекс на map → 64 мьютекса по хешу ключа; один счётчик → массив шардов; один пул → пул на воркера. Контеншн падает пропорционально числу шардов, пока хеш равномерен.
  4. Ограничить конкурентность. Семафор или пул фиксированного размера, настроенный на измеренный $N_{max}$, плюс backpressure на входе. Контринтуитивно, но за точкой разворота ограничение параллелизма увеличивает пропускную способность и радикально улучшает p99.
  5. Один владелец вместо блокировки. Single-writer principle: состояние принадлежит одной горутине или актору, остальные шлют сообщения. Контеншн заменяется очередью, которая ведёт себя предсказуемее. Так устроены Disruptor, актор-модель Erlang/Elixir и хорошие конвейеры на каналах.
  6. Батчинг. Одна блокировка на пачку вместо N блокировок: хуже задержка одного элемента, лучше пропускная способность — размен из https://courses.digitable.life/post/performance/00-overview/.
  7. Структуры для read-mostly. Copy-on-write, seqlock, RCU, sync.Map (полезен ровно в двух сценариях из документации, в остальных проигрывает шардированной map под мьютексами).
  8. Lock-free — последним и по результатам измерения. Только если предыдущие семь исчерпаны, есть бенчмарк и есть человек, готовый это поддерживать через год.

12. Как врут бенчмарки конкурентного кода

Общие правила честного замера — в https://courses.digitable.life/post/performance/02-benchmarking/. У конкурентности есть свои, особенно коварные способы обмануться.

Турбо-частота завышает однопоточную базу. При одном активном ядре процессор поднимает частоту на 20–40% выше базовой, при всех загруженных работает на базовой. Вы делите $T(1)$, полученное на турбо, на $T(N)$, полученное на базовой, и получаете «плохое масштабирование», которого частично нет. Лечится фиксацией частоты (cpupower frequency-set), отключением turbo на время эксперимента или пересчётом на такты через perf stat.

Микробенчмарк с пустой критической секцией измеряет не ваш код. Цикл lock(); counter++; unlock() меряет чистую стоимость примитива при максимально возможном контеншне — режим, которого в проде обычно нет. Числа выходят катастрофические и ведут к преждевременной замене мьютексов на lock-free там, где в реальной нагрузке контеншна 2%. Обратная ошибка симметрична: слишком длинная критическая секция в бенчмарке маскирует контеншн, и вы «доказываете», что мьютекс бесплатен.

Coordinated omission. Закрытая модель нагрузки (фиксированное число потоков, каждый шлёт следующий запрос после ответа) физически не может создать очередь: пока сервис тормозит, клиент не шлёт. Замедление системы скрывается — вы измеряете сервис-тайм, а не время отклика. Термин и разбор — у Гила Тене (How NOT to Measure Latency). Для конкурентных экспериментов это критично: очередь и есть предмет исследования.

Ошибка выжившего в двух видах. Первый: нагрузчик отбрасывает таймауты и считает перцентили по успешным — при росте конкурентности процент отказов растёт, а «p99» улучшается. Второй: вы сравниваете конфигурации, в которых сделано разное количество работы (при 32 воркерах часть запросов отвалилась по лимиту), и радуетесь.

Тепловой и соседский шум. SMT-сосед по физическому ядру делит с вами исполнительные устройства и L1: прогон на 8 «ядрах», из которых 4 — гипертреды, даёт совсем не то, что 8 физических. Проверьте lscpu -e и при необходимости привязывайтесь через taskset -c 0-7. И наконец, одна точка вместо кривой: утверждение «мы ускорились в 1,4 раза» без указания N бессодержательно — на 4 воркерах могло стать лучше, на 32 хуже. Отчёт о конкурентной оптимизации — это две кривые на одном графике, а не два числа.

13. Типичные ошибки

  1. Крутить число воркеров вверх, пока «не станет быстрее». Без кривой вы не знаете, где максимум, и обычно останавливаетесь за ним.
  2. Искать контеншн в CPU-профиле. Ждущие потоки не на CPU: нужен block/mutex/off-CPU-профиль.
  3. Считать, что atomic решает проблему мьютекса. Он убирает парковку, но не когерентность.
  4. Сыпать padding без perf c2c. Раздули структуры, ухудшили локальность, проблему не нашли.
  5. Держать блокировку на время сетевого вызова. Разовая правка, кратный эффект — проверяйте первым делом.
  6. Игнорировать p99 при росте конкурентности. Пропускная способность плюс 5%, хвост плюс 300% — это регресс.
  7. Не фиксировать размер пула по результатам измерения. Это измеренная величина, а не 100 «на всякий случай».
  8. Забыть про лимиты контейнера и не проверить остатки модели. Приложение считает, что у него 64 ядра; USL с плохими остатками — не прогноз, а красивая кривая по случайным точкам.

Мини-итог

  • За словом «конкурентность» прячутся три задачи: ускорить одну работу, скрыть задержку, поднять пропускную способность. Метрики и потолки у них разные — определитесь до первого замера.
  • Закон Амдала считается до оптимизации и по измеренному $p$: $p = \frac{N}{N-1}(1 - 1/S(N))$. Он отвечает на вопрос «стоит ли вообще начинать».
  • USL добавляет когерентность и объясняет разворот кривой вниз. Подгоняется по вашим замерам за десяток строк на Python и даёт конкретное $N_{max}$ для конфига пула.
  • Мьютекс без конкуренции стоит десятки наносекунд, с конкуренцией — микросекунды. Разница в порядках, поэтому важна не средняя стоимость, а доля медленного пути.
  • Контеншн невидим в CPU-профиле. Инструменты: mutex/block-профили Go, perf lock contention, off-CPU flame graphs, JFR, dotnet-counters.
  • False sharing — сериализация без единой блокировки в коде: ищется perf c2c по колонке Hitm, чинится выравниванием и только после измерения. True sharing padding не лечится — шардируйте, батчите, аккумулируйте локально.
  • Ограничение конкурентности — легитимная оптимизация: за точкой $N_{max}$ меньше воркеров значит больше пропускной способности и лучше хвост.
  • Все приведённые числа — иллюстрация порядков на конкретном железе конкретного года, а не константы. Верить стоит только цифрам, снятым на вашей системе, с разогревом, повторами и разбросом.

Источники

Что дальше

Кривая масштабируемости почти всегда упирается не в код приложения, а в общий ресурс за его пределами — и в девяти случаях из десяти этот ресурс называется «база данных». Пул соединений, размер которого никто не измерял; блокировки строк; план запроса, изменившийся после роста таблицы; N+1, который на стенде незаметен, а в проде даёт тысячу round-trip. Следующая статья — про то, как всё это увидеть приборами: EXPLAIN ANALYZE, статистику ожиданий и метрики пула.

Производительность БД: планы запросов, индексы, пул соединений, N+1

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов