Ana içeriğe geç

Bölüm 17:Dosya Sistemi ve I/O

12 dakikalık okumaGüncelleme:

Buraya kadar programın nasıl başladığını, belleğe nasıl yerleştiğini ve nasıl çoğaldığını gördük. Ama bir programın işe yarayabilmesi için dış dünyayla konuşması gerekir: dosya okumak, ağdan veri almak, terminale yazmak.

Linux bunların hepsini tek bir soyutlamaya indirger ve bu, sistemin en zarif tasarım kararlarından biridir.

Bu bölümde neyi çözüyoruz?

  • “Her şey dosyadır” felsefesinin pratikte ne anlama geldiğini göreceğiz.
  • Bir read çağrısının file descriptor’dan VFS’e, oradan inode ve page cache’e uzanan yolunu izleyeceğiz.
  • Bloklanan I/O’nun neden ölçeklenmediğini ve epoll ile io_uring’in bunu nasıl çözdüğünü anlayacağız.

Her Şey Dosyadır

Linux’ta sıradan dosyalar da, dizinler de, klavyen de, ağ soketleri de, hatta çalışan process’lerin kendisi de aynı arayüzün arkasındadır: open, read, write, close.

Bu, sıradan bir kolaylık değil. Aynı read çağrısı bir metin dosyasında, bir TCP bağlantısında ve bir donanım aygıtında çalışır. Shell’deki yönlendirme ve pipe’lar da tam olarak bu tekdüzelik sayesinde mümkündür; 11. bölümde gördüğümüz komut > dosya yapısı, aslında process’in 1 numaralı file descriptor’ını başka bir nesneye bağlamaktan ibarettir.

Bunu somut görmek için /proc dosya sistemine bakabilirsin. Diskte hiçbir karşılığı olmayan, kernel tarafından anlık üretilen sanal bir dosya sistemidir:

cat /proc/cpuinfo | head -3

Burada okuduğun şey bir dosya değil; kernel’ın o anda ürettiği metin. Ama cat bunu bilmez, bilmesine de gerek yoktur.

File Descriptor: Küçük Bir Tam Sayı

Bir process bir dosya açtığında kernel ona küçük bir tam sayı verir: file descriptor (fd). Bu sayı, process’in kendi fd tablosundaki bir indekstir.

Her process üç fd ile başlar: 0 standart girdi, 1 standart çıktı, 2 standart hata. Yeni açılan her nesne, kullanılabilir en küçük numarayı alır — bu davranış tesadüfi değil, standartta garanti edilmiştir ve shell’in yönlendirme numaraları buna dayanır.

Çalışan bir process’in açık fd’lerini doğrudan görebilirsin:

ls -l /proc/self/fd

Kernel tarafında üç katmanlı bir yapı vardır ve bu ayrım 16. bölümdeki fork davranışını açıklar:

  1. fd tablosu — process’e özeldir, fd numarasını bir açık dosya tanımına bağlar.
  2. Açık dosya tanımı — dosya konumunu (offset) ve erişim kipini tutar; fork sonrasında ebeveyn ve çocuk aynı tanımı paylaşır.
  3. inode — dosyanın kendisidir; birden fazla açık dosya tanımı aynı inode’a bakabilir.

Bu yüzden fork sonrası çocuk bir şey okuduğunda ebeveynin okuma konumu da ilerler: offset paylaşılan tanımdadır. Buna karşılık open ile aynı dosyayı iki kez açarsan iki ayrı tanım, dolayısıyla iki ayrı offset elde edersin.

VFS: Ortak Dil

Peki read çağrısı ext4, Btrfs, XFS, NFS ve /proc üzerinde nasıl aynı şekilde çalışır? Cevap VFS (Virtual File System): kernel içinde, gerçek dosya sistemlerinin üzerinde duran bir soyutlama katmanı.

VFS’in bütün modeli dört nesne üzerine kurulu ve dördünü tanımak, /home/emir/notlar.txt gibi bir yolun nasıl gerçek veriye dönüştüğünü de açıklıyor:

İsimlerin inode’da değil dentry’de olması ilk bakışta tuhaf gelebilir ama bir şeyi doğrudan açıklıyor: aynı inode’a birden fazla ad bağlanabilir. Hard link dediğimiz şey tam olarak budur ve dosyayı silmenin neden “adı kaldırmak” anlamına geldiğini de bu açıklar.

Yol çözümleme de bu nesneler üzerinde yürüyen bir zincirdir. /home/emir/notlar.txt için VFS önce kök dizinin inode’unu alır, içinde home dentry’sini arar, bulduğu inode’a geçer, orada emir’i arar, sonra notlar.txt’yi. Her adım bir dizin okuması demektir.

Bu yürüyüşün her seferinde diske inmesi felaket olurdu. Kernel bu yüzden dentry cache tutar: ad → inode eşleşmelerini bellekte saklar. Sık kullandığın yolların çözümlenmesi neredeyse bedavaya gelir; ls komutunu aynı dizinde ikinci kez çalıştırdığında hissettiğin hız farkının bir kısmı buradan gelir.

Peki VFS, tek bir read çağrısını ext4 ile NFS arasında nasıl doğru yere yönlendiriyor? Her dosya sisteminden bir fonksiyon işaretçisi tablosu ister. Fonksiyon işaretçisi, bir fonksiyonun adresini değişkende tutmak demektir; tabloya hangi adresi koyarsan read çağrısı oraya gider. Nesne yönelimli bir arayüzün C ile yazılmış hâli gibi düşünebilirsin.

Kernel kaynağında bu tablonun adı file_operations’dır ve içinde read, write, open gibi her işlem için bir satır bulunur. ext4 o satırlara kendi fonksiyonlarının adresini yazar, NFS bambaşka adresler yazar, /proc bir üçüncüsünü. Syscall geldiğinde VFS yalnızca ilgili satırdaki adrese atlar; hangi dosya sistemine gittiğini bilmesine bile gerek yoktur.

Yeni bir dosya sistemi yazmak, bu tabloyu doldurmak demektir. Kullanıcı tarafındaki hiçbir program değişmez.

inode: Dosyanın Kimliği

Bir dosyanın adı, dosyanın kendisi değildir. Ad, dizinde tutulan bir girdidir ve bir inode numarasına işaret eder. Dosyanın künyesi — boyut, izinler, sahip, zaman damgaları ve veri bloklarının diskte nerede olduğu — inode’da durur. Dikkat et: verinin kendisi inode’un içinde değildir; inode yalnızca o blokların adresini tutar. (Çok küçük dosyalarda bazı dosya sistemleri veriyi doğrudan inode’a sığdırır — buna inline data denir — ama bu bir istisnadır, kural değil.)

ls -li /etc/hostname

Baştaki sayı inode numarasıdır. Bu ayrım birkaç davranışı bir anda açıklar:

Page Cache: Diskin Önündeki Bellek

5. bölümde diskin RAM’e göre binlerce kat yavaş olduğunu görmüştük. Kernel bu farkı page cache ile kapatır: diskten okunan her sayfa, kullanılmayan RAM’de saklanır. Aynı veri tekrar istendiğinde disk hiç dönmez.

Bu yüzden bir dosyayı ikinci kez okumak dramatik biçimde hızlıdır ve bu yüzden free -h çıktısında “kullanılan” bellek beklediğinden yüksek görünür. Boş RAM boşa harcanmış RAM’dir; kernel onu cache olarak değerlendirir ve bir program bellek istediğinde cache’i anında geri verir.

Yazma tarafında varsayılan davranış write-back’tir: write çağrısı veri page cache’e kopyalandığında döner, disk yazımı sonra yapılır. Hızlıdır, ama güç kesilirse son yazılanlar kaybolabilir. Verinin gerçekten diskte olduğundan emin olmak gerektiğinde fsync çağrılır — veritabanlarının her commit’te ödediği bedel tam olarak budur.

Bir yana: sync ve düşen elektrik

Bir editörün “kaydedildi” demesi ile verinin fiziksel olarak diskte olması aynı an değildir. Aradaki pencere genelde milisaniyeler mertebesindedir ve /proc/sys/vm/dirty_expire_centisecs gibi ayarlarla yönetilir. Kritik veri yazan yazılımlar bu pencereyi fsync ile kapatır; kapatmayanlar ise elektrik kesintisinde tutarsız dosya bırakabilir.

Derinleşmefsyncgate: "diske yazdım" demek neden bu kadar zor?

fsync çağırdın ve döndü. Verinin diskte olduğundan emin olabilir misin?

Sorunun cevabı 2018’e kadar herkesin sandığından farklı çıktı ve PostgreSQL geliştiricileri bunu zor yoldan öğrendi. Olay literatüre fsyncgate olarak geçti.

Mekanizma şöyleydi. write çağrısı veriyi page cache’e koyar ve döner; sayfa “kirli” işaretlenir. fsync bu kirli sayfaların diske inmesini ister. Peki disk yazımı başarısız olursa ne olur?

Linux’un o günkü davranışı şuydu: hata işaretlenir, sayfanın kirli bayrağı temizlenir ve hata bir sonraki fsync çağrısına bildirilir. Yalnızca bir kez. Sonraki fsync çağrıları başarı döner — çünkü kirli sayfa kalmamıştır.

Sonuç felaket bir bileşim yarattı: PostgreSQL bir fsync hatası gördüğünde, o zamanki tasarımıyla işlemi yeniden deniyordu. İkinci fsync başarı dönüyordu. Veritabanı “tamam, yazıldı” diyor ve checkpoint’i ilerletiyordu. Oysa veri hiçbir zaman diske inmemişti ve artık cache’te de yoktu. Sessiz veri kaybı.

İşin daha tuhaf tarafı: hata, dosyayı o an açık tutan file descriptor’lara bildiriliyordu. Yazan process ile fsync çağıran process farklıysa — ki PostgreSQL’in mimarisinde tam olarak öyleydi — hata hiç kimseye ulaşmayabiliyordu.

Sonrasında iki şey değişti. Kernel tarafında hata raporlaması sıkılaştırıldı; PostgreSQL tarafında ise mimari karar değişti: fsync hatası artık yeniden denenmez, süreç derhal panic eder ve veritabanı kurtarma moduna girer. Yani “tekrar dene” yerine “asla devam etme”.

Buradan çıkarılacak iki ders var. Birincisi teknik: kalıcılık (durability) bir çağrının dönüş değeri değil, bir protokoldür. Veritabanlarının write-ahead log, checksum ve kurtarma prosedürü tutmasının sebebi budur.

İkincisi daha genel: bir soyutlama en çok hata yolunda sızar. write/fsync arayüzü mutlu yolda kusursuz görünüyordu; sözleşmenin belirsiz olduğu yer, işler ters gittiğinde ne olacağıydı. Bu, sistem tasarımında tekrar tekrar karşına çıkacak bir kalıptır.

Buffered, Direct ve mmap

Aynı dosyayı üç ayrı yoldan okuyabilirsin ve hangisini seçtiğin, programının ne kadar hızlı çalışacağını doğrudan belirler. Üçünü sırayla tanıyalım.

Buffered I/O (varsayılan): veri diskten page cache’e, oradan process’in tamponuna kopyalanır. İki kopya vardır ama cache isabetleri bunu fazlasıyla telafi eder.

Direct I/O (O_DIRECT): page cache atlanır, veri doğrudan process’in tamponuna gider. Yalnızca kendi cache’ini yöneten yazılımlar için mantıklıdır — veritabanları genelde bu grubtadır, çünkü hangi sayfanın sıcak olduğunu kernel’dan daha iyi bilirler. Yanlış yerde kullanıldığında performansı çökertir.

Bellek eşleme (mmap): dosya doğrudan process’in adres alanına eşlenir. Artık read çağrısı yoktur; dosyaya sıradan bir dizi gibi erişilir ve eksik sayfalar 13. bölümde gördüğümüz demand paging ile page fault üzerinden getirilir.

mmap-ornegi.c
int fd = open("veri.bin", O_RDONLY);
struct stat st;
fstat(fd, &st);

// Dosya artık bellekteymiş gibi: veri[0] okumak page fault üretir
char *veri = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

mmap kopya sayısını azaltır ve rastgele erişimde çok rahattır. Buna karşılık her page fault bir kernel geçişidir; sıralı ve büyük okumalarda düz read çoğu zaman daha hızlıdır.

Aynı mekanizma 12. bölümde gördüğümüz program yüklemenin de temelidir: kernel bir ELF’i çalıştırırken dosyayı okumaz, mmap’ler.

OverlayFS: Katmanları Üst Üste Bindirmek

Şimdiye kadar tek bir dosya sisteminden söz ettik. Peki birden fazla dizini tek bir dizinmiş gibi göstermek mümkün mü?

Mümkün ve bunun adı OverlayFS. VFS’in en zarif kullanımlarından biridir: gerçek bir depolama sürücüsü değildir, kendi diski yoktur; yaptığı tek şey başka dosya sistemlerinin üstüne oturup onları birleşik bir görünüm hâlinde sunmaktır.

Mount ederken üç dizin verirsin, dördüncüsü sonucun göründüğü yerdir:

DizinRolü
lowerdirSalt okunur alt katman ya da katmanlar. Birden fazlaysa üst üste bindirilir.
upperdirYazılabilir katman. Bütün değişiklikler buraya gider.
workdirOverlayFS’in kendi iç işlemleri için kullandığı geçici alan.
mergedKullanıcının gördüğü birleşik görünüm.

Okuma isteği geldiğinde OverlayFS dosyayı önce upperdir’de arar, bulamazsa alt katmanlara iner. İlk bulduğu kazanır; bu yüzden üst katman, alttakini “gölgeleyebilir”.

Yazma tarafı ise doğrudan 16. bölümde gördüğümüz fikre bağlanıyor. Salt okunur bir katmandaki dosyayı değiştirmek istediğinde OverlayFS onu önce upperdir’e kopyalar, sonra değişikliği orada yapar. Bu bir copy-on-write işlemidir ve fork’un bellek sayfaları için yaptığının dosya düzeyindeki karşılığıdır: kopyalamayı gerçekten gerekene kadar ertele.

Sonuç, aynı salt okunur tabanın onlarca farklı yazılabilir katmanla paylaşılabilmesidir. Bir sonraki bölümde göreceğimiz container imajlarının hem az yer kaplamasının hem de saniyeler içinde başlayabilmesinin sebebi tam olarak budur.

Bloklanan I/O ve Ölçek Sorunu

Sıradan bir read çağrısı veri hazır değilse bloklar: process uyku durumuna geçer, scheduler CPU’yu başkasına verir. Tek bir bağlantı için bu gayet iyidir.

Peki on bin eşzamanlı bağlantıyı dinleyen bir sunucu? Her bağlantıya bir thread ayırmak, on bin stack ve sürekli context switch demektir — 7. bölümde gördüğümüz gibi her geçişin bir bedeli vardır.

Çözüm, “hangisi hazır olursa onu işle” diyebilmektir. Bu fikrin Linux’taki evrimi:

epoll Nasıl Çalışıyor?

Bugün internetin büyük kısmı bu üç çağrının üzerinde dönüyor, o yüzden açalım.

Bir olay döngüsünün iskeleti bundan ibarettir:

int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sock };
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev);

struct epoll_event olaylar[64];
for (;;) {
  int n = epoll_wait(ep, olaylar, 64, -1);   // sadece hazir olanlar
  for (int i = 0; i < n; i++)
    isle(olaylar[i].data.fd);
}

On bin bağlantın olsa ve bunlardan yalnızca üçünde veri gelse, epoll_wait sana üç eleman döner. select aynı durumda on bin fd’yi baştan sona tarardı. Aradaki fark, C10K probleminin çözümünün adıdır.

İki çalışma kipi var ve aralarındaki fark başa çok iş açar. Level-triggered (varsayılan) kipte, fd’de okunmamış veri kaldığı sürece epoll_wait onu tekrar tekrar bildirir. Edge-triggered kipte ise yalnızca durum değiştiğinde bir kez bildirir; veriyi EAGAIN alana kadar okumazsan kalan kısım için ikinci bir uyarı gelmez ve bağlantı sessizce asılı kalır. Edge-triggered daha az sistem çağrısı demektir ama hata affetmez.

Son bir uyarı: epoll düzenli dosyalarda işe yaramaz. Bir disk dosyası kernel’ın gözünde her zaman “hazır”dır; okuma gerekiyorsa zaten bloklanır. epoll’ün anlamlı olduğu yer soketler, pipe’lar ve terminaller gibi gerçekten beklenebilen nesnelerdir. Dosya I/O’sunu asenkron yapmak isteyenlerin io_uring’e yönelmesinin sebeplerinden biri de bu.

io_uring Syscall’ı Nasıl Ortadan Kaldırıyor?

epoll çağrı sayısını azaltır ama sıfırlayamaz: her epoll_wait ve her read yine bir syscall’dır. io_uring bu son maliyeti de hedefler.

Fikir, halka tampon üzerine kurulu: başı ve sonu birbirine değen sabit boyutlu bir dizi; bir taraf yazar, diğer taraf okur. io_uring böyle iki halka kurar — biri istekler (submission), biri sonuçlar (completion) için.

Kritik ayrıntı şu: bu iki halka mmap ile hem process’in hem kernel’ın adres alanında görünür. Yani aynı fiziksel bellek. İstek eklemek bir syscall değil, sıradan bir bellek yazımıdır. Syscall yalnızca kernel’ı “bak, yeni iş var” diye uyandırmak gerektiğinde atılır. SQPOLL kipinde kernel tarafında bir thread halkayı sürekli yokladığı için o son syscall bile ortadan kalkar.

Sonuç, yüksek yükte saniyede yüz binlerce işlemin tek bir syscall bile atılmadan tamamlanabilmesi.

epoll’ün “hazır olanı bildir” mantığı, çoğu modern async runtime’ının (libuv, tokio) temelidir. Yani JavaScript’te yazdığın await fetch(...) ifadesinin altında, döngünün bir yerinde bu mekanizma çalışıyordur.

Özet

Artık tek bir makinede olan biten her şeyi gördük: donanımdan process’e, bellekten dosyaya. Sıradaki bölümde bu resmi bir kez daha katlıyor ve modern altyapının üstünde durduğu iki fikre bakıyoruz: sanal makineler ve container’lar.

18. bölüme devam et: Sanal Makineler ve Container'lar