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

-

Scenario

Thanks to Mike for re­view­ing this.

You have an ap­pli­ca­tion or a par­tic­u­lar code path of your ap­pli­ca­tion that can­not take the pauses that GC cre­ates. Typ­i­cal ex­am­ples are real time sys­tems, tick by tick fi­nan­cial apps, em­bed­ded sys­tems, etc …

Disclaimer

For any nor­mal kind of ap­pli­ca­tions, YOU DON’T NEED TO DO THIS. You are likely to make your ap­pli­ca­tion run slower or blow up mem­ory. If you have an hot path in your ap­pli­ca­tion (i.e. you are cre­at­ing an ed­i­tor with Intellisense), use the GC la­tency modes. Use the code be­low just un­der ex­treme cir­cum­stance as it is untested, er­ror prone and wacky. You are prob­a­bly bet­ter off wait­ing for an of­fi­cial way of do­ing it (i.e. when this is im­ple­mented)

The problem with TryStartNoGCRegion

There is a GC.TryStartNoGCRegion in .NET. You can use it to stop garbage col­lec­tion pass­ing a to­tal­Bytes pa­ra­me­ter that rep­re­sents the max­i­mum amount of mem­ory that you plan to al­lo­cate from the man­aged heap. Matt de­scribes it here.

The prob­lem is that when/​if you al­lo­cate more than that, garbage col­lec­tion re­sumes silently. Your ap­pli­ca­tion con­tin­ues to work, but with dif­fer­ent per­for­mance char­ac­ter­is­tics from what you ex­pected.

The idea

The main idea is to use ETW events to de­tect when a GC oc­curs and to call an user pro­vided del­e­gate at that point. You can then do what­ever you want in the del­e­gate (i.e. shut­down the process, send email to sup­port, start an­other NoGC re­gion, etc…).

Also, I have wrapped the whole StartNoGCRegion/EndNoGCRegion in an IDisposable wrap­per for easy of use.

The tests

Let’s start by look­ing at how you use it.

using Xunit;
using System.Threading;

namespace LNativeMemory.Tests
{

    // XUnit executes all tests in a class sequentially, so no problem with multi-threading calls to GC
    public class GC2Tests
    {

We need to use a timer to max­i­mize the chances that a GC hap­pens in some of the tests. Also we al­lo­cate an amount that should work in all GC con­fig­u­ra­tion as per the ar­ti­cle above. trigger is a sta­tic field so as to stay zero-al­lo­ca­tion (oth­er­wise the del­e­gate will have to cap­ture the a lo­cal trigger vari­able cre­at­ing a heap al­lo­cated clo­sure). Not that it mat­ters any to be zero-al­lo­ca­tion in this test, but I like to keep ClrHeapAllocationAnalyzer happy.

BTW: XUnit ex­e­cutes all tests in a class se­quen­tially, so no prob­lem with multi-thread­ing calls to GC.

        const int sleepTime = 200;
        const int totalBytes = 16 * 1024 * 1024;
        static bool triggered = false;


First we test that any al­lo­ca­tion that does­n’t ex­ceed the limit does­n’t trig­ger the call to ac­tion.

        [Fact]
        public void NoAllocationBeforeLimit()
        {
            try
            {
                triggered = false;
                var succeeded = GC2.TryStartNoGCRegion(totalBytes, () => triggered = true);
                Assert.True(succeeded);
                Thread.Sleep(sleepTime);
                Assert.False(triggered);

                var bytes = new byte[99];
                Thread.Sleep(sleepTime);
                Assert.False(triggered);
            }
            finally
            {
                GC2.EndNoGCRegion();
                triggered = false;
            }
        }

Then we test that al­lo­cat­ing over the limit does trig­ger the ac­tion. To do so we need to trig­ger a garbage col­lec­tion. Out best at­tempt is with the goofy for loop. If you got a bet­ter idea, shout.

        [Fact]
        public void AllocatingOverLimitTriggersTheAction()
        {
            try
            {
                triggered = false;
                var succeeded = GC2.TryStartNoGCRegion(totalBytes, () => triggered = true);
                Assert.True(succeeded);
                Assert.False(triggered);

                for (var i = 0; i < 3; i++) { var k = new byte[totalBytes]; }

                Thread.Sleep(sleepTime);
                Assert.True(triggered);
            }
            finally
            {
                GC2.EndNoGCRegion();
                triggered = false;
            }
        }

We also test that we can go back and forth be­tween start­ing and stop­ping with­out mess­ing things up.

        [Fact]
        public void CanCallMultipleTimes()
        {

            for (int i = 0; i < 3; i++)
            {
                NoAllocationBeforeLimit();
            }
        }

And lastly, we make sure that we can use our lit­tle wrap­per func­tion, just to be sure every­thing works.

        [Fact]
        public void CanUseNoGCRegion()
        {
            triggered = false;
            using (new NoGCRegion(totalBytes, () => triggered = true))
            {
                for (var i = 0; i < 3; i++) { var k = new byte[totalBytes]; }
                Thread.Sleep(sleepTime);
                Assert.True(triggered);
                triggered = false;
            }
        }
    }
}

Tags