Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri İşletim Sistemi Temelleri — #05

Virtual Memory: Page, Page Table, TLB ve Page Fault

Mert Özen 14 Kas 2026 10 dk 1 görüntülenme
Virtual Memory: Page, Page Table, TLB ve Page Fault

Virtual memory, her process'e kendi adres alanını verir ve adresleri sayfa sayfa fiziksel belleğe çevirir; bir aralığı ayırmak ile ona RAM vermek ayrı işlerdir ve RAM ancak sayfaya dokunulunca gelir. 512 MB'lık allocation'ın resident memory'yi 3 MB, dokununca 513 MB artırdığını ve 131 bin minor fault ürettiğini ölçüyor, .NET'in 17 GB'lık VIRT'inin GC reserve'ü olduğunu ve 256 MB'lık container'ın 1 GB allocation'ı kabul edip OOM killer ile öldüğünü gösteriyoruz.

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 Ubuntu 24.04 tabanlı mcr.microsoft.com/dotnet/sdk:10.0 container'larında (Linux 6.x kernel, 4 KB sayfa, cgroup v2) alındı. Windows ve macOS bilgileri Microsoft Learn ve Apple dokümanlarına, GC'nin (garbage collector; kullanılmayan belleği toplayan bileşen) davranışı dotnet/runtime kaynak koduna dayanır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) izleme ekranında API process'inin virtual memory'si 17 GB görünüyor, resident memory'si ise 27 MB. Gece nöbetindeki biri "17 GB, memory leak var" diye alarm veriyor. Haklı mı?

İki soru daha: Bookstore'un rapor modülü native bellekten 512 MB'lık bir buffer alıyor. Allocation biter bitmez process'in resident memory'si kaç MB artar? Ve container'ın sahip olduğu bellekten çok daha büyük, 64 GB'lık bir allocation istersen ne olur: hemen hata mı alırsın, yoksa başarılı mı olur? Bu yazıda üçünü de ölçüyoruz ve virtual memory'nin nasıl çalıştığına bakıyoruz.

Neden Virtual Address?

İkinci yazıda her process'in kendine ait bir address space'i olduğunu söylemiştik. Bunun anlamı şu: process'in kullandığı adresler fiziksel RAM'deki adresler değil, virtual address'ler. Her bellek erişiminde CPU bu adresi fiziksel bir adrese çevirir ve çeviriyi kernel'in o process için hazırladığı tabloya bakarak yapar. İki process aynı virtual address'i kullansa bile farklı fiziksel belleğe ulaşır; birinin ötekinin belleğini okuması bu yüzden mümkün değildir.

Bu katmanın getirdiği başka kolaylıklar da var. Her process kendini boş ve düzenli bir address space'in sahibi gibi görür; programın nereye yükleneceğini düşünmek zorunda kalmaz. Kernel aynı fiziksel sayfayı birden fazla process'e gösterebilir; shared library'ler böyle paylaşılır. Ve en önemlisi bu yazının konusu: bir adres aralığını ayırmakla o aralığa fiziksel bellek vermek ayrı işler olur. Process'in "17 GB'ı" bu ayrımdan doğuyor.

Page, Page Table ve TLB

Çeviri byte byte değil, sabit boyutlu bloklar hâlinde yapılır: virtual tarafta page, fiziksel tarafta frame. x86-64'te ve bu yazıdaki Linux container'larında sayfa 4 KB; .NET'te Environment.SystemPageSize bu değeri döner ve ölçümde 4096 çıktı. Apple Silicon'lı Mac'lerde sayfa 16 KB'tır; Apple'ın Apple Silicon geçiş dokümanı sayfa boyutunun Intel Mac'lerden farklı olduğunu söyleyip değeri sabit yazmak yerine vm_page_size'dan okumayı öneriyor.

Her process'in bir page table'ı vardır: hangi virtual page'in hangi frame'e karşılık geldiğini, izinlerini (okuma, yazma, çalıştırma) ve sayfanın bellekte olup olmadığını tutar. 64 bit'lik bir address space için düz bir tablo devasa olurdu, bu yüzden tablo ağaç şeklindedir. x86-64'te çoğu sistemde 48 bit'lik virtual address dört parçaya bölünür; her 9 bit bir seviyedeki tablonun indeksidir, son 12 bit sayfanın içindeki offset'tir. Yalnız kullanılan dallar için tablo oluşturulur.

05-address-translation-tr.png

Dört seviyeli bir tabloyu her bellek erişiminde yürümek dört ek bellek okuması demek. Bunu önleyen CPU içindeki TLB (translation lookaside buffer; son kullanılan adres çevirilerini tutan küçük bir cache). Çeviri TLB'de varsa tek adımda biter; yoksa (TLB miss) donanım tabloları yürür ve sonucu TLB'ye koyar. TLB'nin girdi sayısı sınırlı olduğu için çok büyük ve dağınık erişilen bir heap TLB miss'lerle yavaşlayabilir. Buna karşı huge page (Windows'ta large page) var: x86-64'te 2 MB'lık tek bir sayfa, 512 tane 4 KB'lık sayfanın yerine tek bir TLB girdisi kullanır. Microsoft'un dokümanına göre Windows'ta large page için SeLockMemoryPrivilege yetkisi gerekir ve bu bellek hiçbir zaman diske yazılmaz. Linux'ta transparent huge pages kernel tarafından otomatik uygulanır. .NET GC'de large page desteği DOTNET_GCLargePages ile açılabilir ama dokümanda deneysel olarak işaretli ve heap hard limit gerektiriyor.

Demand Paging: Bellek Dokunulunca Gelir

Kernel, process bir adres aralığı istediğinde ona hemen fiziksel bellek vermez; yalnız page table'a "bu aralık geçerli" diye not düşer. Process o aralıktaki bir sayfaya ilk kez dokunduğunda CPU çeviriyi bulamaz ve bir page fault üretir. Kernel bu noktada boş bir frame bulur, içini sıfırlar, page table'a yazar ve process kaldığı instruction'dan devam eder. Buna demand paging denir. Deney uygulamasında native bellek alıp her sayfasına bir byte yazan bir komut var:

// Her sayfaya bir byte yazar; ilk dokunuş page fault üretir
public static void TouchPages(IntPtr block, long size)
{
    int page = Environment.SystemPageSize;
    for (long offset = 0; offset < size; offset += page)
    {
        Marshal.WriteByte(block + (nint)offset, 1);
    }
}

public static IEnumerable<string> DemandPaging(int megabytes)
{
    long size = (long)megabytes << 20;
    yield return $"start          {Snapshot()}";

    IntPtr block = Marshal.AllocHGlobal((nint)size);
    yield return $"allocated      {Snapshot()}";

    TouchPages(block, size);
    yield return $"touched        {Snapshot()}";

    TouchPages(block, size);
    yield return $"touched again  {Snapshot()}";

    Marshal.FreeHGlobal(block);
    yield return $"freed          {Snapshot()}";
}

Snapshot, virtual ve resident memory'yi Process class'ından, page fault sayılarını Linux'ta /proc/self/stat'ın 10. ve 12. alanlarından okuyor:

dotnet Bookstore.OsLab.dll paging 512
start          virtual= 17349 MB  resident=   29 MB  minflt=   2261  majflt=3
allocated      virtual= 17934 MB  resident=   32 MB  minflt=   2602  majflt=3
touched        virtual= 17934 MB  resident=  545 MB  minflt= 133744  majflt=3
touched again  virtual= 17934 MB  resident=  545 MB  minflt= 133780  majflt=3
freed          virtual= 17422 MB  resident=   33 MB  minflt= 133794  majflt=3

İkinci sorunun cevabı: allocation sonrası resident memory yalnız 3 MB arttı, virtual memory ise 585 MB. Fiziksel bellek, sayfalara yazdığımız anda geldi: resident 513 MB arttı ve 131.142 minor page fault oluştu. 512 MB'ın 4 KB'lık sayfa sayısı 131.072; yani neredeyse her sayfa için tam bir fault. İkinci geçişte sayfalar zaten bellekte olduğu için fault sayısı yalnız 36 arttı. FreeHGlobal ile bellek kernel'e geri verildi ve resident eski hâline döndü.

proc(5) man sayfası iki fault türünü diskten okuma gerekip gerekmemesine göre ayırıyor. Minor fault'ta sayfa zaten RAM'dedir ya da kernel sıfırlanmış yeni bir sayfa verir; diske gidilmez. Major fault'ta sayfa diskten okunur: swap'a yazılmış bir sayfa ya da map edilmiş bir dosyanın henüz belleğe gelmemiş kısmı. Diskten okuma mikrosaniyeler değil milisaniyeler sürebilir. ps -o min_flt,maj_flt -p <pid> iki sayıyı bir process için verir; büyüyen bir maj_flt çoğu zaman belleğin yetmediğinin ilk işaretidir.

Bir .NET Process'inde VIRT Neden Devasa?

Şimdi ilk soru. Hiçbir şey yapmayan deney uygulaması bile 17 GB virtual memory gösteriyor. İkinci yazıda bu alanın büyük kısmının GC'nin reserve ettiği tek bir bölge olduğunu görmüştük. Microsoft'un GC configuration dokümanına göre .NET 7'den beri GC heap'i region'lardan oluşuyor ve GC başlarken bir virtual memory aralığını yalnız reserve ediyor, commit etmiyor. Aralığın boyutunu GC.GetConfigurationVariables() ile runtime'a sormak mümkün:

case "gc-config":
    foreach (KeyValuePair<string, object> kv in GC.GetConfigurationVariables())
    {
        if (kv.Key is "GCRegionRange" or "GCHeapHardLimit")
        {
            Console.WriteLine($"{kv.Key} = {kv.Value}");
        }
    }
    break;
dotnet Bookstore.OsLab.dll gc-config
GCHeapHardLimit = 0
GCRegionRange = 16434118656

Yaklaşık 15,3 GB. dotnet/runtime'daki GC kaynağına göre bir limit yoksa Workstation GC bu aralığı 256 GB ile fiziksel belleğin iki katından küçük olanı olarak seçer; Server GC ise büyük olanı. Yani büyük RAM'li bir sunucuda aynı uygulama 256 GB'lık VIRT (top'ta virtual memory'yi gösteren sütun) gösterebilir ve bu tamamen normaldir. Process'in kendi metriklerine bakalım:

dotnet Bookstore.OsLab.dll mem-info
SystemPageSize      = 4096
VirtualMemorySize64 = 17387 MB
WorkingSet64        = 26 MB
PrivateMemorySize64 = 65 MB
GC.GetTotalMemory   = 83 KB

Dört sayı, dört farklı soru. Nöbetçinin alarmı yanlıştı: VIRT bir adres aralığıdır, RAM değil. Bellek sızıntısını gösteren şey zamanla durmadan artan resident ya da private memory'dir. GC heap'indeki canlı object'lerin toplamını veren GC.GetTotalMemory ile resident memory arasındaki fark ve container'da bunun anlamı bir sonraki yazının konusu.

Bellek Metrikleri: RSS, Working Set, Private Bytes ve Commit

Aynı kavramlar platformlarda farklı adlarla görünür. dotnet/runtime kaynağına göre Linux'ta Process class'ının değerleri doğrudan /proc/<pid>/status'tan gelir:

SoruLinuxWindows.NET
Ne kadar adres aralığı ayrılmış?VmSize (VIRT/VSZ)Virtual sizeVirtualMemorySize64
Şu an RAM'de ne kadarı var?VmRSS (RSS)Working setWorkingSet64
Process'e özel ne kadar ayrılmış?VmDataPrivate bytes (commit)PrivateMemorySize64
Sistemin söz verebileceği sınırCommitLimit (mode 2'de)Commit limityok

Windows bu ayrımı API seviyesinde açıkça yapar. Microsoft'un sayfa durumları dokümanına göre bir sayfa free, reserved ya da committed olabilir. Reserve edilen aralığın arkasında hiçbir depolama yoktur; commit edilen sayfa için RAM ve page file toplamından bir pay ayrılır, ama fiziksel sayfa yine ancak ilk okuma ya da yazmada yüklenir. Sistemdeki bütün commit'lerin toplamı commit limit'i, yani RAM ile page file'ların toplamını aşamaz. .NET GC'nin Windows'taki reserve'ü VirtualAlloc'un MEM_RESERVE'üyle, Linux'taki karşılığı izinsiz (PROT_NONE) bir mmap ile yapılır. macOS'ta Activity Monitor'ün Memory sekmesi ve vm_stat komutu sistem genelindeki sayfa sayılarını verir.

Overcommit ve OOM Killer

Linux, process'lere toplamda sahip olduğundan fazla bellek sözü verebilir; buna overcommit denir. Üçüncü soruyu ölçtüm:

dotnet Bookstore.OsLab.dll overcommit 4
dotnet Bookstore.OsLab.dll overcommit 16
dotnet Bookstore.OsLab.dll overcommit 64
cat /proc/sys/vm/overcommit_memory
4 GB reserved, nothing touched: virtual= 21445 MB  resident=   29 MB  minflt=   2262  majflt=3
16 GB reserved, nothing touched: virtual= 33733 MB  resident=   29 MB  minflt=   2255  majflt=3
64 GB reserved, nothing touched: virtual= 82885 MB  resident=   29 MB  minflt=   2274  majflt=3
1

64 GB'lık malloc başarılı oldu ve resident memory hiç değişmedi. Bunu belirleyen vm.overcommit_memory ayarı. Kernel'in overcommit dokümanına göre 0 (çoğu dağıtımda varsayılan) bariz biçimde aşırı istekleri reddeden bir sezgiyle çalışır, 1 her isteği kabul eder, 2 ise toplam commit'i swap ile RAM'in bir yüzdesinin toplamıyla sınırlar ve sınır aşılınca allocation'ı baştan reddeder. Ölçtüğüm ortamda, Docker'ın Linux VM'inde, değer 1; bu yüzden 64 GB bile geçti. Varsayılan 0 ayarlı bir sunucuda aynı istek reddedilebilir; ama orada da gerçek bellek tüketimini belirleyen, sayfalara dokunulması.

Söz verilen bellek gerçekten kullanılmak istendiğinde yetmezse kernel'in yapabileceği tek şey bir process'i öldürmektir: OOM (out of memory; bellek bitince bir process'i sonlandıran kernel mekanizması) killer. Container'a 256 MB limit koyup 1 GB'ı sayfa sayfa doldurdum:

docker run --name oslab-oom --memory=256m --memory-swap=256m -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll oom 1024
echo "exit code: $?"
docker inspect oslab-oom --format "OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}"
allocated 1024 MB: virtual=  3527 MB  resident=   29 MB  minflt=   3469  majflt=4
touched    64 MB: virtual=  3600 MB  resident=   97 MB  minflt=  20248  majflt=4
touched   128 MB: virtual=  3600 MB  resident=  161 MB  minflt=  36640  majflt=4
touched   192 MB: virtual=  3600 MB  resident=  225 MB  minflt=  53031  majflt=4
exit code: 137
OOMKilled=true ExitCode=137

1 GB'lık allocation 256 MB'lık container'da sorunsuz geçti. Process hiçbir exception görmedi, hiçbir log yazmadı; resident memory limite yaklaşınca kernel onu SIGKILL (yakalanamayan öldürme sinyali) ile öldürdü ve exit code 137 oldu. İkinci yazıda 137'nin yalnız "SIGKILL ile öldü" demek olduğunu söylemiştik; OOMKilled=true ise kimin öldürdüğünü söylüyor. Windows'ta ise sistem genelindeki commit limit dolduğunda VirtualAlloc çağrısı baştan başarısız olur ve .NET'te bu bir OutOfMemoryException olarak görünür; hata allocation anında gelir, dokunma anında değil. .NET'in container limitini nasıl algıladığı ve GC'nin buna göre heap sınırı koyması bir sonraki yazıda.

Üretimde Dikkat

Bellek alarmlarını VIRT'e değil, resident ve private memory'ye kur; container'da ise cgroup'un kendi sayacına. VIRT, uygulamanın ne kadar adres ayırdığını söyler ve .NET'te makinenin RAM'ine göre değişir; iki aynı uygulama iki farklı sunucuda çok farklı VIRT gösterebilir.

Swap'a düşen bir .NET sunucusu özellikle kötü davranır. GC, canlı object'leri bulmak için heap'i gezer; heap'in swap'a yazılmış sayfaları bu sırada major fault'larla diskten geri okunur ve milisaniyelik bir GC pause saniyelere uzayabilir. Uygulama "yavaş" görünür ama CPU boştur. Bu yüzden latency'ye duyarlı servislerde swap'ı kapatmak ya da belleği gerçek ihtiyaca göre boyutlamak yaygın bir tercihtir; teşhiste maj_flt'nin artıp artmadığına bak.

Sık Düşülen Tuzaklar

"VIRT 200 GB, memory leak var" paniği. Yukarıdaki gibi .NET GC büyük bir aralığı başlangıçta reserve eder; leak'i gösteren VIRT değil, zamanla düşmeyen resident ya da private memory'dir.

Allocation başarılı oldu diye belleğin var olduğunu sanmak. Linux'ta overcommit açıkken bir allocation'ın başarısı yalnız adres aralığının ayrıldığını gösterir. Bellek yetmediğinde hata bir exception olarak değil, sayfalara dokunurken gelen bir SIGKILL olarak gelir; process'in son log satırı hiçbir ipucu taşımaz.

Ölçümü yanlış anda almak. Büyük bir buffer'ı allocate ettikten hemen sonra resident memory'ye bakıp "bellek kullanmıyor" demek ya da sayfaları dokunduktan sonra FreeHGlobal öncesi bakıp "sızıntı var" demek aynı yanılgının iki yüzü. Bellek ölçümünü bir zaman serisi olarak izle.

Küçük Bir Deneme

★☆☆ paging komutunu 64, 256 ve 1024 MB ile çalıştır. Her biri için minor fault artışını boyut / Environment.SystemPageSize ile karşılaştır.

★★☆ DemandPaging'in bir kopyasını Marshal.AllocHGlobal yerine new byte[megabytes << 20] ile yaz. Allocation sonrası resident memory'nin native versiyondan neden farklı davrandığını, GC'nin yeni bir array'i sıfırlaması açısından yorumla.

★★★ --memory=256m container'ında DOTNET_GCRegionRange vermeden mem-info çalıştır ve VirtualMemorySize64'ü limitsiz container'dakiyle karşılaştır. Farkı dotnet/runtime'daki GC kaynağında region range'in heap hard limit'e göre nasıl hesaplandığına bakarak açıkla.

Bu yazıda bir process'in belleğini sayfa sayfa gördük: reserve edilen, dokunulan ve geri verilen. Bir sonraki yazıda aynı resmi process'in içinden okuyoruz: heap'in OS'tan mmap ile nasıl alındığı, copy-on-write ve shared library'lerin paylaşılması ve .NET'in container memory limit'ini nasıl algılayıp OOMKilled'dan nasıl kaçmaya çalıştığı.