Bölüm 17:Dosya Sistemi ve I/O
CPU'ya “Sen”i Katmak yazısının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Tüm bölümler
- Giriş
- Başlamadan Önce
- Temeller
- Kernel, User Mode ve Syscall
- Mimariler: x86, ARM ve Diğerleri
- Bellek Hiyerarşisi
- Cache Nasıl Çalışır
- Zamanı Dilimle
- İşlemciyi Hızlandıran Hileler
- Tahmin, Spekülasyon ve Spectre
- Bir Program Nasıl Çalıştırılır?
- Shell'den Kernel'e
- Bir ELF Ustasına Dönüşmek
- Bellek Aslında Sanal
- Adres Çevirisi ve TLB
- Bellek Güvenliği ve Sertleştirme
- Fork'lar ve COW'lar Hakkında Konuşalım
- Dosya Sistemi ve I/O
- Sanal Makineler ve Container'lar
- Son Söz
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
epollileio_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 -3Burada 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/fdKernel tarafında üç katmanlı bir yapı vardır ve bu ayrım 16. bölümdeki fork davranışını açıklar:
- fd tablosu — process’e özeldir, fd numarasını bir açık dosya tanımına bağlar.
- Açık dosya tanımı — dosya konumunu (offset) ve erişim kipini tutar;
forksonrasında ebeveyn ve çocuk aynı tanımı paylaşır. - 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:
- superblock — bağlı (mount edilmiş) dosya sisteminin kendisini temsil eder. Blok boyutu, toplam kapasite, hangi sürücünün yönettiği gibi bilgiler buradadır. Her mount noktası için bir tane vardır.
- inode — bir dosyanın kimliği. Az önce konuştuğumuz künye. Dikkat: inode’un adı yoktur.
- dentry (directory entry) — ad ile inode arasındaki bağ.
notlar.txtadının hangi inode’a karşılık geldiğini söyleyen şey budur. - file — açık bir dosyayı temsil eder. Aynı inode’a bakan iki ayrı
opençağrısı iki ayrıfilenesnesi üretir; her birinin kendi okuma konumu (offset) vardır.
İ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;
lskomutunu 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/hostnameBaştaki sayı inode numarasıdır. Bu ayrım birkaç davranışı bir anda açıklar:
- Hard link, aynı inode’a ikinci bir addır. İki ad da eşit derecede “gerçektir”; birini silmek dosyayı yok etmez, yalnızca bağlantı sayacını azaltır.
- Silinen ama açık dosya yer kaplamaya devam eder.
rmyalnızca dizin girdisini kaldırır; inode, onu açık tutan process kapatana kadar yaşar. Log dosyasını sildiğin hâlde diskin bir türlü boşalmamasının nedeni tam olarak budur. - Dosyayı taşımak aynı dosya sistemi içindeyse ucuzdur, çünkü veri kopyalanmaz; yalnızca dizin girdisi değişir.
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:
syncve düşen elektrikBir 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_centisecsgibi ayarlarla yönetilir. Kritik veri yazan yazılımlar bu pencereyifsyncile 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.
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:
| Dizin | Rolü |
|---|---|
| lowerdir | Salt okunur alt katman ya da katmanlar. Birden fazlaysa üst üste bindirilir. |
| upperdir | Yazılabilir katman. Bütün değişiklikler buraya gider. |
| workdir | OverlayFS’in kendi iç işlemleri için kullandığı geçici alan. |
| merged | Kullanı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:
select/poll— ilgilendiğin tüm fd’leri her çağrıda kernel’a yeniden verirsin. Kernel hepsini tek tek tarar; maliyet bağlantı sayısıyla doğru orantılı büyür.epoll— ilgilendiğin fd’leri kernel’da bir kez kaydedersin, sonra yalnızca hazır olanların listesini alırsın. Maliyet bağlantı sayısına değil, o an aktif olanların sayısına bağlıdır. Nginx ve Node.js’in altında bu vardır.io_uring— daha ileri bir adım: process ile kernel arasında paylaşılan iki halka tampon kurulur. Yüksek yükte syscall sayısı neredeyse sıfıra iner.
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.
epoll_create1()— kernel’da bir izleme listesi oluşturur ve sana onun fd’sini verir. Evet, listenin kendisi de bir dosya.epoll_ctl()— listeye fd ekler, çıkarır ya da ilgilendiğin olayı değiştirir. Her fd için bir kez çağırırsın.epoll_wait()— bloklanır ve yalnızca hazır olan fd’lerin listesiyle döner.
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
- Linux’ta dosya, aygıt, soket ve process bilgisi aynı
open/read/writearayüzünün arkasındadır. - File descriptor, process’e özel bir indekstir; açık dosya tanımı offset’i tutar ve
forksonrasında paylaşılır, inode ise dosyanın kendisidir. - VFS, farklı dosya sistemlerini tek bir fonksiyon tablosu arkasında birleştirir.
- Page cache disk erişimini RAM hızına yaklaştırır;
writevarsayılan olarak diske değil cache’e yazar, garanti isteyenfsyncçağırır. mmapdosyayı adres alanına eşler ve program yüklemenin de temelidir.- Bloklanan I/O bağlantı başına thread gerektirir;
epollveio_uringbu maliyeti ortadan kaldırır. - OverlayFS, birden fazla dizini tek bir görünümde birleştirir ve yazmayı copy-on-write ile üst katmana alır.
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