На відміну від мережевого вводу-виводу (TCP, HTTP, Unix-сокети), який в операційних системах функціонує через неблокуючі мультиплексори (epoll у Linux, kqueue у BSD/macOS, IOCP у Windows), дисковий ввід-вивід у більшості POSIX-систем не має універсального неблокуючого системного інтерфейсу. Спроба зчитати файл із фізичного диска блокує системний потік ядра ОС до моменту позиціонування механічних головок або вибірки комірок флеш-пам'яті.
Для забезпечення неблокуючої поведінки головного потоку JavaScript середовище Node.js делегує файлові операції пулу робочих потоків бібліотеки libuv (uv_threadpool_t). За замовчуванням розмір цього пулу становить лише 4 потоки (UV_THREADPOOL_SIZE = 4). Якщо сервіс одночасно ініціює десятки важких викликів fs.readFile, пул потоків повністю вичерпується. Це призводить до блокування не лише дискового I/O, а й ресурсомістких криптографічних обчислень (node:crypto) та дозволу DNS-імен (dns.lookup).
В інженерній розробці високонавантажених систем взаємодія з файловою системою вимагає суворого управління дескрипторами (FileHandle), використання позиційного вводу-виводу (Zero-Copy Positional I/O) та забезпечення транзакційних гарантій атомарності під час раптових перезавантажень або збоїв живлення.
Під час відкриття файлу ядро операційної системи виділяє ціле число — файловий дескриптор (fd), що слугує індексом у внутрішній таблиці відкритих файлів процесу:
EMFILE: too many open files: перевищено ліміт відкритих дескрипторів для поточного процесу. Цей ліміт регулюється системною конфігурацією ulimit -n (за замовчуванням 1024 або 4096 у більшості дистрибутивів Linux).ENFILE: file table overflow: вичерпано глобальний ліміт відкритих дескрипторів ядра для всіх запущених процесів операційної системи (/proc/sys/fs/file-max).Використання методу fs.open() із node:fs/promises повертає об'єкт FileHandle. Якщо розробник не звільняє дескриптор у блоці finally через handle.close(), виникає витік дескрипторів. Під час пікового навантаження сервіс гарантовано аварійно завершує роботу з помилкою EMFILE.
Прямий запис даних у файл за допомогою fs.writeFile не є атомарним. Якщо під час операції станеться збій процесу (OOM Killer, завершення контейнера Kubernetes SIGKILL) або апаратне вимкнення живлення, файл опиниться в пошкодженому проміжному стані (частково перезаписаний або обрізаний до нульового розміру).
Промисловий патерн гарантованого атомарного запису спирається на неподільність виклику POSIX rename(2):
.config.json.tmp.823912) у тій самій директорії та на тій самій точці монтування.handle.sync() (fsync), який примусово скидає кеш сторінок ядра (Page Cache) на фізичний контролер накопичувача.fs.rename(). На рівні файлової системи зміна покажчика inode є неподільною (атомарною). Будь-який паралельний процес бачить або стару, або нову версію файлу — проміжний пошкоджений стан фізично виключений.Для відстеження змін у файловій системі ядро ОС надає спеціалізовані підсистеми:
fs.watchFile: використовує періодичний поллінг метаданих через системний виклик stat(2) (інтервал за замовчуванням — 5007 мс). Створює постійне фонове навантаження на CPU та чергу дискового контролера.fs.watch: спирається безпосередньо на нативні події ядра:
inotify.kqueue та демон FSEvents.ReadDirectoryChangesW.fs.watch: генерація дублюючих подій (деревчання), відсутність рекурсивного спостереження на Linux, втрата подій при переповненні буфера ядра та генерація некоректних імен файлів під час атомарних збережень текстовими редакторами.chokidar: перевірений галузевий стандарт, що нормалізує міжплатформні відмінності, підтримує дебаунсинг подій і коректно відстежує атомарні заміни файлів.Взаємодія з дисковою підсистемою базується на управлінні вказівниками ядра та дворівневому кешуванні даних.
Високорівнева абстракція Node.js, що інкапсулює числовий дескриптор файлу в ядрі ОС і надає методи прямого позиційного читання та запису. Системна помилка ядра, яка виникає під час спроби відкрити новий файл, сокет чи пайп, коли процес вичерпав ліміт виділених дескрипторів (ulimit -n). Системний виклик ядра, що блокує виконання доти, доки всі модифіковані дані та метадані з Page Cache операційної системи не будуть фізично записані на енергонезалежний накопичувач. Операції низькорівневого читання або запису за явно вказаним зміщенням (position) у байтах без зміни глобального файлового курсора.[ JavaScript Event Loop ]
|
| fs.open() / handle.read()
v
+-------------------------------------------------------+
| libuv Thread Pool |
| [ Worker 1 ] [ Worker 2 ] [ Worker 3 ] [ ... ] |
+-------------------------------------------------------+
|
| Блокуючий системний виклик (pread/pwrite)
v
+-------------------------------------------------------+
| OS Kernel (Page Cache) |
| +-------------------------------------------------+ |
| | Dirty Pages Cache (Модифіковані сторінки в RAM) | |
| +-------------------------------------------------+ |
+-------------------------------------------------------+
|
| handle.sync() [fsync]
v
+-------------------------------------------------------+
| Physical Storage (NVMe / SSD / HDD) |
| +-------------------------------------------------+ |
| | Фізичний запис блоків у комірки NAND-пам'яті | |
| +-------------------------------------------------+ |
+-------------------------------------------------------+Реалізація надійного методу атомарного запису, що повністю виключає пошкодження даних при аварійній зупинці процесу.
import { open, rename, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export async function writeAtomic(filePath: string, data: Buffer | string): Promise<void> {
const dir = dirname(filePath);
// Тимчасовий файл створюється суворо в тій самій директорії (гарантія єдиної точки монтування)
const tempPath = join(dir, `.${randomBytes(8).toString('hex')}.tmp`);
let handle;
try {
// 1. Відкриваємо тимчасовий файл із правами 0o644 та прапорцем ексклюзивного створення (w)
handle = await open(tempPath, 'w', 0o644);
// 2. Записуємо корисне навантаження
await handle.writeFile(data);
// 3. ПРИМУСОВИЙ СКИД КЕШУ ОС: гарантує фізичний запис на диск
await handle.sync();
// 4. Закриваємо дескриптор перед атомарним перейменуванням
await handle.close();
handle = null;
// 5. Атомарна підміна цільового файлу (POSIX rename)
await rename(tempPath, filePath);
} catch (error) {
// У разі збою гарантовано звільняємо ресурси та видаляємо тимчасовий файл
if (handle) {
try {
await handle.close();
} catch {}
}
try {
await unlink(tempPath);
} catch {}
throw error;
}
}Позиційне I/O дає змогу вибірково працювати з багатогігабайтними файлами без тривалого послідовного сканування з початку.
import { open } from 'node:fs/promises';
export interface RecordHeader {
id: number;
timestamp: bigint;
payloadLength: number;
}
export class LargeFileIndexer {
/**
* Зчитує бінарний заголовок запису з довільної позиції великого файлу.
*/
public static async readRecordHeaderAt(filePath: string, bytePosition: number): Promise<RecordHeader> {
const handle = await open(filePath, 'r');
try {
// Виділяємо буфер точно під розмір заголовка: 4B (id) + 8B (time) + 4B (len) = 16 байт
const buffer = Buffer.alloc(16);
// Читання рівно 16 байт зі зміщення bytePosition БЕЗ переміщення глобального курсора
const { bytesRead } = await handle.read(buffer, 0, 16, bytePosition);
if (bytesRead < 16) {
throw new Error(`Не вдалося зчитати повний заголовок: отримано лише ${bytesRead} байт`);
}
return {
id: buffer.readUInt32BE(0),
timestamp: buffer.readBigUInt64BE(4),
payloadLength: buffer.readUInt32BE(12),
};
} finally {
// Гарантоване закриття дескриптора
await handle.close();
}
}
/**
* Атомарно оновлює статусний байт за точним зміщенням у файлі.
*/
public static async updateRecordStatus(filePath: string, bytePosition: number, newStatus: number): Promise<void> {
const handle = await open(filePath, 'r+'); // Режим читання та запису без урізання файлу
try {
const statusBuf = Buffer.from([newStatus]);
// Запис 1 байта безпосередньо за зміщенням bytePosition
await handle.write(statusBuf, 0, 1, bytePosition);
// Скидання зміненого блоку на фізичний носій
await handle.sync();
} finally {
await handle.close();
}
}
}Формування унікального шляху .filename.tmp у тій самій директорії для виключення помилки EXDEV.
Виклик open() із правами 0o644 та прапорцем w для прямого небуферизованого запису.
Примусова фіксація сторінок із системного кешу оперативної пам'яті на фізичний диск.
Звільнення дескриптора в системній таблиці процесу перед зміною метаданих файлової системи.
Неподільна зміна вказівника inode; паралельні читачі бачать виключно цілісний стан.
Промисловий менеджер пулу файлових дескрипторів, що надійно запобігає вичерпанню системних лімітів EMFILE під час масових паралельних операцій.
import { open, FileHandle, unlink, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export class ManagedFilePool {
private activeDescriptors: number = 0;
private queue: Array<() => void> = [];
private readonly maxConcurrency: number;
constructor(maxConcurrency: number = 64) {
// Обмежуємо паралелізм значно нижче стандартного ліміту ulimit (1024)
this.maxConcurrency = maxConcurrency;
}
/**
* Захоплює слот у семафорі дескрипторів.
*/
private async acquire(): Promise<void> {
if (this.activeDescriptors < this.maxConcurrency) {
this.activeDescriptors++;
return;
}
await new Promise<void>((resolve) => {
this.queue.push(resolve);
});
this.activeDescriptors++;
}
/**
* Звільняє слот семафора та активує наступне очікуюче завдання.
*/
private release(): void {
this.activeDescriptors--;
const next = this.queue.shift();
if (next) {
next();
}
}
/**
* Виконує файлову операцію з гарантованим закриттям дескриптора та захистом від EMFILE.
*/
public async withFile<T>(
filePath: string,
flags: string,
operation: (handle: FileHandle) => Promise<T>
): Promise<T> {
await this.acquire();
let handle: FileHandle | null = null;
try {
handle = await open(filePath, flags);
return await operation(handle);
} finally {
if (handle) {
try {
await handle.close();
} catch (closeErr) {
console.error(`Помилка закриття дескриптора для ${filePath}:`, closeErr);
}
}
this.release();
}
}
/**
* Виконує атомарний запис із регулюванням конкурентності та синхронізацією fsync.
*/
public async writeAtomic(filePath: string, content: Buffer | string): Promise<void> {
await this.acquire();
const dir = dirname(filePath);
const tempFile = join(dir, `.${randomBytes(6).toString('hex')}.tmp`);
let handle: FileHandle | null = null;
try {
handle = await open(tempFile, 'w', 0o644);
await handle.writeFile(content);
await handle.sync(); // Примусова фіксація на фізичному носії
await handle.close();
handle = null;
await rename(tempFile, filePath);
} catch (error) {
if (handle) {
try {
await handle.close();
} catch {}
}
try {
await unlink(tempFile);
} catch {}
throw error;
} finally {
this.release();
}
}
public getActiveCount(): number {
return this.activeDescriptors;
}
}Розробник створює тимчасовий файл у системній папці /tmp (яка часто змонтована в RAM через tmpfs), а потім викликає fs.rename('/tmp/data.tmp', '/var/data/state.json'). Операційна система відхиляє запит критичною помилкою EXDEV: cross-device link not permitted, оскільки виклик rename(2) не вміє атомарно переносити дані між різними файловими системами.
Вирішення: Завжди створюйте тимчасовий файл у тій самій директорії, де розташований цільовий файл, або на тому самому дисковому розділі.
Відкриття дескриптора через const handle = await fs.open(path) без гарантованого завершення в блоці try ... finally. Якщо під час читання, парсингу або обробки виникає помилка, рядок await handle.close() пропускається. Дескриптор залишається відкритим, і з часом сервіс падає з помилкою EMFILE.
Вирішення: Завжди закривайте await handle.close() у блоці finally або застосовуйте синтаксис await using (Explicit Resource Management у TypeScript 5.2+).
Виклик await handle.writeFile(data) з наступним закриттям файлу без синхронізації. Розробник вважає, що дані збережені на диску, однак ядро ОС лише помістило їх у Page Cache в оперативній пам'яті. У разі аварійного вимкнення живлення чи перезавантаження сервера в наступні кілька секунд несинхронізовані байти зникають.
Вирішення: Для журналів транзакцій та критичних конфігурацій викликайте await handle.sync() перед закриттям дескриптора.
Використання fs.watchFile для моніторингу конфігураційних файлів мікросервісу. Метод безперервно генерує виклики stat, створюючи постійне непотрібне навантаження на диск і процесор.
Вирішення: Використовуйте реактивні системні сповіщення через fs.watch або бібліотеку chokidar, що працює на базі подій ядра ОС.
Захоплення слота в семафорі для запобігання перевищенню системного ліміту ulimit.
Створення файлу .filename.tmp суворо в тій самій директорії монтування.
Виклик handle.sync() для фізичної фіксації байтів на енергонезалежному накопичувачі.
Звільнення дескриптора в системній таблиці процесу перед зміною метаданих файлу.
Виклик POSIX rename(2) для неподільного оновлення робочого файлу, видимого читачам.