Stopping Garbage Collection in .NET Core 3.0 (part II)

-

Let’s see how it’s im­ple­mented. For why it is im­ple­mented, see part I.

Thanks to Mike for re­view­ing this.

using System;
using System.Diagnostics.Tracing;
using System.Runtime;

The FxCop code an­a­lyz­ers get up­set if I don’t de­clare this, which also im­pede me from us­ing un­signed nu­meral types in in­ter­faces.

[assembly: CLSCompliant(true)]

namespace LNativeMemory
{

The first piece of the puz­zle is to im­ple­ment an event lis­tener. It is a not-ob­vi­ous (for me) class. I don’t fully un­der­stand the life­time se­man­tics, but the code be­low seems to do the right thing.

The in­ter­est­ing piece is _started and the method Start(). The con­struc­tor for EventListener al­lo­cates plenty of stuff. I don’t want to do those al­lo­ca­tions af­ter call­ing TryStartNoGCRegion be­cause they would use part of the GC Heap that I want for my pro­gram. In­stead, I cre­ate it be­fore such call, but then I make it switch on’ just af­ter the Start() method is called.

    internal sealed class GcEventListener : EventListener
    {
        Action _action;
        EventSource _eventSource;
        bool _active = false;

        internal void Start() { _active = true; }
        internal void Stop() { _active = false; }

As de­scribed in part one, you pass a del­e­gate at cre­ation time, which is called when garbage col­lec­tion is restarted.

        internal GcEventListener(Action action) => _action = action ?? throw new ArgumentNullException(nameof(action));

We reg­is­ter to all the events com­ing from .NET. We want to call the del­e­gate at the ex­act point when garbage col­lec­tion is turned on again. We don’t have a clean way to do that (aka there is no run­time event we can hook up to, see here, so lis­ten­ing to every sin­gle GC event gives us the most chances of do­ing it right. Also it ties us the least to any pat­tern of events, which might change in the fu­ture.

        // from https://docs.microsoft.com/en-us/dotnet/framework/performance/garbage-collection-etw-events
        private const int GC_KEYWORD = 0x0000001;
        private const int TYPE_KEYWORD = 0x0080000;
        private const int GCHEAPANDTYPENAMES_KEYWORD = 0x1000000;

        protected override void OnEventSourceCreated(EventSource eventSource)
        {
            if (eventSource.Name.Equals("Microsoft-Windows-DotNETRuntime", StringComparison.Ordinal))
            {
                _eventSource = eventSource;
                EnableEvents(eventSource, EventLevel.Verbose, (EventKeywords)(GC_KEYWORD | GCHEAPANDTYPENAMES_KEYWORD | TYPE_KEYWORD));
            }
        }

For each event, I check if the garbage col­lec­tor has ex­ited the NoGC re­gion. If it has, then let’s in­voke the del­e­gate.

        protected override void OnEventWritten(EventWrittenEventArgs eventData)
        {
            var eventName = eventData.EventName;
            if(_active && GCSettings.LatencyMode != GCLatencyMode.NoGCRegion)
            {
                _action?.Invoke();
            }
        }
    }

Now that we have our event lis­tener, we need to hook it up. The code be­low im­ple­ments what I de­scribed ear­lier.

  1. Do your allocations for the event listener
  2. Start the NoGc region
  3. Start monitoring the runtime for the start of the NoGC region
    public static class GC2
    {
        static private GcEventListener _evListener;

        public static bool TryStartNoGCRegion(long totalSize, Action actionWhenAllocatedMore)
        {

            _evListener = new GcEventListener(actionWhenAllocatedMore);
            var succeeded = GC.TryStartNoGCRegion(totalSize, disallowFullBlockingGC: false);
            _evListener.Start();

            return succeeded;
        }

As puz­zling as this might be, I pro­vi­sion­ally be­lieve it to be cor­rect. Apparently, even if the GC is not in a NoGC re­gion, you still need to call EndNoGCRegion if you have called TryStartNoGCRegion ear­lier, oth­er­wise your next call to TryStartNoGCRegion will fail. EndNoGCRegion will throw an ex­cep­tion, but that’s OK. Your next call to TryStartNoGCRegion will now suc­ceed.

Now read the above re­peat­edly un­til you got. Or just trust that it works some­how.

        public static void EndNoGCRegion()
        {
            _evListener.Stop();

            try
            {
                GC.EndNoGCRegion();
            } catch (Exception)
            {

            }
        }
    }

This is used as the de­fault be­hav­ior for the del­e­gate in the wrap­per class be­low. I was made aware by the code an­a­lyzer that I should­n’t be throw­ing an OOF ex­cep­tion here. At first, I dis­missed it, but then it hit me. It is right.

We are not run­ning out of mem­ory here. We sim­ply have al­lo­cated more mem­ory than what we de­clared we would. There is likely plenty of mem­ory left on the ma­chine. Thinking more about it, I grew ashamed of my ini­tial re­ac­tion. Think about a sup­port en­gi­neer get­ting an OOM ex­cep­tion at that point and try­ing to fig­ure out why. So, al­ways lis­ten to Lint …

    public class OutOfGCHeapMemoryException : OutOfMemoryException {
        public OutOfGCHeapMemoryException(string message) : base(message) { }
        public OutOfGCHeapMemoryException(string message, Exception innerException) : base(message, innerException) { }
        public OutOfGCHeapMemoryException() : base() { }

    }


This is an util­ity class that im­ple­ments the IDisposable pat­tern for this sce­nario. The size of the de­fault ephemeral seg­ment comes from here.

    public sealed class NoGCRegion: IDisposable
    {
        static readonly Action defaultErrorF = () => throw new OutOfGCHeapMemoryException();
        const int safeEphemeralSegment = 16 * 1024 * 1024;

        public NoGCRegion(int totalSize, Action actionWhenAllocatedMore)
        {
            var succeeded = GC2.TryStartNoGCRegion(totalSize, actionWhenAllocatedMore);
            if (!succeeded)
                throw new InvalidOperationException("Cannot enter NoGCRegion");
        }

        public NoGCRegion(int totalSize) : this(totalSize, defaultErrorF) { }
        public NoGCRegion() : this(safeEphemeralSegment, defaultErrorF) { }

        public void Dispose() => GC2.EndNoGCRegion();
    }
}

Tags