Тот, кто искал причину скачков P99 в многопоточном сервере, знает это неприятное состояние: процессор загружен не до предела, блокировка занята на микросекунды, а запрос всё равно тянется миллисекундами. Графики не показывают явного виновника. Среднее значение в норме, а хвост распухает.
Седьмого октября 2026 года в Праге, на конференции Linux Plumbers Conference, в секции sched_ext прошёл восемнадцатиминутный доклад Changwoo Min. Он описывает именно такие случаи и вводит термин lock waiter preemption, сокращённо LWP. Автор приводит измерения для рабочих игровых и серверных нагрузок: разбуженный поток, ожидавший блокировку, получал процессор лишь через 2-16 миллисекунд. Это доклад с измерениями и прототипом, а не готовое универсальное решение и не обещание конкретного процента ускорения.
Вытеснение владельца блокировки растягивает критическую секцию и тянет за собой весь хвост задержек
Начать стоит с классической проблемы, которую называют lock holder preemption, или LHP. Поток захватил блокировку, начал работу в критической секции, но планировщик снял его с процессора, чтобы дать время другому. Остальные потоки, которым нужна та же блокировка, ждать вынуждены уже не микросекунды, а целый квант чужой работы. Критическая секция, по своей природе последовательная, растягивается, и вместе с ней растягивается очередь.
Аналогия простая. Это окошко, к которому выстроилась очередь, а кассир в самом разгаре обслуживания вдруг уходит на пять минут по другому делу. Окошко занято, очередь стоит, и никакая скорость остальных сотрудников этого не исправит.
Автор доклада отмечает, что результат выглядит одинаково во многих местах. На серверах падает пропускная способность и вырастает задержка на уровне P99. В играх появляются скачки времени кадра и пропущенные кадры. Причём это относится и к блокировкам внутри ядра Linux, и к пользовательским примитивам синхронизации, построенным на фьютексах и семафорах SysV.
Известные приёмы уже существуют, но у каждого есть своя область действия. Об этом подробнее ниже, а пока важно усвоить главное: вытеснение владельца блокировки измеряется не в доле процента, а в десятках и сотнях раз от длительности самой секции.
Разбуженный ожидающий поток тоже может прождать процессор от 2 до 16 миллисекунд, и этот эффект назван LWP
Есть вторая сторона той же медали, и о ней говорят заметно реже. Допустим, владелец блокировки закончил работу и освободил её. Тот, кто ждал, получает сигнал пробуждения и становится готовым к выполнению. Казалось бы, дальше он сразу побежит. Но процессоры могут быть заняты другими задачами, и разбуженный поток встаёт в очередь на запуск. Всё это время блокировка уже свободна, а работа не идёт: тот, кому она нужна, просто не получил процессор.
Автор называет такое явление lock waiter preemption и определяет его как двойник LHP: это задержка планирования, которую разбуженный ожидающий поток испытывает перед тем, как начать выполнение. Измерения в рабочих игровых и серверных нагрузках дают диапазон 2-16 миллисекунд. Для сравнения: сама критическая секция в хорошо написанном коде занимает единицы или десятки микросекунд. Разница составляет два-три порядка.
Откуда берётся именно такой порядок, подсказывает простая арифметика. Если на процессоре крутятся k вычислительных задач и каждая вправе занять его на квант s, разбуженный поток в худшем случае ждёт около k × s. При двух задачах и кванте в 1 мс это 2 мс. При четырёх задачах и кванте в 4 мс это уже 16 мс. Конкретные причины в измеренных нагрузках автор раскрывает сам, а эта оценка лишь объясняет, почему миллисекунды выглядят правдоподобно: хватает небольшой очереди и привычных квантов.
Принципиальный момент в том, что разбуженный поток нельзя обвинить в медлительности. Он работает быстро. Он просто не работает вообще, пока не доберётся до очереди. Поэтому профилировщик, измеряющий время внутри критической секции, ничего не заметит: задержка накапливается снаружи, между пробуждением и фактическим запуском.
Обычный фьютекс не хранит владельца, поэтому proxy execution и продление кванта закрывают не все случаи
Чтобы понять, почему уже известные приёмы не решают задачу целиком, стоит вспомнить, как устроен фьютекс. Это механизм ядра Linux, на котором держатся мьютексы и семафоры в пользовательских программах. Вся хитрость в быстром пути: когда блокировка свободна, поток захватывает её атомарной операцией над целым числом в обычной памяти, и в ядро вообще не заходит. У стандартной блокировки в glibc такое число принимает три значения: 0 означает свободно, 1 означает занято без ожидающих, 2 означает занято, и есть желающие.
Если же блокировка занята, поток вызывает FUTEX_WAIT и засыпает в ядре. Освобождающий поток, увидев значение 2, вызывает FUTEX_WAKE, и один из спящих становится готовым. Важно, что пробуждение не вручает блокировку, а лишь возвращает ожидающего в игру. К моменту, когда он получит процессор, блокировку может перехватить другой, более проворный поток, и тогда всё начнётся заново.
Теперь о том, почему известные средства работают не везде. В описании доклада указано, что proxy execution решает инверсию приоритетов для мьютексов ядра, но не затрагивает пользовательских ожидающих фьютексов и счётных семафоров. Причина понятна из устройства: обычный фьютекс не знает, кто его владелец, так как значение лежит в памяти пользователя, а идентификатор владельца записывается только у фьютексов с наследованием приоритета. У счётного семафора единственного владельца нет вовсе, поэтому передавать приоритет попросту некому.
Продление кванта владельца устроено иначе: если планировщик заметил, что поток держит блокировку, он даёт ему дорабатывать секцию, не снимая с процессора. Как указано в описании, этот приём защищает обнаруженного владельца, но не помогает отстающему ожидающему. То есть LHP смягчается, а LWP остаётся нетронутым. Именно эту дыру прототип из доклада пытается закрыть.
Арифметика кадров и очередей показывает, во что превращаются лишние миллисекунды ожидания
Миллисекунды стоит перевести в ощутимые вещи. Первый пример: игры. При частоте 60 кадров в секунду на кадр отводится 1000 / 60, то есть около 16,7 мс. Задержка в 2 мс отнимает 12% этого бюджета, а в 16 мс уже 96%, то есть почти весь кадр. При 144 кадрах в секунду бюджет составляет около 6,9 мс, и тогда 2 мс съедают 29%, а 16 мс превышают его более чем вдвое. Достаточно, чтобы такое случилось раз в несколько секунд, и игрок увидит рывок, который не объяснить ни слабым железом, ни загрузкой процессора.
Дальше сервер. Пусть типичный запрос обслуживается за 200 мкс, а 1,5% запросов упираются в LWP с задержкой в 2 мс. Среднее значение вырастет всего на 0,015 × 2000 = 30 мкс, то есть на 15%. На дашборде это незаметно. Зато P99 попадёт на хвост из пострадавших запросов и составит порядка 2,2 мс, а это в 11 раз выше нормы. Среднее здесь молчит, а клиентский тайм-аут срабатывает.
Наконец, пропускная способность. Пусть критическая секция длится 20 мкс. Если каждый новый владелец получает блокировку сразу, потолок равен 1 / 20 мкс = 50 000 операций в секунду. Но предположим худший случай, когда каждая передача блокировки требует пробуждения потока с задержкой D. Тогда потолок падает до 1 / (20 мкс + D). При D = 2 мс получается около 495 операций в секунду, то есть примерно в 100 раз меньше. При D = 16 мс выходит около 62 операций в секунду, то есть примерно в 800 раз меньше. Это оценка сверху для строгой цепочки передач, в реальной системе блокировку нередко перехватывает работающий поток, и картина мягче. Но порядок величин показывает, почему такие задержки нельзя считать мелочью.
Три открытых вопроса доклада касаются BPF, взаимодействия с libc и лёгких блокировок с помощью планировщика
В конце доклад выносит на обсуждение три задачи. Прототип автор собрал в рабочем планировщике sched_ext, и именно на нём стали видны трудности. Итоговых ответов в описании нет, поэтому ниже идут только постановки с пояснениями, почему они непростые.
Первая задача: эффективно распознавать вытеснение, связанное с блокировками, из программ BPF. Сложность в том, что быстрый путь фьютекса не делает системных вызовов, и планировщик ничего не видит. Он замечает только вызовы FUTEX_WAIT и FUTEX_WAKE, то есть медленный путь. Кто сейчас держит блокировку, лежит в памяти пользователя, а чтение чужой памяти из BPF на горячем пути дорого и не всегда безопасно.
Вторая задача: передавать состояние синхронизации между libc и sched_ext. Идея естественная. Библиотека знает, что поток входит в критическую секцию, и могла бы сообщить об этом планировщику. Вопрос в формате и цене такого сообщения. Общая область памяти, как у механизма rseq, обходится дёшево, но требует договорённости о структуре. Системный вызов на каждый захват убил бы смысл быстрого пути.
Третья задача звучит так: сделать лёгкие блокировки с помощью планировщика, которые улучшают задержку и не теряют масштабируемости. Здесь два интереса тянут в разные стороны. Чем больше планировщик вмешивается, тем лучше хвост, но тем выше накладные расходы и риск замедлить систему с сотнями ядер. Золотой середины заранее не видно, поэтому тема и вынесена на обсуждение сообщества.
Временная диаграмма пробуждения и запуска потока отделяет ожидание блокировки от задержки планирования
Говорить о LWP можно сколько угодно, но убедиться на собственной машине полезнее. Для этого нужно разделить время, которое поток проводит в ожидании, на две части. Первая часть: блокировка ещё занята, и поток законно спит. Вторая часть: блокировка уже освобождена, поток разбужен, но процессора не получил. Общая задержка равна сумме этих частей, а LWP отвечает только за вторую.
Тестовая программа на C создаёт четыре потока, которые по очереди берут один мьютекс, удерживают его около 20 мкс и затем засыпают на 100 мкс. Каждый поток замеряет, сколько времени заняло получение блокировки, а в конце программа печатает процентили:
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#define THREADS 4
#define ITERS 20000
static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static long long lat[THREADS][ITERS];
static long long now_ns(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1000000000LL + t.tv_nsec;
}
static void spin_us(int us)
{
long long end = now_ns() + us * 1000LL;
while (now_ns() < end)
;
}
static void *worker(void *arg)
{
long id = (long)arg;
for (int i = 0; i < ITERS; i++) {
long long t0 = now_ns();
pthread_mutex_lock(&m);
lat[id][i] = now_ns() - t0;
spin_us(20);
pthread_mutex_unlock(&m);
usleep(100);
}
return NULL;
}
static int cmp(const void *a, const void *b)
{
long long x = *(const long long *)a, y = *(const long long *)b;
return (x > y) - (x < y);
}
int main(void)
{
static long long all[THREADS * ITERS];
pthread_t th[THREADS];
for (long i = 0; i < THREADS; i++)
pthread_create(&th[i], NULL, worker, (void *)i);
for (int i = 0; i < THREADS; i++)
pthread_join(th[i], NULL);
for (int t = 0; t < THREADS; t++)
for (int i = 0; i < ITERS; i++)
all[t * ITERS + i] = lat[t][i];
qsort(all, THREADS * ITERS, sizeof(long long), cmp);
int n = THREADS * ITERS;
printf("P50 %lld us, P99 %lld us, P99.9 %lld us, max %lld us\n",
all[n / 2] / 1000, all[n * 99 / 100] / 1000,
all[n * 999 / 1000] / 1000, all[n - 1] / 1000);
return 0;
}
Сборка командой gcc -O2 -pthread -o lockbench lockbench.c. Чтобы создать очередь на процессор, рядом запускаются вычислительные нагрузки на тех же четырёх ядрах, восемь штук на девяносто секунд:
for i in 1 2 3 4 5 6 7 8; do
taskset -c 0-3 timeout 90 sh -c 'while :; do :; done' &
done
taskset -c 0-3 ./lockbench
Вторая часть замера делается трассировкой. Скрипт bpftrace запоминает, что поток вошёл в ожидание фьютекса, фиксирует момент пробуждения и считает, сколько времени прошло до фактического запуска:
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_futex
/(args->op & 127) == 0 || (args->op & 127) == 9/ { @infutex[tid] = 1; }
tracepoint:sched:sched_wakeup /@infutex[args->pid]/ { @ts[args->pid] = nsecs; }
tracepoint:sched:sched_switch /@ts[args->next_pid]/ {
@runq_us = hist((nsecs - @ts[args->next_pid]) / 1000);
delete(@ts[args->next_pid]);
}
tracepoint:syscalls:sys_exit_futex /@infutex[tid]/ { delete(@infutex[tid]); }
interval:s:30 { exit(); }'
Значения 0 и 9 в условии соответствуют операциям FUTEX_WAIT и FUTEX_WAIT_BITSET, а маска 127 отбрасывает флаг приватности. Фильтр по состоянию в фьютексе нужен, чтобы в гистограмму не попали пробуждения от usleep. Для общей картины пригодится и штатный инструмент perf:
sudo perf sched record -- taskset -c 0-3 ./lockbench
sudo perf sched latency --sort max | head -20
Порядок эксперимента такой:
- Запустить lockbench без фоновой нагрузки и записать P50, P99, P99.9 и максимум как базовую линию;
- Включить восемь вычислительных нагрузок на тех же четырёх ядрах и повторить измерение;
- Параллельно запустить трассировку bpftrace и получить гистограмму задержки между пробуждением и запуском потока;
- Сопоставить максимум ожидания блокировки из программы с максимумом задержки планирования из гистограммы: если они близки, потери даёт именно LWP, а не долгое удержание блокировки;
- Повторить все шаги под обычным планировщиком и под планировщиком sched_ext, не меняя больше ничего;
- Менять число конкурирующих задач (2, 4, 8, 16) и построить график P99 от их числа, чтобы найти точку излома.
Читать результат лучше всего по хвосту. Если гистограмма разбуженных потоков показывает пик в районе миллисекунд, а максимум ожидания в программе совпадает с ним, налицо тот самый эффект. Если же задержка планирования мала, а ожидание блокировки велико, вина лежит на удержании блокировки, то есть на LHP или просто на длинной критической секции. Один стенд на одной машине не доказывает, что цифры повторятся на другой: число процессоров, квант и политика планировщика меняют картину. Но разложение общей задержки на две части отвечает на вопрос, который обычный профилировщик оставляет без ответа: где именно потерялись миллисекунды, пока поток считался готовым к работе.