Пост 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 вместо догадок.
| Method | Mean | Gen1 | Gen2 | Allocated |
|---|---|---|---|---|
| ReadAll_JsonArray | 14.74 ms | 578.13 | 171.88 | 7.99 MB |
| ReadAll_NdjsonWithSpan | 15.76 ms | 312.50 | 125.00 | 6.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.
Что изменилось
| Operation | Before | After |
|---|---|---|
| Write | Read and rewrite entire file | Append one line |
| Read | One big string plus one full result list | Line by line, Span<char> for trimming and parsing |
| GC pressure | High | Significantly lower |
Файл все еще читается полностью при каждом вызове ReadAllAsync. Это следующая проблема. В следующем посте мы введем System.IO.Pipelines и IAsyncEnumerable: чтение файла chunks и streaming records вызывающему коду без предварительной загрузки всего в memory. Также там появится ArrayPool<T> там, где ему действительно место.
Предыдущие посты серии
- Строим базу данных в текстовом файле - почему async недостаточно
- Исправляем race condition - SemaphoreSlim в действии
- Synchronization Primitives in .NET: User Mode, Kernel Mode, and Thread Affinity
Source code: GitHub - sigmade/sigmade.github.io