Тот, кто искал причину скачков 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

Порядок эксперимента такой:

  1. Запустить lockbench без фоновой нагрузки и записать P50, P99, P99.9 и максимум как базовую линию;
  2. Включить восемь вычислительных нагрузок на тех же четырёх ядрах и повторить измерение;
  3. Параллельно запустить трассировку bpftrace и получить гистограмму задержки между пробуждением и запуском потока;
  4. Сопоставить максимум ожидания блокировки из программы с максимумом задержки планирования из гистограммы: если они близки, потери даёт именно LWP, а не долгое удержание блокировки;
  5. Повторить все шаги под обычным планировщиком и под планировщиком sched_ext, не меняя больше ничего;
  6. Менять число конкурирующих задач (2, 4, 8, 16) и построить график P99 от их числа, чтобы найти точку излома.

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