Недавно я работал над достаточно большим проектом на 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
Как видите, у нас есть 0
, 1
и 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
).0u
,1u
,2u
: соответственно, потоки стандартного ввода (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)
cruiseranonymous
09.06.2025 12:23Оформление не вычитано - в кодофрагментах из исходника везде скопирован и элемент "rust/bash/console", который не часть кода, а указание на каком языке кодофрагмент.
К переводу, впрочем, тоже начинаются вопросы. "# This function exits the script gracefully
" -> "# Эта функция выполняет мягкое завершение скрипта
"? Учитывая, что "мягкое" в тексте вовсю используется про soft-ограничение количества дескрипторов - такой переводу путает. "Аккуратное" скорее уж. Или, из того же кодофрагмента, "беспроблемное"
mayorovp
Я-то думал, щас будет история про утечки, образующиеся по самым хитрым причинам - а нет, тут просто ulimit подняли.
Нет, знание про ulimit и правда нужное, но это ж не повод целую статью из пальца высасывать...
cruiseranonymous
Кроме ulimit тут ещё объяснено что и почему, так что вполне потянет на маленькую главу.
mayorovp
Больше похоже на затянутое введение.