Ana içeriğe geç

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

6 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 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 -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 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/fd

Kernel tarafında üç katmanlı bir yapı vardır ve bu ayrım 12. 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, 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.

include/linux/fs.h
// 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/hostname

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

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: 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.

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.

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 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:

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 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