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

Memory'yi Process Gözüyle Okumak: mmap, Copy-on-Write, Shared Memory ve Container Memory Limit

Mert Özen 14 Kas 2026 14 dk 1 görüntülenme
Memory'yi Process Gözüyle Okumak: mmap, Copy-on-Write, Shared Memory ve Container Memory Limit

Bir process belleği OS'tan mmap ve VirtualAlloc ile alır; dosyaya bağlı sayfalar process'ler arasında paylaşılır, yazıldığında copy-on-write ile kopyalanır ve container'da .NET GC, cgroup limitinin yüzde 75'ini heap sınırı yapar. 256 MB'lık container'da managed allocation'ın OOMKilled yerine 189. MB'ta yakalanabilir bir OutOfMemoryException verdiğini, paylaşılan bir dosyanın iki process'in RSS'inde iki kez sayıldığını ve DATAS'ın Server GC'nin belleğini 593 MB'tan 167 MB'a indirdiğini ölçüyoruz.

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 mcr.microsoft.com/dotnet/sdk:10.0 container'larında (Ubuntu 24.04, Linux 6.x kernel, cgroup v2), Docker'ın --memory ve --cpus limitleriyle alındı. GC (garbage collector; kullanılmayan belleği toplayan bileşen) davranışı dotnet/runtime kaynak koduna ve Microsoft Learn'deki GC configuration dokümanına, Windows bilgileri Microsoft Learn'e dayanır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) siparişleri bir rapor için belleğe topluyor ve API 256 MB limitli bir container'da çalışıyor. Önceki yazıda native bellekten 1 GB isteyen process'in hiçbir hata görmeden kernel tarafından öldürüldüğünü görmüştük. Aynı process 1 GB'ı bu kez managed bellekten, 1 MB'lık byte[]'ler hâlinde isterse ne olur: yine sessizce OOMKilled mı olur, yoksa yakalayabileceğin bir exception mı alırsın? Alırsan kaçıncı MB'ta?

İkinci soru: aynı 64 MB'lık dosyayı belleğe map eden iki process'in her biri top'ta 98 MB RSS (resident set size; process'in o an RAM'de duran sayfaları) gösteriyor. Makinede bu iki process gerçekte kaç MB RAM tutuyor: 196 mı, daha az mı? Bu yazıda bir process'in belleğini içeriden okuyoruz.

Heap OS'tan Nasıl Alınır: mmap, mprotect ve VirtualAlloc

Bir process'in belleği istemesinin yolu syscall'dır. Linux'ta bunun temel aracı mmap: address space'te yeni bir aralık açar ve bu aralık ya bir dosyaya bağlıdır ya da anonimdir (dosyasız, sıfırlarla başlayan bellek). mprotect bir aralığın izinlerini değiştirir, munmap aralığı geri verir. C'nin malloc'u küçük istekleri kendi heap'inden karşılar, büyük istekler için doğrudan mmap çağırır. Windows'ta karşılık VirtualAlloc ve VirtualFree.

.NET GC bu araçları önceki yazıda gördüğümüz reserve ve commit ayrımıyla kullanır. dotnet/runtime'daki Linux katmanında üç adım açıkça görünüyor: reserve, izinsiz (PROT_NONE) bir mmap; commit, aralığın bir parçasını mprotect ile okunur ve yazılır yapmak; decommit ise aynı parçanın üzerine MAP_FIXED ile yeniden izinsiz bir mmap atmak. Kaynaktaki açıklamaya göre bu son tercih, sayfalara artık ihtiyaç olmadığını kernel'e açıkça söylemek ve yeniden commit edilen sayfaların sıfırlanmış gelmesini garanti etmek için. Windows'taki karşılığı VirtualAlloc'un MEM_RESERVE ve MEM_COMMIT bayrakları ile VirtualFree'nin MEM_DECOMMIT bayrağı.

Bunu strace ile görmek mümkün. 512 MB limitli bir container'da deney uygulamasına 64 MB managed bellek aldırdım ve çıktıyı GC'nin reserve ettiği aralığa düşen satırlara indirdim:

strace -f -qq -e trace=mmap,mprotect,munmap -o mm.txt dotnet Bookstore.OsLab.dll fill 64
252   mmap(NULL, 2013270016, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xfffeebfff000
252   mprotect(0xfffeee000000, 4096, PROT_READ|PROT_WRITE) = 0
252   mprotect(0xfffeee001000, 65536, PROT_READ|PROT_WRITE) = 0
252   mprotect(0xfffeeec01000, 1048576, PROT_READ|PROT_WRITE) = 0
252   mprotect(0xfffeeed01000, 1048576, PROT_READ|PROT_WRITE) = 0
...
252   mmap(0xfffeeec21000, 32374784, PROT_NONE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xfffeeec21000

İlk satır yaklaşık 1,9 GB'lık reserve; neden bu boyutta olduğuna container bölümünde döneceğiz. Ardından gelen mprotect'ler commit: önce region başlarındaki küçük parçalar, sonra her 1 MB'lık array için 1 MB. Bu aralıkta toplam 78 çağrı ve 64 MB commit vardı; sondaki MAP_FIXED'lı mmap, GC'nin topladıktan sonra 31 MB'ı OS'a geri verdiği decommit. strace bu ortamda arm64'te çalıştı; adresler mimariye göre değişir, x64 Linux'ta aynı satırlar 7f ile başlayan adreslerle görünür, syscall'lar aynıdır. Region'ların nasıl yönetildiği, yani GC'nin iç algoritması, ilerideki Compiler'lar ve Programlama Dilleri serisinin konusu.

Memory-Mapped File: Dosyayı Address Space'e Koymak

mmap'in dosyaya bağlı kullanımı ayrı bir dünya açar. Dosyayı read ile bir buffer'a kopyalamak yerine kernel dosyanın sayfalarını doğrudan address space'e yerleştirir; process o adrese dokunduğunda sayfa, gerekirse diskten okunarak, page cache'ten gelir. Önceki yazıdaki major fault'un kaynaklarından biri buydu. Runtime'ın assembly'leri yüklerken yaptığı da bu: ikinci yazıda Bookstore.OsLab.dll'in /proc/<pid>/maps'te bir dosya olarak durduğunu görmüştük.

.NET'te bunun API'si MemoryMappedFile. Deney uygulamasında Bookstore'un katalog dışa aktarımını taklit eden 64 MB'lık bir CSV (alanları ayraçla bölünmüş düz metin) dosyasını salt okunur map edip her sayfasını bir kez okuyan bir komut var:

public static IEnumerable<string> Share(string path, int megabytes, TimeSpan hold)
{
    CreateCatalogExport(path, megabytes);
    long size = (long)megabytes << 20;
    yield return $"before map     {RollupLine()}";

    using MemoryMappedFile file = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
    using MemoryMappedViewAccessor view = file.CreateViewAccessor(0, size, MemoryMappedFileAccess.Read);
    ReadEveryPage(view, size);
    yield return $"pages read     {RollupLine()}";

    Thread.Sleep(hold);
    yield return $"after {hold.TotalSeconds,2:F0} s     {RollupLine()}";
}

RollupLine, /proc/self/smaps_rollup dosyasından dört değer okuyor. proc(5)'e göre bu dosya process'in bütün mapping'lerinin smaps değerlerini tek kayıtta toplar. RSS, process'in RAM'deki sayfaları; Shared_Clean, başka bir process'in de map ettiği ve değiştirilmemiş sayfalar; Private_Dirty, yalnız bu process'e ait ve yazılmış sayfalar. PSS (proportional set size; paylaşılan sayfanın, onu paylaşan process sayısına bölünmüş payı) ise her paylaşılan sayfayı paylaşan sayısına böler.

Paylaşılan Sayfalar: RSS Neden Toplanmaz?

Şimdi ikinci soru. Aynı dosyayı iki process'e map ettirdim; A 10 saniye bekliyor ve B, A başladıktan 4 saniye sonra başlıyor:

(dotnet Bookstore.OsLab.dll map-share /tmp/catalog-export.csv 64 10 | sed "s/^/A /") &
sleep 4
dotnet Bookstore.OsLab.dll map-share /tmp/catalog-export.csv 64 10 | sed "s/^/B /"
A before map     Rss=  30 MB  Pss=  12 MB  Shared_Clean=  23 MB  Private_Dirty=   6 MB
A pages read     Rss=  97 MB  Pss=  77 MB  Shared_Clean=  26 MB  Private_Dirty=   7 MB
A after 10 s     Rss=  98 MB  Pss=  43 MB  Shared_Clean=  90 MB  Private_Dirty=   7 MB
B before map     Rss=  30 MB  Pss=  11 MB  Shared_Clean=  23 MB  Private_Dirty=   6 MB
B pages read     Rss=  97 MB  Pss=  44 MB  Shared_Clean=  90 MB  Private_Dirty=   7 MB
B after 10 s     Rss=  98 MB  Pss=  78 MB  Shared_Clean=  26 MB  Private_Dirty=   7 MB

A tek başınayken dosyanın 64 MB'ı onun PSS'ine tamamen yazıldı. B aynı dosyayı okuyunca kernel yeni sayfa ayırmadı; B'nin page table'ı aynı fiziksel sayfaları gösterdi. A'nın ve B'nin Shared_Clean'i 90 MB'a çıktı, PSS'leri 43 ve 44 MB'a indi. İkisinin RSS'i 98 ve 97 MB, toplamı 195 MB; ama RAM'de tutulan 43 + 44 = 87 MB civarında. Sorunun cevabı: daha az. A'nın son satırı B çalışırken, B'nin son satırı A çıktıktan sonra alındı; A gidince sayfalar B'ye tek başına kaldı ve PSS yine 78 MB'a çıktı.

"Before map" satırlarında bile Shared_Clean 23 MB: libc ve runtime'ın native kütüphaneleri container'daki diğer process'lerle zaten paylaşılıyor. Shared library'ler de aynı mekanizmayla, dosyaya bağlı mapping'lerle paylaşılır. Aynı makinedeki on .NET process'i libcoreclr.so'nun code sayfalarını tek kopya olarak kullanır. Bu yüzden process'lerin RSS'lerini toplayıp makinenin belleğiyle karşılaştırmak yanlış sonuç verir; toplanabilir olan PSS'tir.

Shared memory'nin kendisi de bu mekanizmadır: iki process aynı dosyayı ya da aynı isimli bir bellek nesnesini paylaşılan modda map eder ve birinin yazdığını diğeri görür. Windows'ta karşılığı CreateFileMapping ve MapViewOfFile; isimli, dosyasız mapping'ler de vardır. MemoryMappedFile API'sinin ayrıntıları ve stream'lerle karşılaştırması .NET'te I/O İşlemleri serisinin konusu; process'ler arası iletişimdeki yeri sekizinci yazıda.

Copy-on-Write: fork ve Private Mapping

Paylaşılan bir sayfaya yazılırsa ne olur? Mapping private ise (maps'teki p harfi) kernel yazma anında sayfanın bir kopyasını çıkarır, process'in page table'ını kopyaya çevirir ve yazma kopyaya yapılır. Buna copy-on-write denir. fork bu sayede ucuzdur: Linux'un fork(2) man sayfasına göre child, parent'ın sayfalarını kopyalamaz; ikisi de aynı sayfaları copy-on-write olarak paylaşır ve fork'un maliyeti büyük ölçüde page table'ları çoğaltmaktır. İkinci yazıda .NET'in vfork kullandığını, gerekçesinin de parent'ın yazılabilir sayfalarını copy-on-write olarak işaretlemekten kaçınmak olduğunu görmüştük.

.NET'ten fork çağıramayız ama aynı mekanizmayı MemoryMappedFileAccess.CopyOnWrite ile görebiliriz. Bu erişim modu dosyayı private map eder:

using MemoryMappedFile file = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.CopyOnWrite);
using MemoryMappedViewAccessor view = file.CreateViewAccessor(0, size, MemoryMappedFileAccess.CopyOnWrite);

ReadEveryPage(view, size);
yield return $"read only      {RollupLine()}";

// Her sayfanın ilk byte'ını değiştir: kernel sayfanın özel bir kopyasını çıkarır
int page = Environment.SystemPageSize;
for (long offset = 0; offset < size; offset += page)
{
    view.Write(offset, (byte)'#');
}
yield return $"after writes   {RollupLine()}";
yield return $"in view: '{(char)view.ReadByte(0)}'  on disk: '{(char)File.ReadAllBytes(path)[0]}'";
read only      Rss=  96 MB  Pss=  77 MB  Shared_Clean=  25 MB  Private_Dirty=   6 MB
after writes   Rss=  98 MB  Pss=  78 MB  Shared_Clean=  26 MB  Private_Dirty=  71 MB
in view: '#'  on disk: '9'

RSS neredeyse değişmedi ama içeriği değişti: dosyanın page cache'teki 64 MB'ı yerine process'e özel, yazılmış 64 MB geldi ve Private_Dirty 6'dan 71 MB'a çıktı. Dosya diskte aynı kaldı; değişiklik yalnız bu process'in kopyasında. Shared library'lerin değiştirilebilir global verisi de böyle çalışır: ikinci yazıdaki rw-p parçası, bir process yazana kadar paylaşılır.

ASLR ve W^X: Adresler Neden Her Seferinde Değişir?

İkinci yazıda adreslerin her çalıştırmada değiştiğini söyleyip sebebini buraya bırakmıştık. Deney uygulamasına bölgelerin başlangıç adreslerini yazdıran bir komut ekledim ve iki kez çalıştırdım:

dotnet Bookstore.OsLab.dll layout
dotnet host     aaaae26a0000
[heap]          aaab1f891000
malloc(64)      aaab1f918960
malloc(16 MB)   fffb7a8fd010
libcoreclr.so   ffffacea0000
[stack]         ffffe7f6a000

dotnet host     aaaab44c0000
[heap]          aaaadd0ac000
malloc(64)      aaaadd133950
malloc(16 MB)   fffb74c8f010
libcoreclr.so   ffffab030000
[stack]         ffffc4b22000

Program, heap, büyük malloc'un aldığı mmap bölgesi, kütüphaneler ve stack her seferinde farklı yerde. Bu ASLR (address space layout randomization; bölgelerin başlangıç adreslerini her çalıştırmada rastgele seçme). Bu çıktı arm64'ten; x64 Linux'ta program ve heap genellikle 55 ya da 56, kütüphaneler ve stack 7f ile başlar, ama rastgelelik aynıdır. Amaç defansif: bir saldırgan bellek hatası bulsa bile hedef kodun ya da verinin adresini önceden bilemez. Kernel'in sysctl dokümanına göre /proc/sys/kernel/randomize_va_space 1 iken mmap bölgeleri, stack ve PIE (position-independent executable; her adrese yüklenebilen program) kodu rastgele yerleşir, 2 iken heap de; container'da değer 2'ydi. Windows'ta karşılık, Microsoft'un linker dokümanına göre varsayılan olarak açık olan /DYNAMICBASE ile işaretlenmiş image'lar.

İkinci koruma çalıştırma izniyle ilgili. Linux'ta NX (no-execute; CPU'nun bir sayfadaki byte'ları kod olarak çalıştırmayı reddetmesi), Windows'ta DEP (Data Execution Prevention; Windows'un aynı korumaya verdiği ad). Microsoft'un DEP dokümanına göre heap'te ya da stack'te kod çalıştırmaya çalışan uygulama access violation alır. Yukarıdaki maps çıktılarında heap ve stack'in rw-p olması, x harfinin olmaması bu yüzden. JIT (just-in-time; method'ları çalışma anında makine koduna çeviren derleyici) ise doğası gereği kod üretir. .NET, runtime kaynağındaki varsayılan değere göre RISC-V (açık bir işlemci mimarisi) dışında W^X (write xor execute; bir sayfanın aynı anda hem yazılabilir hem çalıştırılabilir olmaması) ile çalışır:

dotnet Bookstore.OsLab.dll wx
DOTNET_EnableWriteXorExecute=0 dotnet Bookstore.OsLab.dll wx
mappings: writable=76 executable=28 writable+executable=0
mappings: writable=84 executable=28 writable+executable=7

Varsayılan durumda hem yazılabilir hem çalıştırılabilir tek bir mapping yok; W^X kapatılınca 7 tane oluşuyor. Bazı profiler'ların dokümanı bu ayarın kapatılmasını istiyor; üretimde açık kalmalı.

Container Memory Limit'i: .NET Bunu Nasıl Görür?

Gelelim ilk soruya. Container'ın bellek limiti bir cgroup ayarıdır. Kernel'in cgroup v2 dokümanına göre memory.max sert sınırdır; kullanım bu sınıra gelip düşürülemezse cgroup içinde OOM (out of memory; bellek bitince bir process'i sonlandıran kernel mekanizması) killer çalışır. Önceki yazıdaki SIGKILL (yakalanamayan öldürme sinyali) buydu. .NET runtime başlarken bu dosyayı okur ve GC için bir heap hard limit belirler. Microsoft'un GC configuration dokümanına göre limit elle verilmemişse container'da varsayılan, 20 MB ile container limitinin yüzde 75'inden büyük olanıdır; bu ayar .NET Core 3.0 ile geldi.

docker run --rm --memory=256m --memory-swap=256m -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll gc-limits
GCHeapHardLimit         = 201326592
GCRegionRange           = 1006632960
HeapCount               = 1
GCDynamicAdaptationMode = 1
IsServerGC              = False
TotalAvailableMemory    = 192 MB
memory.max              = 268435456

256 MB'ın yüzde 75'i: 192 MB. GC.GetGCMemoryInfo().TotalAvailableMemoryBytes da container limitini değil bu sınırı döndürüyor. Önceki yazının üçüncü alıştırmasının cevabı da burada: dotnet/runtime'daki GC kaynağına göre tek bir hard limit varsa region range limitin beş katı, yani 960 MB. 512 MB'lık container'daki strace'te gördüğümüz 1,9 GB'lık reserve de 384 MB'ın beş katıydı.

Şimdi 1 GB'ı managed bellekten isteyelim. FillManaged her adımda 1 MB'lık bir array alıp içini dolduruyor ve listede tutuyor:

try
{
    byte[] chunk = new byte[1 << 20];
    // Sayfalara gerçekten yaz ki RSS'e yansısın
    Array.Fill(chunk, (byte)1);
    cart.Add(chunk);
}
catch (OutOfMemoryException ex)
{
    failure = $"OutOfMemoryException after {cart.Count} MB: {ex.Message}";
    break;
}
docker run --rm --memory=256m --memory-swap=256m -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll fill 1024
TotalAvailableMemoryBytes = 192 MB, memory.max = 268435456
   64 MB  GC.GetTotalMemory=  64 MB  GC committed=  18 MB  RSS= 115 MB  memory.current=94 MB
  128 MB  GC.GetTotalMemory= 128 MB  GC committed=  78 MB  RSS= 184 MB  memory.current=161 MB
OutOfMemoryException after 189 MB: Exception of type 'System.OutOfMemoryException' was thrown.
at failure  GC.GetTotalMemory= 189 MB  GC committed= 189 MB  RSS= 247 MB  memory.current=224 MB
after GC    GC.GetTotalMemory=   0 MB  GC committed= 158 MB  RSS= 216 MB  memory.current=193 MB

Bu kez process ölmedi. GC, 192 MB'lık sınıra gelince allocation'ı reddetti ve 189. MB'ta yakalanabilir bir OutOfMemoryException geldi; container'ın memory.events dosyasında oom_kill 0 kaldı. Sınırın yüzde 75'te olmasının sebebi geri kalan çeyreğin GC heap'i dışındaki belleğe kalması: runtime'ın kendisi, JIT'lenmiş kod, native kütüphaneler, thread stack'leri. Bu koruma yalnız managed heap için. Önceki yazıdaki gibi native bellek alan bir kod, ya da managed heap küçükken native tarafta şişen bir kütüphane, GC'nin sınırına hiç takılmadan container'ı OOM killer'a götürebilir.

GC.GetTotalMemory, RSS ve memory.current

Aynı çıktıda dört sayı var ve her biri başka bir şeyi ölçüyor. GC.GetTotalMemory heap'teki object'lerin toplamı; GC'den sonra 0'a indi. TotalCommittedBytes, son GC'nin gördüğü commit edilmiş heap; ölçüm anına değil son GC'ye ait olduğu için 64 MB satırında 18 MB görünüyor. GC'den sonra bile 158 MB: GC boşalan bellek için hemen decommit yapmaz. RSS process'in RAM'deki bütün sayfaları, shared library'ler dahil. memory.current ise kernel'in bu cgroup'a yazdığı bellek: cgroup dokümanına göre bir sayfa onu ilk oluşturan cgroup'a yazılır ve serbest bırakılana kadar orada kalır; page cache de buna dahildir. OOM kararı bu son sayıya göre verilir, GC.GetTotalMemory'ye göre değil.

Server GC açıldığında resim değişir. Microsoft'un dokümanına göre Server GC her mantıksal CPU için ayrı bir heap ve ayrı bir GC thread'i kullanır ve kaynak tüketimi yüksek olabilir; ASP.NET Core'un bellek dokümanına göre ASP.NET Core uygulamalarında varsayılan GC budur. .NET 9'dan beri Server GC'de varsayılan olan DATAS (Dynamic Adaptation To Application Sizes; heap sayısını ve boyutunu uygulamanın gerçek ihtiyacına göre ayarlayan mod) bunu dengelemek için var. 2 GB ve 4 CPU'luk bir container'da toplam 4 GB'lık kısa ömürlü allocation yapan churn komutunu üç ayarla çalıştırdım:

docker run --rm --memory=2g --memory-swap=2g --cpus=4 -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 sh -c '
dotnet Bookstore.OsLab.dll churn 4096
DOTNET_gcServer=1 dotnet Bookstore.OsLab.dll churn 4096
DOTNET_gcServer=1 DOTNET_GCDynamicAdaptationMode=0 dotnet Bookstore.OsLab.dll churn 4096'
gen0 GCs=  920  GC committed=  61 MB  peak RSS= 133 MB
gen0 GCs=  103  GC committed= 101 MB  peak RSS= 167 MB
gen0 GCs=    9  GC committed= 529 MB  peak RSS= 593 MB

Workstation GC 920 küçük GC ile 133 MB'ta kaldı. DATAS'sız Server GC yalnız 9 GC yaptı ama 593 MB'a çıktı; aynı işi yapan kod, container limiti dar olsaydı OOMKilled olacak kadar bellek kullandı. DATAS açıkken 103 GC ve 167 MB. Ölçüm iki kez tekrarlandı; DATAS satırı ikinci koşuda 188 MB idi, diğerleri birkaç MB içinde kaldı.

Üretimde Dikkat

Container'da çalışan bir .NET servisinin bellek alarmını memory.current'a ya da orkestratörün bundan türettiği "working set" metriğine kur ve RSS ile birlikte bir zaman serisi olarak izle. OOMKilled olmuş bir pod'un grafiğinde iki tipik şekil var. RSS düzgün artıp limitin hemen altında kesiliyorsa ve son anlarda GC.GetTotalMemory düşükse büyüyen şey native bellektir. RSS testere dişi çiziyorsa ve tepeler limite değiyorsa GC'nin hard limit'i limite fazla yakındır ya da GC dışı bellek çeyrekten büyüktür. memory.events'teki oom_kill sayacı olayın kernel tarafındaki kanıtıdır.

Kubernetes'te request ve limit'in GC ile ilişkisini yayınlanmış Kubernetes serisinin resource request ve limit yazısında anlatmıştık. Buradan eklenecek tek kural: aynı node'daki birçok küçük pod için Server GC'yi açıyorsan DATAS'ı kapatma; kapatıyorsan GCHeapCount ya da GCHeapHardLimitPercent ile heap'i sınırla.

Sık Düşülen Tuzaklar

Runtime'ın container limitini görmediğini varsaymak. Bu .NET Core 3.0 öncesi için doğruydu; bugün runtime cgroup'u okur. Yine de DOTNET_GCHeapHardLimit gibi elle verilmiş eski bir ayar container limitinin önüne geçer; Dockerfile'larda ve deploy manifest'lerinde unutulmuş GC ayarlarını kontrol et.

GC.GetTotalMemory'yi process'in belleği sanmak. Bu metod yalnız canlı managed object'leri sayar; commit edilmiş ama boş heap, native bellek, shared library'ler ve page cache dışarıda kalır. Yukarıdaki ölçümde GC sonrası 0 MB derken container 193 MB kullanıyordu.

RSS'leri toplayıp "makinede bellek bitti" demek. Paylaşılan sayfalar her process'in RSS'inde ayrı ayrı sayılır; aynı image'tan çalışan birçok process'te gerçek tüketimi PSS ya da cgroup sayacı verir.

Küçük Bir Deneme

★☆☆ gc-limits komutunu 128, 512 ve 1024 MB limitlerle çalıştır. GCHeapHardLimit'in limitin yüzde 75'i, GCRegionRange'in bunun beş katı olduğunu doğrula; sonra DOTNET_GCHeapHardLimitPercent=0x32 (yüzde 50) ver ve farkı gözle.

★★☆ fill komutunu 256 MB limitle ve DOTNET_gcServer=1 ile çalıştır. Exception'ın kaçıncı MB'ta geldiğini Workstation GC ile karşılaştır ve memory.events'i kontrol et.

★★★ map-share'i MemoryMappedFileAccess.ReadWrite ile değiştirip iki process'ten aynı dosyaya yazdır. Bir process'in yazdığını diğerinin görüp görmediğini ve Shared_Dirty'nin nasıl değiştiğini ölç; sonucu CopyOnWrite ile karşılaştır.

Bu yazıda belleği process'in içinden okuduk: OS'tan nasıl alındığını, dosyalarla ve diğer process'lerle nasıl paylaşıldığını ve container sınırının .NET'e nasıl yansıdığını. Bir sonraki yazıda kalıcılığa geçiyoruz: file system, inode ve file descriptor, page cache ve "yazdım" dediğin verinin diske gerçekten ne zaman ulaştığı.