Серияның 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:
asyncwaiting-ті 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 қажет етеді:
- Current file content оқу.
- Existing records deserialize жасау.
- New record қосу.
- New collection serialize жасау.
- Оны қайта жазу.
Егер басқа operation осы sequence ортасында кірсе, result predictable болмайды.
Келесі не
Бірінші fix - critical section қорғау. Келесі post ішінде SemaphoreSlim қосып, бір уақытта тек бір write operation file өзгерте алатындай етіп writes safe жасаймыз.
Бұл immediate corruption problem шешеді, бірақ жаңа сұрақ тудырады: бір writer бәрін block етсе, reads-пен не болады?
Storage дәл осы жерде interesting бола бастайды.