Почему многопоточность иногда делает программу медленнее, чем один поток
Первое, что делает разработчик, когда сервис не успевает обрабатывать запросы — добавляет потоков или воркеров. Логика простая: было одно ядро, стало восемь, значит должно стать в восемь раз быстрее. На практике вы получаете код, который на четырёх потоках работает медленнее, чем на одном, а CPU при этом занят на 100%, но полезной работы делает меньше. Разберём, откуда берётся этот эффект и почему он не баг, а следствие того, как на самом деле устроены процессор и планировщик ОС.
Содержание
- Переключение контекста: у параллелизма есть накладные расходы
- Конкуренция за блокировки: потоки, которые ждут друг друга
- False sharing: когда кеш-линия делает независимые данные общими
- Закон Амдала: у параллелизма есть математический потолок
- Сколько потоков нужно на самом деле
- Как распознать, что дело именно в многопоточности
Переключение контекста: у параллелизма есть накладные расходы
Поток — не бесплатная абстракция. Когда ядро операционной системы переключает CPU с одного потока на другой, оно сохраняет регистры, указатель стека, состояние FPU/SIMD текущего потока и загружает состояние следующего. Это делает планировщик ядра (в Linux — CFS, постепенно заменяемый на EEVDF), и каждое такое переключение стоит времени процессора, которое не идёт на вашу задачу.
Хуже самой смены регистров обходится побочный эффект: переключение контекста почти всегда обнуляет часть содержимого кеша L1/L2 текущего ядра. Новый поток приходит на ядро "с холодным кешем" — первые обращения к памяти идут в основную память с задержкой на порядок выше. Если потоков больше, чем физических ядер, планировщик вынужден постоянно менять их местами, и кеш не успевает прогреться ни для одного из них.
Посмотреть число переключений контекста можно так:
# Общее число переключений контекста с момента загрузки
grep ctxt /proc/stat
# Переключения контекста для конкретного процесса
cat /proc/<PID>/status | grep ctxt_switches
# Живая статистика по системе, колонка cs
vmstat 1
Если колонка cs растёт пропорционально росту числа потоков, а throughput приложения не растёт или падает — это первый признак, что вы упёрлись в накладные расходы планировщика, а не в реальную вычислительную мощность. Чем больше в программе точек синхронизации, тем больше таких переключений — и тем ближе мы подходим к следующей причине замедления.
Конкуренция за блокировки: потоки, которые ждут друг друга
Большинство многопоточных программ обращаются к общим данным: счётчикам, кешам в памяти, пулам соединений, очередям задач. Чтобы два потока не записали в одну структуру одновременно, используются блокировки — мьютексы, спинлоки, семафоры. Проблема в том, что блокировка по определению сериализует доступ. Если критическая секция занимает заметное время, а к ней обращаются много потоков, реальный параллелизм схлопывается до последовательного выполнения именно этого участка — плюс расходы на очередь и futex-будильники для потоков, уснувших в ожидании.
pthread_mutex_t counter_lock;
long total_requests = 0;
void handle_request() {
do_actual_work(); // хорошо масштабируется на любое число потоков
pthread_mutex_lock(&counter_lock);
total_requests++; // маленькая, но частая точка синхронизации
pthread_mutex_unlock(&counter_lock);
}
По отдельности do_actual_work() масштабируется линейно. Но если запросов очень много, а мьютекс на инкремент общего счётчика вызывается на каждый из них, именно эта строка становится узким местом: чем больше потоков одновременно приходят к pthread_mutex_lock, тем больше времени они проводят не в полезной работе, а в очереди на вход в критическую секцию. В худшем случае прирост потоков не увеличивает, а уменьшает пропускную способность.
Найти такие места помогает профилировщик с поддержкой блокировок:
perf record -e sched:sched_switch -a -g -- sleep 10
perf report
strace -f -e trace=futex -p <PID>
Частые futex(FUTEX_WAIT) от разных потоков на одном адресе — это и есть конкуренция за блокировку. Решение обычно не в удалении синхронизации (это небезопасно), а в сокращении критической секции до минимума, использовании атомарных операций (std::atomic, __sync_fetch_and_add) вместо мьютекса там, где достаточно инкремента, или переходе на lock-free структуры там, где это оправдано сложностью.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверFalse sharing: когда кеш-линия делает независимые данные общими
Процессор кеширует память не побайтово, а блоками фиксированного размера — кеш-линиями, обычно по 64 байта на x86-64. Когерентность кеша между ядрами (протокол вроде MESI) отслеживает состояние кеш-линий целиком, а не отдельных переменных внутри них.
Представьте два потока на разных ядрах, каждый пишет только в свою переменную — логически данные не связаны. Но если переменные оказались рядом в памяти и попали в одну кеш-линию, каждая запись одного потока делает копию этой линии в кеше другого ядра невалидной. Процессор заново синхронизирует линию между ядрами при каждой записи — хотя программа вообще не делит данные между потоками.
// Классический false sharing: массив счётчиков "на поток"
struct {
long counter; // каждый поток пишет только в свой counter
} thread_stats[8]; // но соседние counter лежат в одной кеш-линии
// Лечится выравниванием каждого элемента на границу кеш-линии
struct {
long counter;
char padding[64 - sizeof(long)];
} __attribute__((aligned(64))) thread_stats[8];
Обнаружить false sharing без спецсредств сложно — программа проходит все тесты, логика корректна, но производительность масштабируется не линейно, а иногда падает при добавлении ядер. Помогает perf c2c (cache-to-cache), который ищет кеш-линии с высокой конкуренцией между ядрами:
perf c2c record -- ./your_program
perf c2c report
Практический вывод: структуры, к которым параллельно и часто пишут разные потоки (счётчики метрик "на воркер", буферы статистики, шарды хеш-таблиц), стоит выравнивать и дополнять так, чтобы независимые элементы физически не попадали в одну кеш-линию. Для read-only данных это неактуально — конкуренция возникает именно на записи.
Закон Амдала: у параллелизма есть математический потолок
Даже без блокировок и false sharing у ускорения от многопоточности есть жёсткий предел, сформулированный Джином Амдалом в 1967 году. В любой реальной программе есть часть работы, которую нельзя распараллелить принципиально — инициализация, чтение входных данных, сборка результата, работа с общим состоянием.
Ускорение(N) = 1 / ((1 − P) + P / N)
где P — доля работы, которую можно распараллелить, N — число потоков, а (1 − P) — неизбежно последовательная часть.
При N → ∞ ускорение стремится не к бесконечности, а к пределу 1 / (1 − P). Если 90% программы параллелится идеально, а 10% строго последовательны, теоретический максимум ускорения — 10x, сколько бы ядер вы ни добавили. Если последовательная доля — 20%, потолок падает до 5x. Это не оценка "на глаз" — это математическое следствие формулы; сама доля P для конкретного кода — то, что нужно измерить профилировщиком, а не предположить.
Практическое следствие: прежде чем добавлять потоки, стоит понять, какая часть нагрузки в принципе последовательна. Для веб-сервера это может быть запись в один файл лога без буферизации на каждый запрос — типичный "невидимый" последовательный участок: он выглядит мелочью, но именно он определяет предел масштабирования, потому что каждый поток рано или поздно в него упирается.
Есть более практичный родственник закона Амдала — закон Густафсона: если с ростом числа ядер растёт не только параллелизм, но и сам объём задачи (больше данных, больше запросов), эффективный потолок выглядит не так пессимистично. Жёсткий предел Амдала актуален прежде всего для фиксированного объёма работы.
Сколько потоков нужно на самом деле
Число потоков — не параметр "чем больше, тем лучше", а величина, которую нужно подбирать под характер нагрузки и число доступных ядер.
CPU-bound задачи (шифрование, сжатие, вычисления, кодирование медиа) — оптимум обычно близок к числу физических ядер, не логических: гиперпоточность здесь почти ничего не даёт, потому что оба логических потока делят одни и те же исполнительные блоки. Проверить топологию:
nproc --all # логические ядра
lscpu | grep -E "Core|Socket|Thread" # физические ядра, сокеты, SMT
IO-bound задачи (веб-сервер, работа с БД, сеть) — здесь число потоков может и должно превышать число ядер, потому что поток бо́льшую часть времени не грузит CPU, а ждёт ответа диска или сети. Конкретный множитель зависит от вашей нагрузки и подбирается нагрузочным тестированием, а не формулой.
Типичные отправные точки из документации популярного ПО — ориентиры для старта, не гарантированный оптимум для вашей нагрузки:
| Приложение | Параметр | Стартовый ориентир |
|---|---|---|
| nginx | worker_processes | auto (по числу ядер) |
| Gunicorn (Python/WSGI) | workers | (2 × ядра) + 1 — формула из документации Gunicorn |
| PostgreSQL | max_worker_processes | обычно не выше числа ядер сервера БД |
| Redis | — | однопоточная обработка команд по дизайну |
Redis сознательно однопоточен для обработки команд именно из-за проблем, описанных выше: авторы предпочли предсказуемую производительность без блокировок сложной многопоточной модели с конкуренцией за общие структуры. Это не значит, что многопоточность — плохая идея в принципе, это значит, что для класса задач "много быстрых операций над общей структурой в памяти" однопоточная модель с событийным циклом может быть быстрее корректно написанного многопоточного аналога — просто потому что не тратит время на синхронизацию вообще.
На арендованном сервере это напрямую влияет на конфигурацию: если у вас 4 виртуальных ядра, не имеет смысла запускать 32 воркера "на всякий случай" — это увеличит переключения контекста и конкуренцию за кеш, не добавив пропускной способности. Полезно прогнать нагрузочный тест с разным N и посмотреть, где throughput перестаёт расти — эта точка и есть практический потолок, часто заметно ниже теоретического числа ядер.
Если вы подбираете число воркеров под конкретное железо, учитывайте топологию CPU: на серверах с несколькими физическими процессорами добавляется ещё один фактор — NUMA-топология и разница в скорости доступа к памяти между процессорами, которая может свести на нет выигрыш от лишних потоков без правильной привязки к узлу. На виртуальных машинах стоит посмотреть на привязку vCPU к физическим ядрам через CPU pinning — она снижает случайные переключения потоков между ядрами хоста.
Как распознать, что дело именно в многопоточности
Прежде чем переписывать архитектуру, убедитесь, что причина падения производительности — накладные расходы параллелизма, а не что-то более прозаичное:
- Сравните throughput на разном числе потоков. Запустите одну нагрузку с 1, 2, 4, 8, 16 потоками и постройте график. Рост, выход на плато, затем снижение — это и есть практический предел масштабирования на конкретном железе.
- Смотрите на
csвvmstat 1во время нагрузки. Резкий рост без роста полезной работы — сигнал, что CPU уходит на переключения, а не на вычисления. - Проверьте
%syвtop/mpstat. Если системное время растёт вместе с числом потоков быстрее пользовательского (%us), значит ядро всё больше ресурса тратит на обслуживание параллелизма. - Профилируйте блокировки, а не только "горячие функции". Обычный CPU-профилировщик покажет, где программа тратит время в вычислениях, но не покажет, где потоки простаивают в ожидании — для этого нужны
perf sched,strace -e futexили метрики вашего рантайма. - Не забывайте про виртуализацию. На VPS часть CPU-времени может уходить не на переключения контекста в гостевой ОС, а на steal time — когда гипервизор отдаёт физическое ядро другому арендатору. Выглядит как падение производительности при росте потоков, но причина внешняя. Мы разбирали это в статье про steal time и то, как понять, что сосед по железу ест ваш процессор.
Бывает и обратная ситуация: один поток действительно не выжимает из железа всё, на что оно способно, и добавление потоков — верное решение. У современных NVMe-накопителей десятки аппаратных очередей команд, и один поток физически не успевает загрузить их все одновременно — здесь несколько потоков ввода-вывода дают честный прирост, потому что ограничение не в CPU-синхронизации, а в параллелизме самого устройства. Разница в том, что именно является узким местом: устройство, которое можно занять параллельно, или общий ресурс в памяти процесса, доступ к которому обязан быть последовательным.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если добавление потоков не помогает, стоит ли вообще использовать многопоточность?
Смотря для какой задачи. Для CPU-bound вычислений без общего состояния — почти всегда да, эффект близок к линейному до числа физических ядер. Для задач с интенсивной работой над общими структурами данных иногда честнее один поток с событийным циклом (как в Redis или nginx для сетевого ввода-вывода), чем многопоточная модель с постоянной синхронизацией.
Как понять долю последовательной части P для закона Амдала на своём коде?
Профилированием, а не догадкой. Замерьте время выполнения на одном потоке и на максимальном числе доступных ядер, посчитайте фактическое ускорение и через формулу Амдала вычислите P в обратную сторону — это даст честную оценку для вашей нагрузки на вашем железе.
Гиперпоточность считается за отдельные ядра при расчёте числа воркеров?
nproc покажет логические ядра, включая гиперпоточные, но для CPU-bound задач прирост от второго логического потока на одном физическом ядре обычно заметно меньше прироста от полноценного ядра — оба логических потока делят исполнительные блоки и кеш L1/L2. Ориентируйтесь на lscpu, где физические ядра и потоки на ядро показаны отдельно.
Спинлоки — это всегда быстрее мьютексов?
Нет, зависит от длины ожидания. Спинлок эффективен, когда блокировка удерживается очень короткое время — поток крутится в цикле проверки вместо ухода в сон, экономя на переключении контекста. Если критическая секция длинная, спинлок впустую сжигает CPU-время, тогда как мьютекс усыпит поток и освободит ядро для полезной работы.
Можно ли автоматически подобрать оптимальное число потоков без ручного тестирования?
Полностью автоматически нет — оптимум зависит от характера нагрузки, железа и доли последовательной работы, которые заранее не известны. Некоторые пулы потоков умеют адаптивно менять число воркеров под текущую нагрузку, но это снижает, а не убирает необходимость первоначального нагрузочного теста.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →