Управление файловыми дескрипторами

Файловые дескрипторы лежат в основе всех механизмов ввода/вывода, предоставляемых системой. Они используются для реализации функций на основе FILE *, определённых в <stdio.h>, и все средства файлового и сетевого взаимодействия, предоставляемые средами Python и Java, в конечном счёте реализованы на их основе.

Файловые дескрипторы представляют собой небольшие неотрицательные целые числа в пользовательском пространстве, а на стороне ядра за ними стоят сложные структуры данных, которые иногда могут достигать очень больших размеров.

Закрытие дескрипторов

Если дескриптор больше не используется программой и не закрыт явно, его номер не может быть использован повторно (что само по себе создаёт проблемы, см. Работа с ограничением select), а ресурсы ядра не освобождаются. Поэтому важно закрывать все дескрипторы в максимально ранний из возможных моментов, но не раньше.

Обработка ошибок при закрытии дескриптора

Системный вызов close всегда успешен в том смысле, что переданный файловый дескриптор после вызова функции уже никогда не является действительным. Однако close всё равно может вернуть ошибку, например, при сбое файловой системы. Но эта ошибка не очень полезна, потому что отсутствие ошибки не означает, что все кеши сброшены и предыдущие записи стали устойчивыми. Программы, которым требуются такие гарантии, должны открывать файлы с флагом O_SYNC или использовать fsync или fdatasync, а также, возможно, выполнять fsync для каталога, содержащего файл.

Закрытие дескрипторов и состояние гонки

В отличие от идентификаторов процессов, которые переиспользуются постепенно, ядро всегда выделяет наименьший неиспользуемый файловый дескриптор при создании нового дескриптора. Это означает, что в многопоточной программе, которая постоянно открывает и закрывает файловые дескрипторы, дескрипторы переиспользуются очень быстро. Если закрытие дескриптора и другие операции с тем же дескриптором не синхронизированы (как правило, с помощью мьютекса), возникают состояния гонки, и операции ввода/вывода применяются к неверному файловому дескриптору.

Иногда возникает необходимость одновременно закрыть файловый дескриптор, в то время как другой поток может собираться использовать его в системном вызове. Чтобы обеспечить такую возможность, программе необходимо создать один специальный файловый дескриптор, для которого все операции ввода/вывода завершаются ошибкой. Один из способов достичь этого — использовать socketpair, закрыть один из дескрипторов и вызвать shutdown(fd, SHUTRDWR) для другого.

При одновременном закрытии дескриптора программа не вызывает close для дескриптора. Вместо этого программа использует dup2, чтобы заменить закрываемый дескриптор на фиктивный дескриптор, созданный ранее. Таким образом, ядро не будет переиспользовать дескриптор, но выполнит все остальные действия, связанные с закрытием дескриптора (например, если дескриптор ссылается на потоковый сокет, противоположная сторона будет уведомлена).

Это лишь набросок, и многие детали опущены. Требуются дополнительные структуры данных, чтобы определить, когда безопасно действительно закрыть дескриптор, и для этого необходима правильная блокировка.

Зависшее состояние после закрытия

По умолчанию закрытие потокового сокета возвращает управление немедленно, а ядро пытается отправить данные в фоновом режиме. Это означает, что из пользовательского пространства невозможно реализовать точный учёт использования сетевых ресурсов.

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

В настоящее время не существует общеприменимых мер защиты на уровне приложений. Смягчение ситуации возможно с помощью iptables (в частности, с типом совпадения connlimit) и специализированных фильтрующих устройств для сетевого трафика, направленного на отказ в обслуживании.

Эти проблемы не связаны с состоянием TIME_WAIT, которое часто можно увидеть в выводе netstat. При необходимости ядро автоматически завершает такие сокеты.

Предотвращение утечки файловых дескрипторов в дочерние процессы

Дочерние процессы, созданные с помощью fork, разделяют начальный набор файловых дескрипторов с родительским процессом. По умолчанию файловые дескрипторы также сохраняются, если новый образ процесса создаётся с помощью execve (или любых других функций, таких как system или posix_spawn).

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

  • Установить флаг close-on-exec для всех вновь создаваемых файловых дескрипторов. Традиционно этот флаг управляется с помощью флага FD_CLOEXEC, используя операции F_GETFD и F_SETFD функции fcntl.

    Однако в многопоточном процессе возникает состояние гонки: между моментом создания дескриптора и установкой FD_CLOEXEC может быть создан дочерний процесс. Поэтому многие системные вызовы, создающие дескрипторы (такие как open и openat), теперь принимают флаг O_CLOEXEC (SOCK_CLOEXEC для socket и socketpair), который обеспечивает атомарную установку флага FD_CLOEXEC для файлового дескриптора. Кроме того, было введено несколько новых системных вызовов, таких как pipe2 и dup3.

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

  • После вызова fork, но перед созданием нового образа процесса с помощью execve, закрываются все файловые дескрипторы, которые не понадобятся дочернему процессу.

    Традиционно это реализовывалось как цикл по файловым дескрипторам в диапазоне от 3 до 255, а позже — до 1023. Но это лишь приближение, поскольку можно легко создавать файловые дескрипторы за пределами этого диапазона (см. Работа с ограничением select). Другой подход заключается в чтении /proc/self/fd и закрытии неожиданных дескрипторов, перечисленных там, но этот подход работает значительно медленнее.

В настоящее время среды, которые заботятся об утечке файловых дескрипторов, реализуют второй подход. OpenJDK 6 и 7 входят в их число.

Работа с ограничением select

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

Функция select поддерживает не более FD_SETSIZE файловых дескрипторов (то есть максимально допустимое значение для файлового дескриптора — FD_SETSIZE - 1, обычно 1023). Если процесс открывает много файлов, дескрипторы могут превысить эти пределы. Опрашивать такие дескрипторы с помощью select невозможно.

Если библиотека, которая создаёт множество файловых дескрипторов, используется в одном процессе с библиотекой, которая использует select, по крайней мере одну из них необходимо изменить. Вызовы select можно заменить вызовами poll или другим механизмом обработки событий. Замена функции select является рекомендуемым подходом.

В качестве альтернативы библиотека с интенсивным использованием дескрипторов может переместить дескрипторы за пределы FD_SETSIZE с помощью следующей процедуры.

  • Создайте файловый дескриптор fd обычным способом, желательно с флагом O_CLOEXEC.

  • Перед выполнением любых других операций с дескриптором fd вызовите:

	  int newfd = fcntl(fd, F_DUPFD_CLOEXEC, (long)FD_SETSIZE);
  • Проверьте, что результат newfd неотрицателен; в противном случае закройте fd, сообщите об ошибке и верните управление.

  • Закройте fd и продолжайте использовать newfd.

Новый дескриптор был выделен за пределами FD_SETSIZE. Хотя этот алгоритм создаёт состояние гонки в том смысле, что первые FD_SETSIZE дескрипторов могут быть заняты, для возникновения проблемы требуется очень высокая степень физического параллелизма.