← Назад ко всем статьям

Строим базу данных в текстовом файле - почему async недостаточно

Опубликовано

Пост 1 из серии: Advanced C# for Your Next Interview

Идея этой серии

Senior .NET interviews часто выходят за рамки “объясните async/await”. Вас спрашивают про SemaphoreSlim vs ReaderWriterLockSlim, когда использовать ValueTask вместо Task, чем System.IO.Pipelines отличается от обычного Stream, и что Span<T> на самом деле делает с аллокациями.

Проблема изучения этих тем по отдельности в том, что трудно почувствовать, зачем они нужны. Поэтому вместо того, чтобы проходить их по одной в теории, мы построим что-то реальное: простое file-based storage, где каждая проблема сама обоснует следующее решение.

Каждый пост серии берет предыдущую версию, ломает ее под нагрузкой и исправляет одним новым концептом. К концу у вас будет рабочий код, который затрагивает SemaphoreSlim, IAsyncEnumerable, Span<T>, ArrayPool, Channel<T>, System.IO.Pipelines, ValueTask и Source Generators: все в контексте и все по причине.

Что мы строим

CRUD storage, который сохраняет записи в файл. Interface одинаковый для всех версий:

public interface IFileStorage<T>
{
    Task WriteAsync(T record, CancellationToken ct = default);
    Task<T?> FindAsync(Guid id, CancellationToken ct = default);
    Task DeleteAsync(Guid id, CancellationToken ct = default);
    IAsyncEnumerable<T> ReadAllAsync(CancellationToken ct = default);
}

И record, который мы сохраняем:

public record FileRecord(
    Guid Id,
    string Name,
    string Payload,
    DateTime CreatedAt
);

Все просто. Каждая версия storage реализует один и тот же interface, поэтому implementations легко менять в benchmarks и stress tests.

Версия 01 - наивная реализация

Первая версия делает ровно то, чего вы ожидаете. Прочитать файл, deserialize, добавить record, serialize обратно, записать файл.

public class NaiveFileStorage : IFileStorage<FileRecord>
{
    private readonly string _filePath;

    public NaiveFileStorage(string filePath)
    {
        _filePath = filePath;

        if (!File.Exists(_filePath))
            File.WriteAllText(_filePath, "[]");
    }

    public async Task WriteAsync(FileRecord record, CancellationToken ct = default)
    {
        var json = await File.ReadAllTextAsync(_filePath, ct);
        var records = JsonSerializer.Deserialize<List<FileRecord>>(json) ?? [];

        records.Add(record);

        await File.WriteAllTextAsync(_filePath, JsonSerializer.Serialize(records), ct);
    }
}

Код чистый, читаемый и полностью async. На первый взгляд все нормально.

Запускаем под нагрузкой

Запустим 20 параллельных writes и посмотрим, что произойдет:

var tasks = Enumerable.Range(0, 20).Select(i =>
    storage.WriteAsync(
        new FileRecord(
            Guid.NewGuid(),
            $"Record-{i}",
            $"Data-{i}",
            DateTime.UtcNow))
).ToList();

await Task.WhenAll(tasks);

Output:

[FAIL] Record-15 didn't write: The process cannot access the file...
[FAIL] Record-0  didn't write: The process cannot access the file...
[FAIL] Record-3  didn't write: The process cannot access the file...

19 из 20 records пропали.

Почему так происходит

Корневая причина - классическая read-modify-write race condition.

Все 20 tasks стартуют одновременно. До того как хотя бы одна из них закончит запись, все они читают файл и видят один и тот же пустой список:

[]

Каждая task добавляет свой record и пытается записать [Record-X] обратно на диск. Но File.WriteAllTextAsync открывает файл эксклюзивно. Когда несколько tasks пытаются писать одновременно, OS отказывает и выбрасывает IOException.

Успевают только tasks, которые записали первыми, и даже они перезаписывают друг друга.

Теоретически этот pattern может приводить и к тихой потере данных. Если timing чуть другой и tasks не сталкиваются на write lock, они все равно перезаписывают друг друга без exception. Последний writer wins, остальные исчезают без следа.

В нашем случае Windows поймал collision и выбросил ошибку, что на самом деле лучший исход. По крайней мере вы знаете, что что-то пошло не так.

Async - это не synchronization

Главный урок простой:

async делает ожидание non-blocking. Он не делает shared state безопасным.

Этот код async, потому что он не блокирует thread, пока идет file I/O. Это полезно, но ничего не говорит о том, разрешено ли двум operations трогать один и тот же файл одновременно.

Shared resource здесь - файл. Каждая write operation требует exclusive access ко всей read-modify-write sequence:

  1. Прочитать текущее содержимое файла.
  2. Deserialize существующие records.
  3. Добавить новый record.
  4. Serialize новую collection.
  5. Записать ее обратно.

Если другая operation зайдет в эту sequence посередине, результат уже непредсказуем.

Что дальше

Первое исправление - защитить critical section. В следующем посте мы добавим SemaphoreSlim и сделаем writes безопасными, разрешив только одной write operation изменять файл за раз.

Это решит immediate corruption problem, но также создаст новый вопрос: если один writer блокирует всех, что происходит с reads?

Вот там storage начинает становиться интересным.