Every object that the Kladde creates with new lands on the heap, and sometime later someone cleans it away: the garbage collector (GC), the
garbage collection of .NET. Most of the time you do not notice it. It becomes noticeable in two situations: when an app under load stops noticeably often
because it allocates too much, and when its memory grows until the container terminates it. For the first, you measured and saved
allocations in lessons 3.12.1 and 3.12.2. This lesson explains what the GC does with them, which kinds there are, how it behaves in a container with a
memory limit, and how you find a leak before it makes the Kladde restart in Azure.
New in this lesson: nothing to install. You know dotnet-counters and dotnet-gcdump from lesson 3.9.5, as local tools in the
Kladde's repository. For the exercise with a container you need Docker Desktop or Podman, as in K2.
Generations
The GC starts from an observation that is true for almost all programs: most objects die young. A string for a log line, a list for a response, an object for a request live for milliseconds. That is why it divides the heap into generations:
- Generation 0: every new object is created here. When the generation's budget is full, the GC cleans only it: it looks for the objects that someone can still reach and moves them to generation 1. Everything else is free. Because almost everything is dead, this is fast.
- Generation 1: a buffer between young and old. What survives a generation 1 collection goes to generation 2.
- Generation 2: objects that live long: caches, singletons, configuration. A generation 2 collection (a full collection) looks at the whole heap and is correspondingly expensive.
- The heap for large objects (Large Object Heap): objects from 85,000 bytes, mostly large arrays. They are collected only with generation 2
and normally not moved, because copying would be expensive. Many large, short-lived arrays are therefore a well-known pattern for trouble, and
ArrayPool<T>from lesson 3.12.2 the usual remedy.
During a collection, the GC briefly stops the app's threads. For generations 0 and 1 this is usually very short, because little survives. It collects generation 2 in the background while the app keeps running, and stops it only briefly at the beginning and at the end.
Two rules for your code follow from this:
- Short-lived objects are cheap, but not free. Every allocation fills the budget of generation 0, and every full generation 0 means one
collection. That is the
Gen0column in BenchmarkDotNet. - Medium-lived objects are the most expensive. An object that lives a few seconds and then dies survives two collections, is copied twice, and in the end burdens the expensive generation 2. A cache whose entries expire after a short time can be exactly such a pattern.