Недавно я работал над достаточно большим проектом на Rust. К моему удивлению, мне никак не удавалось заставить тесты работать правильно.

Команда cargo test запускала выполнение всех тестов в репозитории, но спустя пару миллисекунд все тесты завершались сбоями из-за не очень знакомой мне ошибки:

rustIo(Os { code: 24, kind: Other, message: "Too many open files" })

К счастью, описание ошибки достаточно понятно, поэтому я смог за приемлемое время разобраться в её причинах. Я начал копаться и в процессе исследований кое-чему научился.

Вы когда-нибудь задавались вопросом, как программы одновременно жонглируют различными задачами: чтением файлов, отправкой данных по сети или даже простым отображением текста на экране? Всё это становится возможным благодаря дескрипторам файлов (в Unix-системах).

По своей сути, дескриптор файла (file descriptor, часто сокращаемый до fd) — это просто положительное целое число, которое операционная система использует для идентификации открытого файла. В Unix «всё — это файл». Несмотря на значение этого слова, дескриптор файла может ссылаться не только на обычные файлы диска. Он может обозначать:

  • Обычные файлы: документы, изображения и код, с которыми вы обычно взаимодействуете.

  • Папки: да, даже папки в какой-то мере считаются файлами, позволяя программам получать список своего содержимого.

  • Конвейеры (pipe): они используются для коммуникаций между процессами и позволяют задействовать вывод одной программы в качестве ввода другой.

  • Сокеты (socket): конечные точки сетевых коммуникаций, будь то общение с веб-сервером или с другим приложением на локальной машине.

  • Устройства: аппаратные устройства; например, доступ к клавиатуре, мыши и принтеру тоже осуществляется через дескрипторы файлов.

Когда программе нужно взаимодействовать с каким-то из этих ресурсов, то она сначала просит ядро «открыть» его. Если это происходит успешно, ядро возвращает дескриптор файла, который программа затем использует для всех последующих операций (чтения, записи, закрытия и так далее).

Стандартно каждый процесс Unix запускается как минимум с тремя стандартными дескрипторами файлов, открываемыми автоматически:

  • 0: стандартный ввод (stdin) — обычно подключается к клавиатуре для пользовательского ввода.

  • 1: стандартный вывод (stdout) — обычно подключается к терминалу для отображения обычного вывода программы.

  • 2: стандартная ошибка (stderr) — тоже обычно подключается к терминалу, но для отображения конкретно сообщений об ошибках.

В macOS это можно проверить, открыв терминал и выполнив /dev/fd.

console$ ls -lah /dev/fd
Permissions Size User         Date Modified Name
crw--w----  16,2 mattrighetti  4 Jun 00:44  0
crw--w----  16,2 mattrighetti  4 Jun 00:44  1
crw--w----  16,2 mattrighetti  4 Jun 00:44  2
dr--r--r--     - root         24 May 08:23  3

В Linux тоже можно сделать нечто подобное, но репозиторий отличается и обычно соответствует паттерну /proc/<pid>/fd. Выполнив ту же самую команду в Linux, я получил следующее:

console$ echo $$ // выводит id текущего процесса
2806524

$ sudo ls -lah /proc/2806524/fd
total 0
dr-x------ 2 root root 11 Jun  4 00:40 .
dr-xr-xr-x 9 pi   pi    0 Jun  4 00:39 ..
lrwx------ 1 root root 64 Jun  4 00:40 0 -> /dev/null
lrwx------ 1 root root 64 Jun  4 00:40 1 -> /dev/null
lrwx------ 1 root root 64 Jun  4 00:40 10 -> /dev/ptmx
lrwx------ 1 root root 64 Jun  4 00:40 11 -> /dev/ptmx
lrwx------ 1 root root 64 Jun  4 00:40 2 -> /dev/null
lrwx------ 1 root root 64 Jun  4 00:40 3 -> 'socket:[14023056]'
lrwx------ 1 root root 64 Jun  4 00:40 4 -> 'socket:[14023019]'
lrwx------ 1 root root 64 Jun  4 00:40 5 -> 'socket:[14022300]'
lrwx------ 1 root root 64 Jun  4 00:40 6 -> 'socket:[14023037]'
lrwx------ 1 root root 64 Jun  4 00:40 7 -> /dev/ptmx
l-wx------ 1 root root 64 Jun  4 00:40 8 -> /run/systemd/sessions/1501.ref

Как видите, у нас есть 01 и 2, но присутствует и куча других дескрипторов файлов.

Ещё одна полезная команда для проверки открытых дескрипторов файлов — это lsof, имя которой расшифровывается как «list open files» («список открытых файлов»).

console$ lsof -p $(echo $$)
COMMAND   PID         USER   FD   TYPE DEVICE SIZE/OFF                NODE NAME
zsh     39367 mattrighetti  cwd    DIR   1,17     2496              250127 /Users/mattrighetti
zsh     39367 mattrighetti  txt    REG   1,17  1361200 1152921500312522433 /bin/zsh
zsh     39367 mattrighetti  txt    REG   1,17    81288 1152921500312535786 /usr/share/locale/en_US.UTF-8/LC_COLLATE
zsh     39367 mattrighetti  txt    REG   1,17   170960 1152921500312525313 /usr/lib/zsh/5.9/zsh/zutil.so
zsh     39367 mattrighetti  txt    REG   1,17   118896 1152921500312525297 /usr/lib/zsh/5.9/zsh/terminfo.so
zsh     39367 mattrighetti  txt    REG   1,17   171344 1152921500312525281 /usr/lib/zsh/5.9/zsh/parameter.so
zsh     39367 mattrighetti  txt    REG   1,17   135696 1152921500312525255 /usr/lib/zsh/5.9/zsh/datetime.so
zsh     39367 mattrighetti  txt    REG   1,17   135568 1152921500312525291 /usr/lib/zsh/5.9/zsh/stat.so
zsh     39367 mattrighetti  txt    REG   1,17   338592 1152921500312525247 /usr/lib/zsh/5.9/zsh/complete.so
zsh     39367 mattrighetti  txt    REG   1,17   136880 1152921500312525293 /usr/lib/zsh/5.9/zsh/system.so
zsh     39367 mattrighetti  txt    REG   1,17   593088 1152921500312525303 /usr/lib/zsh/5.9/zsh/zle.so
zsh     39367 mattrighetti  txt    REG   1,17   134928 1152921500312525287 /usr/lib/zsh/5.9/zsh/rlimits.so
zsh     39367 mattrighetti  txt    REG   1,17   117920 1152921500312525263 /usr/lib/zsh/5.9/zsh/langinfo.so
zsh     39367 mattrighetti  txt    REG   1,17  2289328 1152921500312524246 /usr/lib/dyld
zsh     39367 mattrighetti  txt    REG   1,17   208128 1152921500312525249 /usr/lib/zsh/5.9/zsh/complist.so
zsh     39367 mattrighetti  txt    REG   1,17   118688 1152921500312525285 /usr/lib/zsh/5.9/zsh/regex.so
zsh     39367 mattrighetti  txt    REG   1,17   118288 1152921500312525305 /usr/lib/zsh/5.9/zsh/zleparameter.so
zsh     39367 mattrighetti    0u   CHR   16,1  0t17672                1643 /dev/ttys001
zsh     39367 mattrighetti    1u   CHR   16,1  0t17672                1643 /dev/ttys001
zsh     39367 mattrighetti    2u   CHR   16,1  0t17672                1643 /dev/ttys001
zsh     39367 mattrighetti   10u   CHR   16,1   0t5549                1643 /dev/ttys001

Согласно документации lsof:

  • cwd: текущая рабочая папка процесса.

  • txt: исполняемые файлы или общие библиотеки, загруженные в память (например, /bin/zsh, модули наподобие zutil.so или системные библиотеки наподобие /usr/lib/dyld).

  • 0u1u2u: соответственно, потоки стандартного ввода (0), вывода (1) и ошибки (2). Суффикс u означает, что дескриптор открыт и для чтения, и для записи. Они привязаны к /dev/ttys001 (моему текущему устройству терминала).

  • 10u: ещё один дескриптор файла (тоже привязанный к /dev/ttys001); вероятно, используется для дополнительных взаимодействий с терминалом.

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

Вы когда-нибудь задавались вопросом, сколько дескрипторов файлов может быть открыто одновременно? Ответ на него стандартен для сферы разработки ПО: это зависит от различных факторов.

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

В macOS эти ограничения можно изучить при помощи команд sysctl и ulimit в терминале.

console$ sysctl kern.maxfiles
kern.maxfiles: 245760

$ sysctl kern.maxfilesperproc
kern.maxfilesperproc: 122880

$ ulimit -n
256
  • kern.maxfiles — это максимальное абсолютное число дескрипторов файлов, которое может быть открыто во всей системе macOS в любой момент времени. Это глобальное значение, не позволяющее системе исчерпать ресурсы дескрипторов файлов, даже если выполняется много разных приложений.

  • kern.maxfilesperproc — это строгое ограничение количества дескрипторов, которое может открыть один процесс. Его можно считать максимальным ограничением для приложения. Процесс ни при каких условиях не может открыть больше файлов, чем это установленное ядром ограничение.

  • ulimit -n — это «мягкое» ограничение оболочки на количество открытых дескрипторов файлов. Если процесс пытается открыть больше файлов, чем это мягкое значение, то операционная система обычно возвращает ошибку (например, «Too many open files»). Удобно то, что процесс сам может повысить своё мягкое ограничение, но не выше, чем строгое.

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

Мне захотелось подтвердить эту гипотезу, поэтому я создал скрипт мониторинга, следящий за PID cargo test и выводящий с разными интервалами количество открытых дескрипторов файлов.

bash#!/bin/bash

# Эта функция выполняет мягкое завершение скрипта
function cleanup() {
    echo -e "\nstopping."
    exit 0
}

# Эта функция инкапсулирует логику форматирования и вывода вывода мониторинга.
# Аргументы :
#   $1: исходный PID
#   $2: общее количество открытых файлов
print_status() {
    local initial_pid="$1"
    local total_open_files="$2"
    echo "$(date '+%H:%M:%S') - Main PID ($initial_pid) - open: ${total_open_files}"
}

PROCESS_NAME="cargo"
COMMAND_ARGS="test"

echo "press ctrl+c to stop."

# Находим Process ID (PID) исходной команды.
INITIAL_PID=$(pgrep -f "$PROCESS_NAME.*$COMMAND_ARGS" | head -n 1)

if [ -z "$INITIAL_PID" ]; then
    echo "waiting for '$PROCESS_NAME $COMMAND_ARGS' to start..."
    # Если этот процесс не найден сразу же, ожидаем его в цикле.
    sleep 0.01
    while [ -z "$INITIAL_PID" ]; do
        INITIAL_PID=$(pgrep -f "$PROCESS_NAME.*$COMMAND_ARGS" | head -n 1)
    done
fi

echo "Found '$PROCESS_NAME $COMMAND_ARGS' with PID: $INITIAL_PID"

# команда trap перехватывает сигнал INT (срабатывающий при нажатии Ctrl+C)
# и вызывает функцию cleanup для выполнения беспроблемного выхода.
trap cleanup INT

while true; do
    # проверяем, продолжает ли работать основной процесс (INITIAL_PID).
    if ! ps -p "$INITIAL_PID" > /dev/null; then
        echo "PID $INITIAL_PID no longer running. bye!"
        break
    fi

    # `sudo lsof -p "$INITIAL_PID"` создаёт список всех открытых файлов для этого конкретного PID.
    # `2>/dev/null` перенаправляет stderr (ошибки вида "process not found") на null.
    # `grep -v " txt "` фильтрует загруженный исполняемый код и библиотеки, обеспечивая более точный посчёт.
    # `wc -l` подсчитывает строки, то есть, по сути, количество открытых файлов.
    # `tr -d ' '` устраняет все пробелы в начале/конце для чистоты вычислений.
    OPEN_FILES_COUNT=$(sudo lsof -p "$INITIAL_PID" 2>/dev/null | grep -v " txt " | wc -l | tr -d ' ')

    # Делаем так, чтобы COUNT не была пустой (это может быть, если lsof ничего не вернула)
    if [ -z "$OPEN_FILES_COUNT" ]; then
        OPEN_FILES_COUNT=0
    fi

    print_status "$INITIAL_PID" "$TOTAL_OPEN_FILES"
done

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

Теперь я могу запустить в одном терминале скрипт, а в другом терминале — cargo test. Для получения качественной выборки данных мне пришлось сделать это пару раз — после компиляции кода Rust работает довольно быстро, а скрипт мониторинга выполняется не так быстро, чтобы отлавливать изменения в открытых дескрипторах файлов.

console$ sudo ./monitor.sh
press ctrl+c to stop.
waiting for 'cargo test' to start...
Found 'cargo test' with PID: 44152
01:46:21 - Main PID (44152) - open: 14
01:46:21 - Main PID (44152) - open: 32
01:46:21 - Main PID (44152) - open: 78
01:46:21 - Main PID (44152) - open: 155
01:46:21 - Main PID (44152) - open: 201
01:46:21 - Main PID (44152) - open: 228
01:46:21 - Main PID (44152) - open: 231
01:46:21 - Main PID (44152) - open: 237 # ошибки начали возникать здесь
01:46:21 - Main PID (44152) - open: 219
01:46:21 - Main PID (44152) - open: 205
01:46:21 - Main PID (44152) - open: 180
01:46:21 - Main PID (44152) - open: 110
01:46:21 - Main PID (44152) - open: 55
01:46:21 - Main PID (44152) - open: 28
01:46:21 - Main PID (44152) - open: 15
01:46:21 - Main PID (44152) - open: 0
PID 44152 no longer running. bye!

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

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

console$ ulimit -n 8192
$ ulimit -n
8192

Теперь cargo test выполняется ожидаемым образом, а ошибка «Too many open files» не выбрасывается.

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

На показанном выше графике представлено количество открытых дескрипторов файлов с новым мягким ограничением. Как вы видите, максимальное значение достигает примерно 1600, что намного выше исходных 256.

В целом, это оказалось забавным упражнением, которое позволило мне многое узнать о дескрипторах файлов и их работе в системах, подобных Unix. Теперь вы знаете, как устранить эту проблему, если она возникнет в ваших собственных проектах!

Комментарии (4)


  1. mayorovp
    09.06.2025 12:23

    Я-то думал, щас будет история про утечки, образующиеся по самым хитрым причинам - а нет, тут просто ulimit подняли.

    Нет, знание про ulimit и правда нужное, но это ж не повод целую статью из пальца высасывать...


    1. cruiseranonymous
      09.06.2025 12:23

      Кроме ulimit тут ещё объяснено что и почему, так что вполне потянет на маленькую главу.


      1. mayorovp
        09.06.2025 12:23

        Больше похоже на затянутое введение.


  1. cruiseranonymous
    09.06.2025 12:23

    Оформление не вычитано - в кодофрагментах из исходника везде скопирован и элемент "rust/bash/console", который не часть кода, а указание на каком языке кодофрагмент.

    К переводу, впрочем, тоже начинаются вопросы. "# This function exits the script gracefully" -> "# Эта функция выполняет мягкое завершение скрипта"? Учитывая, что "мягкое" в тексте вовсю используется про soft-ограничение количества дескрипторов - такой переводу путает. "Аккуратное" скорее уж. Или, из того же кодофрагмента, "беспроблемное"