Mert Özen Her Satırda Daha İleri
Backend .NET'te I/O İşlemleri — #02

FileStream ve Async File I/O: FileStreamOptions, RandomAccess ve Atomik Yazma

Mert Özen 14 Kas 2026 16 dk 1 görüntülenme
FileStream ve Async File I/O: FileStreamOptions, RandomAccess ve Atomik Yazma

Bir dosyayı yarım kalmadan değiştirmenin yolu, içeriği aynı klasörde geçici bir dosyaya yazıp Flush(flushToDisk: true) ile diske indirmek ve File.Move ile tek adımda yerine koymaktır. FileStreamOptions'ı, Windows ve Linux'ta async I/O farkını, RandomAccess ile offset tabanlı okumayı, CreateNew ile race'siz oluşturmayı ve FileSystemWatcher'ın kaçırdıklarını yakalayan bir Worker'ı Bookstore örnekleriyle gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Microsoft.Extensions.Hosting 10.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) her siparişin faturası PDF olarak diske yazılıyor. Kod şöyle:

await using var fs = new FileStream(path, FileMode.Create);
await fs.WriteAsync(pdfBytes);

PDF'i üreten kod yazmanın ortasında exception fırlatırsa, dün doğru şekilde yazılmış olan fatura dosyasının yerinde ne kalır?

İkinci soru: new FileStream(path, FileMode.Open) ile açtığın bir dosyada ReadAsync çağırıyorsun. Bu çağrı Windows'ta gerçekten asenkron mu çalışır, yoksa bir thread pool thread'ini meşgul mü eder?

Üçüncüsü: ERP (Enterprise Resource Planning; muhasebe, stok ve satışı yöneten kurumsal yazılım) sistemi her gece inbox klasörüne orders-2026-11-14.jsonl bırakıyor. FileSystemWatcher'ın Created event'inde dosyayı hemen okuyan bir Worker kaç sipariş görür?

Bir önceki yazıda Stream contract'ına bakmıştık. Bu yazıda en çok kullandığımız stream'e, FileStream'e ve dosya sistemiyle çalışmanın üretimde can sıkan taraflarına geçiyoruz. Örnekler serinin repo'sunda Bookstore.IO.Files console uygulaması ve Bookstore.OrderImport.Worker servisi olarak duruyor:

dotnet run --project src/Bookstore.IO.Files -- buffering
dotnet run --project src/Bookstore.OrderImport.Worker

.NET 6'daki Yeniden Yazım ve FileStreamOptions

FileStream .NET 6'da neredeyse baştan yazıldı. En görünür değişiklikler şunlar: async yol belirgin şekilde hızlandı, Position artık her okuma ve yazmada işletim sistemiyle senkronize edilmiyor (yalnız bellekte tutuluyor) ve onlarca constructor overload'unun yerine tek bir FileStreamOptions parametresi geldi:

var options = new FileStreamOptions
{
    Mode = FileMode.CreateNew,
    Access = FileAccess.Write,
    Share = FileShare.None,
    Options = FileOptions.Asynchronous | FileOptions.WriteThrough,
    BufferSize = 0,
    PreallocationSize = 256 * 1024
};

await using var stream = new FileStream(path, options);
Console.WriteLine($"IsAsync: {stream.IsAsync}, Length after open: {stream.Length}");
await stream.WriteAsync(new byte[10_000]);
Console.WriteLine($"Length after writing 10,000 bytes: {stream.Length}, Position: {stream.Position}");
IsAsync: True, Length after open: 0
Length after writing 10,000 bytes: 10000, Position: 10000

Alanları tek tek geçelim. Mode dosya yoksa ne olacağını, varsa ne olacağını belirler; CreateNew dosya varsa exception fırlatır, birazdan bunun neden önemli olduğunu göreceğiz. Access varsayılan olarak Read, Share da varsayılan olarak Read. Share diğer process'lere ne izin verdiğini söyler: FileShare.None ile açtığın sürece başka kimse dosyayı açamaz.

BufferSize varsayılan olarak 4096. 0 ya da 1 vermek FileStream'in kendi buffer'ını kapatır; 1'in bu anlamı her sürümde vardı, 0 .NET 6 ile eklendi. Kendi büyük buffer'ını yönetiyorsan (ör. 1 MB'lık okumalar) FileStream'in ayrıca 4 KB'lık bir kopya yapmasına gerek yoktur. Ama küçük parçalar halinde yazıyorsan buffer'ı kapatmak pahalıdır. 100.000 kısa satırı üç farklı buffer boyutuyla yazdık:

for (int i = 0; i < 100_000; i++)
{
    await fs.WriteAsync(line); // 35 byte'lık bir CSV satırı
}
BufferSize      0:   179 ms
BufferSize   4096:     8 ms
BufferSize  65536:     8 ms

Buffer olmadan her WriteAsync ayrı bir sistem çağrısına dönüşüyor. Mutlak sayılar makineye ve işletim sistemine göre değişir, ama 20 kattan fazla fark her yerde ciddi bir fark.

PreallocationSize, dosya oluşturulurken diskte o kadar yer ayırmasını işletim sistemine söyler. Çıktıda gördüğün gibi Length 0 kalır; ayrılan yer dosyanın boyutunu değil, diskteki rezervasyonu etkiler. Yazacağın boyutu önceden biliyorsan (fatura byte'ları elindeyse) bunu vermek, dosyanın büyüdükçe parçalanmasını azaltır ve yer yetmeyecekse hatayı yazmanın ortasında değil daha dosya açılırken almanı sağlar. FileOptions.WriteThrough ise yazmaların işletim sisteminin cache'inde beklemeden diske gitmesini ister; her yazmayı yavaşlattığı için yalnız gerçekten gerektiğinde kullan.

Gerçek Async I/O: Windows ve Linux Farkı

İkinci sorunun cevabı FileOptions.Asynchronous'ta. Windows'ta bir dosya iki şekilde açılabilir: senkron ya da overlapped (asenkron) handle ile. Dosyayı FileOptions.Asynchronous ya da useAsync: true ile açarsan ReadAsync işletim sistemine bir istek bırakır ve thread'i serbest bırakır; işlem bitince I/O completion port üzerinden haber gelir. Bu bayrak olmadan açtığın dosyada ReadAsync çağırırsan .NET senkron okumayı bir thread pool thread'inde çalıştırır. Kod async görünür, ama okuma süresince bir thread bloklanır ve Microsoft'un .NET 6 yazısına göre bu yolda iptal desteği de yoktur. Tersi de geçerli: async açılmış bir dosyada senkron Read çağırırsan .NET asenkron bir işlem başlatıp bitmesini bekler.

Linux ve macOS'ta durum farklı. Bu platformlarda normal dosyalar için .NET'in kullandığı bir async dosya API'si yok; her async dosya işlemi thread pool'da senkron bir işlem olarak çalışır ve senkron/asenkron handle ayrımı da yoktur. Yani aynı kod Windows'ta thread kullanmadan beklerken Linux'ta bir thread'i meşgul eder. Bu genelde sorun değildir, ama yüzlerce eşzamanlı büyük dosya okuması yapan bir servisi Linux container'da çalıştırıyorsan thread pool'un büyümesini izlemek gerekir.

Pratik kural basit: async method'ları kullanacaksan dosyayı FileOptions.Asynchronous ile aç. File.ReadAllTextAsync, File.WriteAllBytesAsync, File.ReadLinesAsync gibi hazır method'lar bunu kendi içinde zaten yapıyor.

Bütün Dosya mı, Satır Satır mı: ReadAllTextAsync ve ReadLinesAsync

File.ReadAllTextAsync kısa ve rahattır, ama dosyanın tamamını tek bir string olarak belleğe alır. .NET 7 ile gelen File.ReadLinesAsync ise satırları IAsyncEnumerable<string> olarak tek tek verir. 200.000 siparişlik, 68 MB'lık bir JSON Lines dosyası üzerinde ikisini karşılaştırdık:

string all = await File.ReadAllTextAsync(path);

int count = 0;
int longest = 0;
await foreach (string line in File.ReadLinesAsync(path))
{
    count++;
    longest = Math.Max(longest, line.Length);
}
File: 68,001,648 bytes
ReadAllTextAsync: one string of 68,001,648 chars (136,003,296 bytes), allocated 275,416,712 bytes
ReadLinesAsync:   200,000 lines, longest 466 chars, allocated 158,499,368 bytes

Toplam allocation'lar birbirine yakın görünebilir, ama asıl fark aynı anda bellekte duran veride. ReadAllTextAsync 136 MB'lık tek bir string tutuyor; .NET string'leri UTF (Unicode Transformation Format; Unicode karakterlerini byte'a çeviren kodlama ailesi) ailesinden UTF-16'yı kullandığı, yani karakter başına en az 2 byte tuttuğu için 68 MB'lık ASCII (İngilizce harf, rakam ve temel işaretlerden oluşan 7 bitlik karakter seti) dosya bellekte iki katına çıkıyor. ReadLinesAsync'te ise herhangi bir anda yalnız o anki satır canlı, en uzunu 466 karakter. Satırları kısa ömürlü olduğu için GC (garbage collector; kullanılmayan belleği geri toplayan runtime bileşeni) onları ucuza toplar. Dosya boyutunu kontrol etmediğin her yerde satır satır okumayı tercih et; 5 GB'lık bir dosyada ReadAllTextAsync zaten çalışmaz.

RandomAccess: Position'sız, Offset Tabanlı Okuma

FileStream'in bir Position'ı var ve aynı stream'i iki task'tan aynı anda okumak bu yüzden güvenli değil. .NET 6 ile gelen RandomAccess class'ı bu durumu ortadan kaldırıyor: File.OpenHandle ile bir SafeFileHandle alırsın, her okuma ve yazmada offset'i kendin verirsin. Handle'ın paylaşılan bir konumu olmadığı için aynı handle'ı birden fazla task güvenle kullanabilir. Sipariş dosyasını dört aralığa bölüp her aralıktaki satır sonlarını paralel saydık:

using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read,
    FileOptions.Asynchronous);
long length = RandomAccess.GetLength(handle);
const int parts = 4;
long chunk = (length + parts - 1) / parts;

// Her task aynı handle'ın kendi aralığını okur; paylaşılan bir Position yok.
long[] counts = await Task.WhenAll(Enumerable.Range(0, parts).Select(async p =>
{
    long start = p * chunk;
    long end = Math.Min(start + chunk, length);
    byte[] buffer = new byte[1 << 20];
    long lines = 0;
    for (long offset = start; offset < end;)
    {
        int toRead = (int)Math.Min(buffer.Length, end - offset);
        int n = await RandomAccess.ReadAsync(handle, buffer.AsMemory(0, toRead), offset);
        if (n == 0) break;
        lines += buffer.AsSpan(0, n).Count((byte)'\n');
        offset += n;
    }
    return lines;
}));
Length 68,001,648, 4 ranges of 17,000,412 bytes
Lines per range: 49,965 + 50,060 + 49,956 + 50,019 = 200,000

Satır sonu saymak için aralık sınırlarının bir satırın ortasına denk gelmesi sorun değil, çünkü her \n byte'ı tam olarak bir aralıkta sayılıyor. Satırları gerçekten parse edeceksen sınırları satır başına hizalaman gerekir; bunu ve paralel okumanın ne zaman gerçekten hızlandırdığını ölçerek serinin büyük dosyalar yazısında ele alacağız. Burada dikkat edilecek nokta, ReadAsync'in de istenenden az byte dönebilmesi; döngü bu yüzden n'e göre ilerliyor. RandomAccess pipe ve socket gibi offset kavramı olmayan handle'ları desteklemez.

Yarım Kalmayan Dosya: Flush, Atomik Değiştirme ve CreateNew

Doğrudan yazmanın sonucu

İlk sorunun cevabını görelim. 200.000 byte'lık eski faturanın üzerine yenisini yazarken 80.000. byte'ta exception fırlatıyoruz:

await using var fs = new FileStream(path, FileMode.Create);
await fs.WriteAsync(v2.AsMemory(0, 80_000));
throw new IOException("renderer crashed");
Direct write:  renderer crashed, file is now 80,000 bytes

FileMode.Create dosyayı açar açmaz sıfırlar. Exception'dan sonra elinde ne eski fatura var ne yenisi; 80.000 byte'lık, açılmayan bir PDF var. Müşteri tam o sırada faturasını indirirse bozuk dosyayı alır.

Geçici dosya ve File.Move

Çözüm, yeni içeriği aynı klasörde geçici bir dosyaya yazmak, diske indiğinden emin olmak ve ancak ondan sonra tek bir adımda asıl dosyanın yerine koymak:

public static async Task WriteAllBytesAsync(string path, ReadOnlyMemory<byte> content, CancellationToken ct = default)
{
    string fullPath = Path.GetFullPath(path);
    string directory = Path.GetDirectoryName(fullPath)!;
    string tempPath = Path.Join(directory, $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");

    try
    {
        var options = new FileStreamOptions
        {
            Mode = FileMode.CreateNew,
            Access = FileAccess.Write,
            Share = FileShare.None,
            Options = FileOptions.Asynchronous,
            PreallocationSize = content.Length
        };

        await using (var stream = new FileStream(tempPath, options))
        {
            await stream.WriteAsync(content, ct);
            stream.Flush(flushToDisk: true);
        }

        File.Move(tempPath, fullPath, overwrite: true);
    }
    catch
    {
        File.Delete(tempPath);
        throw;
    }
}
Atomic write:  cancelled, file is 200,000 bytes of 'A'
Atomic write:  done, file is 200,000 bytes of 'B'
Temp files left: 0

İptal edilen yazmada eski fatura (A'lar) olduğu gibi duruyor, başarılı yazmada yenisi (B'ler) geliyor ve geride geçici dosya kalmıyor. Üç ayrıntı önemli. Birincisi Flush(flushToDisk: true): normal Flush veriyi işletim sistemine teslim eder, ama veri hâlâ işletim sisteminin cache'inde olabilir. flushToDisk: true Windows'ta FlushFileBuffers'ı, Unix'te fsync'i çağırarak verinin diske yazılmasını bekler. Bunu yapmazsan elektrik kesintisinden sonra yeniden adlandırma diske yazılmış, içerik yazılmamış olabilir.

İkincisi geçici dosyanın aynı klasörde olması. Aynı volume içindeki yeniden adlandırma yalnız dosya sistemi kaydını değiştirir ve diğer process'ler ya eski dosyayı ya yenisini görür, ikisinin karışımını görmez. Geçici dosyayı Path.GetTempPath()'e yazarsan ve orası başka bir diskteyse File.Move kopyala-sil yapar ve atomik olmaktan çıkar. Aynı sebeple File.Replace de kaynak ve hedefin aynı volume'da olmasını şart koşar. File.Replace, eski dosyanın bir yedeğini almak istediğinde ya da Windows'ta hedefin özniteliklerini ve ACL'lerini (Access Control List; dosyaya kimin erişebileceğini belirleyen liste) korumak istediğinde tercih edilir; hedef dosya yoksa exception fırlatır.

Üçüncüsü Windows'a özgü bir durum: hedef dosyayı başka bir process FileShare.Delete vermeden açık tutuyorsa (müşteri tam o an faturayı indiriyorsa), değiştirme işlemi exception ile başarısız olabilir. Böyle bir ortamda kısa bekleyip birkaç kez deneyen bir retry ekle.

File.Exists ile Open arasındaki race

Fatura numarası bir kez kullanılmalı; aynı faturayı iki istek aynı anda üretmeye çalışırsa ikincisi durmalı. Akla gelen ilk kod if (!File.Exists(path)) kontrolünden sonra dosyayı oluşturmak. İki kontrol arasındaki boşlukta başka bir istek dosyayı oluşturabilir ve ikisi de yazar. Doğru yol kontrolü işletim sistemine bırakmak:

try
{
    await using var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
    await fs.WriteAsync(Encoding.ASCII.GetBytes($"worker {worker}"));
    return $"worker {worker}: created";
}
catch (IOException ex) when (File.Exists(path))
{
    return $"worker {worker}: {ex.GetType().Name}, file already exists";
}
worker 1: created
worker 2: IOException, file already exists
worker 3: IOException, file already exists
worker 4: IOException, file already exists

Dört task aynı anda denedi, yalnız biri kazandı. FileMode.CreateNew "yoksa oluştur" kontrolünü işletim sistemine tek bir işlem olarak yaptırır. Aynı mantık klasör kilidi gibi kullanılan .lock dosyaları için de geçerli.

Günlük Sipariş Dosyasını İzleyen Worker

Üçüncü soruya gelelim. FileSystemWatcher'ın Created event'i dosya oluşturulduğu anda gelir, yazma bittiğinde değil. ERP dosyayı beş parçada yazıyorsa ilk event'te okuyan kod ya dosyanın bir kısmını görür ya da dosya kilitli olduğu için exception alır. Ayrıca tek bir yazma birden fazla Changed event'i üretebilir, event'ler başka bir thread'de gelir ve işletim sisteminin bildirimlerini tutan iç buffer (varsayılan 8 KB, en fazla 64 KB) taşarsa event'ler kaybolur; o zaman yalnız Error event'i gelir. Yani FileSystemWatcher "bir şey değişti" sinyali olarak güvenilir, "dosya hazır" bilgisi olarak güvenilir değildir.

Bu yüzden Worker üç şey yapıyor: event'leri dosya başına tek bir kayıtta topluyor (debounce), dosya bir süre sessiz kaldıktan sonra onu exclusive açmayı deniyor ve kaçan event'lere karşı açılışta ve belli aralıklarla klasörü tarıyor:

public sealed class OrderFileWatcher(
    OrderFileImporter importer,
    IOptions<OrderImportOptions> options,
    TimeProvider time,
    ILogger<OrderFileWatcher> logger) : BackgroundService
{
    private readonly OrderImportOptions _options = options.Value;

    // Path -> son event zamanı. Aynı dosyanın event'leri tek kayıtta birleşir.
    private readonly ConcurrentDictionary<string, DateTimeOffset> _pending = new();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        string inbox = Path.GetFullPath(_options.InboxPath);
        Directory.CreateDirectory(inbox);
        Directory.CreateDirectory(_options.ProcessedPath);

        using var watcher = new FileSystemWatcher(inbox, _options.Filter)
        {
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size,
            InternalBufferSize = 64 * 1024
        };
        watcher.Created += (_, e) => Touch(e.FullPath);
        watcher.Changed += (_, e) => Touch(e.FullPath);
        watcher.Renamed += (_, e) => Touch(e.FullPath);
        watcher.Error += (_, e) =>
        {
            logger.LogWarning(e.GetException(), "Watcher error, rescanning {Inbox}", _options.InboxPath);
            Rescan(inbox);
        };
        watcher.EnableRaisingEvents = true;

        Rescan(inbox); // servis kapalıyken gelmiş dosyalar
        logger.LogInformation("Watching {Inbox} for {Filter}", _options.InboxPath, _options.Filter);

        DateTimeOffset lastRescan = time.GetUtcNow();
        using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250), time);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            if (time.GetUtcNow() - lastRescan >= _options.RescanInterval)
            {
                Rescan(inbox);
                lastRescan = time.GetUtcNow();
            }
            await ProcessDueFilesAsync(stoppingToken);
        }
    }

    private void Touch(string path) => _pending[path] = time.GetUtcNow();

    private void Rescan(string inbox)
    {
        foreach (string path in Directory.EnumerateFiles(inbox, _options.Filter))
        {
            _pending.TryAdd(path, DateTimeOffset.MinValue);
        }
    }

    private async Task ProcessDueFilesAsync(CancellationToken ct)
    {
        DateTimeOffset now = time.GetUtcNow();
        foreach (var (path, lastEvent) in _pending)
        {
            if (now - lastEvent < _options.QuietPeriod)
            {
                continue; // hâlâ event geliyor: dosyanın oturmasını bekle
            }
            if (!File.Exists(path))
            {
                _pending.TryRemove(path, out _);
                continue;
            }

            try
            {
                var result = await importer.ImportAsync(path, ct);
                string target = Path.Join(_options.ProcessedPath, Path.GetFileName(path));
                File.Move(path, target, overwrite: true);
                _pending.TryRemove(path, out _);
                logger.LogInformation("Imported {File}: {Orders} orders, {Total:N2} TRY",
                    Path.GetFileName(path), result.Orders, result.Total);
            }
            catch (IOException ex) when (ex is not FileNotFoundException)
            {
                // Üretici dosyayı hâlâ tutuyor; sonraki tick'te tekrar dene.
                logger.LogDebug("{File} is busy: {Message}", Path.GetFileName(path), ex.Message);
                _pending[path] = time.GetUtcNow();
            }
        }
    }
}

OrderFileImporter dosyayı FileShare.None ile açıyor. Üretici dosyayı hâlâ yazma modunda açık tutuyorsa bu açma IOException ile başarısız olur ve dosya bir sonraki tura kalır. Sessizlik süresi ile kilit kontrolü birbirini tamamlıyor: biri "son bir saniyedir event gelmedi" der, diğeri "kimse dosyayı tutmuyor" der. TimeProvider'ı constructor'dan almak da bilinçli bir seçim; testte zamanı elle ilerletebilmek için.

Denemek için Worker'ı başlattık. inbox'ta servis kapalıyken gelmiş bir dosya vardı. Sonra üretici bir dosyayı doğrudan asıl adıyla, 400 ms arayla beş parçada yazdı; ardından bir başkasını filtreye uymayan geçici bir adla yazıp tek adımda yeniden adlandırdı. dotnet run Worker'ı kendi proje klasöründe çalıştırdığı için inbox o klasörün altında:

dotnet run --project src/Bookstore.IO.Files -- drop src/Bookstore.OrderImport.Worker/inbox slow
dotnet run --project src/Bookstore.IO.Files -- drop src/Bookstore.OrderImport.Worker/inbox atomic
03:50:07.133 producer: wrote 2000 orders
03:50:07.557 producer: wrote 2000 orders
03:50:07.970 producer: wrote 2000 orders
03:50:08.374 producer: wrote 2000 orders
03:50:08.783 producer: wrote 2000 orders
03:50:09.185 producer: done
03:50:12.302 producer: renamed into place
03:50:12.317 producer: done
03:50:04.210 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Watching inbox for orders-*.jsonl
03:50:04.529 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-13.jsonl: 10000 orders, 8,966,678.30 TRY
03:50:10.295 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-14.jsonl: 10000 orders, 8,966,678.30 TRY
03:50:13.530 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-15.jsonl: 10000 orders, 8,966,678.30 TRY

Servis açılışta bekleyen dosyayı taramayla buldu. Parça parça yazılan dosya, üretici bittikten yaklaşık 1,1 saniye sonra, yani sessizlik süresi dolunca bir kez ve eksiksiz işlendi. Yeniden adlandırılan dosya ise Renamed event'iyle geldi. En temiz yol bu sonuncusu: üreticiyi kontrol edebiliyorsan dosyayı geçici bir adla yazıp bitince yeniden adlandırmasını iste. Kontrol edemiyorsan (banka, ERP, başka bir ekip) debounce ve kilit kontrolü şart. BackgroundService ve hosting'in genel kullanımı Background Job'lar ve Scheduled Job'lar serisinde anlatıldı.

Cross-Platform Path'ler

Windows'ta geliştirip uygulamayı Linux container'da çalıştırdığında path'ler sürpriz çıkarır. Üç farka dikkat et.

Büyük/küçük harf: NTFS (Windows'un varsayılan dosya sistemi) varsayılan olarak harf duyarsızdır, Linux dosya sistemleri duyarlıdır. Windows'ta Invoice-BS-42.PDF diye aranan dosya invoice-BS-42.pdf olarak bulunur, container'da bulunmaz. Dosya adlarını kodda tek bir kurala göre üret (ör. hep küçük harf) ve kullanıcıdan gelen adla dosya sisteminde arama yapma.

Path.Combine ile Path.Join: ikinci argüman kök ile başlıyorsa Combine ilk argümanı tamamen atar:

Console.WriteLine($"Combine: {Path.Combine("invoices", "/etc/passwd")}");
Console.WriteLine($"Join:    {Path.Join("invoices", "/etc/passwd")}");

string root = Path.GetFullPath("invoices");
string requested = Path.GetFullPath(Path.Join(root, "../appsettings.json"));
bool inside = requested.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal);
Combine: /etc/passwd
Join:    invoices/etc/passwd
'../appsettings.json' stays inside invoices: False

Join argümanları yalnız birleştirir, ama .. parçalarını da olduğu gibi bırakır. Kullanıcı girdisinden path üretiyorsan tek güvenli yol, sonucu Path.GetFullPath ile normalize edip izin verdiğin kök klasörün içinde kaldığını kontrol etmek. Path traversal saldırısının ayrıntısı OWASP (Open Web Application Security Project; web güvenliği üzerine çalışan açık topluluk) Top 10 serisinin path traversal'ı anlatan yazısında; burada yalnız bu kontrolü hatırlatıyoruz. Bu arada ayırıcı karakter Windows'ta \, Linux'ta /; path'leri string birleştirerek değil bu method'larla kur.

Geçersiz karakterler de platforma göre değişir. Path.GetInvalidFileNameChars() Windows'ta :, *, ?, ", <, >, | gibi birçok karakteri döner, Linux'ta yalnız / ve null karakterini. Linux'ta üretilen orders:2026-11-14.jsonl adlı bir dosya Windows'a kopyalanamaz. Dosya adlarını harf, rakam, tire ve noktayla sınırlamak en az sorun çıkaran yol.

Üretimde Dikkat

Disk dolabilir. IOException'ı yakalayıp loglamak yetmez; yarım kalmış geçici dosyayı da silmek gerekir ki dolu disk bir de çöp dosyalarla dolmasın. Atomik yazma kodundaki catch bloğu bunu yapıyor. Servis açılırken eski .tmp dosyalarını temizleyen bir adım eklemek de iyi bir fikir, çünkü process'in öldürüldüğü durumda catch çalışmaz.

Antivirüs ve yedekleme yazılımları Windows'ta dosyaları kısa süreliğine açık tutar. Az önce yazdığın dosyayı taşımaya çalışırken nadiren bir sharing violation alabilirsin; dosya işlemlerinde kısa bir retry bu yüzden yaygın bir uygulama.

SMB (Windows'un dosya paylaşım protokolü) ya da NFS (Unix dünyasındaki karşılığı) üzerinden bağlanan ağ klasörlerinde FileSystemWatcher'a ve dosya kilitlerine daha az güven. Event'ler gecikebilir ya da hiç gelmeyebilir; periyodik tarama burada asıl mekanizma olur, event'ler yalnız gecikmeyi kısaltır.

İzlenecek metrikler: işlenmeyi bekleyen dosya sayısı, en eski bekleyen dosyanın yaşı ve Watcher error log'larının sayısı. Bekleyen dosyanın yaşı büyüyorsa ya üretici dosyayı kapatmıyor ya da işleme hata veriyordur.

Sık Düşülen Tuzaklar

Async API'yi FileOptions.Asynchronous olmadan kullanmak. new FileStream(path, FileMode.Open) ile açıp ReadAsync çağırmak Windows'ta her çağrıda bir thread pool thread'ini bloklar. Constructor'da useAsync: true ya da FileStreamOptions.Options ver.

File.Exists ile Open arasında karar vermek. Kontrol ile işlem arasında dosyanın durumu değişebilir. "Yoksa oluştur" için FileMode.CreateNew, "varsa aç" için doğrudan açıp FileNotFoundException'ı yakala.

Faturayı ya da export'u doğrudan asıl adıyla yazmak. Yazma yarıda kalırsa okuyucular bozuk dosya görür. Geçici dosya, Flush(true), aynı klasörde File.Move.

Created event'inde dosyayı hemen okumak. Dosya o anda daha yazılıyor olabilir. Debounce, exclusive açma denemesi ve periyodik tarama.

Path.Combine'a kullanıcı girdisi vermek. Kök ile başlayan bir girdi bütün path'i değiştirir; .. içeren bir girdi klasörün dışına çıkar. Normalize et ve kökün içinde kaldığını kontrol et.

Küçük Bir Deneme

★☆☆ buffering komutuna BufferSize = 1 ve BufferSize = 1 << 20 seçeneklerini ekle. 1'in 0 ile aynı sonucu verdiğini, 1 MB'ın ise 64 KB'a göre bir şey kazandırıp kazandırmadığını ölç.

★★☆ AtomicFile.WriteAllBytesAsync'e Windows'taki sharing violation için bir retry ekle: IOException gelirse 50, 100 ve 200 ms bekleyerek üç kez dene. Test için hedef dosyayı başka bir FileStream ile FileShare.Read vererek açık tut.

★★★ OrderFileWatcher için bir test yaz: Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider ile zamanı elle ilerlet, sessizlik süresi dolmadan dosyanın işlenmediğini ve dolduktan sonra tam bir kez işlendiğini doğrula.

Şimdiye kadar veriyi ya satır satır ya da büyük byte blokları hâlinde okuduk. Bir sonraki yazıda System.IO.Pipelines'a geçiyoruz: bir mesajın iki buffer'a bölündüğü durumu, buffer'ları kopyalamadan nasıl parse edeceğimizi ve PipeReader ile backpressure'ı NDJSON (Newline Delimited JSON; her satırında ayrı bir JSON değeri bulunan metin formatı) sipariş akışı üzerinde ölçerek göreceğiz.