Дескрипторы файлов в Node.js: FileHandle, флаги и EMFILE¶
Источник: theNodeBook — Node.js File Descriptors: FileHandle, Flags & EMFILE
Дескриптор файла в Node.js — это процессно-локальный handle для открытого файлового или файлоподобного ресурса. В основе механики лежат fs.open(), FileHandle, флаги открытия, корректное закрытие и ошибка EMFILE. На POSIX-системах дескриптор — целое число в таблице дескрипторов процесса. В Windows libuv сопоставляет платформенные handle с кроссплатформенной формой, которую Node экспонирует через API fs.
Каждая файловая операция в Node.js в итоге опирается на небольшой токен внутри процесса. Его нужно закрывать: утечка дескрипторов удерживает состояние ОС и рано или поздно упирается в лимиты процесса или системы. Ниже — как устроена таблица дескрипторов, что делают флаги, почему закрытие — отдельный шаг жизненного цикла и когда FileHandle предпочтительнее сырого целого числа.
Дескрипторы файлов в Node.js¶
fs.open() просит ОС открыть путь с флагами и режимом. ОС возвращает состояние открытого файла. Node представляет его как числовой дескриптор или как FileHandle. Дескрипторы обязательно закрывать: утечка удерживает ресурсы ядра, пока процесс не завершится или handle не освободится явно.
На POSIX это неотрицательное целое — индекс в таблице дескрипторов процесса. В Windows ОС отдаёт libuv HANDLE, а libuv возвращает Node значение в форме дескриптора. В JavaScript в обоих случаях чаще всего видно одно и то же: целое число. Передать его в read, в write, закрыть по завершении работы.
За размером числа скрывается больше состояния, чем кажется. fs.readFile(), fs.createReadStream(), fs.writeFile(), fs.open(), FileHandle, сокеты, pipe, stdin, stdout — всё это встречается с ОС через открытый ресурс, зафиксированный в состоянии ядра.
1 2 3 4 5 | |
Напечатанное значение может быть 17, 22 или рядом с ними. Node открывает несколько дескрипторов при старте, поэтому в реальном процессе пользовательский код редко получает 3. Число имеет смысл только внутри этого процесса. Другой процесс тоже может иметь fd 17, указывающий на другой файл, сокет, pipe или терминал.
Таблица дескрипторов¶
Процесс стартует с тремя уже открытыми дескрипторами.
0— стандартный ввод (stdin).1— стандартный вывод (stdout).2— стандартный поток ошибок (stderr).
Родительский процесс настраивает их до того, как Node выполнит ваш код. В shell fd 0 обычно ведёт на ввод терминала, fd 1 — на вывод терминала, fd 2 — на тот же терминал через поток ошибок. В service manager, контейнере или pipeline эти дескрипторы могут указывать на pipe, сокеты, коллекторы логов или файлы.
Node обрабатывает их как streams — об этом уже шла речь в разделе про потоки. На нижнем уровне это всё равно дескрипторы. console.log() в итоге пишет байты в fd 1. process.stderr.write() пишет байты в fd 2.
После первых трёх ядро для каждого открытия выдаёт наименьший свободный номер дескриптора. Закрыли один — слот снова доступен.
1 2 3 4 5 6 7 8 9 | |
c часто равен a: закрытие a освобождает слот в таблице. Точные значения зависят от дескрипторов, которые Node уже держит, но повторное использование — поведение ОС.
Запись в таблице указывает на open file description в памяти ядра. Там хранятся текущее смещение, режим доступа, поведение append, флаги состояния и ссылка на объект файла. На POSIX-файловых системах объект файла ведёт к inode. Если дескриптор — сокет или pipe, в ядре вместо inode — состояние сокета или pipe.
Один интерфейс. Разные объекты ядра.
Поэтому дескриптор может относиться не только к обычному файлу. TCP-сокеты занимают дескрипторы. Unix domain sockets — тоже. Pipe — тоже. /dev/null занимает один после открытия. Таблица дескрипторов лишь фиксирует «fd N указывает на этот открытый объект ядра»; какие операции допустимы, определяет сам объект.
Открытие файла¶
fs.open() — место, где путь в JavaScript превращается в дескриптор.
1 2 3 4 5 6 7 8 9 | |
Колбэк получает fd, потому что открытие завершилось в нативном коде. Node преобразовал путь и флаги, вызвал слой C++-привязок, создал запрос libuv к файловой системе и передал блокирующую работу libuv.
Путь открытия в ядре имеет фиксированную форму. Разрешение пути по компонентам. Проверка прав. Применение флагов. Создание или поиск объекта файла. Выделение open file description. Установка указателя на него в таблице дескрипторов процесса. Возврат номера дескриптора.
В Linux и macOS нативный вызов — open() или близкий родственник. В Windows libuv вызывает CreateFileW() и сопоставляет возвращённый HANDLE со своей файловой абстракцией. API fs в Node скрывает большую часть этого разделения, но на Windows ещё проявляются режимы совместного доступа, длина пути и права.
Дескриптор привязан к процессу. Два процесса, открывшие /tmp/data.txt, получают отдельные записи в таблице дескрипторов и отдельные open file description. У каждого открытия своё смещение в файле. Прочитали 100 байт через один дескриптор — смещение для этой open file description сдвинулось на 100. Отдельный вызов open сохраняет своё смещение.
Дублированные дескрипторы ведут себя иначе. dup(), fork() и наследование дескрипторов могут дать несколько записей в таблице, указывающих на одну open file description. Такие дескрипторы разделяют смещение. Прочитали 100 байт через один — следующее чтение через другой начнётся с новой позиции. Node держит прикладной код подальше от сырого наследования после fork(), но поведение ОС объясняет странные эффекты при передаче дескрипторов между процессами.
Флаги определяют состояние открытия¶
Второй аргумент fs.open() задаёт режим доступа и поведение при создании. Строковые флаги сопоставляются битовым флагам ОС.
'r' — только чтение. Файл должен существовать. Отсутствующий путь даёт ENOENT.
'r+' — чтение и запись. Файл должен существовать. Смещение с байта 0.
'w' — запись, создание при необходимости, усечение существующего содержимого до нуля байт при открытии.
'w+' — чтение и запись с тем же созданием и усечением.
'a' — дозапись, создание при необходимости, каждая запись попадает в конец файла.
'a+' — чтение и дозапись. Чтение может использовать позиции; запись по-прежнему append.
'wx' — запись с эксклюзивным созданием. Если путь уже есть, открытие падает с EEXIST. Проверка и создание выполняются внутри одной операции ядра: при гонке двух процессов за один путь побеждает один.
1 2 3 4 5 6 7 8 9 | |
Такой шаблон часто используют для lock-файлов и однократного создания. handleOpenError() может залогировать EEXIST и пробросить остальное. finally закрывает fd. Если writeSync() бросит исключение после успешного open, дескриптор всё равно закроется. Флаг важнее отдельной проверки существования: fs.existsSync() и затем fs.openSync(..., 'w') дают гонку между вызовами. O_EXCL сворачивает решение в одну операцию open.
fs.existsSync() перед fs.openSync(..., 'w') — классическая гонка. Для атомарного «создать только если нет» используйте 'wx' / O_EXCL.
Числовые флаги тоже поддерживаются:
1 2 3 4 5 6 7 8 | |
Строковые флаги читабельнее в прикладном коде. Числовые нужны для конкретного платформенного флага, при портировании логики из C или на границе с нативным addon, где уже оперируют битами.
Режим создания и umask¶
Третий аргумент fs.open() применяется, когда open создаёт файл. Он задаёт запрашиваемые биты прав.
1 2 3 4 | |
0o600 даёт владельцу чтение и запись. Группа и остальные получают 0. Три восьмеричные цифры кодируют владельца, группу и others. Внутри каждой цифры: чтение — 4, запись — 2, выполнение — 1.
Режим создания по умолчанию в Node — 0o666: чтение и запись для владельца, группы и others до применения маски процесса. umask процесса снимает биты с запрошенного режима. Типичная маска 0o022 превращает 0o666 в 0o644: владелец read/write, остальные только read.
Передача 0o666 запрашивает широкие права, но маска ОС сужает результат. В продакшене umask может отличаться от dev-shell — service manager, контейнер или init-система задают свою маску. Это источник реальных багов с правами файлов.
В Windows фактические решения о правах принимают ACL. Node по возможности сопоставляет POSIX-режимы с поведением Windows, но серьёзный контроль прав на Windows — отдельная Windows-специфичная работа.
Закрытие — часть операции¶
Открыть. Использовать. Закрыть.
Жизненный цикл короткий, и за каждым шагом стоит состояние. open выделяет слот в таблице дескрипторов и open file description в ядре. read, write, fstat, fsync и родственные вызовы используют это состояние. close освобождает слот дескриптора и уменьшает счётчик ссылок на open file description.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Вызов read с явной позицией 0 читает с начала и не трогает текущее смещение дескриптора. close внутри колбэка, потому что дескриптор остаётся открытым до завершения асинхронной операции. Форма колбэков быстро становится громоздкой — и в этом смысл: сырой fd-код требует явного пути закрытия, а закрытие тоже может завершиться ошибкой. Перенесите fs.close() выше fs.read() — read гоняется с повторным использованием дескриптора или падает с EBADF.
close() освобождает дескриптор, но записанные байты могут оставаться в грязных страницах page cache. Для устойчивости к сбою процесса после записи вызывайте fs.fsync() или FileHandle.sync(), затем закрывайте.
Повторное использование номера дескриптора даёт коварные баги при неверном порядке закрытия. Код закрыл fd 18, позже другая часть процесса открыла сокет и получила снова 18. Устаревший асинхронный колбэк со старым целым числом может работать уже с новым сокетом. Высокоуровневые API Node владеют дескрипторами внутри и снижают риск; с сырыми fd жизненный цикл остаётся на вас.
Утечки приводят к EMFILE¶
Утечённый дескриптор остаётся выделенным до выхода процесса или пока какой-то путь очистки его не закроет.
Одна утечка почти незаметна. Утечка на запрос валит сервис. ОС задаёт лимит дескрипторов на процесс, а Node делит этот бюджет между файлами, сокетами, pipe, stdio, внутренними дескрипторами libuv и активными TCP-соединениями.
Проверка лимита в shell:
1 | |
Типичные значения — от сотен до тысяч и выше. В контейнерах и под service manager лимит может отличаться от интерактивной оболочки.
Когда процесс исчерпывает запас, open падает с EMFILE.
1 2 3 4 5 6 7 | |
Каждый успешный open занимает слот. Сниппет утечёт все дескрипторы. Рано или поздно fs.open() начнёт возвращать EMFILE — обычно до достижения настроенного лимита, потому что Node и сам процесс уже держат дескрипторы.
Реальные утечки часто сидят в ветках ошибок.
1 2 3 4 5 6 7 8 9 10 11 | |
Если doWork() сообщает об ошибке, дескриптор остаётся открытым: обработчик ошибки выходит до close. В callback-коде нужен явный путь закрытия и при успехе, и при сбое.
Ранний return из обработчика ошибки до fs.close() — частая причина утечки дескрипторов под нагрузкой.
В Linux список открытых дескрипторов текущего процесса:
1 2 3 4 | |
Снаружи — lsof:
1 | |
Вывод показывает дескрипторы, целевые пути, сокеты, pipe и удалённые файлы, всё ещё удерживаемые открытыми. Счётчик, растущий со временем при стабильном трафике, обычно означает утечку. Плоский счётчик под нагрузкой — процесс открывает и закрывает с устойчивой скоростью.
Поднять лимит увеличивает запас:
1 | |
Это помогает серверу с большим числом легитимных одновременных сокетов. Утечка всё равно добьёт процесс после большего числа запросов. Сначала исправьте путь закрытия, затем подберите лимит под реальную конкурентность.
FileHandle — лучший выбор по умолчанию¶
Сырые целые fd легко обработать неверно. open() из node:fs/promises оборачивает дескриптор в FileHandle.
1 2 3 4 5 | |
Свойство .fd открывает доступ к нижележащему дескриптору, но большинству кода лучше оставаться на объекте. У FileHandle есть read(), write(), stat(), readFile(), writeFile(), truncate(), sync() и close(). Ресурс и операции над ним собраны вместе.
Для handle, живущего дольше одной операции, используйте try / finally.
1 2 3 4 5 6 7 8 9 10 | |
finally выполняется и при успехе, и при ошибке. Дескриптор закроется, если парсинг бросит исключение, read упадёт или код вернётся раньше. Поэтому FileHandle — разумный дефолт для нового кода.
В актуальных релизах Node есть явное управление ресурсами: FileHandle реализует Symbol.asyncDispose, и await using может закрыть handle при выходе из области видимости, если runtime и инструменты поддерживают синтаксис.
1 2 3 4 5 6 7 | |
Здесь два await. await open() ждёт открытия. await using регистрирует асинхронный disposer для привязки. В ESM top-level await using остаётся в контексте модуля, как его обычно показывают в примерах. При выходе из блока Node вызывает async disposer handle и закрывает дескриптор.
Сборка мусора — запасной диагностический путь. Если FileHandle стал недостижим, оставаясь открытым, Node может закрыть его и выдать предупреждение. Считайте предупреждение багом: момент GC не связан с давлением на дескрипторы, всплесками трафика или вашим лимитом fd.
Сырые дескрипторы всё ещё уместны: legacy callback-код, ожидания нативных addons, низкоуровневые API на целых числах. Для прикладного кода на async / await FileHandle яснее.
Путь через libuv¶
API файлового I/O отдают async-колбэки и promises, но обычные файловые операции в типичном пути Node всё равно выполняются как блокирующие вызовы ОС.
libuv даёт файловую работу через uv_fs_*: uv_fs_open(), uv_fs_read(), uv_fs_write(), uv_fs_close() и остальные. Каждый асинхронный вызов использует запрос uv_fs_t. C++-привязка Node заполняет его путём, fd, флагами, mode, буферами, смещениями и состоянием колбэка.
Для обычных файлов переносимый readiness-based async I/O ограничен платформой. В Linux есть io_uring, старый Linux AIO с ограничениями, readiness API macOS лучше подходят сокетам, чем файлам, в Windows — overlapped I/O с другой семантикой. Стабильный путь Node через libuv в обычной работе отдаёт файловые вызовы в пул потоков. По умолчанию четыре потока; размер задаёт UV_THREADPOOL_SIZE.
Последовательность для fs.open('/tmp/x', 'r', cb) конкретна.
Node валидирует аргументы в JavaScript. C++-привязка создаёт объект FS-запроса. libuv ставит работу в thread pool. Поток-воркер вызывает платформенный open. Ядро разрешает путь, проверяет права, выделяет состояние открытого файла и возвращает fd-подобное значение или ошибку. Воркер записывает результат в uv_fs_t и постит завершение в event loop. На главном потоке Node читает результат, собирает аргументы колбэка и вызывает ваш callback.
Event loop остаётся доступным, пока поток воркера блокируется в syscall. Но пул потоков может стать узким местом. Запустите 500 одновременных fs.open() с дефолтным пулом — четыре выполняются, остальные ждут в очереди libuv. Очередь примет лишнюю работу. Растёт задержка.
Пул общий. dns.lookup(), многие crypto-операции, сжатие и пользовательский код через libuv конкурируют с файловой системой. Увеличение UV_THREADPOOL_SIZE может помочь file-heavy нагрузкам, но растёт число нативных потоков и накладные расходы планировщика. Измеряйте workload. Слепая установка большого числа часто переносит ожидание из очереди libuv в планировщик ядра или на устройство хранения.
После того как воркер дошёл до open(), в ядре остаётся ещё один слой состояния. В Linux у процесса таблица дескрипторов. Записи указывают на open file description. Те указывают на inode или другие объекты. Несколько дескрипторов могут указывать на одну open file description после dup или наследования. Несколько open file description могут указывать на один inode после независимых open.
Отсюда поведение смещения. Два независимых fs.open('/tmp/log', 'r') дают отдельные open file description со своими смещениями. Дублированный дескриптор разделяет смещение с оригиналом. Режим append добавляет правило: при O_APPEND ядро ставит каждую запись в конец файла как часть операции write.
close() разматывает ссылки. Слот в таблице дескрипторов освобождается. Счётчик ссылок на open file description уменьшается. Когда закрыт последний дескриптор на эту open file description, ядро её освобождает. Если запись каталога уже удалена, на POSIX данные файла живут до последнего close. Поэтому место на диске может не освобождаться после rm, пока процесс держит файл открытым.
Дочерние процессы, которые порождает Node, получают контролируемый набор дескрипторов. libuv открывает fd с close-on-exec где уместно; API child_process передают только stdio и явно настроенные через stdio записи. Случайные файлы приложения не утекают в дочерние программы.
Синхронные API идут короче по нативному пути. fs.openSync() вызывает операцию на главном потоке JavaScript и блокирует до ответа ОС. Вызов может быть дешёвым при горячем кэше метаданных. Он может остановить весь процесс при медленном, удалённом или перегруженном хранилище или при ожидании инфраструктуры прав. Синхронные файловые вызовы уместны при старте, в CLI и скриптах, где блокировка event loop не стоит ничего в терминах трафика запросов.
Кроссплатформенные случаи¶
Большая часть кода на дескрипторах остаётся переносимой при использовании API Node и node:path. Несколько платформенных нюансов всё же важны.
Разделители путей различаются. Для них — path.join() и path.resolve().
Регистр имён различается. Файловые системы Linux обычно чувствительны к регистру. Windows и macOS по умолчанию часто считают File.txt и file.txt одним путём. Тесты, проходящие на одной платформе, на другой дают коллизии имён.
Биты прав различаются. POSIX-режимы и umask естественны в Unix-семействе. В Windows реальную модель несут ACL.
Блокировки файлов различаются. POSIX-локи обычно advisory. Режимы совместного доступа Windows могут запретить другие open. В ядре Node ограниченная поддержка locking; приложениям с lock-файлами часто нужен пакет под задачу и проверка на целевой ОС.
Длина пути различается. Современные Windows API при правильных префиксах и настройках процесса тянут длинные пути; старые допущения про MAX_PATH ещё всплывают в инструментах. Лимиты POSIX зависят от файловой системы и считаются в байтах.
Практика в продакшене¶
Предпочитайте API, которые владеют дескрипторами внутри. readFile, writeFile, appendFile, createReadStream и createWriteStream открывают и закрывают сами. Берите fs.open() или FileHandle, когда нужны повторные операции на одном дескрипторе, произвольный доступ, явные вызовы durability или интеграция с кодом, который уже ждёт fd.
Ограничивайте fan-out. Обработка 50 000 файлов может укладываться в небольшой набор активных дескрипторов.
1 2 3 4 5 6 7 | |
Так через ваш код одновременно проходит не больше 50 файловых операций. Точное число зависит от лимита дескрипторов, задержки хранилища, давления на thread pool и остальной нагрузки процесса.
В продакшене отслеживайте открытые дескрипторы. В Linux экспортируйте счётчик /proc/self/fd как gauge. Смотрите его рядом с RPS и активными сокетами. Растущий счётчик при плоском трафике — сигнал утечки.
Закрывайте в finally. Используйте await using, где toolchain это поддерживает. Держите сырые целые fd в минимальной области, где они действительно нужны.
Дескрипторы — конечный ресурс процесса. Node даёт высокоуровневые API, которые забирают большую часть жизненного цикла, но ядро по-прежнему учитывает каждый открытый файл, сокет и pipe. Когда счётчик упирается в лимит, следующий open падает. Исправление обычно скучное: меньше одновременных open, жёстче cleanup и метрики, которые показывают рост до того, как первым алертом станет EMFILE.
Связанное чтение¶
- Предыдущая: Zero-copy и scatter/gather в Node.js
- Далее: Чтение и запись файлов в Node.js