Странное поведение File.AppendAllText
В упрощённом виде мой код выглядит следующим образом:
async Task Foo() {
while(true) {
...
lock (syncObj) {
...
File.AppendAllText(FileName, data);
}
}
}
void Main() {
...
tasks.Add(Task.Factory.StartNew(Foo, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap());
...
}
Очень редко я получаю IOException: The process cannot access the file 'path' because it is being used by another process. Как такое возможно, если доступ к файлу синхронизирован конструкцией lock? Больше нигде в коде доступ к файлу не осуществляется и другие процессы с файлом не работают. Возможно это какая то особенность WinAPI вызова, стоящего за AppendAllText, и редко выполнение может завершится раньше того момента как файл будет освобожден?
.NET 6.0, Windows
Ответы (3 шт):
Я бы сделал это через Task.ContinueWith, и никакой syncObj не нужен. У меня прекрасно работает.
readonly Queue<string> _events = new ();
Task _lasttask = Task.CompletedTask;
void DataChanged(string data) {
try {
lock (_events) _events.Enqueue(data);
_lasttask = _lasttask.ContinueWith(Foo);
}
catch { }
}
void Foo() {
try {
lock (_events) data = _events.Dequeue();
File.AppendAllText(FileName, data);
}
catch { }
}
Инициализация переменных условно объединена с объявлением.
Зачем делать асинхронную функцию, внутри которой синхронизировать доступ к файлу? Что за бред?
Вынесите взаимодействие с файлом вообще куда-то. Сделайте нормальный асинхронный метод с async/await. Доступ к файлу реализуйте через StreamWriter и всё у вас будет прекрасно.
Тут напрашивается паттерн Producer/Consumer. Множество потоков пишут в потокобезопасную коллекцию (лучше всего взять современный Channel), один поток выгребает сообщения из канала и пишет в файл.
На SO есть примеры использования Channel.
Использование lock внутри асинхронной функции противопоказано. Как я понимаю, это что-то похожее на логирование. В этом случае нужно минимизировать количество операций ввода-вывода, тем самым поднять производительность записи и снизить нагрузку на диск. Не нужно 200 раз в секунду открывать/закрывать файл на запись, это медленно. Как показал @rotabor, здесь сгодилась бы очередь.
И да, вы правы в том что в некоторых случаях операционная система может не успеть отпустить файл после закрытия, так как в зависимости от производительности файловой системы, в редких случаях закрытие файла может выполниться асинхронно, и в теории вы можете словить то самое исключение.
Я предлагаю объединять операции записи в группы. То есть с определённой периодичностью вычитываем буфер и пишем в файл одной операцией, затем ждём следующую порцию при закрытом файле.
private readonly Channel<string> _channel = Channel.CreateUnbounded<string>();
async Task Foo()
{
ChannelReader<string> reader = _channel.Reader;
while (true)
{
try
{
if (!await reader.WaitToReadAsync())
return; // данных больше не будет, вызван Complete()
using var sw = File.AppendText(FileName);
while (reader.TryRead(out string data))
{
await sw.WriteAsync(data);
}
}
catch (IOException) { } // файл занят, попробовать ещё раз
}
}
Писать в канал - потокобезопасная операция.
ChannelWriter<string> logger = _channel.Writer;
string text = "Hello!";
logger.TryWrite(text); // всегда вернёт true для Unbounded канала.
Суть решения в том, что файл не будет закрываться и переоткрываться на каждую запись. Канал будет ждать, когда придут данные, как только они придут, файл откроется и будет производиться непрерывная запись до тех пор, пока очередь строк на запись не будет полностью вычищена. Затем файл закроется и будет ожидаться следующая порция данных. Гарантию, что данные не пропадут и будут записаны если не с первой, то со следующей попытки, даст try-catch.
При завершении работы приложения просто вызовите logger.Complete(), чтобы закрыть канал (навсегда) и метод Foo завершится нормальным образом. По сути, это реализация шаблона проектирования Producer/Consumer. Вам нужно запустить всего один раз метод Foo, чтобы он обработал всё что попадёт в канал до конца. Либо если файлов для записи несколько, то столько методов, сколько есть файлов. Для каждого файла потребуется свой отдельный канал.
Почитать про каналы: Introduction to System.Threading.Channels