Добавить в корзинуПозвонить
Найти в Дзене
Цифровая Переплавка

🔐 Lock Contention: невидимый тормоз в высоконагруженных системах

Иногда, чтобы ускорить систему, нужно не добавлять ресурсы, а убрать препятствия. Так случилось с командой Tinybird, которая столкнулась с таинственной проблемой, растянувшейся на целый год. Представьте себе: ваш сервер почти не нагружен, CPU едва «шевелится», но система работает медленно. Странно? Добро пожаловать в мир Lock Contention. Недавно разработчик ClickHouse Максим Кита выступил с докладом на конференции C++ Russia 2025 и подробно разобрал, как он боролся с таким скрытым ограничением производительности, как спор за блокировки. Lock contention (борьба за блокировку) возникает тогда, когда много потоков одновременно пытаются захватить одну и ту же блокировку (mutex). В результате большая часть времени тратится не на полезную работу, а на ожидание освобождения ресурса. Чем больше конкуренция за ресурс, тем больше простоя потоков и ниже производительность системы. Признаки проблемы: В ClickHouse подобный сценарий привёл к почти годичному «детективу», где единственной зацепкой был
Оглавление

Иногда, чтобы ускорить систему, нужно не добавлять ресурсы, а убрать препятствия. Так случилось с командой Tinybird, которая столкнулась с таинственной проблемой, растянувшейся на целый год. Представьте себе: ваш сервер почти не нагружен, CPU едва «шевелится», но система работает медленно. Странно? Добро пожаловать в мир Lock Contention.

Недавно разработчик ClickHouse Максим Кита выступил с докладом на конференции C++ Russia 2025 и подробно разобрал, как он боролся с таким скрытым ограничением производительности, как спор за блокировки.

🚨 Что такое Lock Contention и почему это важно?

Lock contention (борьба за блокировку) возникает тогда, когда много потоков одновременно пытаются захватить одну и ту же блокировку (mutex). В результате большая часть времени тратится не на полезную работу, а на ожидание освобождения ресурса. Чем больше конкуренция за ресурс, тем больше простоя потоков и ниже производительность системы.

Признаки проблемы:

  • 📉 CPU простаивает, хотя есть нагрузка.
  • ⏳ Система отвечает медленно при росте числа параллельных запросов.
  • 🐞 Нет очевидных узких мест (память, сеть, диск свободны).

В ClickHouse подобный сценарий привёл к почти годичному «детективу», где единственной зацепкой был периодически всплывающий метрика ContextLockWait.

🕵️‍♂️ Как обнаружить проблему?

Изначально было подозрение, что причина в неэффективном использовании блокировки Context. Чтобы подтвердить гипотезу, разработчик периодически собирал дампы стека потоков прямо на живом сервере:

SELECT thread_name, arrayStringConcat(arrayMap(x -> demangle(addressToSymbol(x)), trace), '\n') AS stack_trace
FROM system.stack_trace FORMAT Vertical;

📌 Что это дало:
Выяснилось, что многие потоки ждут доступа к методам класса Context, например Context::getSettings(). После анализа стало понятно: один глобальный mutex создаёт огромную очередь.

⚙️ Технические детали решения

До исправления архитектура была такой:

  • 🔗 Один глобальный mutex управлял всеми ресурсами, включая глобальные и локальные настройки.
  • 📌 Это приводило к блокировке потоков даже при чтении локальных настроек.

Новая архитектура:

  • 🔀 Вместо одного mutex использовали два типа read-write mutex (shared_mutex):
    🟢 Один глобальный read-write mutex (для редко изменяемых данных, таких как пути и конфигурация).
    🔵 Отдельные локальные read-write mutex'ы для каждого объекта Context.

Использование shared_mutex позволило одновременно многим потокам читать данные без конфликтов, и лишь иногда блокировать запись. Переход на эту модель снизил контеншн в десятки раз!

Также при инициализации редко изменяемых объектов применили паттерн std::call_once, чтобы вообще исключить блокировки после первого вызова.

🧪 Анализ потоковой безопасности (Thread Safety Analysis)

Для гарантии корректности разработчики использовали инструмент Clang Thread Safety Analysis, позволяющий отследить проблемы синхронизации ещё на этапе компиляции. Это позволило избежать множества потенциальных ошибок при разделении mutex'ов.

Пример использования:

class ContextSharedPart {
mutable ContextSharedMutex mutex;
String path GUARDED_BY(mutex);

public:
String getPath() const {
SharedLockGuard lock(mutex); // shared-блокировка для чтения
return path;
}
};

📌 Что это дало:
Разработчики смогли убедиться, что после разделения на несколько mutex'ов новые проблемы синхронизации не возникли.

🚀 Как изменилась производительность?

После внедрения изменений были проведены замеры с реальной нагрузкой:

  • 📈 Производительность выросла в 3 раза по количеству запросов в секунду.
  • 💻 Использование CPU выросло с 20% до 60%.
  • 🕒 Медианное время выполнения запросов снизилось в 2 раза.
  • 🐢 Самые медленные запросы ускорились в 12 раз.

При дальнейшем увеличении конкурентности (до 1000 запросов одновременно) CPU использовался уже почти на 100%, что подтвердило полное устранение bottleneck'а в виде Lock Contention.

🔍 Личный взгляд автора

Проблема Lock Contention — коварная, но очень важная в современном мире многоядерных процессоров и многопоточных приложений. Часто её легко не заметить, поскольку привычные инструменты мониторинга показывают, что ресурсы свободны. Именно поэтому важно внедрять в приложения дополнительные метрики и инструменты профилирования.

Лично меня впечатлил подход ClickHouse с использованием статического анализа потоковой безопасности (Thread Safety Analysis). Это отличный пример того, как компилятор может помочь обнаружить проблемы на самых ранних этапах, а не на продакшене, когда всё уже сломано.

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

🌐 Ссылки и дополнительные материалы:

🔗 Оригинальная статья Максима Киты «Lock Contention»
🔗
Pull Request с реализацией нового дизайна блокировок в ClickHouse