Пост 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:
- Прочитать текущее содержимое файла.
- Deserialize существующие records.
- Добавить новый record.
- Serialize новую collection.
- Записать ее обратно.
Если другая operation зайдет в эту sequence посередине, результат уже непредсказуем.
Что дальше
Первое исправление - защитить critical section. В следующем посте мы добавим SemaphoreSlim и сделаем writes безопасными, разрешив только одной write operation изменять файл за раз.
Это решит immediate corruption problem, но также создаст новый вопрос: если один writer блокирует всех, что происходит с reads?
Вот там storage начинает становиться интересным.