Состояние гонки при использовании dup2

Эта справочная страница для системного вызова dup2 говорит:

EBUSY (только для Linux) Это может быть возвращено dup2() или dup3() во время состояние гонки с открытым (2) и dup().

В каком состоянии гонки он говорит и что мне делать, если dup2 дает ошибку EBUSY? Должен ли я повторить попытку, как в случае EINTR?

Ответ 1

В fs/file.c, do_dup2() есть объяснение:

/*
 * We need to detect attempts to do dup2() over allocated but still
 * not finished descriptor.  NB: OpenBSD avoids that at the price of
 * extra work in their equivalent of fget() - they insert struct
 * file immediately after grabbing descriptor, mark it larval if
 * more work (e.g. actual opening) is needed and make sure that
 * fget() treats larval files as absent.  Potentially interesting,
 * but while extra work in fget() is trivial, locking implications
 * and amount of surgery on open()-related paths in VFS are not.
 * FreeBSD fails with -EBADF in the same situation, NetBSD "solution"
 * deadlocks in rather amusing ways, AFAICS.  All of that is out of
 * scope of POSIX or SUS, since neither considers shared descriptor
 * tables and this condition does not arise without those.
 */
fdt = files_fdtable(files);
tofree = fdt->fd[fd];
if (!tofree && fd_is_open(fd, fdt))
    goto Ebusy;

Похоже, что EBUSY возвращается, когда дескриптор, который должен быть освобожден, находится в некотором неполном состоянии, когда он все еще открыт (fd_is_open, но не присутствует в fdtable).

РЕДАКТИРОВАТЬ (больше информации и делать бонусы)

Чтобы понять, как может произойти !tofree && fd_is_open(fd, fdt), посмотрим, как будут открываться файлы. Здесь упрощенный вариант sys_open:

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
    /* ... irrelevant stuff */
    /* allocate the fd, uses a lock */
    fd = get_unused_fd_flags(flags);
    /* HERE the race condition can arise if another thread calls dup2 on fd */
    /* do the real VFS stuff for this fd, also uses a lock */
    fd_install(fd, f);
    /* ... irrelevant stuff again */
    return fd;
}

В основном происходят две очень важные вещи: дескриптор файла выделяется и только тогда он фактически открывается VFS. Эти две операции изменяют fdt процесса. Они оба используют блокировку, поэтому ничего плохого не следует ожидать внутри этих двух вызовов.

Чтобы запомнить, какой fds был выделен, бит-бит, называемый open_fds, используется fdt. После get_unused_fd_flags() выделен fd и соответствующий бит установлен в open_fds. Блокировка на fdt была выпущена, но реальное задание VFS еще не выполнено.

В этот точный момент другой поток (или другой процесс в случае общего fdt) может вызвать dup2, который не будет блокироваться, поскольку блокировки были выпущены. Если dup2 принял нормальный путь здесь, fd будет заменен, но fd_install будет выполняться для старого файла. Следовательно, проверка и возврат EBUSY.

Я нашел дополнительную информацию об этом состоянии гонки в комментариях fd_install(), который подтверждает мое объяснение:

/* The VFS is full of places where we drop the files lock between
 * setting the open_fds bitmap and installing the file in the file
 * array.  At any such point, we are vulnerable to a dup2() race
 * installing a file in the array before us.  We need to detect this and
 * fput() the struct file we are about to overwrite in this case.
 *
 * It should never happen - if we allow dup2() do it, _really_ bad things
 * will follow. */

Ответ 2

Я не совсем понимаю выбор Linux, но комментарий от ядра Linux в другом ответе указывает на то, с чем я работал в OpenBSD 13 лет назад, поэтому здесь я попытался вспомнить, что это за черт продолжается.

Из-за способа реализации open он сначала выделяет файловый дескриптор, а затем пытается завершить операцию открытия с разблокировкой таблицы дескриптора файла. Одна из причин может заключаться в том, что мы фактически не хотим вызывать побочные эффекты open (самый простой из них будет меняться atime в файле, но, например, открывающие устройства могут иметь гораздо более серьезные побочные эффекты), если он терпит неудачу, потому что мы отсутствуем файловых дескрипторов. То же самое относится ко всем другим операциям, которые выделяют дескрипторы файлов, когда вы читаете текст ниже, просто замените open на "любой системный вызов, который выделяет файловые дескрипторы". Я не помню, было ли это поручено POSIX или просто "Вещи всегда были сделаны".

open может выделять память, спускаться в файловую систему и делать кучу вещей, которые потенциально блокируют в течение длительного времени. В худшем случае для файловых систем, таких как плавкий предохранитель, он может даже вернуться к пользовательской. По этой причине (и другим) мы фактически не хотим, чтобы таблица дескриптора файла была заблокирована в течение всей открытой операции. Замки внутри ядра неплохо держаться во время сна, вдвойне, поэтому, если завершение заблокированной операции может потребовать взаимодействия с userland [1].

Проблема возникает, когда кто-то вызывает open в одном потоке (или процесс, который использует одну и ту же файловую дескрипторную таблицу), он выделяет файловый дескриптор и еще не завершил его пока в то же время другой поток выполняет dup2, указывающий на тот же дескриптор файла, что только open. Поскольку незавершенный файловый дескриптор по-прежнему недействителен (например, read и write вернет EBADF при попытке его использования), мы пока не можем его закрыть.

В OpenBSD это решается путем отслеживания выделенных, но еще не открытых файловых дескрипторов со сложным подсчетом ссылок. Большинство операций просто притворяются, что дескриптор файла не существует (но он также не выделяется), и он просто вернет EBADF. Но для dup2 мы не можем притворяться, что его нет, потому что это так. Конечным результатом является то, что если два потока одновременно вызывают open и dup2, open фактически выполняет полную открытую операцию над файлом, но поскольку dup2 выиграл гонку для файлового дескриптора, последнее, что open делает - уменьшить счетчик ссылок на только что выделенный файл и снова закрыть его. Между тем dup2 выиграл гонку и сделал вид, что закрыл дескриптор файла, который open получил (что на самом деле не выполнял, фактически это было open). На самом деле не имеет значения, какое поведение выбирает ядро, поскольку в обоих случаях это раса, которая приведет к неожиданному поведению для open или dup2. В лучшем случае Linux, возвращающий EBUSY, просто сокращает окно гонки, но гонка по-прежнему существует, и ничего не мешает вызвать вызов dup2, так как open возвращается в другом потоке и заменяет дескриптор файла перед вызывающий open имеет возможность использовать его.

Ошибка в вашем вопросе, скорее всего, случится, когда вы нажмете эту гонку. Чтобы избежать этого, не dup2 в дескриптор файла вы не знаете состояния, если вы не уверены, что нет никого другого, который будет обращаться к таблице дескриптора файла одновременно. И единственный способ быть уверенным в том, чтобы быть единственным потоком (файловые дескрипторы открываются за спиной библиотеками все время) или точно знать, какой дескриптор файла вы переписываете. Причиной dup2 по нераспределенному файловому дескриптору разрешено в первую очередь то, что это общая идиома для закрытия fds 0, 1 и 2 и dup2/dev/null в них.

С другой стороны, не закрывая дескрипторы файла перед dup2, потеряет возврат ошибки из close. Я бы не стал беспокоиться об этом, поскольку ошибки из close глупы и не должны быть там в первую очередь: Обработка файлов с ошибками только для чтения файлов Для другого примера неожиданного поведения потоков и того, как файловые дескрипторы ведут себя странно из-за того, о чем я говорил, см. Этот вопрос: дескриптор Socket не освобождается при выполнении 'close()' для многопоточного клиента UDP

Вот пример кода для запуска этого:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <err.h>
#include <pthread.h>

static void *
do_bad_things(void *v)
{
    int *ip = v;
    int fd;

    sleep(2);   /* pretend this is proper synchronization. */

    if ((fd = open("/dev/null", O_RDONLY)) == -1)
        err(1, "open 2");

    if (dup2(fd, *ip))
        warn("dup2");

    return NULL;
}

int
main(int argc, char **argv)
{
    pthread_t t;
    int fd;

    /* This will be our next fd. */
    if ((fd = open("/dev/null", O_RDONLY)) == -1)
        err(1, "open");
    close(fd);

    if (mkfifo("xxx", 0644))
        err(1, "mkfifo");

    if (pthread_create(&t, NULL, do_bad_things, &fd))
        err(1, "pthread_create");

    if (open("xxx", O_RDONLY) == -1)
        err(1, "open fifo");

    return 0;
}

A FIFO - это стандартный метод, позволяющий open блокировать столько, сколько пожелаете. Как и ожидалось, это работает тихо на OpenBSD, а на Linux dup2 возвращается EBUSY. В MacOS по какой-то причине он убивает оболочку, где я "echo foo > xxx", в то время как нормальная программа, которая просто открывает ее для написания, отлично работает, я понятия не имею, почему.

[1] Анекдот здесь. Я участвовал в написании файловой системы с плавким предохранителем, используемой для реализации AFS. Одна ошибка, которая у нас была, заключалась в том, что мы удерживали блокировку объекта файла при вызове в пользовательскую область. Протокол блокировки для поиска записей в каталогах требует, чтобы вы удерживали блокировку каталога, затем просматривали запись в каталоге, блокировали объект под этой записью каталога, а затем освобождали блокировку каталога. Поскольку мы удерживали блокировку объекта файла, в него вошел другой процесс и попытался найти файл, что привело к тому, что этот процесс спал для блокировки файлов, сохраняя при этом блокировку каталога. Другой процесс пришел, попытался найти директорию и в итоге оставил блокировку родительского каталога. Короче говоря, мы закончили цепочку замков, пока мы не дошли до корневого каталога. Тем временем демон файловой системы все еще разговаривал с сервером по сети. По какой-то причине сетевая операция завершилась неудачно, и демону файловой системы необходимо было зарегистрировать сообщение об ошибке. Для этого ему пришлось прочитать некоторую базу данных локали. И для этого ему нужно было открыть файл, используя полный путь. Но поскольку корневой каталог был заблокирован кем-то другим, демон ждал эту блокировку. И у нас была длинная блокировка цепочки 8 замков. Поэтому ядро ​​часто выполняет сложную спортивную гимнастику, чтобы избежать блокировки во время длительных операций, особенно операций с файловой системой.