Bölüm 13: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
- Bellek Hiyerarşisi
- Zamanı Dilimle
- Modern İşlemci Teknikleri
- Bir Program Nasıl Çalıştırılır?
- Shell'den Kernel'e
- Bir ELF Ustasına Dönüşmek
- Bilgisayarındaki Çevirmen
- Bellek Güvenliği ve Sertleştirme
- Container ve İzolasyon
- Fork'lar ve COW'lar Hakkında Konuşalım
- Dosya Sistemi ve I/O
- Sistemi Gözlemlemek
- 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 düzenli dosyalar da, dizinler de, klavyeniz 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. Kabuktaki yönlendirme ve pipe’lar da tam olarak bu tekdüzelik sayesinde mümkündür; 7. 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 kabuğun 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 12. 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, her dosya sisteminden bir dizi fonksiyon işaretçisi ister — “senin read’in nerede, write’ın nerede” — ve syscall geldiğinde ilgili dosya sisteminin işlevini çağırır. Nesne yönelimli bir arayüzün C ile yazılmış hâli gibi düşünebilirsin.
// Her dosya sistemi bu tabloyu kendi işlevleriyle doldurur
struct file_operations {
ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);
ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
int (*open)(struct inode *, struct file *);
/* ... */
};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 gerçek verisi — boyut, izinler, sahip, zaman damgaları ve veri bloklarının nerede olduğu — inode’da durur.
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ı silmenize rağmen diskin boşalmamasının nedeni 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#
3. 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ğinizden 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.
Buffered, Direct ve mmap#
Aynı dosyayı okumanın üç farklı yolu vardır ve seçim performansı belirler:
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 9. 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 8. bölümde gördüğümüz program yüklemenin de temelidir: kernel bir ELF’i çalıştırırken dosyayı okumaz, mmap’ler.
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 — 4. 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. Program isteklerini gönderi halkasına yazar, kernel sonuçları tamamlanma halkasına koyar. Yüksek yükte syscall sayısı neredeyse sıfıra iner.
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.
Artık sistemin tüm ana parçalarını gördük. Bir sonraki bölümde bunların hiçbirini teoriden okumak zorunda kalmayacağız: kendi makinemizde çalışan bir programın syscall’larını, sayfa hatalarını ve cache davranışını doğrudan ölçeceğiz.
14. bölüme devam et: Sistemi Gözlemlemek