Файловые дескрипторы и stdio
Файловый дескриптор (FD) — небольшое неотрицательное целое, которым процесс обращается к открытому объекту ядра: файлу, сокету, pipe, устройству, eventfd или epoll-экземпляру. Это не сам файл и не указатель в памяти пользователя. В Linux таблица дескрипторов процесса сопоставляет номер FD со ссылкой на open file description (описание открытого файла) в ядре; оно, в свою очередь, ссылается на inode, сокет или иной объект.
Зачем это на интервью
Тема связывает API open/read/write/close, сеть, процессы, fork/exec и диагностику аварий вида too many open files. На E4 ожидают, что кандидат отличает FD от пути и понимает жизненный цикл сокета в сервисе. На Senior-интервью полезно объяснить разделяемое смещение после dup или fork, пределы ресурсов, наследование через exec и безопасный graceful shutdown.
Минимум для E4
- Назвать стандартные потоки:
0— stdin,1— stdout,2— stderr; они лишь соглашение, поэтому могут быть переназначены. - Объяснить, что
open,socket,accept,pipeвозвращают FD, аcloseосвобождает номер и уменьшает ссылку на объект ядра. - Закрывать ресурс на всех путях выполнения; в Go вызывать
defer f.Close()сразу после успешного открытия, если время жизни функции подходит. - Отличать лимит процесса
RLIMIT_NOFILEот системного лимита открытых файлов и проверять реальное потребление через/proc/<pid>/fdилиlsof. - Знать, что
readиwriteмогут вернуть меньше байтов, чем запрошено; обрабатывать ошибку и количество байтов.
Минимальный диагностический сценарий: сервис перестал принимать соединения, а accept или open возвращает EMFILE. Сначала фиксируют PID и число ссылок в /proc/<pid>/fd, классифицируют FD по целям симлинков, ищут монотонный рост и только затем временно повышают лимит. Повышение ulimit -n не устраняет утечку.
Углубление для E5/Senior
Три уровня состояния
| Уровень | Что хранит | Что происходит при dup |
|---|---|---|
| Таблица FD процесса | номер, флаг FD_CLOEXEC, ссылка на open file description | появляется новый номер с отдельным descriptor flag |
| Open file description | статусные флаги (O_APPEND, O_NONBLOCK), текущее смещение, ссылка на объект | разделяется между старым и новым FD |
| Объект ядра | inode, pipe, socket и его собственное состояние | остаётся тем же, пока есть ссылки |
dup, dup2 и dup3 создают другой номер, но обычно делят смещение. Поэтому два os.File, построенные поверх дубликатов одного FD, не получают независимых офсетов. Два независимых open одного пути, напротив, создают разные open file descriptions и смещения.
После fork дочерний процесс получает копию таблицы FD, но ссылки ведут к тем же open file descriptions. После exec FD обычно сохраняются. Флаг close-on-exec (FD_CLOEXEC, удобнее задавать атомарным O_CLOEXEC) закрывает конкретный FD во время успешного exec. Он предотвращает утечку слушающего сокета в запущенный дочерний процесс и особенно важен в многопоточном коде: отдельные open, затем fcntl создают окно гонки.
Неблокирующий ввод-вывод и готовность
O_NONBLOCK меняет поведение операций: вместо ожидания они могут вернуть EAGAIN/EWOULDBLOCK. Это не «данные потеряны», а сигнал повторить операцию после уведомления механизма готовности, например epoll. Готовность не гарантирует, что следующий read вернёт весь прикладной кадр: TCP — поток байтов, а write в сокет может быть частичной. Production-цикл должен иметь буферы, state machine и backpressure, а не busy loop на EAGAIN.
select и poll передают набор FD при каждом вызове; epoll хранит interest list в ядре и масштабируется лучше на большом числе соединений. Edge-triggered режим требует читать или писать до EAGAIN, иначе событие можно не увидеть повторно. Level-triggered проще для начала, но всё равно требует ограничения работы одного соединения, чтобы не голодали другие.
Эксплуатационные trade-offs
Лимит FD — это запас ресурса, а не только настройка. Одно входящее TCP-соединение занимает минимум один FD, исходящие соединения, файлы логов, epoll, pipes и DNS-резолвер добавляют свои. Capacity-план считают с резервом и ограничением concurrency; наблюдают process_open_fds, process_max_fds, ошибки EMFILE/ENFILE и скорость закрытий. Неразумно просто увеличить предел: растёт потенциальная память и blast radius утечки.
Для передачи FD между процессами используют SCM_RIGHTS через Unix domain socket. Это позволяет supervisor-у открыть привилегированный listener, а worker-ам не выдавать лишние права. Нужно явно договориться о владельце и закрытии: получатель получает новый FD, а закрытие у отправителя не закрывает уже переданную копию.
Ключевые понятия
stdio и перенаправление
stdin, stdout, stderr — потоки C-библиотеки, обычно поверх FD 0, 1 и 2. Shell строит pipeline и редиректы, дублируя и закрывая FD до запуска программы. Например, cmd >out 2>&1 направляет stdout в файл, затем делает stderr дубликатом текущего stdout. Порядок существенен: cmd 2>&1 >out сначала направляет stderr туда, куда stdout был в тот момент, а stdout затем — в файл.
Буферизация stdio — слой выше ядра. При выводе в терминал stdout обычно line-buffered, при выводе в файл — fully buffered; stderr часто небуферизован. Поэтому смешанные printf и write без синхронизации могут поменять порядок. В Go fmt.Fprintln(os.Stdout, ...) пишет через os.File; для управляемой буферизации применяют bufio.Writer и проверяют ошибку Flush.
Закрытие, повторное использование и ошибки
close(fd) делает номер доступным для немедленного повторного использования. Нельзя «на всякий случай» закрывать FD в другом потоке: он может уже обозначать совершенно иной объект. Владелец должен сериализовать завершение работы. Ошибку close нельзя всегда игнорировать: при записи в NFS или отложенном flush она может сообщить о потере данных; однако повторный close после ошибки опасен именно из-за повторного использования номера.
EINTR означает, что системный вызов был прерван обработчиком сигнала. Современные обёртки иногда перезапускают вызов, но код, работающий с raw syscall, обязан следовать контракту конкретного вызова и не терять уже возвращённое число байтов. Подробнее о доставке см. сигналы Unix.
Go: владение ресурсом
func copyFile(dst, src string) (err error) {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() {
if closeErr := in.Close(); err == nil {
err = closeErr
}
}()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer func() {
if closeErr := out.Close(); err == nil {
err = closeErr
}
}()
_, err = io.Copy(out, in)
return err
}У вызывающего кода должен быть понятный контракт: функция либо закрывает переданный io.Closer, либо нет. Для http.Response.Body тело закрывает вызывающий после чтения; для net.Listener прекращение Accept достигается через Close. Не размещайте defer в бесконечном цикле, который открывает файлы: закрытия отложатся до выхода из функции. Закрывайте ресурс в конце каждой итерации или выносите итерацию в отдельную функцию.
Типовые вопросы
- Чем FD отличается от файла и почему два FD могут иметь общее смещение?
- FD — индекс в таблице процесса.
dupиforkдают несколько индексов на одну open file description, поэтому смещение и статусные флаги общие; два вызоваopenсоздают разные описания.
- FD — индекс в таблице процесса.
- Почему
defer file.Close()внутри большого цикла опасен?deferвыполнится при возврате из окружающей функции, а не в конце итерации. До возврата можно исчерпатьRLIMIT_NOFILE; нужна вложенная функция или явное закрытие.
- Как устроен
EMFILEи как его расследовать?- Это лимит FD конкретного процесса. Проверяют
/proc/<pid>/limits, число и типы записей в/proc/<pid>/fd, метрики и места открытия; затем исправляют владение/пулы, а лимит меняют осознанно.
- Это лимит FD конкретного процесса. Проверяют
- Чем
FD_CLOEXECотличается отO_NONBLOCK?- Первый — descriptor flag конкретного номера и действует при
exec; второй — status flag open file description и влияет на I/O, а приdupразделяется.
- Первый — descriptor flag конкретного номера и действует при
- Может ли
writeуспешно записать не все байты?- Да, особенно для сокетов, pipes и неблокирующих FD. Нужно учитывать возвращённое
n, дописывать остаток либо пользоваться API, который делает это по контракту.
- Да, особенно для сокетов, pipes и неблокирующих FD. Нужно учитывать возвращённое
- Почему нельзя закрыть сокет из случайной goroutine без протокола владения?
Closeразблокирует операции, но гонка с повторным использованием или конкурентными записью/закрытием усложняет семантику. Отмену и единственного владельца определяют на уровне компонента; деталь shutdown связана с Go.
Практика
- Найдите карту FD процесса. Запустите небольшой HTTP-сервис, сделайте несколько keep-alive запросов и сравните
ls -l /proc/<pid>/fdдо и после. Критерий готовности: в отчёте подписаны минимум stdin/stdout/stderr, listener и один принятый сокет; объяснено, почему номер не равен типу объекта. - Воспроизведите утечку и исправьте её. Напишите Go-цикл, который открывает файл без закрытия, затем вариант с закрытием внутри итерации. Критерий готовности: первый вариант завершается на лимите или демонстрирует рост FD, второй сохраняет число FD в ограниченном диапазоне и не содержит
deferв бесконечном цикле. - Соберите pipeline. На shell соедините две программы через pipe и направьте stdout и stderr в разные файлы. Критерий готовности: показаны команды, содержимое обоих файлов и объяснён порядок
2>&1. - Проверьте partial write. Создайте неблокирующий
socketpairи пишите крупные блоки, обрабатываяEAGAIN. Критерий готовности: получатель сверяет длину и хеш потока, отправитель не делает busy loop и ждёт готовности записи.
Частые ошибки и ловушки
- Считать FD уникальным идентификатором объекта на всё время жизни процесса: номер переиспользуется сразу после
close. - Путать file descriptor с file description и делать неверный вывод о независимости смещений после
dup. - Читать из TCP «один
read— одно сообщение»; границы сообщений задаёт протокол приложения. - Игнорировать ошибку
Flush,SyncилиCloseпри критичной записи и одновременно повторно вызыватьcloseпосле ошибки. - Использовать
selectбез таймаута или context в коде, который должен прекращаться; это даёт зависание shutdown. - Увеличивать
nofile, не измерив количество соединений и не устранив утечку.
Связанные темы
База разработки и Computer Science · Процессы, потоки и goroutine · Виртуальная память · Сигналы Unix · Go: runtime и стандартная библиотека · Проектирование Go-приложений · Linux, сети и cloud
Источники
- POSIX.1-2024:
open,read,write,close,dup,fcntl,fork,execиpipe. - Linux man-pages:
open(2),close(2),dup(2),fcntl(2),epoll(7),proc_pid_fd(5),signal(7). - The Linux Programming Interface, главы об I/O, файловых дескрипторах и advanced I/O.
- Документация Go: пакеты
os,io,net,bufio,os/signalиsyscall.