Баг в ядре Linux и как правильно жаловаться
Я работаю системным программистом в компании КриптоПро. Нередко мои задачи связаны с ошибками, которые лежат на самом нижнем уровне современных операционных систем, под которые мы пишем ПО. Я хочу поведать тебе, Хабр, об одной из таких ошибок и о том, как я жаловался на неё разработчикам.
Я отвечаю за поддержку одной из наших библиотек с C-интерфейсом, написанной на C и C++. Мой коллега из другого отдела сообщил, что его нагрузочный тест нашей библиотеки на C# в Linux выдаёт ошибку в хитром сценарии: нужно иметь два процесса по пять потоков, делающих некоторые идентичные вызовы. Если процесс один, а потоков много, то проблема не проявляется. Если процессов два, но в каждом по одному потоку, то проблема не проявляется. Путём просмотра исходников нагрузочного теста и логов работы библиотеки удалось перенести проблему в маленький юнит-тест на C++ с использованием нашего API.
Вскрылось очень интересное. Ошибку возвращала функция lockf, отвечающая за взятие общесистемной блокировки на открытый файл. Функция в общих чертах работает так: если один процесс заблокировал файл с помощью lockf, то другие процессы при попытке заблокировать его с помощью lockf ждут его освобождения первым процессом. Все остальные операции с файлом по-прежнему доступны другим процессам, то есть эта блокировка не навязывается процессам, которые не пытались взять блокировку явным вызовом lockf.
errno было равно 35, что означало код EDEADLK с описанием «Resource deadlock avoided». Я полез в маны. «Allocating a system resource would have resulted in a deadlock situation. The system does not guarantee that it will notice all such situations. This error means you got lucky and the system noticed; it might just hang.» Система говорит нам: если она сделает то, что мы попросили, то произойдёт взаимная блокировка, система этого не хочет, поэтому просто вернёт ошибку. Но что это значило в моём случае?
Для воспроизведения ошибки требовалось минимум два процесса минимум по два потока в каждом. Первый поток первого процесса успешно взял лок на файл А. Первый поток второго процесса успешно взял лок на файл Б. Второй поток первого процесса хотел взять лок на файл Б, но не смог — лок принадлежит второму процессу. Он ждёт освобождения лока на файл Б. Второй поток второго процесса хотел взять лок на файл А, но не смог — лок принадлежит первому процессу. Ожидания освобождения не будет, lockf немедленно вернёт ошибку, ведь с точки зрения детектора взаимных блокировок в ядре Linux настал момент бить в набат!
На первый взгляд, тут как будто бы происходит хрестоматийный deadlock из учебников по параллельному программированию. Или всё же нет? Предположим, второй поток второго процесса стал бы ждать освобождения лока на файл А. Встали бы намертво все четыре потока обоих процессов? Почти наверняка нет. Обычно планирование потоков и процессов происходит так, что каждый из них получит управление через какое-то время. Тогда первый поток первого процесса мог бы продолжить исполнение и отпустить лок на файл А, тем самым дав возможность взять лок второму потоку второго процесса. Или же первый поток второго процесса продолжит исполнение, отпустит лок на файл Б, тем самым дав возможность взять лок второму потоку первого процесса. На уровне процессов взаимная блокировка как будто бы есть, а на уровне потоков её нет.
Такое поведение меня удивило. Я написал маленький юнит-тест на C с использованием только libc, и он подтвердил, что почти наверняка изначальная ошибка вызывалась ровно этим. Код лежит тут. Компиляция: gcc -o edeadlk ./edeadlk.c -lpthread. Запуск: ./edeadlk a b в первом эмуляторе терминала, ./edeadlk a b во втором эмуляторе терминала.
Я погрузился в документацию. С точки зрения libc, функция lockf работает на уровне процессов («Locks are associated with processes»), она ничего не знает про потоки, которые могут разрешить какие-то противоречия. Если процесс 1 взял лок А и ждёт лок Б, а процесс 2 наоборот, то тормозим немедленно. «The specified region is being locked by another process. But that process is waiting to lock a region which the current process has locked, so waiting for the lock would result in deadlock. The system does not guarantee that it will detect all such conditions, but it lets you know if it notices one.»
POSIX тоже утверждает, что поведение своего рода правильное: «A potential for deadlock occurs if the threads of a process controlling a locked section are blocked by accessing a locked section of another process. If the system detects that deadlock would occur, lockf () shall fail with an [EDEADLK] error.»
strace не показывал мне вызовов lockf, потому что lockf в glibc реализована через fcntl: «On Linux, lockf () is just an interface on top of fcntl (2) locking». Я нашёл также такие слова: «When placing locks with F_SETLKW, the kernel detects deadlocks, whereby two or more processes have their lock requests mutually blocked by locks held by the other processes. For example, suppose process A holds a write lock on byte 100 of a file, and process B holds a write lock on byte 200. If each process then attempts to lock the byte already locked by the other process using F_SETLKW, then, without deadlock detection, both processes would remain blocked indefinitely. When the kernel detects such deadlocks, it causes one of the blocking lock requests to immediately fail with the error EDEADLK; an application that encounters such an error should release some of its locks to allow other applications to proceed before attempting regain the locks that it requires. Circular deadlocks involving more than two processes are also detected. Note, however, that there are limitations to the kernel’s deadlock-detection algorithm; see BUGS.»
Так-так-так, а что там? «The deadlock-detection algorithm employed by the kernel when dealing with F_SETLKW requests can yield both false negatives (failures to detect deadlocks, leaving a set of deadlocked processes blocked indefinitely) and false positives (EDEADLK errors when there is no deadlock). For example, the kernel limits the lock depth of its dependency search to 10 steps, meaning that circular deadlock chains that exceed that size will not be detected. In addition, the kernel may falsely indicate a deadlock when two or more processes created using the clone (2) CLONE_FILES flag place locks that appear (to the kernel) to conflict.»
Нехорошо. Похоже, тут мы наступили на грабли, и теперь требовалось как-то преодолеть найденный фатальный недостаток lockf. Я прочитал про целый вагон разных примитивов синхронизации в *nix, и самым надёжным решением в моей конкретной ситуации мне показалось использование очень похожей функции flock вместо lockf. Она не совсем идентична lockf, между ними есть тонкие различия, но кажется, они в целом схожи. К счастью, flock не имеет глубоко под капотом такой хитрой логики по детектированию взаимных блокировок: «flock () does not detect deadlock». Я проверил, что эти изменения лечат маленький юнит-тест на C для libc, маленький юнит-тест на C++ для нашей библиотеки, нагрузочный тест на C# для нашей библиотеки, закоммитился, проверил ночные тесты и обрадовался.
Казалось бы, тут и сказочке конец, но я же хороший парень, надо же пожаловаться на проблему мейнтейнерам Linux! Для выполнения моей рабочей задачи этого не требовалось, но хотелось получить подтверждение результатов моего исследования от авторитетных людей, а ещё сделать доброе дело.
Хорошо, первым делом наверное пойдём в баг-трекер ядра Linux. Что мы там видим?
«Please use your distribution’s bug tracking tools
This bugzilla is for reporting bugs against upstream Linux kernels.
If you did not compile your own kernel from scratch, you are probably in the wrong place.
Please use the following links to report a bug to your distribution instead:
Ubuntu | Fedora | Arch | Mint | Debian | Red Hat | OpenSUSE | SUSE»
Моя проблема воспроизводилась на ряде старых ядер и в разных дистрибутивах, и я был на 99% уверен, что вижу её в коде последней ревизии. Ядро из исходников я никогда не собирал, ради такого повода даже попробовал это сделать, но за 20 минут не смог и потерял мотивацию. Окей…
Пожаловался в баг-трекер Ubuntu. За неделю никакого ответа не получил. Окей…
Пожаловался в рассылку LKML, главную рассылку разработчиков ядра Linux. За неделю никакого ответа не получил. Окей…
Случайно наткнулся на файл MAINTAINERS в исходниках ядра, который говорил, что жаловаться надо не в LKML, а в более узкоспециализированную рассылку. У меня была жалоба на функцию lockf и на код в файле fs/locks.c. Посмотрим.
FILE LOCKING (flock() and fcntl()/lockf())
M: Jeff Layton
L: linux-fsdevel@vger.kernel.org
S: Maintained
F: fs/fcntl.c
F: fs/locks.c
F: include/linux/fcntl.h
F: include/uapi/linux/fcntl.h
Пожаловался в рассылку linux-fsdevel. За неделю никакого ответа не получил. Окей…
Если честно, мне это уже порядком поднадоело. Жалуешься-жалуешься, на блюдечке подносишь юнит-тест, умоляешь сказать хотя бы, баг это или такая особенная фича, правильно ли я всё понял, а в ответ просто гробовая тишина. Печалит это всё. Однако я уже столько времени и сил вложил в сие предприятие, что хотелось довести его до какого-то ощутимого результата. И я пошёл ещё дальше.
Я набрался наглости и написал лично человеку, который отвечал за этот участок кода — Jeff Layton. И оказалось, что так надо было поступить с самого начала! Всего через пару часов Джефф очень дружелюбно, конструктивно и подробно ответил на все мои вопросы, и я наконец успокоился. Наша переписка проходила всё в той же рассылке linux-fsdevel. По словам моего собеседника, в Linux:
— поведение lockf (и fcntl с POSIX-локами) кривое, но менять его не будут из соображений обратной совместимости
— использовать flock или OFD locks вместо lockf — нормальная идея
— хорошо бы поправить man-страницу, где подчеркнуть, что lockf вообще не стоит использовать в многопоточных программах
Для реализации последнего пункта во мне уже не хватило любви к Open Source, и я остановился.
А потом опять замотивировался и написал этот пост.