File System Nasıl Çalışır: Inode, File Descriptor, Page Cache ve fsync
Bir dosyaya yazmak veriyi önce kernel'in page cache'ine koyar; diske ancak fsync (.NET'te Flush(true)) ile ya da kernel'in arka plandaki yazmasıyla iner. Inode, hard link ve file descriptor'ı gösteriyor, 1000 siparişte fsync'in yazmayı yaklaşık 60 kat yavaşlattığını, Linux'ta .NET'in FileShare.None lock'unun advisory olduğunu ve temp dosyası + rename ile atomic write'ı strace ile ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı. Ölçümler mcr.microsoft.com/dotnet/sdk:10.0 container'larında (Ubuntu 24.04, Linux 6.x kernel, overlay ve ext4 file system) alındı. .NET'in dosya davranışı dotnet/runtime kaynak koduna, Windows bilgileri Microsoft Learn'e dayanır; Windows'taki davranışlar bu yazı için çalıştırılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) her siparişi bir log dosyasına bir satır olarak yazıyor, FileStream.Flush() çağırıyor ve müşteriye "siparişin alındı" diyor. İki saniye sonra sunucunun elektriği kesiliyor. Sipariş satırı diskte mi?
İki soru daha: aynı 1000 siparişi her satırdan sonra Flush(flushToDisk: true) ile yazarsan süre kaç katına çıkar: iki mi, on mu, elli mi? Ve Windows'ta geliştirip Linux container'ında çalıştırdığın MVC (Model-View-Controller; sunucu tarafında sayfa üreten ASP.NET Core modeli) uygulaması View("index") dediğinde neden yalnız production'da hata verir? Bu yazıda file system'in bir uygulama için ne yaptığına bakıyoruz.
File, Directory ve Inode
File system'in sana verdiği soyutlama basit: adı olan, byte dizisinden oluşan dosyalar ve onları gruplayan directory'ler. Altında üç ayrı yapı var. Dosyanın içeriği disk bloklarında durur. Boyut, sahip, izinler, zaman damgaları ve blokların listesi inode (dosyanın metadata'sını ve bloklarının yerini tutan kayıt) içindedir. Dosyanın adı ise inode'da değil, directory'de durur: directory, adları inode numaralarına bağlayan bir tablodur. Container'da bir katalog dosyası, ona bir hard link ve bir symbolic link oluşturdum:
printf "9780441172719;Dune\n" > catalog.csv
ln catalog.csv catalog-hard.csv
ln -s catalog.csv catalog-soft.csv
ls -li
stat -c "%n inode=%i links=%h size=%s blocks=%b" catalog.csv
rm catalog.csv
cat catalog-hard.csv
cat catalog-soft.csv
347603 -rw-r--r-- 2 root root 19 Nov 14 03:58 catalog-hard.csv
347604 lrwxrwxrwx 1 root root 11 Nov 14 03:58 catalog-soft.csv -> catalog.csv
347603 -rw-r--r-- 2 root root 19 Nov 14 03:58 catalog.csv
catalog.csv inode=347603 links=2 size=19 blocks=8
9780441172719;Dune
cat: catalog-soft.csv: No such file or directory
İlk sütun inode numarası. Hard link yeni bir dosya değil, aynı inode'a (347603) ikinci bir addır; link sayısı bu yüzden 2. Symbolic link ise kendi inode'u (347604) olan ayrı bir dosyadır ve içinde yalnız hedefin adı yazar. rm aslında "sil" demez, adı directory'den kaldırır; Linux'un unlink(2) man sayfasına göre inode ve bloklar ancak son ad kaldırıldığında ve dosyayı açık tutan process kalmadığında serbest bırakılır. Bu yüzden hard link üzerinden veri okunmaya devam etti, symbolic link ise artık var olmayan bir adı gösterdiği için kırıldı. .NET'te symbolic link için File.CreateSymbolicLink ve FileSystemInfo.LinkTarget var; hard link için bir API yok.
File Descriptor ve "Too many open files"
Bir dosyayı açtığında kernel sana bir file descriptor (fd; process'in açık dosyalar tablosundaki sıra numarası) verir. Sonraki her read ve write bu numarayla yapılır. Aradaki yapı iki katmanlı: open(2) man sayfasına göre her open kernel'de yeni bir open file description oluşturur ve offset ile açılış bayrakları orada tutulur; fd bu kayda işaret eder, kayıt da inode'a. Windows'ta aynı rolü handle oynar.

Fd sayısı sınırlıdır ve socket'ler de fd'dir; Networking serisinin TCP yazısında anlattığımız socket exhaustion bu yüzden aynı hatayla biter. Bookstore'un fatura üreten bir job'ının dosyaları kapatmayı unuttuğunu düşünelim:
public static (int Opened, string Error) OpenUntilFailure(string directory)
{
List<FileStream> open = [];
try
{
for (int i = 0; ; i++)
{
open.Add(new FileStream(Path.Combine(directory, $"invoice-{i}.pdf"), FileMode.Create));
}
}
catch (IOException ex)
{
return (open.Count, ex.Message);
}
finally
{
foreach (FileStream fs in open)
{
fs.Dispose();
}
}
}
ulimit -n 64
dotnet Bookstore.OsLab.dll fd-limit /tmp/invoices
soft limit: Max open files 64 64 files
open descriptors before: 45
opened 18 files, then: Too many open files : '/tmp/invoices/invoice-18.pdf'
open descriptors after: 45
Process bir şey yapmadan önce 45 descriptor tutuyordu: stdin, stdout ve stderr, runtime'ın açtığı assembly dosyaları, diagnostics socket'i, sayımı yapan directory handle'ı. 18 dosyadan sonra 64'lük sınır doldu ve kernel'in EMFILE (process'in fd sınırı doldu) hatası .NET'te IOException olarak geldi. Bir ayrıntı daha: ulimit -n 64 hem soft hem hard limiti 64 yapar. Yalnız soft limiti düşürdüğümde sınır hiç devreye girmedi, çünkü dotnet/runtime'ın PAL (Platform Adaptation Layer; runtime'ın OS farklarını gizleyen katmanı) başlangıç kodu açılışta soft limiti hard limite yükseltir. Container'da hard limit genellikle yüksektir; o denemede process sınıra değil container'ın bellek limitine çarptı.
Page Cache: write Neden Bu Kadar Hızlı?
write çağrısı veriyi diske yazmaz. Kernel byte'ları page cache'teki sayfalara kopyalar, sayfayı dirty (değişmiş ama diske yazılmamış) olarak işaretler ve hemen döner. Diske yazma işini kernel'in flusher thread'leri arka planda yapar. Kernel'in vm sysctl dokümanına göre dirty_expire_centisecs bir dirty sayfanın ne kadar yaşlanınca yazılmaya aday olacağını, dirty_writeback_centisecs bu thread'lerin ne sıklıkla uyanacağını belirler; container'da değerler 3000 ve 1500'dü, yani 30 ve 15 saniye. Okumada da durum aynı: bir kez okunan dosya page cache'te kalır ve ikinci okuma diske gitmez.
Uygulama ile kernel arasında bir buffer daha var: FileStream'in kendi buffer'ı (varsayılan 4096 byte). Write önce bu buffer'a yazar, Flush() buffer'ı bir syscall ile kernel'e verir. Flush(flushToDisk: true) ise bir adım daha gider. dotnet/runtime kaynağına göre Linux'ta bu çağrı fsync'e, Windows'ta FlushFileBuffers'a iner; fsync(2) man sayfasına göre fsync dosyanın değişmiş verisini ve metadata'sını cihaza aktarır ve cihaz yazdığını bildirene kadar bekler.
Flush, Flush(true) ve fsync'in Maliyeti
Deney uygulamasında 1000 sipariş satırını iki şekilde yazan bir komut var:
// Her siparişten sonra bir flush: toDisk=true ise fsync de yapılır
public static FlushResult WriteOrders(string path, int count, bool toDisk)
{
using FileStream fs = new(path, FileMode.Create, FileAccess.Write, FileShare.Read);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < count; i++)
{
fs.Write(OrderLine(i));
fs.Flush(flushToDisk: toDisk);
}
return new FlushResult(toDisk ? "Flush(true)" : "Flush()", count, sw.Elapsed.TotalMilliseconds);
}
dotnet Bookstore.OsLab.dll flush 1000
Flush() 1000 orders in 7.3 ms ( 7.3 us per order)
Flush(true) 1000 orders in 442.1 ms ( 442.1 us per order)
İkinci çalıştırmada 8,0 ve 518,4 ms çıktı: fsync'li yazma yaklaşık 60 kat yavaş. Bu oran diske bağlıdır ve ölçüm bir Linux VM'inin sanal diskinde alındı; dönen diskte fark çok daha büyük, pil destekli yazma cache'i olan bir RAID (birden çok diski tek bir disk gibi kullanan yapı) kartında daha küçük olur. strace sayıları da gösteriyor:
strace -f -qq -c -e trace=write,pwrite64,fsync dotnet Bookstore.OsLab.dll flush 1000
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
72.49 0.020509 20 1000 fsync
27.32 0.007731 3 2000 pwrite64
0.19 0.000054 6 8 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.028294 9 3008 total
İki mod için 2000 pwrite64 (.NET'in FileStream'i Linux'ta offset'i kendisi tutup positional write kullanır) ve yalnız ikinci mod için 1000 fsync. strace -c'nin süre sütunu syscall'ların harcadığı CPU zamanıdır; fsync'in asıl maliyeti CPU değil, cihazın yazmayı bitirmesini beklemek ve bu bekleme duvar saatinde görünür. Birinci sorunun cevabı da burada: Flush() sonrası veri kernel'deydi, diskte değil. Process çökse veri kaybolmazdı, çünkü page cache kernel'e aittir; ama elektrik kesilirse son 30 saniyeye kadar olan yazmalar gider.
Journaling, Crash Consistency ve WAL
Elektrik kesintisinde file system'in kendisi de bozulabilir: bir dosyaya blok eklemek birkaç ayrı yazmadır (veri bloğu, inode, boş blok bitmap'i) ve yarısı yazılmışken kesilebilir. Journaling file system'ler bunu değişikliği önce bir journal'a yazarak çözer; açılışta yarım kalan işlem journal'dan tamamlanır ya da atılır. Kernel'in ext4 dokümanına göre varsayılan data=ordered modunda yalnız metadata journal'a yazılır ve dosya verisi, ona ait metadata commit edilmeden önce diske indirilir. NTFS (Windows'un varsayılan file system'i) de metadata için bir log tutar. Journal file system'in yapısını korur; senin uygulamanın verisinin tutarlı olduğunu garanti etmez.
Veritabanları bu yüzden kendi log'larını tutar. WAL (write-ahead logging; değişikliği önce sıralı bir log'a yazıp diske indirme, sonra asıl veri sayfasını güncelleme) her commit'te log'u fsync ile indirir ve veri sayfalarını sonra, toplu ve rastgele sırayla yazar. Sıralı log yazmak rastgele sayfa yazmaktan ucuzdur ve çökme sonrası log'dan geri dönülür. SQL Server'ın transaction log'unu yayınlanmış SQL Server serisinin transaction ve transaction log yazısında anlatmıştık. Kendi dosyanı yazarken aynı soruyu sor: bu veri kaybolursa ne olur?
Platform Farkları: Büyük-Küçük Harf, Path ve Açık Dosya
Bookstore'un MVC uygulamasında Views/Home/Index.cshtml var ama kodda bir yerde View("index") yazıyor. Windows ve macOS'ta varsayılan file system'ler büyük-küçük harfe duyarsızdır; ext4 ve container'daki overlay duyarlıdır:
dotnet Bookstore.OsLab.dll case-check
File.Exists("Views/Home/Index.cshtml") = True
File.Exists("Views/Home/index.cshtml") = False
Geliştiricinin makinesinde çalışan sayfa, Linux container'ında "view bulunamadı" hatası verir. Aynı aileden ikinci hata path ayracıdır: "Views\\Home" gibi elle yazılmış ters bölü Linux'ta ayraç değil, dosya adının bir karakteridir. Path.Combine ve Path.DirectorySeparatorChar bu yüzden var.
Üçüncü fark açık dosyanın silinmesi. Linux'ta silme, adı directory'den kaldırmaktır ve açık dosyayı etkilemez:
dotnet Bookstore.OsLab.dll open-delete /tmp/stock-export.csv
File.Exists after delete: False
still readable: Dune;Emma;The Hobbit
fd 37 -> /tmp/stock-export.csv (deleted)
Dosya directory'den kayboldu ama fd hâlâ inode'u tutuyor; disk alanı fd kapanınca geri gelir. "Diskte yer bitti ama büyük dosyayı sildim" şikâyetinin sebebi çoğu zaman budur ve /proc/<pid>/fd'deki (deleted) satırları suçluyu gösterir. Windows'ta davranış farklı: dosyayı açan handle silmeye izin vermemişse silme sharing violation ile başarısız olur. .NET'te bu izin FileShare.Delete'tir ve dotnet/runtime kaynağına göre FileStream'in varsayılan paylaşım modu FileShare.Read; yani Windows'ta açık bir log dosyasını başka bir process varsayılan olarak silemez.
Aynı Dosyaya İki Process: Lock ve Atomic Write
Windows'ta FileShare değerleri kernel tarafından zorlanır. Linux'ta dotnet/runtime, FileShare.None için flock ile exclusive bir lock alır. Bir process dosyayı tutarken ikinci bir .NET process'i ve bir shell aynı dosyaya erişmeye çalıştı:
dotnet Bookstore.OsLab.dll lock /tmp/daily-report.pdf 6 &
sleep 3
dotnet Bookstore.OsLab.dll open /tmp/daily-report.pdf
echo "Dune" > /tmp/daily-report.pdf && echo "shell write ok: $(cat /tmp/daily-report.pdf)"
pid=438 holds /tmp/daily-report.pdf with FileShare.None for 6 s
IOException: The process cannot access the file '/tmp/daily-report.pdf' because it is being used by another process.
shell write ok: Dune
.NET process'i Windows'takine benzer bir hata aldı, ama shell dosyaya rahatça yazdı. flock(2) man sayfasına göre bu lock advisory'dir (yalnız lock'a bakmayı kabul eden programlar arasında geçerli): flock çağırmayan bir program onu görmez. Linux'ta iki farklı programın aynı dosyaya yazmasını lock ile değil, tasarımla önlemek gerekir.
Tasarımın en bilinen parçası atomic write: dosyayı yerinde değiştirmek yerine geçici bir dosyaya yaz, diske indir ve tek bir rename ile yerine koy. rename(2) man sayfasına göre hedef varsa yerine atomik olarak geçilir; okuyan biri ya eski ya yeni dosyayı görür, yarım yazılmış bir dosyayı asla:
// Önce geçici dosyaya yaz, diske indir, sonra tek bir rename ile yerine koy
public static void AtomicSave(string path, string content)
{
string temp = path + ".tmp";
using (FileStream fs = new(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
fs.Write(Encoding.UTF8.GetBytes(content));
fs.Flush(flushToDisk: true);
}
File.Move(temp, path, overwrite: true);
}
strace -f -qq -e trace=openat,pwrite64,fsync,rename,renameat,renameat2 dotnet Bookstore.OsLab.dll atomic-save /tmp/price-list.json "{\"Dune\":420.00}"
840 openat(AT_FDCWD, "/tmp/price-list.json.tmp", O_WRONLY|O_CREAT|O_CLOEXEC, 0666) = 32
840 pwrite64(32, "{\"Dune\":420.00}", 15, 0) = 15
840 fsync(32) = 0
840 renameat(AT_FDCWD, "/tmp/price-list.json.tmp", AT_FDCWD, "/tmp/price-list.json") = 0
strace çıktısındaki sıra tam olarak istediğimiz: yaz, fsync, rename. strace bu ortamda arm64'te çalıştı; x64'te kernel'de ayrı bir rename syscall'ı olduğu için glibc onu kullanır ve son satır rename("/tmp/price-list.json.tmp", "/tmp/price-list.json") = 0 olarak görünür. dotnet/runtime'a göre File.Move overwrite: true ile Linux'ta doğrudan rename çağırır; iki path farklı file system'lerdeyse kopyalayıp silmeye düşer ve atomiklik kaybolur. Windows'ta File.Move Win32'nin dosya taşıma API'sine, File.Replace ise ReplaceFile'a gider. Tam dayanıklılık için bir adım daha var: fsync(2) man sayfasına göre dosyayı fsync'lemek, directory'deki yeni girdinin diske indiğini garanti etmez; bunun için directory'nin kendisi de fsync'lenmelidir ve .NET bunun için bir API sunmaz.
Üretimde Dikkat
Her yazmada fsync yapmak yerine işi toplu yap: birkaç siparişi bir buffer'da birleştirip tek bir fsync'le indirmek (group commit) veritabanlarının da kullandığı yöntemdir. Hangi verinin fsync gerektirdiğine karar ver: log ve önbellek dosyaları için page cache yeterlidir, para ve sipariş verisi ise zaten bir veritabanında olmalıdır.
Linux'ta fd sayısını izle. ls /proc/<pid>/fd | wc -l zamanla artıyorsa bir yerde kapatılmayan dosya ya da socket vardır. Container'da disk dolduğunda (deleted) olarak açık duran log dosyalarına bak.
Sık Düşülen Tuzaklar
Flush() çağırdım, veri diskte sanmak. Veri kernel'e geçti, o kadar. Elektrik kesintisine dayanması gereken veri için Flush(true) ya da FileOptions.WriteThrough gerekir; dotnet/runtime'a göre ikincisi Linux'ta dosyayı O_SYNC ile açar.
Aynı dosyaya iki process'ten yazmak. dotnet/runtime kaynağına göre FileMode.Append Linux'ta O_APPEND kullanmaz: dosyayı açıp sonuna gider ve sonra kendi offset'iyle yazar. Aynı anda append eden iki process birbirinin satırlarının üzerine yazabilir; lock'lar da advisory'dir. Her process kendi dosyasına yazsın ya da yazma tek bir process'ten geçsin.
Dosyayı yerinde değiştirmek. File.WriteAllText(path, ...) önce dosyayı sıfırlar sonra yazar; arada çöken bir process boş ya da yarım bir dosya bırakır. Konfigürasyon, fiyat listesi gibi bütün olarak okunan dosyalarda temp dosyası ve rename kullan.
Küçük Bir Deneme
★☆☆ flush komutunu kendi makinende Docker container'ında ve doğrudan Windows'ta (.NET 10 SDK ile) çalıştır. Windows'ta Flush(true)'nun FlushFileBuffers maliyetini Linux'taki fsync'le karşılaştır.
★★☆ WriteOrders'ın bir kopyasını her 50 siparişte bir Flush(true) yapacak şekilde yaz. 1000 sipariş için süreyi ve elektrik kesintisinde kaybedilebilecek en fazla sipariş sayısını hesapla.
★★★ AtomicSave'e directory fsync'i ekle: Linux'ta directory'yi open ile açıp fsync çağıran küçük bir P/Invoke yaz ve strace ile çağrının gerçekten yapıldığını doğrula.
Bu yazıda verinin bir dosyaya giden yolunu izledik: fd, open file tablosu, inode ve page cache. Bir sonraki yazıda I/O'nun beklemesine bakıyoruz: blocking ve non-blocking I/O, epoll ve IOCP (I/O completion port; Windows'un tamamlanma bildirimli I/O mekanizması), await sırasında neden hiçbir thread'in beklemediği, pipe'lar, Unix domain socket'ler ve signal'lar.