Баг в ядре linux: waitpid(WNOHANG) еще как HANG
Наткнулся на баг в планировщике Linux. Чтобы его воспроизвести, должны сойтись звёзды, и они у меня сошлись:
- Запускаем дочерний процесс с несколькими потоками, где потоки прибиты к одному ядру N
- Запускаем поток-обсервер, который сканирует procfs, а именно директории по каждому треду
- На том же ядре N держим соседа, который никогда не спит
- Убиваем дочерний процесс и ждем его через
waitpid - Вы прелестны, баг воспроизведен (на ядре 6.14)
Теперь поток с waitpid(WNOHANG) ушел в livelock: он ждет, когда зачистятся все записи procfs умершего процесса. А зачистить их должен поток самого умирающего процесса, который вытеснили с ядра на полпути kill‘а. Он в состоянии runnable, но планировщик его больше никогда не выберет.
Минимальный репродьюсер waitpid_livelock_mini.c и с бубенчиками для удобства waitpid_livelock.c.
Багов на самом деле два: один в VFS, второй в планировщике.
Первое подозрение: двое чистят одно поддерево procfs
Когда кто-нибудь читает /proc/<pid>/task/<tid>/stat, ядро создаёт dentry - запись в кэше директорий. Так делает любой обходчик потоков, например, top. Записи живут в кэше, пока процесс не умрет, и при выходе он обязан убрать их за собой. Убирают их двое, почти одновременно, и в этом вся соль.
Во-первых, каждый не-лидерный поток чистит свои записи сам, по пути
do_exit ->
release_task ->
proc_flush_pid ->
proc_invalidate_siblings_dcache ->
d_invalidate ->
shrink_dcache_parent.
Во-вторых, родитель, вызвавший waitpid, чистит поддерево лидера группы - тем же самым путем, но из wait_task_zombie(), куда он попадает через do_wait().
Окно между ними создаёт само ядро: умирающий поток выписывается из всех списков процессов до того, как начинает чистку. Для родителя группа уже выглядит пустой, лидера уже можно забирать - а чистка ещё идет. Никакой порядок вызовов в userspace это окно не закрывает.
Отсюда родилась моя первая гипотеза и первый же кандидат в починку: раз дерутся за поддерево, давайте его не создавать - перестанем читать пер-тредовые stat, и чистить будет нечего. Но это сомнительно, ведь кто угодно в системе может походить по procfs.
Дефект 1: вечный retry в VFS
Убийство одной dentry идет по шагам: пометить умирающей, отвязать inode, отдать CPU другим через cond_resched() в __dentry_kill(), пометить убитой, выдернуть из списка, освободить.
Если поток ушел с CPU на среднем шаге, запись остаётся полумертвой. И вот тут второй участник упирается в shrink_dcache_parent():
for (;;) {
struct select_data data = {.start = parent};
d_walk(parent, &data, select_collect);
...
cond_resched();
if (!data.found)
break;
...
}
Выход из цикла один - data.found должен обнулиться. А select_collect() на умирающей записи только увеличивает счетчик, сделать с ней он ничего не может:
} else if (dentry->d_lockref.count < 0) {
data->found++;
}
cond_resched() в цикле есть, так что намертво ядро не залипает, реапер честно отдает процессор. Но проверок сигналов нет ни одной, а прогресса не будет никогда. Отсюда 100% system CPU и неубиваемость: SIGKILL доставится только на выходе из syscall, а выхода нет.
Само по себе это не страшно. Тот, кто держит dentry, обычно возвращается через микросекунды, и цикл прокручивается пару раз. У меня в трейсах эта точка проходилась 72 раза за сессию и 71 раз завершалась штатно.
Дефект 2: runnable, но никогда не выбран
Осталось объяснить, почему один раз из 72 умирающий поток не вернулся.
do_exit перед этим вызывает sched_autogroup_exit_task(): выходящий поток выселяют из автогруппы в корневую task group. Там он оказывается один, и при постановке в новую очередь ему достаётся отрицательный lag - несколько миллисекунд «долга» по vruntime. По правилам EEVDF это значит, что он не имеет права на выполнение - в терминах ядра он не eligible. Право дается при lag >= 0, когда задача получила процессора меньше, чем ей причиталось. Его не выберут, пока avg_vruntime очереди не дорастёт до его vruntime.
Единственный его конкурент на корневом уровне - group entity автогруппы. И если внутри автогруппы на этом ядре крутится хоть один поток, который никогда не спит, то group entity никогда не покидает очередь. Планировщику всегда есть кого выбрать вместо призрака.
Дальше должно было бы сработать самоисцеление: avg_vruntime растёт, долг гасится, призрак получает право на выполнение. Но не растёт. В 6.14 живут два непочиненных дефекта, из-за которых avg_vruntime под постоянным reweight’ом группы ползет назад и разъезжается с min_vruntime. Насколько плохо он не догоняет, будет видно дальше по замерам.
Итого: один поток спит в цикле без выхода, второй готов бежать, но его не зовут.
Охотимся за призраками
Зависший в ядре поток заметить тяжело.
/proc/<tid>/stack пустой, wchan пустой - unwinder не ходит по бегущему таску. cat /proc/<tid>/syscall виснет сам и намертво: wait_task_inactive спинит, пока цель не сойдет с CPU, а она не сходит. perf record -t виснет по той же причине. В ps поток показан как S, потому что do_wait выставляет TASK_INTERRUPTIBLE до скана детей - при 100% загрузке ядра.
Что работает:
- Дельта
stimeиз/proc/<tid>/stat, поле 15. Растёт на 100 тиков в секунду - значит крутится в ядре, а не спит. Самый дешевый дискриминатор, с него и стоит начинать. echo l > /proc/sysrq-trigger- NMI-дамп стеков по всем CPU. Единственный способ снять стек бегущего таска.- bpftrace на
__dentry_killиsched_switch- так нашелся призрак и егоprev_state. /sys/kernel/debug/sched/debug- тут призрак вообще проявляется. В списках задач его нет, зато он виден лишним весом в очереди.
Я тут ошибся: решил, что призрак заблокировался и спит, и полез искать, кто его держит. Свип всего хоста не нашел ни одной ссылки, а bpf-стек потом показал prev_state=256 - поток все это время был runnable, просто его не звали.
Стеки
Оба сняты инструментально. Слева - тот, кто крутится в ядре, справа - тот, кого не выбирают:
reaper (waitpid, NMI) призрак (bpftrace, sched_switch)
_raw_spin_lock __schedule
d_walk __cond_resched
shrink_dcache_parent __dentry_kill
d_invalidate shrink_dentry_list
proc_invalidate_siblings_dcache shrink_dcache_parent
proc_flush_pid d_invalidate
release_task proc_invalidate_siblings_dcache
wait_task_zombie proc_flush_pid
__do_wait release_task
kernel_wait4 <- waitpid(WNOHANG) exit_notify <- do_exit
Правый стек снят на событии sched_switch с prev_state=256. Это TASK_REPORT_MAX - так трейспоинт кодирует путь вытеснения. Любой настоящий сон дал бы маленькое ненулевое значение. То есть поток не заблокировался, а отдал CPU добровольно и остался runnable.
Репродьюсер
Две версии: минимальная с механизмом, и полная с watchdog’ом, авто-релизом и тумблерами для удобства.
gcc -O2 -Wall -pthread -o mini waitpid_livelock_mini.c
./mini 100000 8 26 # раунды, потоки в ребенке, ядро-жертва
Весь смысл программы - в трех ролях потоков и одном цикле:
// вечность: никогда не спит, поэтому group entity автогруппы не уходит из очереди
static void *spinner(void *arg) { pin(); for (;;) sched_yield(); }
// вход: пробуждения взводят need_resched, PELT-качели стопорят avg_vruntime
static void *napper(void *arg) {
struct duty *d = arg; pin();
struct timespec t = { 0, d->sleep_ns };
for (;;) { nanosleep(&t, NULL); busy_us(d->work_us); }
}
// поток ребенка: умрет внутри чистки своих /proc-записей на целевом ядре
static void *idler(void *arg) { pin(); pause(); return NULL; }
warm_dentries(child); // то же, что делает top -H
while (waitpid(child, NULL, WNOHANG) == 0) // блокироваться не имеет права
usleep(1000); // но виснет именно здесь
Вход - это потоки, которые спят по 100 мкс и просыпаются работать на 3 мкс. Они нужны по двум причинам. Во-первых, cond_resched() переключает только когда уже взведен need_resched, а взводят его пробуждения; чистый спиннер пробуждений не создаёт, и вытеснить призрака ему нечем. Во-вторых, их постоянное появление и исчезновение и создаёт reweight-churn, который стопорит avg_vruntime. Без них не зависнет.
Вечность - это поток в цикле sched_yield(). Он держит group entity автогруппы вечно runnable. Тут важно само наличие такого соседа, а не их количество. Как только в автогруппе на этом ядре не остаётся ни одного runnable, призрак становится единственной сущностью в корне и его выбирают. Спящие потоки зависание удержать не могут: каждый простаивает 97% времени, и очередь регулярно пустеет. Но с одним никогда не спящим соседом она не пустеет никогда.
Зависает на первых сотнях итераций, почти мгновенно:
rounds=100000 threads=8 cpu=26 spinners=2 parkers=6 wakers=1 delay=500us hz=100
sched: autogroup=1 PLACE_LAG=on
*** LIVELOCK: waitpid(WNOHANG) is not returning
stuck 5s on child 3653369 (round 127)
stuck 5s, reaper stime=590 ticks (spinning in kernel)
...
stuck 10s, reaper stime=1091 ticks (spinning in kernel)
REPRODUCED: livelock at round 127
real 0m11.002s
user 0m0.069s
sys 0m10.969s
Программа сама себя отпускает через 10 секунд (--hold оставит её висеть, если нужно поснимать улики).
Взвешиваем призрака
А теперь то, ради чего стоит запускать с --hold. Умирающий поток выписан из всех pid-хэшей до начала чистки, поэтому его нет ни в ps, ни в /proc, ни в sysrq-t, ни даже в списке runnable tasks в sched/debug. Но в очереди он стоит, и выдает его арифметика весов.
Три снимка корневой cfs_rq во время зависания:
| снимок 1 | снимок 2 | снимок 3 | |
|---|---|---|---|
cfs_rq[26]:/ .load |
1923101 | 1992755 | 1873429 |
autogroup .se->load.weight |
874525 | 944179 | 824853 |
| разность | 1048576 | 1048576 | 1048576 |
1048576 - это NICE_0_LOAD, вес обычной задачи с nice 0. Берется он в два шага: в таблице sched_prio_to_weight значению nice 0 соответствует 1024, а в поле load.weight веса хранятся домноженными на SCHED_FIXEDPOINT_SHIFT, ещё на 1024 - отсюда NICE_0_LOAD_SHIFT равный 20 и NICE_0_LOAD как 1 << 20 на 64-битных ядрах. В шестнадцатеричном виде это 0x10_0000, и разность в таблице выше сразу читается как круглое число.
Значит, в корневой очереди сидит ровно одна обычная задача, которой нет ни в одном списке.
Там же видно, почему она не едет:
cfs_rq[26]:/
.left_vruntime : 2975563425.023211 <- призрак, стоит намертво
.avg_vruntime : 2975563416.822499 <- должен догнать, но не догоняет
За минуту между снимками left_vruntime не изменился ни в одном разряде, а avg_vruntime прошел 0.22 мс из нужных 8.2 и один раз уехал назад. Вес group entity при этом гуляет на десятки тысяч - это и есть тот самый reweight-churn, который держит avg.
Причинность проверяется одной командой. kill -STOP соседу, который не спит: group entity уходит из очереди, призрак остаётся единственным в дереве, и его выбирают, даже когда права на выполнение у него нет. Он дочищает dentry - и waitpid возвращается через миллисекунды.
Исправляем проблему (точнее не вызываем ее)
Каждый из трех способов по отдельности убирает зависание.
Способ первый: не держать вечно-runnable соседей
Он же --spinners=0 в репродьюсере. Самое честное решение и единственное, которое делается в своем коде. Если в idle-петле стоит sched_yield() или spin_loop() - то нужно заменить на сон хотя бы в сотню микросекунд. Заодно уйдет yield deadline runaway: yield_task_fair на каждом вызове делает deadline += slice, и тайтовый yield-цикл раздувает дедлайн неограниченно. У моих спиннеров он был раскручен на 2.8e9 мс - это 929 миллионов yield’ов.
Способ второй: выключить автогруппы
sysctl -w kernel.sched_autogroup_enabled=0. Автогруппы - это десктопная механика: ядро автоматически заворачивает каждую сессию в отдельную планировочную группу, чтобы make -j64 в одном терминале не мешал видео в другом. Побочный эффект - лишний уровень иерархии с group entity, чей avg_vruntime и застревает под reweight’ом. Выключаем группы - выходящий поток остаётся в одном дереве со всеми, и право на выполнение возвращается к нему штатно.
Способ третий: отключить перенос lag
echo NO_PLACE_LAG > /sys/kernel/debug/sched/features. PLACE_LAG - это фича EEVDF, которая при новой постановке задачи в очередь сохраняет ее lag - «долг» или «кредит» по времени CPU. Задумка такая: поспал - не получай бонус за счёт других; побыл вытесненным - тебе вернут. Именно этот перенос долга и лишает призрака права на выполнение при выселении в корневую группу. Выключение убирает ловушку, но меняет размещение при пробуждении вообще всем задачам в системе, так что держать его стоит только как диагностический инструмент.
Что будет на исправленных ядрах
Резонный вопрос: оба дефекта чинят, что изменится?
VFS-половина починена Виро и вошла в v7.1, бэкпортов в stable нет. Busy-wait там заменен на completion: наткнувшись на умирающую dentry, поток вставляет struct completion_list в dentry->waiters и засыпает, а будит его dentry_unlist() - тот самый поток, которого никогда не выбирают.
Занятно, что сам по себе этот фикс мою проблему не решил бы. Процессор освободился бы, но waitpid все так же не вернулся бы - только тихо, из сна вместо спина.
Кстати, deadlock’ом это не станет ни в одной комбинации. Цикла ожиданий тут нет: одна сторона просто ждет другую, а другая готова бежать, но не получает CPU. Это старвация. Порядок блокировок тут не поможет, призраку нужен процессор.
Половина планировщика - это класс reweight/PLACE_LAG: revert плохого placement-фикса и серия zero_vruntime, которая заменяет min_vruntime на величину, привязанную к avg_vruntime. Она и есть настоящее лекарство. Причем вышла раньше VFS-фикса, а фиксы в ядре накапливаются - поэтому в mainline нет и не будет версии, где починен только VFS:
| Ядро | Планировщик | VFS | Что увидишь |
|---|---|---|---|
| 6.6 - 6.18 (у меня 6.14) | баг | баг | waitpid не возвращается, 100% system, SIGKILL не помогает |
| ~6.19 - 7.0 | починен | баг | зависания нет: призрака выбирают, kill дописывается за микросекунды |
| 7.1 и новее | починен | починен | зависания нет, и busy-wait из VFS убран заодно |
| дистрибутивное 6.x с бэкпортом одного VFS | баг | починен | waitpid не возвращается, но CPU свободен - тихое зависание |
Последняя строка гипотетическая: так будет, если применить на старое ядро один VFS-патч и не трогать планировщик.
VFS-фикс меняет только то, как баг выглядит. Убирает его только фикс планировщика.
Оговорка про номера версий: обе серии ходили по спискам в 2026 году, и точные релизы стоит перепроверить по git log на момент чтения. Что не зависит от нумерации - порядок: планировщик починили раньше, чем VFS.
Вывод
В этой истории нет ни одной ошибки в userspace. waitpid(WNOHANG) по своему зомби - каноничнее некуда, чтения /proc - тоже. Предусловия «не читайте procfs умирающего процесса» не существует нигде. Все, что нужно для беды - совпадение двух ядерных дефектов и один никогда не спящий сосед на том же ядре.