Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NetDriver is consuming too much CPU resources in ReadConsoleKeyInfo. #3520

Closed
BDisp opened this issue May 30, 2024 · 13 comments
Closed

NetDriver is consuming too much CPU resources in ReadConsoleKeyInfo. #3520

BDisp opened this issue May 30, 2024 · 13 comments
Assignees
Labels
bug v2 For discussions, issues, etc... relavant for v2
Milestone

Comments

@BDisp
Copy link
Collaborator

BDisp commented May 30, 2024

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 BDisp added bug v2 For discussions, issues, etc... relavant for v2 labels May 30, 2024
@BDisp BDisp self-assigned this May 30, 2024
BDisp added a commit to BDisp/Terminal.Gui that referenced this issue May 30, 2024
@dodexahedron
Copy link
Collaborator

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

@BDisp
Copy link
Collaborator Author

BDisp commented May 30, 2024

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.

@dodexahedron
Copy link
Collaborator

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).

@BDisp
Copy link
Collaborator Author

BDisp commented May 31, 2024

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

BDisp added a commit to BDisp/Terminal.Gui that referenced this issue May 31, 2024
…ces in ReadConsoleKeyInfo."

This reverts commit 79192ed.
BDisp added a commit to BDisp/Terminal.Gui that referenced this issue May 31, 2024
…eadConsoleKeyInfo and in CheckWindowSizeChange.
@dodexahedron
Copy link
Collaborator

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

@BDisp
Copy link
Collaborator Author

BDisp commented Jun 1, 2024

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.

@dodexahedron
Copy link
Collaborator

dodexahedron commented Jun 3, 2024

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
Copy link
Collaborator

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.

@BDisp
Copy link
Collaborator Author

BDisp commented Jun 3, 2024

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

@dodexahedron
Copy link
Collaborator

dodexahedron commented Jun 4, 2024

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).

@BDisp
Copy link
Collaborator Author

BDisp commented Jun 4, 2024

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 e03b20e 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.

@dodexahedron
Copy link
Collaborator

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.

@tig tig added this to the V2 Beta milestone Jun 9, 2024
@tig tig modified the milestones: V2 Beta, V2 Release Aug 6, 2024
@BDisp
Copy link
Collaborator Author

BDisp commented Aug 11, 2024

This was already fixed on #3515.

@BDisp BDisp closed this as completed Aug 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug v2 For discussions, issues, etc... relavant for v2
Projects
Status: ✅ Done
Development

No branches or pull requests

3 participants