Spotřebu paměti aplikace v .NET z větší části určuje garbage collector, ne váš kód. Aplikace v ASP.NET Core ve výchozím stavu běží se Server GC, který vyměňuje paměť za propustnost: drží samostatnou haldu pro každý procesor a sbírá méně často. Konzolové aplikace a worker služby běží s Workstation GC, který sbírá častěji a zůstává menší. Uvnitř kontejneru runtime přečte limit paměti a spravovanou haldu ve výchozím nastavení omezí na 75 % z něj a od .NET 9 si Server GC přizpůsobuje počet hald skutečné velikosti aplikace (DATAS). Malé API, které „žere 400 MB“ na plánu se 4 GB, je tedy často jen GC, který dostal prostor a využívá ho - a stejná aplikace na plánu s 1 GB poběží s mnohem méně.
Tento článek vysvětluje, co GC dělá s pamětí, kterou dostal, která nastavení to mění, jak číst čísla, na kterých skutečně záleží, a jak poznat únik paměti od haldy, která je prostě velká.
Kam se v procesu .NET ztrácí paměť#
Graf paměti ukazuje celý proces. Spravovaná halda - objekty, které alokuje váš kód - je největší část, ale zdaleka ne jediná:
- Halda GC, rozdělená do generací. Nové objekty jdou do generace 0; ty, které přežijí, se povýší do 1 a potom do 2. Objekty o velikosti 85 000 bajtů a víc jdou rovnou na haldu velkých objektů (LOH), která se sbírá spolu s generací 2 a ve výchozím stavu se nedefragmentuje. Připnuté objekty mají od .NET 5 vlastní haldu.
- Alokovaný, ale prázdný prostor haldy. Po sběru si GC nechá paměť, kterou bude pravděpodobně znovu potřebovat, místo aby ji hned vracel operačnímu systému. To je hlavní důvod, proč graf po špičce provozu neklesne.
- Kód přeložený JIT a data runtime: metadata typů, tabulky metod, přeložený kód každé metody, která kdy běžela. Velká aplikace s mnoha závislostmi nese desítky megabajtů tohoto bez ohledu na provoz.
- Nativní paměť: cokoli alokované mimo GC - databázové ovladače s nativními částmi, knihovny pro obrázky, kompresní buffery, SQLite.
- Zásobníky vláken, jeden na vlákno. Dohromady bývají malé, dokud něco nevytvoří stovky vláken.
Z toho plynou dva různé druhy selhání. Když spravovaná halda dosáhne svého limitu, GC vyhodí uvnitř aplikace OutOfMemoryException. Když celý proces dosáhne limitu kontejneru, zastaví ho jádro zvenčí, bez výjimky a bez poslední řádky v logu. Na RE:NODE se kontejner, který dosáhne limitu paměti, zastaví a čistě restartuje místo toho, aby swapoval, takže druhý případ se projeví jako restart. Swap v Linuxu a OOM killer vysvětluje stranu jádra.
Workstation a Server GC#
.NET má dvě varianty GC a která se použije, závisí na typu projektu, ne na serveru:
| Workstation GC | Server GC | |
|---|---|---|
| Výchozí pro | Konzolové aplikace, worker služby | Projekty ASP.NET Core (Web SDK) |
| Haldy | Jedna | Jedna na logický procesor (upravuje DATAS) |
| Vlákna GC | Sbírá ve vlákně, které sběr spustilo | Vyhrazené vlákno na každou haldu |
| Rozpočet gen 0 | Malý | Velký |
| Spotřeba paměti | Nižší | Vyšší |
| Propustnost | Nižší pod zátěží | Vyšší pod zátěží |
Oba běží ve výchozím stavu v souběžném (background) režimu, který umožňuje sběr generace 2 souběžně s vaším kódem, místo aby ho zastavoval.
Větší rozpočty Server GC znamenají, že mezi sběry alokuje déle a po tu dobu drží víc paměti. Na stroji se 16 jádry a bez limitu může proces se Server GC sedět na několika stovkách megabajtů a skoro nic nedělat. Odtud pochází lidová moudrost, že ASP.NET Core je žravé na paměť. Runtime optimalizuje na propustnost, protože mu bylo řečeno, že může.
Dvě pravidla o výchozím chování, která na malých plánech důležitá jsou:
- Jeden procesor znamená Workstation GC. Pokud runtime vidí jediný procesor, použije Workstation GC bez ohledu na nastavení. Plán s půlkou vCPU se obvykle tváří jako jeden procesor (viz níže), takže malá aplikace v ASP.NET Core na nejmenším plánu už běží s úspornějším kolektorem.
- Můžeš si vybrat sám. V souboru projektu nebo jako proměnnou prostředí:
<PropertyGroup> <ServerGarbageCollection>false</ServerGarbageCollection> <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection></PropertyGroup>DOTNET_gcServer=0Přepnutí webové aplikace na Workstation GC na dvoujádrovém plánu citelně sníží paměť a při vysokém počtu požadavků trochu ubere propustnost. U aplikace, která obsluhuje pár požadavků za sekundu, rozdíl v propustnosti neuvidíš; rozdíl v paměti ano.
DATAS: Server GC, který si určí velikost sám#
Dynamic Adaptation To Application Sizes (DATAS) mění Server GC z „jedna halda na jádro, vždy“ na „tolik hald, kolik zátěž aplikace ospravedlní“. Začíná s jednou haldou, další přidává podle růstu tlaku na alokace a rozpočet generace 0 upravuje podle velikosti živých dat, ne podle počtu jader.
V .NET 8 bylo opt-in, od .NET 9 je se Server GC zapnuté ve výchozím stavu. Pokud jsi na .NET 9 nebo 10 a aplikace používá Server GC, už ho máš. Prakticky to znamená, že klidná aplikace v ASP.NET Core na stroji s mnoha jádry si už nerezervuje paměť, jako by běžela na plný výkon.
Můžeš ho vypnout, což je občas správně pro službu, kde záleží na propustnosti a která je pořád vytížená:
DOTNET_GCDynamicAdaptationMode=0Ekvivalent pro MSBuild je <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>. V .NET 8 ho zapneš nastavením na 1. U malých a středních aplikací na skromných plánech nech DATAS zapnuté.
Co runtime vidí uvnitř kontejneru#
Runtime čte limity cgroup kontejneru, v1 i v2, a podle nich upraví dvě věci.
Limit paměti. Když má kontejner limit paměti, GC nastaví pevný limit spravované haldy na 75 % z něj (nebo 20 MB, podle toho, co je větší). Zbývající čtvrtina zůstává pro nativní paměť, kód, zásobníky a všechno ostatní, co proces potřebuje. Na plánu s 1 GB může spravovaná halda vyrůst asi na 768 MB; na 4 GB asi na 3 GB.
Počet CPU. Environment.ProcessorCount odpovídá kvótě CPU kontejneru zaokrouhlené nahoru na celé číslo. Limit půl CPU se zobrazí jako 1; jedno a půl jako 2. Toto číslo rozhoduje o tom, kolik hald smí Server GC vytvořit, jak si thread pool určí velikost a kolik věcí runtime dělá paralelně. Na RE:NODE je limit CPU tvrdé omezení na podíl, který sis koupil, takže pohled runtime „2 procesory“ na plánu s 1,5 vCPU je správný v počtu, ale velkorysý v čase: proces dostane 150 % času jednoho jádra rozdělených mezi ta vlákna, ne víc. Počet můžeš přepsat pomocí DOTNET_PROCESSOR_COUNT, pokud výchozí hodnota vede k příliš mnoha haldám nebo vláknům.
Pro dimenzování z toho plyne, že stejná aplikace spotřebuje na různých plánech různé množství paměti, protože GC dostane různý prostor. Nečti číslo paměti z velkého stroje s předpokladem, že tolik potřebuješ i ty.
Nastavení, která mění spotřebu paměti#
| Nastavení | runtimeconfig / MSBuild | Proměnná prostředí | Efekt |
|---|---|---|---|
| Server GC | ServerGarbageCollection | DOTNET_gcServer | 0 pro workstation, 1 pro server |
| Souběžné GC | ConcurrentGarbageCollection | DOTNET_gcConcurrent | Sběry gen 2 na pozadí |
| Pevný limit haldy | System.GC.HeapHardLimit | DOTNET_GCHeapHardLimit | Absolutní strop spravované haldy |
| Procento limitu haldy | System.GC.HeapHardLimitPercent | DOTNET_GCHeapHardLimitPercent | Strop jako podíl limitu |
| Šetření paměti | System.GC.ConserveMemory | DOTNET_GCConserveMemory | 0-9; víc defragmentuje, aby ušetřil paměť |
| DATAS | GarbageCollectionAdaptationMode | DOTNET_GCDynamicAdaptationMode | 1 zapnuto, 0 vypnuto |
Kdy sáhnout na limit haldy: pokud tvoje aplikace používá znatelné množství nativní paměti - knihovnu na zpracování obrázků, velkou cache SQLite, vestavěný engine - výchozích 25 % rezervy nemusí stačit a proces zabije limit kontejneru dřív, než GC vůbec pocítí tlak. Snížení spravovaného limitu na 60 % dá nativnímu kódu víc místa a donutí GC sbírat důrazněji a dřív. Opačný případ, zvýšení na 85 % a víc, je bezpečný jen u aplikací s téměř žádnými nativními alokacemi.
GCConserveMemory je pro aplikace se silnou fragmentací LOH: velké buffery, které se opakovaně alokují a uvolňují, zanechávají díry, které GC nedefragmentuje. Hodnota kolem 5 způsobí, že GC při vysoké fragmentaci LOH defragmentuje, za cenu určité zátěže CPU.
Čtení čísel, na kterých záleží#
Working set procesu (číslo v grafu) ti řekne, jestli narazíš na limit. Neřekne ti proč. K tomu se zeptej GC:
app.MapGet("/debug/memory", () =>{ var info = GC.GetGCMemoryInfo(); return new { heapMB = info.HeapSizeBytes / 1_048_576, committedMB = info.TotalCommittedBytes / 1_048_576, fragmentedMB = info.FragmentedBytes / 1_048_576, limitMB = info.TotalAvailableMemoryBytes / 1_048_576, workingSetMB = Environment.WorkingSet / 1_048_576, gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2), serverGc = System.Runtime.GCSettings.IsServerGC, cpus = Environment.ProcessorCount };});Dej ho za autentizaci, nebo ho po použití odstraň. Jak výstup číst:
limitMBje paměť, o které si GC myslí, že ji má. Pokud ukazuje celou paměť hostitele místo tvého plánu, runtime limit kontejneru nezjistil a nic dalšího z tohoto článku neplatí, dokud to nevyřešíš.heapMBve srovnání sworkingSetMBukáže, jestli je růst spravovaný (tvoje objekty), nebo nativní (něco mimo GC).committedMBvýrazně nadheapMBje paměť, kterou GC drží pro opětovné použití. To není únik.- Rychle rostoucí
gen2při stálé zátěži znamená, že objekty žijí dost dlouho na povýšení a pak umírají - často je to cache bez limitu velikosti nebo objekty jednotlivých požadavků, které zachytilo něco dlouhožijícího.
Pro hlubší pohled čtou tytéž údaje živě diagnostické nástroje dotnet-counters a dotnet-gcdump, které umí pořídit snímek haldy, jejž otevřeš ve Visual Studiu nebo PerfView. Musí běžet tam, kde běží proces, což je na VDS snadné a jinde to závisí na prostředí. Od .NET 9 runtime tyto údaje publikuje i jako vestavěné metriky v meteru System.Runtime, takže je exportér OpenTelemetry pošle tam, kde metriky držíš. Čtení grafu zátěže serveru popisuje graf v panelu.
Úniky paměti a věci, které na únik jen vypadají#
Skutečný únik je paměť, která při stálé zátěži neomezeně roste a nikdy se nesbírá, protože na ni pořád něco odkazuje. V .NET jsou obvyklými viníky:
- Cache bez omezení. Statický
DictionaryneboConcurrentDictionary, který jen přibývá.IMemoryCacheje neomezená, dokud nenastavíšSizeLimita každé položce nedášSize. - Obsluhy událostí na dlouho žijících objektech. Přihlášení krátce žijícího objektu k události singletonu drží krátce žijící objekt naživu navždy, pokud se neodhlásí.
- Disposable objekty získané z kořenového providera. Transientní
IDisposablezískaný z kořenového poskytovatele služeb aplikace - místo ze scope - se sleduje až do ukončení aplikace. Získávej ho ze scope, hlavně v background službách. - Dlouho žijící `DbContext`. Change tracker v EF Core drží každou entitu, kterou načetl. Kontext držený v singletonu nebo v background smyčce roste, dokud není uvolněn. Používej scope na jednotku práce a
AsNoTracking()pro čtení. - Bufferované velké požadavky a odpovědi. Načtení celého uploadu do
MemoryStreamho umístí na LOH; několik souběžných uploadů po 50 MB jsou stovky megabajtů. Streamuj raději na disk nebo do objektového úložiště.
Věci, které na únik vypadají, ale nejsou: paměť, která po startu stoupá a pak se ustálí (JIT, rozehřívání cache, GC hledající svůj rozpočet); paměť, která po špičce provozu neklesne (alokovaný prostor držený pro opětovné použití); pilovitý průběh, který se vrací na stejné dno (běžný sběr). Dno úniku stoupá. Sleduj spodní body grafu v řádu hodin, ne špičky v řádu minut. Úniky paměti herních serverů popisuje stejnou úvahu pro jiný druh procesu.
Dimenzování plánu pro aplikaci v .NET#
Jde o pracovní údaje pro aplikaci závislou na frameworku na .NET 8 až 10 při skromné zátěži. Změř si vlastní; závislosti je mění víc než cokoli jiného.
| Aplikace | Typický working set | Plán, se kterým začít |
|---|---|---|
| Discord bot nebo worker služba | 60-150 MB | 1 GB |
| Minimal API, bez ORM | 50-120 MB | 1 GB |
| API s EF Core, nízký provoz | 120-300 MB | 1-2 GB |
| Web na Razor Pages nebo MVC | 150-400 MB | 2 GB |
| Blazor Server, desítky uživatelů | 300 MB plus stav na uživatele | 2-4 GB |
Přidej rezervu na buildy, pokud kompiluješ na serveru: SDK a kompilátor potřebují víc paměti než většina malých aplikací za běhu. Nasazení aplikace v ASP.NET Core z GitHubu popisuje přesun buildu do CI a Blazor Server vs WebAssembly vysvětluje, proč Blazor Server roste s uživateli, ne s požadavky.
Přejdi na vyšší plán, když dno grafu paměti, ne špička, leží nad zhruba 70 % limitu, nebo když se aplikace restartuje na limitu. Na RE:NODE se restart na limitu paměti počítá do crash watcheru - tři za hodinu vyvolají varování a otevřou tiket - takže únik dlouho nezůstane bez povšimnutí. Přechod na vyšší plán změní limit na stávajícím serveru, nepřestavuje ho.
FAQ#
Proč moje aplikace v ASP.NET Core používá na větším plánu víc paměti?
Protože GC dostane víc paměti i CPU a podle toho dimenzuje své rozpočty. Aplikace víc nespotřebovává proto, že by to potřebovala; sbírá méně často, protože může. Při nižším limitu bude sbírat častěji a zůstane menší.
Mám volat GC.Collect(), abych uvolnil paměť?
Ne. Vynucené sběry aplikaci pozastaví, povýší objekty, které se chystaly zemřít, a paměť obvykle o minutu později zůstane tam, kde byla. Pokud je paměť problém, oprav to, co ji drží, nebo nastav limit haldy.
Je Workstation GC pro webovou aplikaci pomalejší?
Pod velkou souběžnou zátěží ano. U aplikace na jednom nebo dvou CPU s mírným provozem se rozdíl těžko měří a úspora paměti je skutečná. V kontejneru s jedním CPU už Workstation GC stejně používáš.
Co se stane, když spravovaná halda dosáhne pevného limitu?
GC sbírá, jak nejdůrazněji umí, a pokud to nestačí, vyhodí OutOfMemoryException u alokace, která selhala. Pokud dřív dosáhne limitu kontejneru celý proces, jádro ho zastaví bez jakékoli výjimky.
Využívá Native AOT méně paměti?
V klidu ano, protože není JIT a runtime má méně metadat. Pod zátěží se GC chová stejně. Možnosti dotnet publish popisují, co Native AOT stojí na kompatibilitě.




Komentáře
Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.