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

Text file ішінде database құру - неге async жеткіліксіз

Жарияланды

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

Бұл серияның идеясы

Senior .NET interviews көбіне “async/await түсіндіріңіз” деңгейінен әрі өтеді. Сізден SemaphoreSlim vs ReaderWriterLockSlim, Task орнына ValueTask қашан қолдану керек, System.IO.Pipelines regular Stream-нен қалай ерекшеленеді және Span<T> allocations-қа нақты не істейді деп сұрауы мүмкін.

Бұл topics-ті isolation ішінде оқу қиын, өйткені олардың неге маңызды екенін сезіну оңай емес. Сондықтан оларды theory ішінде бір-бірлеп өтудің орнына, real нәрсе құрамыз: simple file-based storage, әрі әр problem келесі solution-ды өзі негіздейді.

Бұл сериядағы әр post previous version алады, оны load астында бұзады және бір new concept арқылы fixes етеді. Соңында сізде SemaphoreSlim, IAsyncEnumerable, Span<T>, ArrayPool, Channel<T>, System.IO.Pipelines, ValueTask және Source Generators қолданатын working code болады: бәрі context ішінде, бәрі reason үшін.

Біз не құрамыз

Records-ты file ішіне сақтайтын CRUD storage. Interface барлық versions үшін бірдей:

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

Simple. Storage-тың әр version бірдей interface implementation береді, сондықтан benchmarks және stress tests ішінде implementations ауыстыру оңай.

Version 01 - naive implementation

Бірінші version күткеніңізді дәл жасайды. File оқиды, deserialize жасайды, record қосады, қайта serialize етеді, file жазады.

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

Clean, readable, fully async. Бір қарағанда мұнда проблема жоқ.

Load астында іске қосу

20 parallel 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...

20 records ішінен 19 жоғалды.

Неге бұлай болады

Root cause - classic read-modify-write race condition.

Барлық 20 tasks бір уақытта start жасайды. Олардың ешқайсысы жазып үлгермей тұрып, бәрі file оқиды және бірдей empty list көреді:

[]

Әр task өз record-ын қосып, [Record-X] мәнін disk-ке жазуға тырысады. Бірақ File.WriteAllTextAsync file-ды exclusively ашады. Бірнеше tasks бір уақытта жазуға тырысқанда, OS бас тартып, IOException тастайды.

Бірінші жазып үлгерген tasks ғана succeed етеді, әрі олардың өзі бірін-бірі overwrite етеді.

Theory бойынша бұл pattern silent data loss та тудыруы мүмкін. Timing сәл басқаша болып, tasks write lock-та collide етпесе, олар бәрібір бірін-бірі exception-сыз overwrite етеді. Last writer wins, қалғандары ізсіз жоғалады.

Біздің жағдайда Windows collision ұстап, error тастады. Бұл actually better outcome: кем дегенде бір нәрсе дұрыс емес екенін білесіз.

Async synchronization емес

Маңызды сабақ simple:

async waiting-ті non-blocking етеді. Ол shared state-ті safe етпейді.

Бұл code async, өйткені file I/O жүріп жатқанда thread block болмайды. Бұл пайдалы, бірақ екі operation бір file-ға бір уақытта touch ете ала ма деген сұраққа жауап бермейді.

Shared resource мұнда - file. Әр write operation толық read-modify-write sequence үшін exclusive access қажет етеді:

  1. Current file content оқу.
  2. Existing records deserialize жасау.
  3. New record қосу.
  4. New collection serialize жасау.
  5. Оны қайта жазу.

Егер басқа operation осы sequence ортасында кірсе, result predictable болмайды.

Келесі не

Бірінші fix - critical section қорғау. Келесі post ішінде SemaphoreSlim қосып, бір уақытта тек бір write operation file өзгерте алатындай етіп writes safe жасаймыз.

Бұл immediate corruption problem шешеді, бірақ жаңа сұрақ тудырады: бір writer бәрін block етсе, reads-пен не болады?

Storage дәл осы жерде interesting бола бастайды.