Skip to content

[SignalR] Avoid blocking common InvokeAsync usage #42796

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

Merged
merged 6 commits into from
Aug 21, 2022

Conversation

BrennanConroy
Copy link
Member

@BrennanConroy BrennanConroy commented Jul 18, 2022

Proposal to fix #41997

Pros:

  • Similar to streaming where once you start streaming we no longer consider the hub method blocking
  • Fairly low code
  • User code works as expected, and since you need parallel invokes enabled, they should be aware of thready safety concerns with accessing state after getting a client result

Cons:

  • IHubContext injected and used in Hubs for InvokeAsync can still block
    • Likely will need an analyzer for this, or come up with some way to detect this/prevent this
  • Semaphore acquire/release is more complex now
  • Still need to define MaximumParallelInvocationsPerClient > 1

@BrennanConroy BrennanConroy added the area-signalr Includes: SignalR clients and servers label Jul 18, 2022
finally
{
// Re-acquire the SemaphoreSlim, this is because when the hub method completes it will call release
await _parallelInvokes.WaitAsync(CancellationToken.None);
Copy link
Member

@gfoidl gfoidl Jul 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idea:

Must _parallelInvokes be a SemaphoreSlim or can other sync-types be used?
E.g. with a bounded-channel (of maxInvokeLimit) the reader / writer pair can be used for synchronization too. IIf "waiting" will succeed synchronous, then the advantage is that it's a ValueTask, thus saving the allocation for the Task.
(Of course, if it real async mostly, then the benefit is much lower).

PS: in the past I ran some benchmarks (in my projects) with such an approach, and the channels variant looked better than SemaphoreSlim (due Task -> ValueTask mostly).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Channels would completely break the concept I'm trying to implement here, so while it might be better from a Task alloc perspective, it is much worse from a behavior perspective 😞

If we decide to go a different direction, then I think the Channel is worth revisiting to see if we can replace SemaphoreSlim.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we talked past each other.

I meant something like this

using System.Threading.Channels;

ChannelBasedSemaphore semaphore = new(1);

ValueTask task0 = semaphore.WaitAsync();
ValueTask task1 = semaphore.WaitAsync();

Console.WriteLine(task0.IsCompleted);   // true
Console.WriteLine(task1.IsCompleted);   // false

semaphore.Release();

await Task.Yield();
Console.WriteLine(task1.IsCompleted);   // true

internal class ChannelBasedSemaphore
{
    private readonly Channel<int> _channel;

    public ChannelBasedSemaphore(int initialCount)
    {
        _channel = Channel.CreateBounded<int>(initialCount);

        for (int i = 0; i < initialCount; ++i)
        {
            _channel.Writer.TryWrite(42);    // any dummy-value will do
        }
    }

    public void Release() => _channel.Writer.TryWrite(42);

    public async ValueTask WaitAsync(CancellationToken cancellationToken = default)
    {
        _ = await _channel.Reader.ReadAsync(cancellationToken);
    }
}

as replacement for SemaphoreSlim (kind of drop-in replacement).
(Note: only hacked togehter, no perf-optimization, etc.).

@BrennanConroy
Copy link
Member Author

Using EchoAll - WS scenario on aspnet-perf-win:
Before:
~1.2m RPS
After with SemaphoreSlim:
~1.1m RPS
After with Channel:
~1.4m RPS

Allocations are hard to judge from the counters, but looking at perf view we can see both variations of this PR added a state machine (since we can't avoid it today even with Channels, maybe we can dig into it in 8).
image

@davidfowl
Copy link
Member

Keep the channel, wrap it in a struct or class the hides the channel type and mimics the semaphore with a comment that we're saving allocations 😄

@davidfowl
Copy link
Member

Do we need a limit here?

@BrennanConroy
Copy link
Member Author

Do we need a limit here?

With a limit do we throw when another InvokeAsync call is made?

@davidfowl
Copy link
Member

Lets keep it as is, no limit.

@BrennanConroy BrennanConroy marked this pull request as ready for review August 16, 2022 18:39
{
if (channelSemaphore.AttemptWait())
{
_ = RunTask(callback, channelSemaphore, state);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be delaying shutdown if the callbacks are still running. Is that happening? I have a feeling that ChannelBasedSemaphore will need to implement IAsyncDisposable or have some way to wait for all capacity to return before moving on.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it!

I assume that hub methods don't block the receive loop like streaming and client-invoking methods cannot delay shutdown. I don't think this is the most critical thing since streaming already behaves this way, but we should probably file a follow up issue.

@BrennanConroy BrennanConroy changed the base branch from main to release/7.0-rc1 August 19, 2022 21:18
@BrennanConroy
Copy link
Member Author

I assume that hub methods don't block the receive loop like streaming and client-invoking methods cannot delay shutdown. I don't think this is the most critical thing since streaming already behaves this way, but we should probably file a follow up issue.

Right, so today if you enable parallel invokes we don't block the receive loop (unless you're at max invokes + 1) and consequently do delay some of the shutdown logic. Tomorrow (after this change), you won't block the receive loop for non-parallel invokes unless you have a pending invoke. So it's a slight change, but we do part of the shutdown logic always regardless of the receive loop state. So this is mostly delaying calling OnDisconnectedAsync and un-referencing connection state.

@adityamandaleeka
Copy link
Member

Approved for RC1, this fixes a potentially bad issue in a new-to-7 feature.

@BrennanConroy
Copy link
Member Author

Had to rebase due to conflicts with another change in test files.
@dotnet/aspnet-admins please merge (auto-merge?) when build passes (assuming HTTP3 doesn't fail on this PR)

@dougbu dougbu enabled auto-merge (squash) August 20, 2022 22:24
@dougbu dougbu merged commit f121405 into release/7.0-rc1 Aug 21, 2022
@dougbu dougbu deleted the brecon/unblock branch August 21, 2022 00:11
@BrennanConroy BrennanConroy added this to the 7.0-rc1 milestone Aug 22, 2022
halter73 pushed a commit to halter73/AspNetCore that referenced this pull request Nov 20, 2024
* [SignalR] Avoid blocking common InvokeAsync usage
* channel
* fixup test
* fb
* sealed
* crazy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-signalr Includes: SignalR clients and servers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Hub methods can soft-lock the connection
6 participants