ТестированиеНагрузочное тестированиеИнженер по нагрузочному тестированию

Сценарий: при увеличении числа виртуальных пользователей пропускная способность почти перестаёт расти, p95 ...

Сценарий: при увеличении числа виртуальных пользователей пропускная способность почти перестаёт расти, p95 задержки увеличивается, а загрузка CPU остаётся низкой. Как механизм, показанный в фрагменте, объясняет такое поведение?

workers = 100
for request in requests:
    with global_lock:
        result = update_shared_state(request)
    response = handle(request, result)
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Основной вывод: узким местом может быть сериализация доступа к общему состоянию. Несмотря на низкую загрузку CPU, потоки конкурируют за global_lock, поэтому одновременно выполняется только ограниченное число критических секций; новые запросы накапливаются в очереди, задержка растёт, а пропускная способность выходит на плато.

Исторический контекст

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

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

Постановка проблемы

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

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

Подробное решение

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

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

Для подтверждения гипотезы нужно измерить не только CPU и задержку, но и время ожидания блокировки, число конкурирующих потоков, длительность критической секции и распределение задержек. Профилировщик или трассировка должны показать, что значительная часть времени проводится до входа в update_shared_state, а не внутри последующей обработки.

wait = now() acquire(global_lock) lock_wait = now() - wait result = update_shared_state(request) release(global_lock) record("lock_wait", lock_wait)

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

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

Ситуация из практики

Сервис подсчитывал агрегаты по запросам, и нагрузочный тест показал плато на 2 000 операций в секунду. Вариант с увеличением числа рабочих потоков не помог: он усилил конкуренцию за общий объект и немного ухудшил p99.

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

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

Что кандидаты часто упускают

  1. Как отличить конкуренцию за блокировку от нехватки CPU?

    Нужно сопоставить загрузку CPU с профилем ожиданий. При нехватке CPU обычно растёт время выполнения на процессоре, очереди планировщика и утилизация вычислительных ресурсов. При конкуренции за блокировку заметны длительные периоды ожидания, низкая или умеренная загрузка CPU и высокая доля времени вне критической секции; окончательное подтверждение даёт профилировщик блокировок или трассировка.

  2. Почему увеличение числа экземпляров сервиса может не устранить проблему?

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

  3. Почему средняя задержка может скрыть проблему с блокировкой?

    Небольшая средняя задержка возможна, если большинство запросов проходит до начала очереди, а меньшая часть попадает в длинное ожидание. В таком случае p95 или p99, максимальная глубина очереди и время ожидания блокировки покажут деградацию значительно раньше среднего значения. Для оценки SLA следует анализировать распределение задержек по этапам, а не только одну агрегированную метрику.