← Барлық мақалаларға оралу

Text file ішінде database құру - Span<T> арқылы allocations азайту

Жарияланды

Серияның 4-посты: Advanced C# for Your Next Interview

Алдыңғы posts ішінде SemaphoreSlim арқылы data corruption түзеттік. Writes енді safe. Бірақ әр operation әлі де мынаны жасайды:

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

Бұл whole file content үшін big string, барлық records бар list және оның артындағы барлық deserialized objects деген сөз. Method return болғанда GC соның көбін cleanup етуі керек. Load астында бұл garbage collector-ға constant pressure жасайды.

Бүгін соның ең үлкен бөлігін түзетеміз.

1-қадам - File format өзгерту

Бірінші problem 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 қосу үшін біз әр жолы whole file оқып, қайта жазамыз. 10 000 records болса, бұл wasted IO көп.

Append-only format-қа көшеміз. Бір big JSON array орнына әр record өз line алады:

{"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 тек бір line append жасайды:

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 қосу үшін whole file оқу енді жоқ. Тек соңына бір append.

2-қадам - Parsing үшін Span

Енді қалай оқитынымызды қарайық. Old approach whole file үшін string allocate етеді, кейін барлық records-ты бір big JSON array-дан deserialize жасайды. Мұның бәрі heap-ке түседі және GC жинауы керек.

NDJSON format арқылы line by line оқи аламыз. File.ReadLinesAsync әлі де әр line үшін string береді, бірақ line trimming немесе slicing кезінде extra strings жасаудан қашамыз. Біз Span<char> қолданамыз: existing memory үстіндегі view.

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() allocate етпейді. Ол string-пен бірдей memory-ға pointing жасайтын Span<char> жасайды. Span үстіндегі Trim() да allocate етпейді. Ол view start және end ғана adjusts етеді. Span-ды ReadOnlySpan<char> қабылдайтын JsonSerializer.Deserialize ішіне directly береміз.

Span<T> - stack-only type. Span itself boxed болмайды және GC collect ететін another heap object болмайды.

Толық 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;
}

Clean және simple. File-sized big string жоқ, бір large document ретінде parse етілетін JSON array жоқ. Біз returned List<FileRecord> және records themselves әлі allocate етеміз, өйткені method full list қайтарады. Бірақ read path ішіндегі largest avoidable allocation алып тасталды.

Бұл шынымен көмектесе ме?

Guessing орнына 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 almost half болды. Бірақ Mean time мәніне қараңыз: ол roughly same, optimized version үшін тіпті сәл жоғары. Көп адам қателесетін жер осы: fewer allocations automatically faster code right now дегенді білдірмейді. Олар GC үшін later less work дегенді білдіреді. Real benefit sustained load астында көрінеді, single 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

File әр ReadAllAsync call кезінде әлі толық оқылады. Бұл келесі problem. Келесі post ішінде System.IO.Pipelines және IAsyncEnumerable енгіземіз: file chunks бойынша оқылады және records caller-ға бәрін memory-ға алдын ала жүктемей streaming жасалады. Сонымен қатар ArrayPool<T> да өз орнына келеді.

Осы сериядағы алдыңғы posts

  1. Text file ішінде database құру - неге async жеткіліксіз
  2. Race condition түзету - SemaphoreSlim in Action
  3. Synchronization Primitives in .NET: User Mode, Kernel Mode, and Thread Affinity

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