Terminal.Gui icon indicating copy to clipboard operation
Terminal.Gui copied to clipboard

NetDriver is consuming too much CPU resources in ReadConsoleKeyInfo.

Open BDisp opened this issue 1 year ago • 12 comments

My laptop looks like a fryer running NetDriver version 2, because it is using Task.Delay, which should only be used for very short-term cases and not for the entire execution of an application. When Task.Delay is used during the entire application the async must be used to be really awaiting, otherwise it will return immediately without waiting for the elapsed time.

BDisp avatar May 30 '24 22:05 BDisp

Yeah. We should have zero instances of Task.Delay anywhere in the code.

dodexahedron avatar May 30 '24 23:05 dodexahedron

Yeah. We should have zero instances of Task.Delay anywhere in the code.

Yes it suits. The only one I can't avoid is the CheckWindowSizeChange method because it is necessary to monitor the console resizing. But now it is using async Task and thus it is respecting the elapsed time.

BDisp avatar May 30 '24 23:05 BDisp

We can probably use (and probably should use) something like (Auto|Manual)ResetEvent or another synchronization primitive for that, rather than sleeps in method calls that may or may not execute on another thread or even asynchronously at all (you can't control it when using the TAP like this).

dodexahedron avatar May 31 '24 01:05 dodexahedron

There is already ManualResetEvent for those we can control using like a switch. For the console resizing we have to monitoring periodically.

BDisp avatar May 31 '24 07:05 BDisp

A timer is much more appropriate than sleeps, if periodic polling is the only reliable means of doing it.

dodexahedron avatar Jun 01 '24 04:06 dodexahedron

A timer is much more appropriate than sleeps, if periodic polling is the only reliable means of doing it.

I opted for a simpler solution taking advantage of the cancellation token by just adding the Wait method after Task.Delay(int, CancellationToken).Wait(CancellationToken). This way the set time is respected and does not return immediately or returns immediately if the cancellation token is cancelled.

BDisp avatar Jun 01 '24 11:06 BDisp

It's not really simpler, though.

  • It's a lot more code, especially after generation.
  • It's much heavier, due to what is generated for everything about async and Task operations.
  • It has no guarantee of how it will run. Might be synchronous; Might be asynchronous. You can't control it).
  • If it errors out, it is unrecoverable without a watchdog to restart it.
  • It still uses a timer, anyway. However, it is significantly less efficient, as it creates and destroys one each time.
  • Awaiting a Task.Delay in a loop is extra code (both written and generated) achieving the same outcome as a Thread.Sleep in a loop.

Instead, simply have the application own a System.Timers.Timer object, which sits there free-running.

  • Takes one line to create, one line to subscribe a handler, one line to start, and one line to stop.
  • Whatever code would otherwise be inside the loop just goes in the handler.
  • An exception doesn't stop it from running.
  • The Elapsed event can be subscribed to by anything else that might want periodic dispatch, too, without need for any other infrastructure.
  • Timing and dispatch of the Elapsed event are not dependent on the code inside the handlers.
  • The timer can be suspended and resumed at will, if necessary, at any point, with a simple Start or Stop call.

dodexahedron avatar Jun 03 '24 02:06 dodexahedron

Just to show how significant seemingly small details like this really can be, here's a comparison of how much more actual code is created by the await Task.Delay method vs the timer method.

Take the following two simple applications that do essentially the same thing:

    // This is in both applications and is simply what keeps it from exiting immediately.
    private static readonly ManualResetEventSlim _appKeepalive = new();
    public static async Task Main()
    {
        while(!_appKeepalive.IsSet)
        {
            StuffInsideLoop();
            await Task.Delay(250).ConfigureAwait(true);
        }
    }

    private static void StuffInsideLoop()
    {
        ConsoleKeyInfo key = Console.ReadKey(true);
        Console.Out.Write(key.KeyChar);
        if (key is { Key: ConsoleKey.Q })
        {
            _appKeepalive.Set();
        }
    }

And using System.Timers.Timer:

public static class Program
{
    private static readonly System.Timers.Timer _eventDispatchTimer = new(250){ AutoReset = true};

    // This is in both applications and is simply what keeps it from exiting immediately.
    private static readonly ManualResetEventSlim _appKeepalive = new();
    public static void Main()
    {
        _eventDispatchTimer.Elapsed+= StuffInsideLoop;
        _eventDispatchTimer.Start();
        _appKeepalive.Wait( -1 );
    }

    private static void StuffInsideLoop(object? sender, ElapsedEventArgs e)
    {
        ConsoleKeyInfo key = Console.ReadKey(true);
        Console.Out.Write(key.KeyChar);
        if (key is { Key: ConsoleKey.Q })
        {
            _appKeepalive.Set();
        }
    }
}

The first results in 13.5kB of CIL.

The second results in 12.5kB of CIL.

There's a lot of extra stuff going on for just that little tiny program.

And when I compile down to native, the first one is 8kB larger than the second, when optimized and without debug symbols included. That's a lot of executable code, and a lot of run-time resources as well.

These effects will be even more significant in an application doing more than just this, like anything referencing Terminal.Gui.

dodexahedron avatar Jun 03 '24 03:06 dodexahedron

But with Task.Delay I'm taking advantage of using the CancellationTokenSource by catching the cancellation to exit the loop.

BDisp avatar Jun 03 '24 16:06 BDisp

You don't even need it with a timer. You simply call Stop or Dispose it or, if the application is exiting, let it die on its own with the AppDomain.

But you can pass a CT to it if you want, anyway. But it'd be more advantageous to register for the cancellation callback from the CTS, rather than making a new CT each call, which is what happens.

And, wanna know how CancellationTokenSource is implemented? A Timer and a ManualResetEvent, plus a couple other WaitHandles for synchronizing things like the registration callback list (which is an event, but with specific synchronization behavior.

But CT is a readonly struct. Every time you pass it, you're making at least one copy.

Besides, that's very improper use of the CT and of the Task itself, anyway. You do not return from a canceled task. You throw OperationCanceledException. Returning can result in resource leaks and synchronization issues that can't be traced because you've thrown the handle away without letting the caller know what happene. At that point, a Task is completely pointless and you may as well just use ThreadPool.QueueUserWorkItem instead (don't do that).

dodexahedron avatar Jun 04 '24 05:06 dodexahedron

But you can pass a CT to it if you want, anyway. But it'd be more advantageous to register for the cancellation callback from the CTS, rather than making a new CT each call, which is what happens.

It's not making a new CT anymore. In this commit https://github.com/gui-cs/Terminal.Gui/pull/3515/commits/e03b20e5b5d74ad0977d18d61679e5b975c79683 I made it as readonly, like I did in the v1 on #3519. But I believe we can achieve better result with a timer but I prefer you implement it because I'm not prepared to do that as well as you.

BDisp avatar Jun 04 '24 13:06 BDisp

That's fine with me.

When we get closer to or maybe actually in beta, I'll be happy to take a performance pass over things like this.

dodexahedron avatar Jun 04 '24 21:06 dodexahedron

This was already fixed on #3515.

BDisp avatar Aug 11 '24 20:08 BDisp