Алексей Меленчук
вся записка
узел 01 · Ядро
дата
объём 5 мин чтения

Почему добавление потоков снижает пропускную способность

Закон Амдала объясняет, почему ускорение упирается в потолок. Он не объясняет, почему после некоторого числа потоков система начинает работать хуже.

Коротко. Параллельная часть даёт потолок ускорения, а согласование между потоками даёт спад после максимума. Пока в модели нет второго слагаемого, «добавим воркеров» выглядит безопасным решением, и оно им не является.

Знакомая картина: увеличили число воркеров с восьми до шестнадцати, пропускная способность выросла на треть. Увеличили до тридцати двух — выросла на пять процентов. До шестидесяти четырёх — упала.

Последний шаг обычно объясняют нехваткой памяти или переключением контекста. Иногда так и есть. Но существует более общая причина, и она предсказуема арифметически.

Амдал объясняет половину

Закон Амдала (1967) описывает предел ускорения, если часть работы принципиально последовательна. Если доля последовательного кода — α, то при N процессорах ускорение не превысит

S(N) = N / (1 + α·(N − 1))

При α = 0.05 предел равен двадцати, сколько процессоров ни добавляй. Вывод известен и обычно на этом заканчивают.

Проблема в том, что эта модель монотонна. Она говорит: ускорение выходит на плато. Она не говорит, что оно может пойти вниз. А оно идёт.

Второе слагаемое

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

C(N) = N / (1 + σ·(N − 1) + κ·N·(N − 1))

Первое слагаемое σ — та же последовательная доля, что у Амдала: очередь к общему ресурсу. Второе, κ, — цена того, что участники должны договариваться между собой, и она растёт как N², потому что пар участников квадратично много.

Отсюда и берётся спад. Пока N мало, линейный рост выигрывает. Когда N растёт, квадратичное слагаемое обгоняет, и кривая заворачивает вниз. Максимум достигается примерно при

N* ≈ √((1 − σ) / κ)

Практическое следствие важнее формулы: у системы есть оптимальное число параллельных исполнителей, и оно конечно. Не «столько, сколько ядер», а величина, которая зависит от того, сколько участники тратят на согласование.

Где прячется κ в обычном веб-бэкенде

Согласование редко выглядит как согласование. Оно выглядит так.

Строка в базе, которую все обновляют. Счётчик остатка, баланс, последовательность. Каждый писатель ждёт блокировку. Это чистый σ, пока писателей мало, и превращается в κ, когда база начинает тратить время на управление очередью ожидающих.

Пул соединений. Тридцать воркеров на пул в десять соединений — это двадцать ожидающих. Добавление воркеров не добавляет пропускной способности вообще: узкое место не в них.

Общий кэш с инвалидацией. Каждая запись рассылает уведомления всем остальным. Число уведомлений растёт как произведение числа писателей на число читателей.

Сборщик мусора. На JVM параллельные сборщики останавливают все потоки на фазах, требующих согласованного снимка. Чем больше потоков, тем дороже такая остановка.

Что делать, когда общий ресурс убрать нельзя

Иногда согласование — не дефект архитектуры, а суть задачи. Счётчик остатка обязан быть один, иначе он не счётчик. Тогда работают три приёма, и все три уменьшают κ, а не обходят его.

Укрупнение операций. Сто потоков, каждый инкрементирует счётчик, — сто попыток взять блокировку. Тот же результат достигается накоплением в памяти и одной записью на пачку. Число участников согласования падает на порядок, а κ квадратично зависит от него.

Плата — задержка и риск потерять накопленное при падении процесса. Приём годится для счётчиков и метрик и не годится для денег.

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

Работает, пока чтение суммы редкое: иначе стоимость переезжает с записи на чтение.

Один исполнитель на ключ. Изменения по конкретной сущности идут через очередь с партиционированием по её идентификатору. Внутри ключа конкуренции нет вовсе, между ключами — полный параллелизм.

Самый дорогой вариант: он меняет модель взаимодействия с синхронной на асинхронную, и это видно пользователю.

Почему реплики ведут себя иначе

Отдельная путаница: добавление реплик и добавление потоков — разные операции, и модель к ним применяется по-разному.

Потоки внутри процесса делят память, пул соединений, сборщик мусора. У них высокое κ, и потолок наступает быстро.

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

Отсюда наблюдение, которое стоит проверить у себя: если добавление экземпляров перестало помогать, узкое место почти всегда не в них.

Как это использовать без академии

Модель полезна не тем, что её можно подогнать, а тем, что она задаёт правильный вопрос. Вместо «сколько воркеров поставить» — «что эти воркеры делят между собой».

Порядок, которым я иду:

  1. Снять пропускную способность при нескольких значениях параллелизма: 1, 2, 4, 8, 16, 32. Не два замера, а кривую — по двум точкам максимум не виден.
  2. Найти, где кривая перестаёт расти. Это и есть рабочий диапазон.
  3. Если максимум наступил раньше, чем ожидалось, искать общий ресурс. Он всегда есть; вопрос, какой именно.

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

Оговорка, без которой это вредный совет

Кривая, снятая на синтетической нагрузке, описывает синтетическую нагрузку. Реальный трафик неоднороден: часть запросов дешёвые, часть дорогие, и профиль меняется по часам.

Поэтому число из замера — не константа для конфигурации, а порядок величины. Если замер говорит «оптимум около двадцати», это значит «не сто», а не «ровно двадцать».

Когда всё это не нужно. Сервис с одним экземпляром и десятком запросов в минуту работает в точке, где обе поправки исчезающе малы. Разговор начинается там, где параллелизм уже увеличивали и результат оказался не тем, которого ждали.