Каждый администратор и разработчик под Windows рано или поздно сталкивается с ситуацией, когда файл визуально нигде не открыт, все редакторы закрыты, а система упорно отвечает, что объект занят другим процессом. Причина кроется в том, как ядро Windows управляет доступом к объектам через дескрипторы и какие обещания процессы дают друг другу при открытии файла. Понимание этой механики превращает загадочную ошибку в обычную инженерную задачу с инструментами диагностики и предсказуемым решением, а заодно объясняет, почему та же операция в Linux завершается молча и без жалоб.
Дескриптор как опосредованная ссылка на объект ядра
Дескриптор, или handle, это не указатель на файл и не путь к нему. Это числовой индекс в приватной таблице дескрипторов конкретного процесса, которую ведёт ядро. Когда программа вызывает CreateFile, ядро находит или создаёт объект файла в своём пространстве, записывает ссылку на этот объект в таблицу процесса и возвращает программе только индекс. Сам объект ядра при этом один на всех: два процесса, открывшие один и тот же документ, держат два разных дескриптора в собственных таблицах, но обе записи ссылаются на единый FILE_OBJECT, а тот в свою очередь связан со структурами файловой системы и кэшем.
Такое опосредование даёт ядру полный контроль: оно знает, сколько ссылок существует на каждый объект, и уничтожает объект только когда счётчик ссылок падает до нуля. Пока жив хотя бы один дескриптор, объект существует, а файл считается открытым. Именно поэтому закрытие окна редактора само по себе ничего не гарантирует: окно исчезло, а дескриптор в таблице процесса мог остаться из-за ветки кода, где CloseHandle забыли или где исключение перескочило через строку закрытия. Процесс продолжает существовать, таблица жива, ссылка на объект ядра не снята, и любая попытка удалить файл наталкивается на эту невидимую ниточку.
Дескрипторы существуют не только для файлов. Потоки, события, мьютексы, секции памяти, ключи реестра, сокеты, таймеры и задания открываются через тот же механизм, поэтому утечка дескрипторов возможна на любом типе объектов, а симптомы у неё общие. Само число дескрипторов тоже говорит о здоровье процесса: у типичной службы их сотни, у браузера тысячи, и резкий рост без деловой причины всегда повод насторожиться.
Режимы совместного доступа и флаги FILE_SHARE
Разгадка фразы о занятом файле лежит в параметре dwShareMode вызова CreateFile. Открывая файл, процесс обязан декларировать, что он разрешает делать с этим файлом остальным. Флаг FILE_SHARE_READ позволяет другим открывать файл для чтения, FILE_SHARE_WRITE разрешает чужую запись, а FILE_SHARE_DELETE допускает переименование и удаление, пока файл открыт. Если процесс не указал нужный флаг, любая несовместимая операция будет отклонена с ошибкой совместного доступа ещё до проверки прав, то есть даже администратор с полным доступом получит отказ.
Классическая ситуация выглядит так. Программа открыла лог на дозапись и указала только FILE_SHARE_READ, потому что автор не подумал о ротации. Файл открыт секунды, потом программа зависла в ожидании сети, держа дескриптор. Пользователь видит в проводнике обычный файл, ни одно окно его не показывает, но удалить его нельзя: запрошенная операция удаления несовместима с шарингом, который декларировал держатель. Ядро сравнивает желаемый доступ с суммой уже выданных обещаний и честно отказывает. Визуально всё закрыто, а на уровне объектов всё занято, и именно этот разрыв между видимым и реальным сбивает с толку новичков.
Отдельная тонкость касается самого удаления. Если все держатели открыли файл с FILE_SHARE_DELETE, то удаление в Windows на самом деле помечает файл как ожидающий удаления: имя исчезает из каталога, но данные и сам объект живут, пока не закроется последний дескриптор. Программы могут продолжать читать уже «удалённый» файл до конца, а новые попытки открыть старое имя получают отказ об ожидающем удалении. Это промежуточное состояние тоже удивляет: папка выглядит пустой, а место на диске не освободилось, потому что физическое освобождение кластеров ждёт нулевого счётчика ссылок.
Типовые удержатели дескрипторов
Одни и те же компоненты снова и снова оказываются в роли невидимого держателя. Зная их, розыск виновника занимает минуты:
- утечка CloseHandle в программе, которая отработала с ошибкой или упала в обработчике исключения до строки закрытия, из-за чего дескриптор остался в таблице живого процесса;
- предпросмотр миниатюр и панель сведений проводника, где Explorer открывает файл ради эскиза или метаданных и держит дескриптор, пока кэш не решит его отпустить;
- антивирусное сканирование по доступу, когда фильтр-драйвер безопасности захватывает файл в момент записи и проверяет содержимое дольше, чем длится попытка удаления;
- служба индексации поиска, открывающая документы для построения индекса в фоне и возвращающаяся к изменённым файлам через заметный интервал;
- проецирование файла в память через CreateFileMapping и MapViewOfFile, когда секция живёт, пока отображение не снято, даже если исходный файловый дескриптор уже закрыт.
Случай с отображением в память особенно коварен: разработчик закрывает хэндл файла сразу после MapViewOfFile и считает дело сделанным, но объект секции продолжает ссылаться на файл, и удаление блокируется до UnmapViewOfFile и CloseHandle секции. Точно так же библиотека, загруженная как модуль, держит свой образ отображённым до FreeLibrary или конца процесса, поэтому перезаписать работающую DLL «на живую» не выходит. К этому же семейству относятся службы сброса дампов, клиенты синхронизации папок и средства резервного копирования, которые открывают файл с низким приоритетом, но всё равно декларируют ограниченный шаринг. Даже скрипт, читающий файл построчно в цикле с задержкой, способен минутами удерживать дескриптор между итерациями.
Инструменты диагностики занятых дескрипторов
Windows предлагает целую лестницу средств от встроенных до отладочных. Самый доступный путь это Resource Monitor: на вкладке ЦП есть поле Associated Handles, куда вводится имя файла или его часть, и монитор немедленно показывает процессы, держащие совпадающие дескрипторы. Инструмент встроен в систему, не требует установки и для разовой бытовой задачи обычно полностью достаточен.
Process Explorer идёт дальше: команда Find Handle or DLL ищет подстроку по всем дескрипторам и отображённым модулям всех процессов, показывает тип объекта и позволяет одним щелчком перейти к держателю и даже закрыть конкретный дескриптор. Нижняя панель в режиме дескрипторов раскрывает всю таблицу выбранного процесса с типами и именами объектов. Консольные handle.exe из Sysinternals и встроенный openfiles дают тот же ответ в текстовом виде, удобном для скриптов и удалённых сессий. Openfiles дополнительно умеет показывать файлы, занятые через сетевые общие ресурсы с другой машины, хотя локальный режим ведения списка объектов требует предварительного включения и перезагрузки.
Типовой порядок действий выглядит так: сначала поиск имени файла в Resource Monitor или Process Explorer, затем проверка, не является ли держатель системным фоновым компонентом вроде антивируса или индексатора, потом анализ, закрыт ли ресурс в приложении корректно, и только после этого решение о способе снятия. Такой маршрут отсекает типичную ошибку, когда администратор силой закрывает дескриптор у антивируса посреди сканирования и получает повреждённый отчёт или повторный полный проход по диску. Для серверных расследований имеет смысл сразу собирать вывод handle.exe с ключом -a, чтобы видеть весь спектр открытых объектов подозрительного процесса.
Цена принудительного снятия и вежливая альтернатива
Закрытие чужого дескриптора из Process Explorer выглядит соблазнительно просто, но это хирургия без наркоза. Программа продолжает считать, что индекс в её таблице валиден; при следующей операции чтения она получит ERROR_INVALID_HANDLE, а при неудачном совпадении числа таблица может переиспользовать тот же индекс под другой объект, и программа запишет данные не в тот файл, а в чужой канал или ключ реестра. Последствия такой подмены непредсказуемы вплоть до порчи чужих данных. Принудительное снятие оправдано только для зависших процессов, которые всё равно будут завершены, либо когда потеря состояния держателя заведомо приемлема.
Цивилизованный механизм называется Restart Manager. Это системная служба и набор API, через которые установщик или программа обслуживания спрашивает у системы, кто держит файл, а затем просит держателей корректно освободить ресурс. Приложения, зарегистрированные в Restart Manager, получают уведомление, сохраняют состояние, закрывают дескрипторы и после завершения операции перезапускаются с восстановлением данных. Именно так работают установщики обновлений, которые заменяют занятые библиотеки без перезагрузки, и установщик платформы .NET идёт тем же путём, поэтому обновление рантайма почти никогда не требует ребута. Для своего продукта регистрация в Restart Manager это вопрос нескольких вызовов при старте, а выигрыш в том, что обновление перестаёт быть лотереей с занятыми файлами и вопросом «закройте всё вручную» исчезает из инструкций.
Открытые файлы в Linux и судьба inode
Linux демонстрирует противоположную философию. Там удаление это unlink, отрыв имени от inode, а сам inode и данные живут, пока открыт хотя бы один файловый дескриптор или существует ещё одно жёсткое имя. Поэтому работающая программа спокойно дозаписывает «удалённый» лог, а обновлённый пакет подхватывается после перезапуска службы, которая до того работала со старой версией библиотеки прямо из файла без имени. Ошибки «файл занят» при удалении как явления просто не существует.
Расхождение объясняется контрактами. Windows сделала совместный доступ опциональным и защитным по умолчанию: если автор не разрешил удаление, система оберегает его данные от исчезновения под ним, что исторически удобно для настольных приложений и установщиков, привыкших к стабильным путям. Unix поставил на соглашение о том, что ответственность за гонки несут сами программы, а файловая система лишь гарантирует целостность того, что уже открыто. Плата подхода Linux это невидимые потребители места: du показывает пустое дерево, а df сообщает о переполненном диске, пока команда lsof с фильтром по удалённым файлам не выведет процессы, удерживающие старые inode. Администратору там приходится перезапускать службу, чтобы освободить место, тогда как администратору Windows приходится искать держателя, чтобы удалить файл. Зеркальные проблемы одного и того же решения о времени жизни объектов.
Утечки дескрипторов и рост системной таблицы
Долгоживущий процесс с дефектным закрытием ресурсов копит дескрипторы безгранично: таблица процесса растёт, ядро выделяет память под записи, проверка прав и поиск замедляются, а в пределе процесс упирается в архитектурный лимит около шестнадцати миллионов дескрипторов или исчерпывает выгружаемый пул ядра. Симптомы коварны: служба работает недели, потом внезапно начинает отказывать в открытии файлов и сокетов при свободной памяти и диске, а перезапуск «лечит» её до следующего накопления.
Диагностика строится на наблюдении за колонкой Handles в диспетчере задач и за счётчиком Handle Count в Performance Monitor: монотонный рост без плато при стабильной нагрузке означает утечку. Утилита handle.exe с ключом -s показывает суммы по типам объектов и мгновенно указывает, что именно течёт: файлы, события или ключи реестра. В отладчике WinDbg команда !handle с параметрами фильтрации выводит статистику и конкретные записи таблицы процесса, а при включённом трассировании стеков выделения показывает и место в коде, где ресурс родился. Исправление всегда одно и то же: каждый успешный CreateFile, OpenEvent или RegOpenKeyEx должен иметь гарантированный CloseHandle по всем путям выхода, включая исключения, чему в C++ помогают RAII-обёртки вроде wil::unique_handle, а в управляемом коде конструкция using и шаблон SafeHandle. Дисциплина закрытия дешевле любой последующей охоты за тысячами сиротских записей в таблице ядра, а периодический контроль счётчиков на нагрузочных стендах превращает утечку из полевого сюрприза в обычный дефект, пойманный до релиза.
Restart Manager как взрослый способ. Современные установщики и перезаписыватели файлов не обязаны воевать с дескрипторами вручную: Windows предлагает Restart Manager API, который спрашивает систему, кто удерживает файл, аккуратно просит держателей завершиться через зарегистрированные обратные вызовы и перезапускает их после операции. Именно поэтому обновления store-приложений больше не требуют закрытия браузера «Вручную» - сервис пробует договориться, и лишь при немотивированном отказе падает обратно в старую парадигму «перезагрузка всё вылечит». Для администратора это означает, что поиск упрямого держателя дескриптора - диагностика одноразовая, а не ритуал: выясняя, кто держит, читается и причина, по которой держатель никогда не закрывает файл штатно.
Метку «файл занят» стоит читать в буквальном смысле данных. Режимы совместного доступа при открытии - тонкий контракт: файл, открытый с FILE_SHARE_DELETE, можно переименовать и удалить даже во время чтения, в то время как открытие через старые функции без этого флага делает именно защитную блокировку. Многие неловкие ситуации с «занятым» лог-файлом рождаются именно из неправильного выбора флагов при открытии, и правильное лечение там - не завершение чужих процессов, а корректный контракт совместного доступа.