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

Строим базу данных в текстовом файле - меньше аллокаций со Span<T>

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

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

В предыдущих постах мы исправили data corruption с помощью SemaphoreSlim. Теперь writes безопасны. Но каждая operation все еще делает это:

var json = await File.ReadAllTextAsync(_filePath, ct);
var records = JsonSerializer.Deserialize<List<FileRecord>>(json) ?? [];

Это означает большую string со всем содержимым файла, list со всеми records и все deserialized objects за ним. Когда method returns, GC должен очистить большую часть этого. Под нагрузкой это создает постоянное давление на garbage collector.

Сегодня исправим самую большую часть этой проблемы.

Шаг 1 - меняем формат файла

Первая проблема даже больше, чем allocations. Посмотрите, что делает WriteAsync:

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);

Чтобы добавить один record, мы каждый раз читаем и перезаписываем весь файл. С 10 000 records это много потраченного впустую IO.

Переходим на append-only format. Вместо одного большого JSON array каждая record получает отдельную строку:

{"Id":"a1b2...","Name":"Record-1","Payload":"...","CreatedAt":"2026-01-01"}
{"Id":"c3d4...","Name":"Record-2","Payload":"...","CreatedAt":"2026-01-01"}
{"Id":"e5f6...","Name":"Record-3","Payload":"...","CreatedAt":"2026-01-01"}

Это называется NDJSON: Newline Delimited JSON. Теперь WriteAsync просто добавляет одну строку:

public async Task WriteAsync(FileRecord record, CancellationToken ct = default)
{
    await _writeLock.WaitAsync(ct);
    try
    {
        var line = JsonSerializer.Serialize(record) + "\n";
        await File.AppendAllTextAsync(_filePath, line, ct);
    }
    finally
    {
        _writeLock.Release();
    }
}

Больше не нужно читать весь файл, чтобы добавить один record. Только один append в конец.

Шаг 2 - Span для parsing

Теперь посмотрим, как мы читаем. Старый подход allocates string для всего файла, затем deserializes все records из одного большого JSON array. Все это попадает в heap и должно быть собрано GC.

С форматом NDJSON можно читать line by line. File.ReadLinesAsync все еще дает нам string для каждой строки, но мы избегаем создания лишних strings при trimming или slicing строки. Используем Span<char>: view поверх уже существующей memory.

await foreach (var line in File.ReadLinesAsync(_filePath, ct))
{
    var span = line.AsSpan().Trim();
    if (span.IsEmpty) continue;

    var record = JsonSerializer.Deserialize<FileRecord>(span);
    if (record is not null)
        result.Add(record);
}

line.AsSpan() не allocates. Он создает Span<char>, который указывает на ту же memory, что и string. Trim() на span тоже не allocates. Он просто меняет start и end view. Span напрямую передается в JsonSerializer.Deserialize, который принимает ReadOnlySpan<char>.

Span<T> - stack-only type. Сам span не boxed и не становится еще одним heap object, который GC должен собирать.

Полный ReadAllAsync

public async Task<List<FileRecord>> ReadAllAsync(CancellationToken ct = default)
{
    if (!File.Exists(_filePath))
        return [];

    var result = new List<FileRecord>();

    await foreach (var line in File.ReadLinesAsync(_filePath, ct))
    {
        var span = line.AsSpan().Trim();
        if (span.IsEmpty) continue;

        var record = JsonSerializer.Deserialize<FileRecord>(span);
        if (record is not null)
            result.Add(record);
    }

    return result;
}

Чисто и просто. Нет большой string размером с файл, нет JSON array, который нужно parse как один большой document. Мы все еще allocate returned List<FileRecord> и сами records, потому что method возвращает полный list. Но мы убрали самую крупную avoidable allocation из read path.

Это реально помогает?

Измерим через BenchmarkDotNet вместо догадок.

MethodMeanGen1Gen2Allocated
ReadAll_JsonArray14.74 ms578.13171.887.99 MB
ReadAll_NdjsonWithSpan15.76 ms312.50125.006.34 MB
[MemoryDiagnoser]
[SimpleJob]
public class ReadBenchmarks
{
    private const int RecordCount = 10_000;
    private string _jsonFilePath = null!;
    private string _ndjsonFilePath = null!;

    [GlobalSetup]
    public async Task Setup()
    {
        _jsonFilePath = Path.GetTempFileName();
        _ndjsonFilePath = Path.GetTempFileName();
        await File.WriteAllTextAsync(_jsonFilePath, "[]");

        NaiveFileStorage jsonStorage = new(_jsonFilePath);
        MemoryOptimizedStorage ndjsonStorage = new(_ndjsonFilePath);

        for (int i = 0; i < RecordCount; i++)
        {
            FileRecord record = new(
                Guid.NewGuid(),
                $"Record-{i}",
                $"Payload-{i}",
                DateTime.UtcNow);

            await jsonStorage.WriteAsync(record);
            await ndjsonStorage.WriteAsync(record);
        }
    }

    [Benchmark(Baseline = true)]
    public async Task<List<FileRecord>> ReadAll_JsonArray()
    {
        string json = await File.ReadAllTextAsync(_jsonFilePath);
        return JsonSerializer.Deserialize<List<FileRecord>>(json) ?? [];
    }

    [Benchmark]
    public async Task<List<FileRecord>> ReadAll_NdjsonWithSpan()
    {
        List<FileRecord> result = [];

        await foreach (string line in File.ReadLinesAsync(_ndjsonFilePath))
        {
            ReadOnlySpan<char> span = line.AsSpan().Trim();
            if (span.IsEmpty)
            {
                continue;
            }

            FileRecord? record = JsonSerializer.Deserialize<FileRecord>(span);
            if (record is not null)
            {
                result.Add(record);
            }
        }

        return result;
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        File.Delete(_jsonFilePath);
        File.Delete(_ndjsonFilePath);
    }
}

Allocated memory снизилась на 21%. Gen1 collections почти сократились вдвое. Но обратите внимание на Mean time: он примерно такой же, даже немного выше у optimized version. Именно здесь многие ошибаются: fewer allocations не означают автоматически, что code прямо сейчас быстрее. Они означают меньше работы для GC позже. Настоящая польза проявляется под sustained load, а не в одном isolated call.

Что изменилось

OperationBeforeAfter
WriteRead and rewrite entire fileAppend one line
ReadOne big string plus one full result listLine by line, Span<char> for trimming and parsing
GC pressureHighSignificantly lower

Файл все еще читается полностью при каждом вызове ReadAllAsync. Это следующая проблема. В следующем посте мы введем System.IO.Pipelines и IAsyncEnumerable: чтение файла chunks и streaming records вызывающему коду без предварительной загрузки всего в memory. Также там появится ArrayPool<T> там, где ему действительно место.

Предыдущие посты серии

  1. Строим базу данных в текстовом файле - почему async недостаточно
  2. Исправляем race condition - SemaphoreSlim в действии
  3. Synchronization Primitives in .NET: User Mode, Kernel Mode, and Thread Affinity

Source code: GitHub - sigmade/sigmade.github.io