Производительность конкурентного кода: контеншн, 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 по вашим замерам
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. Как снять кривую масштабируемости честно
Кривая — главный артефакт этой статьи, и её легко испортить.
- Меняйте ровно одно: число воркеров. Не размер входа, не время прогона, не количество клиентов.
- Каждая точка — до стационара. Прогрев (JIT, кэши, пул соединений, page cache) отбрасывается — подробно в https://courses.digitable.life/post/performance/02-benchmarking/.
- k повторов на точку, медиана и разброс. Одна точка на N — это не измерение, а анекдот.
- Порядок точек рандомизируйте. Иначе дрейф (нагрев железа, рост БД) запишется в тренд по N.
- Считайте не только пропускную способность. Если p99 вырос втрое при +8% rps, вы не выиграли.
- Следите, чтобы генератор нагрузки не стал узким местом. Классика: кривая заворачивается вниз, потому что задыхается k6, а не сервис.
- Фиксируйте частоту 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 monitor → monitor-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. Чтобы записать хотя бы один байт, ядро обязано получить линию в эксклюзивное владение, а значит — отобрать её у всех остальных.
инкремент стоит как промах в LLC
Каждая пара «инкремент туда, инкремент сюда» превращается в раунд по интерконнекту. Инкремент, который должен стоить один такт, стоит сотню и больше. При этом блокировок в коде нет, 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. Полная карта: что ещё ломает масштабирование
масштабируется)) Сериализация глобальный мьютекс один писатель в лог пул соединений на один слот блокировка на время I O горячая строка в транзакции БД Когерентность атомарный счётчик на всех false sharing на кэш-линии shared map под RWMutex кросс-сокетный трафик NUMA Планировщик потоков больше чем ядер миграции между ядрами cgroup CPU quota и троттлинг GOMAXPROCS не знает про лимит контейнера Рантайм GC как общий ресурс аллокатор с общей ареной GIL в Python единственный event loop Не конкурентность вовсе упёрлись в диск упёрлись в сеть внешний сервис отвечает медленно узкое место в клиенте нагрузки
Больше потоков, чем ядер. Каждый лишний 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. Алгоритм диагностики
воркеров больше, rps не растёт"] --> B{"CPU загружен
под 100%?"} B -- "нет, CPU 20-40%" --> C{"Потоки заблокированы?
off-CPU профиль"} C -- "да, ждут мьютекс" --> D["mutex/block-профиль:
найти владельца
критической секции"] C -- "да, ждут I/O" --> E["Это не конкурентность:
идти в 06-io, 08-database,
10-network"] C -- "нет, потоков мало" --> F["Упёрлись в лимит:
пул, семафор, очередь,
cgroup quota"] B -- "да, 100%" --> G{"IPC выше 1,0?
perf stat"} G -- "да" --> H["Честная работа:
оптимизировать алгоритм,
см. 03-cpu-profiling"] G -- "нет, IPC ниже 0,5" --> I{"perf c2c: горячая линия
с высоким Hitm?"} I -- "да, разные смещения" --> J["FALSE SHARING:
padding и выравнивание"] I -- "да, одно смещение" --> K["TRUE SHARING:
шардировать, батчить,
убрать общее состояние"] I -- "нет" --> L["Промахи кэша по данным:
см. 05-cache-and-locality"] D --> M["Сузить критическую секцию,
вынести I/O, разбить
на stripe-блокировки"] M --> N["Снять кривую заново,
подогнать USL,
сравнить sigma и kappa"] J --> N K --> N N --> O{"N_max вырос?"} O -- "да" --> P["Зафиксировать в CI
как регрессионный тест"] O -- "нет" --> A
Обратите внимание на замыкающую петлю: после починки кривая снимается заново. Убрав главный контеншн, вы почти всегда обнажаете следующий — узкое место переезжает, а не исчезает.
11. Что делать: стратегии по убыванию эффекта
Порядок неслучаен. Первые пункты дают кратный выигрыш и упрощают код, последние — проценты и усложняют.
- Убрать общее состояние. Per-worker данные, иммутабельность, копия вместо ссылки. Самая быстрая синхронизация — та, которой нет; часто это ещё и упрощение архитектуры.
- Не держать блокировку дольше нужного. Вынести I/O, логирование, аллокации и сериализацию за пределы критической секции. Обычно правка на десять строк с эффектом в разы.
- Шардировать. Один мьютекс на map → 64 мьютекса по хешу ключа; один счётчик → массив шардов; один пул → пул на воркера. Контеншн падает пропорционально числу шардов, пока хеш равномерен.
- Ограничить конкурентность. Семафор или пул фиксированного размера, настроенный на измеренный $N_{max}$, плюс backpressure на входе. Контринтуитивно, но за точкой разворота ограничение параллелизма увеличивает пропускную способность и радикально улучшает p99.
- Один владелец вместо блокировки. Single-writer principle: состояние принадлежит одной горутине или актору, остальные шлют сообщения. Контеншн заменяется очередью, которая ведёт себя предсказуемее. Так устроены Disruptor, актор-модель Erlang/Elixir и хорошие конвейеры на каналах.
- Батчинг. Одна блокировка на пачку вместо N блокировок: хуже задержка одного элемента, лучше пропускная способность — размен из https://courses.digitable.life/post/performance/00-overview/.
- Структуры для read-mostly. Copy-on-write, seqlock, RCU,
sync.Map(полезен ровно в двух сценариях из документации, в остальных проигрывает шардированной map под мьютексами). - 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. Типичные ошибки
- Крутить число воркеров вверх, пока «не станет быстрее». Без кривой вы не знаете, где максимум, и обычно останавливаетесь за ним.
- Искать контеншн в CPU-профиле. Ждущие потоки не на CPU: нужен block/mutex/off-CPU-профиль.
- Считать, что atomic решает проблему мьютекса. Он убирает парковку, но не когерентность.
- Сыпать padding без
perf c2c. Раздули структуры, ухудшили локальность, проблему не нашли. - Держать блокировку на время сетевого вызова. Разовая правка, кратный эффект — проверяйте первым делом.
- Игнорировать p99 при росте конкурентности. Пропускная способность плюс 5%, хвост плюс 300% — это регресс.
- Не фиксировать размер пула по результатам измерения. Это измеренная величина, а не 100 «на всякий случай».
- Забыть про лимиты контейнера и не проверить остатки модели. Приложение считает, что у него 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}$ меньше воркеров значит больше пропускной способности и лучше хвост.
- Все приведённые числа — иллюстрация порядков на конкретном железе конкретного года, а не константы. Верить стоит только цифрам, снятым на вашей системе, с разогревом, повторами и разбросом.
Источники
- Gene Amdahl. Validity of the single processor approach to achieving large scale computing capabilities, AFIPS 1967; John Gustafson. Reevaluating Amdahl’s Law, CACM 1988.
- Neil Gunther. Guerrilla Capacity Planning и USL; Baron Schwartz. Practical Scalability Analysis with the Universal Scalability Law.
- Brendan Gregg. Systems Performance, 2nd ed. — главы про CPU, планировщик и off-CPU-анализ; Off-CPU Analysis.
- Joe Mario. C2C — False Sharing Detection in Linux Perf; man perf-c2c(1) и perf-lock(1).
- Ulrich Drepper. Futexes Are Tricky и What Every Programmer Should Know About Memory — раздел про когерентность кэшей до сих пор лучший.
- Paul McKenney. Is Parallel Programming Hard, And, If So, What Can You Do About It? — свободная книга, эталон по RCU и per-CPU-структурам. Herlihy, Shavit. The Art of Multiprocessor Programming, 2nd ed., 2020.
- Go: Diagnostics, runtime.SetMutexProfileFraction, The Go Memory Model; LMAX Disruptor technical paper.
- Gil Tene. How NOT to Measure Latency; Jeff Preshing. Preshing on Programming — memory ordering и lock-free без магии.
- Смежное на портале: https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/ про планировщик, https://courses.digitable.life/post/computer-science/06-memory-hierarchy/ про иерархию памяти, https://courses.digitable.life/post/golang/04-concurrency/ про модель конкурентности Go, https://courses.digitable.life/post/distributed-systems/10-coordination/ про координацию, где тот же $\kappa$ измеряется уже в миллисекундах RTT.
Что дальше
Кривая масштабируемости почти всегда упирается не в код приложения, а в общий ресурс за его пределами — и в девяти случаях из десяти этот ресурс называется «база данных». Пул соединений, размер которого никто не измерял; блокировки строк; план запроса, изменившийся после роста таблицы; N+1, который на стенде незаметен, а в проде даёт тысячу round-trip. Следующая статья — про то, как всё это увидеть приборами: EXPLAIN ANALYZE, статистику ожиданий и метрики пула.
Производительность БД: планы запросов, индексы, пул соединений, N+1