Спинлоки eBPF и искусство отладки ядра Linux
Как ошибки со спинлоками в eBPF проявляются в ядре Linux, почему их трудно найти и что процесс отладки говорит о модели безопасности eBPF.

Ошибка со спинлоком в eBPF не сообщает о себе ни стектрейсом, ни сообщением об ошибке. Она проявляется тем, что машина перестаёт отвечать. Ни логов, ни предупреждений, ни мягкой деградации — просто ядро одного CPU крутится вечно в ожидании блокировки, которая никогда не будет снята. Если повезёт, сработает таймер watchdog, и вы получите kernel panic с трассировкой. Если не повезёт, машина просто зависнет, и её придётся перезагружать с питания.
eBPF должен быть безопасным. Он работает в ядре, но проходит через верификатор, который доказывает, что программы завершаются, не обращаются к некорректной памяти и не портят состояние ядра. Так как же ошибки со спинлоками проскальзывают сквозь него? Потому что верификатор проверяет отдельные программы, а ошибки со спинлоками возникают из взаимодействия между программами, картами (maps) и решениями планировщика ядра — то, что никакой статический верификатор полностью предсказать не может.
Для чего нужны спинлоки eBPF
Программы eBPF выполняются в контексте ядра, часто одновременно на нескольких CPU. Когда две программы eBPF должны обновить одно и то же значение в карте — счётчик, структуру данных, конечный автомат — им нужна синхронизация. Для этого eBPF предоставляет bpf_spin_lock и bpf_spin_unlock.
Спинлок — самая простая из возможных блокировок: пытаешься захватить её, а если она занята, крутишься в плотном цикле, пока её не освободят. Никакого сна, никакой передачи управления, никаких очередей. Это подходит для eBPF, потому что программы eBPF выполняются в контекстах, где сон запрещён: обработчики прерываний, softirq, участки с отключённой вытесняющей многозадачностью. Мьютекс, который усыпил бы вызывающий код, уронил бы ядро. Спинлок просто сжигает такты процессора, пока блокировка не освободится.
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
Звучит просто. И для простых случаев вроде «захватить, обновить значение, отпустить» всё работает нормально. Проблемы начинаются, когда схемы блокировок усложняются или когда взаимодействие программ eBPF с собственными блокировками ядра создаёт неожиданные зависимости.
Как проявляются ошибки со спинлоками
Классическая ошибка со спинлоками — дедлок: программа A держит блокировку 1 и пытается захватить блокировку 2, а программа B держит блокировку 2 и пытается захватить блокировку 1. Обе крутятся вечно, ожидая друг друга. В контексте ядра это означает, что эти ядра CPU выбыли — они больше никогда ничего не выполнят.
Верификатор eBPF предотвращает часть таких случаев. Он отклоняет программы, которые одновременно удерживают больше одного спинлока, что исключает классический дедлок AB-BA. Но он не может предотвратить все сценарии дедлоков, потому что некоторые возникают из взаимосвязи между спинлоками eBPF и собственными блокировками ядра.
Рассмотрим такой сценарий: программа eBPF, прикреплённая к tracepoint, захватывает спинлок в карте. Пока блокировка удерживается, на том же CPU срабатывает аппаратное прерывание. Обработчик прерывания запускает другую программу eBPF, которая пытается захватить тот же спинлок. Дедлок: прерывание не может завершиться, пока не получит блокировку, а блокировка не может быть снята, пока не возобновится прерванная программа, что невозможно, пока не завершится прерывание.
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
Собственные спинлоки ядра решают это, запрещая прерывания на время удержания блокировки (spin_lock_irqsave). Спинлоки eBPF делают то же самое: bpf_spin_lock отключает вытеснение и, начиная с ядер 5.1, отключает softirq. Но не все контексты прерываний покрыты, и не все версии ядра обрабатывают это одинаково — именно здесь и живут тонкие ошибки.
Проблема отладки
Отлаживать проблемы со спинлоками в eBPF трудно по конкретным причинам.
Воспроизведение недетерминировано. Ошибки со спинлоками зависят от таймингов: какой CPU запускает какую программу, когда срабатывают прерывания, сколько длится критическая секция. Тест, который 999 раз проходит нормально, может зайти в дедлок на тысячный. Надёжно воспроизвести такие ошибки в разработке сложно, потому что у вашей машины для разработки другая топология CPU, частота прерываний и профиль нагрузки, чем в продакшене.
Сбой уничтожает улики. Когда случается дедлок со спинлоком, затронутые CPU перестают выполнять что-либо, в том числе инфраструктуру логирования и мониторинга, которая могла бы рассказать, что произошло. Если дедлок на одном CPU, машина может ещё кое-как тянуть. Если заблокировано несколько CPU (или если заблокированный CPU держит ресурсы, нужные другим), вся машина зависает.
Привычные инструменты отладки не помогают. Нельзя просто подключить отладчик к ядру, чтобы посмотреть состояние при дедлоке (ну, можно через KGDB, но последовательное соединение нужно настроить до дедлока). dmesg бесполезен, потому что туда ничего не пишется. Единственная надёжная информация — из вывода детектора зависаний (lockup detector), если он сработает до того, как машина окончательно умрёт.
Стратегии, которые реально работают
Несмотря на эти трудности, ошибки со спинлоками в eBPF всё же находят и исправляют. Вот как.
Анализ порядка блокировок. Прежде чем что-либо запускать, проанализируйте поведение блокировок каждой программы eBPF. Какие карты использует каждая программа? Какие блокировки она захватывает? Могут ли две программы, работающие с одной блокировкой, выполняться на одном CPU (один и тот же tracepoint или tracepoint против прерывания)? Это утомительный ручной анализ, но он позволяет поймать самые частые паттерны дедлоков до того, как они случатся.
Мониторинг времени удержания блокировок. Инструментируйте программы eBPF, чтобы измерять, как долго удерживаются спинлоки. Спинлок, удерживаемый дольше нескольких микросекунд, — это красный флаг: он расширяет окно для дедлоков, вызванных прерываниями, и заставляет другие CPU впустую крутиться. Короткие критические секции одновременно и быстрее, и безопаснее.
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
Структуры данных per-CPU. Лучший баг со спинлоком — тот, который невозможен. Если каждый CPU работает со своей копией данных, а агрегируете вы позже, конкуренции нет и блокировки не нужны. BPF_MAP_TYPE_PERCPU_HASH и BPF_MAP_TYPE_PERCPU_ARRAY в eBPF созданы именно для этого. Счётчики, гистограммы и аккумуляторы почти никогда не требуют спинлоков для общего состояния.
Атомарные операции вместо блокировок. Для простых операций — увеличить счётчик, сравнить и заменить значение — __sync_fetch_and_add и подобные атомарные операции работают быстрее и не приводят к дедлокам. Для инкремента счётчика спинлок не нужен. Оставляйте спинлоки для операций, где действительно нужно атомарно обновить несколько полей.
Что это говорит о модели безопасности eBPF
История безопасности eBPF впечатляет, но неоднозначна. Верификатор гарантирует, что отдельные программы безопасны: они завершаются, не обращаются к некорректной памяти и не портят состояние ядра. Но «безопасные» программы могут сложиться в небезопасную систему, если их взаимодействие создаёт временные зависимости, которые верификатор не способен проанализировать.
Это фундаментальное ограничение статического анализа. Верификатор видит каждую программу изолированно. Он не знает, какие другие программы загружены, какие карты они используют совместно и в каких контекстах ядра работают. Программа, которая захватывает спинлок, «безопасна» — она корректно захватывает и освобождает блокировку. Но безопасна ли она в сочетании со всеми остальными загруженными программами eBPF, зависит от условий во время выполнения.
Сообщество ядра решает эту проблему постепенно. Недавние патчи добавили ограничения на то, какие типы программ eBPF могут использовать спинлоки, как долго могут длиться критические секции и какие операции запрещены, пока спинлок удерживается. Каждое ограничение сужает окно для ошибок, но одновременно ограничивает то, что могут делать программы eBPF.
Практические правила работы с блокировками в eBPF
Поработав с достаточным количеством проблем со спинлоками, начинаешь видеть паттерны, как их избегать.
- Предпочитайте карты per-CPU. Если ваши данные можно разбить по CPU и агрегировать в пользовательском пространстве, так и делайте. Никаких блокировок, никакой конкуренции, никаких дедлоков. Это покрывает 80% сценариев — счётчики, журналы событий, гистограммы.
- Предпочитайте атомарные операции спинлокам. Если нужен общий счётчик или compare-and-swap, используйте атомарные операции. Они работают без блокировок и не могут зайти в дедлок.
- Если без спинлоков не обойтись, держите критические секции крошечными. Увеличить счётчик, обновить временную метку, поменять указатель. Ничего больше. Никогда не вызывайте вспомогательные функции, удерживая блокировку: вы не знаете, какие блокировки они берут внутри.
- Никогда не держите одну и ту же блокировку из двух типов программ eBPF. Если программа kprobe и программа tracepoint блокируют одно и то же значение в карте, вас отделяет от дедлока одно прерывание. Используйте отдельные карты или данные per-CPU.
- Тестируйте под нагрузкой. Ошибки со спинлоками зависят от времени. Они проявляются при конкуренции: высокая загрузка CPU, частые прерывания, много программ eBPF, работающих одновременно. Тестируйте на реалистичной продакшен-нагрузке, а не на простаивающих машинах разработчика.
eBPF кардинально изменил то, как мы взаимодействуем с ядром Linux, дав программам пользовательского пространства безопасный доступ к наблюдаемости и сетевым возможностям на уровне ядра. Но у «безопасности» есть границы. Верификатор делает eBPF намного безопаснее, чем написание сырых модулей ядра, и весьма существенно. Но он не делает его безопасным так, как безопасно программирование в пользовательском пространстве. Понимание того, где проходят эти границы, особенно в вопросах общего состояния и блокировок, — это то, что отличает программы eBPF, которые работают в продакшене, от тех, что работают только в тестах.


