MAATRIX / Блог / Что такое spinlock и почему сервер бывает занят на 100%, ничего не делая

Что такое spinlock и почему сервер бывает занят на 100%, ничего не делая

MAATRIX

Вы открываете top, видите процесс с 100% CPU и первая мысль — «он что-то интенсивно считает». Заходите глубже: запросов почти нет, диск не читает, сеть простаивает, а ядро всё равно горит. Это не всегда «завис» и не всегда утечка ресурсов в привычном смысле. Часто это spinlock — блокировка, которая ждёт не засыпая, а крутясь в цикле. И понимание разницы между «CPU занят работой» и «CPU занят ожиданием» экономит часы диагностики.

Что такое spinlock и чем он отличается от mutex

И spinlock, и mutex решают одну и ту же задачу — не пустить два потока одновременно в критическую секцию (участок кода, который трогает общие данные и не терпит параллельного доступа). Разница в том, что происходит с потоком, который блокировку не получил.

Mutex (от mutual exclusion) устроен через операционную систему. Поток, не получивший блокировку, вызывает системный вызов, который переводит его в состояние ожидания (в Linux — состояние S, прерываемый сон). Ядро снимает поток с процессора, отдаёт CPU кому-то другому, а когда владелец блокировки её освобождает — будит ожидающий поток обратно. Пока поток спит, он не потребляет ни такта процессора.

Spinlock устроен иначе. Поток, не получивший блокировку, не уходит в сон, а в цикле проверяет условие: «свободна ли блокировка? нет — проверить ещё раз». Псевдокод примерно такой:

while (atomic_test_and_set(&lock) == LOCKED) {
    /* крутимся, ничего не делаем, просто проверяем снова */
}
/* блокировка наша — заходим в критическую секцию */

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

Ключевое отличие для диагностики: ожидание mutex не видно в загрузке CPU (поток спит, счётчик простаивает), а ожидание spinlock выглядит ровно как полезная работа — ядро процессора занято на 100%, хотя реальной пользы от этого ноль.

Почему поток, который ничего не делает, жрёт 100% CPU

Здесь кроется главная путаница при чтении метрик. top, htop, mpstat и большинство мониторингов считают время CPU по тому, выполняет ли ядро процессора инструкции прямо сейчас — им всё равно, полезные это инструкции или холостой цикл проверки флага.

Поток в spinlock-цикле формально не простаивает: процессор выбирает следующую инструкцию, декодирует её, выполняет сравнение, делает условный переход — и так миллионы раз в секунду. Для планировщика ОС и для счётчиков user/system времени это неотличимо от «настоящей» вычислительной нагрузки. Разница видна только на уровне того, *что именно* делает код — а это уже требует профилирования, а не просто взгляда на проценты в top.

Отсюда типичная картина инцидента: нагрузка на CPU 100%, а RPS (запросов в секунду) падает или стоит на месте, полезная работа не растёт. Один или несколько потоков не двигают бизнес-логику вперёд — они молотят вхолостую, ожидая освобождения блокировки, которую держит кто-то другой (а тот, в свою очередь, может ждать диск, сеть или ту же блокировку с другой стороны).

Важный нюанс: если владелец блокировки в этот момент вытеснен планировщиком (ждёт своей очереди в run queue), а его место в ядре занял поток со spinlock-ожиданием, ситуация усугубляется вдвойне — владелец не может отпустить блокировку, потому что не выполняется, а spinlock-поток тем временем сжигает ядро впустую. Такой эффект называют lock holder preemption, и он особенно заметен в виртуальных машинах — гипервизор может вытеснить vCPU ровно в момент удержания блокировки. Если на сервере параллельно растёт steal time, стоит проверить, не с этим ли эффектом связана нагрузка — подробнее об этом в статье про steal time и то, как понять, что сосед ест ваш CPU.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Когда spinlock — это правильное решение

Spinlock — не баг и не признак плохого кода. Это осознанный компромисс, который в определённых условиях выигрывает у mutex.

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

Именно поэтому spinlock — стандартный инструмент внутри ядра операционной системы и в низкоуровневых частях рантаймов:

  • Ядро Linux использует spinlock (и его более развитые варианты — очередные/queued-spinlock) для защиты структур данных, к которым может обратиться обработчик прерывания — а из контекста прерывания уснуть просто нельзя, там нет планировщика в привычном смысле.
  • Многие реализации мьютексов в языках и рантаймах на самом деле адаптивные: сначала поток недолго спинится (вдруг блокировку вот-вот отпустят), и только если это не помогло — уходит в настоящий сон через системный вызов. Так устроены, например, мьютексы в некоторых рантаймах пользовательского пространства поверх futex в Linux.
  • Блокировки уровня «структура данных в памяти внутри одного процесса», где вероятность длительного удержания крайне мала (счётчики ссылок, короткие критические секции в аллокаторах памяти).

Условие простое: чем короче и предсказуемее критическая секция и чем больше ядер процессора реально свободно для того, чтобы «покрутиться» без вреда для остальных, тем оправданнее spinlock.

Когда spinlock превращается в проблему

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

Несколько сценариев, где spinlock из оптимизации превращается в источник проблемы:

  • Внутри критической секции происходит что-то тяжёлое: аллокация памяти, обращение к диску, сетевой вызов, ожидание другой блокировки. Секция, которая должна была занимать наносекунды, внезапно занимает микро- или миллисекунды — а остальные потоки в это время крутятся вхолостую.
  • Много потоков претендуют на одну блокировку одновременно (high contention). Даже если каждое удержание короткое, при десятках потоков суммарное время ожидания растёт нелинейно — и вместе с ним растёт впустую сожжённый CPU.
  • Виртуализация и переподписка CPU (overcommit). Если гипервизор вытесняет vCPU, который держит spinlock, ожидающие потоки на других vCPU крутятся до тех пор, пока хозяин блокировки снова не получит физическое ядро — это может занять значительно дольше, чем предполагал автор кода.
  • NUMA-эффекты: если поток постоянно перечитывает переменную блокировки из памяти на «дальнем» узле NUMA, каждая итерация цикла стоит дороже, и нагрузка на межпроцессорную шину растёт вместе с числом спинящих ядер.

Итог во всех случаях один: CPU показывает 100%, полезная работа не растёт, а иногда даже деградирует — чем больше потоков крутится в ожидании одной блокировки, тем больше ресурсов уходит на бесполезные проверки, а не на её разрешение.

Где это реально встречается на практике

Знать теорию полезно, но узнать спинлок в дикой природе — отдельный навык. Несколько мест, где инженер чаще всего сталкивается с этим эффектом:

Внутри ядра Linux. Обработчики прерываний и код, выполняющийся с отключёнными прерываниями, используют spinlock, потому что уснуть там нельзя в принципе. Если такая блокировка окажется удержана дольше расчётного времени, это проявляется как всплеск времени в состоянии system и рост задержек по всей системе — с сетевыми картами и прерываниями похожая картина разбирается в статье про прерывания и то, почему сетевая карта отбирает у вас целое ядро.

Базы данных. Внутренние структуры для защиты общих буферов, счётчиков блокировок, списков соединений во многих СУБД защищены короткими spin-based примитивами (в PostgreSQL это исторически называется spinlock/LWLock на низком уровне). При росте числа одновременных соединений и высокой конкуренции за одни и те же строки или индексы это может проявляться как непропорциональный рост CPU относительно фактической полезной нагрузки на базу.

Рантаймы языков программирования. Мьютексы и каналы во многих современных языках реализованы как адаптивные блокировки: короткая фаза активного ожидания (spin), затем переход к «настоящему» сну через futex. Если критическая секция внутри такого мьютекса оказывается длиннее, чем рассчитывал рантайм (например, синхронный сетевой вызов внутри блокировки, которая должна была защищать только доступ к map), фаза спина начинает съедать заметное время CPU впустую.

Приложения с ручной синхронизацией. Если разработчик реализовал собственный spinlock (высокопроизводительные очереди сообщений, лок-фри структуры данных с fallback на спин), а нагрузочный профиль на проде оказался не тем, что закладывался при проектировании, эффект проявляется так же — это частный случай более общей проблемы — почему многопоточность иногда делает программу медленнее, чем один поток, когда накладные расходы на синхронизацию перевешивают выигрыш от параллелизма.

Как диагностировать «CPU 100%, а толку ноль»

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

Порядок действий, который работает в большинстве случаев:

  1. Подтвердить, что нагрузка реальная, а не мнимая. Сравните загрузку CPU с фактическим уровнем полезной работы — RPS, транзакциями в секунду, числом обработанных задач. Если CPU высокий, а пропускная способность не растёт или падает — это сигнал присмотреться к блокировкам.
  1. Посмотреть распределение по ядрам, а не только средний показатель:
mpstat -P ALL 1

Если несколько ядер стабильно сидят в %usr или %sys около 100%, а число реально работающих потоков заметно больше числа физических ядер — вероятность spin-ожидания растёт.

  1. Профилировать через perf, а не гадать, где именно горит CPU:
sudo perf top -p <PID>

Спин-цикл в профиле выглядит характерно: горячая функция вроде spin_lock, pthread_spin_lock, __lll_lock_wait, futex_wait (в фазе спина) или собственный короткий цикл сравнения-обмена (compare-and-swap) наверху профиля, занимающий несоразмерно большую долю времени относительно объёма реальной логики.

  1. Посмотреть состояния потоков. Поток в spin-цикле в выводе ps -eLo будет в состоянии R (running/runnable) — в отличие от потока, ожидающего mutex через системный вызов, который будет в состоянии S (sleeping) или D (uninterruptible sleep, если ждёт ввод-вывод):
ps -eLo pid,tid,stat,pcpu,comm | grep <имя-процесса>

Много потоков в состоянии R при неизменной суммарной полезной работе процесса — прямое указание на активное ожидание, а не на вычисления.

  1. Проверить количество переключений контекста. При спин-ожидании оно парадоксально *ниже*, чем можно было бы ожидать при той же загрузке CPU через обычные блокировки — поток не уходит в сон и не пробуждается заново, он просто продолжает выполняться на своём ядре:
vmstat 1
pidstat -w -p <PID> 1
  1. Если приложение под виртуализацией — проверить steal time. Высокий %steal вместе с признаками spin-ожидания почти наверняка означает эффект lock holder preemption: гипервизор забирает физическое ядро у владельца блокировки, а остальные vCPU крутятся вхолостую.

Практические выводы для эксплуатации

Из всего вышесказанного следуют несколько рабочих правил, которые стоит держать в голове, когда встречаете «CPU 100%, а толку ноль»:

  • Не судите о характере нагрузки по одной цифре загрузки CPU. 100% в top может означать как реальные вычисления, так и активное ожидание — сопоставляйте загрузку с фактической полезной пропускной способностью системы.
  • Для собственного кода с ручной синхронизацией используйте spinlock только там, где вы точно знаете верхнюю границу времени удержания и она мала. Если внутри критической секции возможен системный вызов, обращение к диску или сети — это кандидат на обычный mutex, а не на spin.
  • На виртуальных машинах будьте внимательнее к overcommit CPU. Если хост-гипервизор агрессивно переподписывает физические ядра, любой код со spin-ожиданием внутри гостевой ОС становится уязвим к вытеснению владельца блокировки. Для нагрузок, чувствительных к задержкам синхронизации, разумнее закладывать запас по физическим ядрам, чем рассчитывать на плотный overcommit — поэтому для баз данных и сервисов с интенсивной внутренней синхронизацией часто выбирают выделенные, а не сильно переподписанные ядра.
  • При настройке баз данных следите за метриками ожидания на блокировках (в PostgreSQL — pg_stat_activity с состоянием wait_event, в других СУБД — их аналоги), а не только за загрузкой CPU в целом. Рост конкуренции за одни и те же блокировки — частая причина, когда добавление ядер не даёт ожидаемого прироста производительности.
  • При переносе нагрузки на новое железо перепроверяйте профиль CPU заново — число физических ядер и уровень overcommit напрямую влияют на то, останется ли spinlock быстрым решением или станет источником паразитной нагрузки.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если CPU занят на 100%, но нагрузка на сайт низкая — это точно spinlock?

Не обязательно. Похожую картину даёт бесконечный цикл без синхронизации, busy-wait в опросе (polling) без задержки или банальная утечка, из-за которой поток крутится по кругу без реальной блокировки. Spinlock — одна из вероятных причин, но подтвердить её можно только профилированием (perf top, состояния потоков), а не одной метрикой загрузки CPU.

Можно ли просто заменить все spinlock на mutex, чтобы не терять CPU впустую?

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

Почему в контейнере с ограничением по CPU (cgroup) проблема spinlock ощущается острее?

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

Спинлок — это признак плохо написанного кода?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →