dotnet / AspNetCore.Docs

Documentation for ASP.NET Core
https://docs.microsoft.com/aspnet/core
Creative Commons Attribution 4.0 International
12.63k stars 25.29k forks source link

How to deal with concurrency when using SignalR? #27956

Open olegon opened 1 year ago

olegon commented 1 year ago

Help us make content visible

Describe the new topic

EDIT by @Rick-Anderson to add the following meta-data:


Document Details

Do not edit this section. It is required for learn.microsoft.com ➟ GitHub issue linking.

davidfowl commented 1 year ago

As we iterate, I'll enumerate some of the details (I'm not sure we have this documented anywhere).

Hub Lifetime

SignalR Hubs are transient, that is, they exist for as long as an invocation between the server and single client:

After the specific method invocation is complete, the hub is disposed. Any usage of the hub properties will throw an ObjectDisposedException.

Dependency Injection

SignalR will create a new dependency injection scope per hub invocation. This means scoped services resolved from Hub instances will NOT have the same lifetime as request scoped services.

State

Since Hubs are transient in nature, make them inappropriate for storing state that needs to live outside of specific invocations. To store state that persists across invocations, register a singleton service the dependency injection container and inject it into the hub.

Here's an example of storing all connections associated with the hub on this specific server instance:

using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();
// Register the Connections<T> type in the dependency injection container
// there will be a unique instance per hub
builder.Services.AddSingleton(typeof(Connections<>));

var app = builder.Build();

app.MapHub<Chat>("/chat");

app.Run();

class Chat : Hub
{
    private readonly Connections<Chat> _connections;

    public Chat(Connections<Chat> connections)
    {
        _connections = connections;
    }

    public override Task OnConnectedAsync()
    {
        _connections.All.TryAdd(Context.ConnectionId, Context);

        return base.OnConnectedAsync();
    }

    public override Task OnDisconnectedAsync(Exception? exception)
    {
        _connections.All.TryRemove(Context.ConnectionId, out _);

        return base.OnDisconnectedAsync(exception);
    }

    public Task Send(string message) =>
        Clients.All.SendAsync("Receive", message);

}

// Stores the connections for a specific hub type
class Connections<T> where T : Hub
{
    public ConcurrentDictionary<string, HubCallerContext> All { get; } = new();
}

Thread Safety

The singleton state can be accessed concurrently by many threads and is thus using a ConcurrentDictionary to ensure safe access. Any singleton state needs to be protected this way or using a lock or any other synchronization primitive.

Concurrency

Server

SignalR hubs can limit the concurrent calls from single clients for non-streaming invocations using the MaximumParallelInvocationsPerClient option. By default, this concurrency limit is 1.

// Increase the number of concurrent invocations to 5
builder.Services.AddSignalR()
    .AddHubOptions<Chat>(o => o.MaximumParallelInvocationsPerClient = 5);

This is the maximum number of invocations the client can execute concurrently before throttling occurs. When that happens, the client invocation will be queued, and no more data will be read for that client until the existing invocations complete.

NOTE: This concurrency limit does NOT apply to streaming invocations. The client can spawn concurrency streams without limits.

Sample

Server

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR()
    .AddHubOptions<Chat>(o => o.MaximumParallelInvocationsPerClient = 5);

var app = builder.Build();

app.MapHub<Chat>("/chat");

app.Run();

class Chat : Hub
{
    public async Task Send(string message) 
    {
        await Task.Delay(500);
        await Clients.All.SendAsync("Receive", message);
    }
}

Client

var connection = new HubConnectionBuilder()
                .WithUrl("/chat")
                .Build();

connection.On("Receive", (string message) => Console.WriteLine(message));

await connection.StartAsync();

// Attempt to invoke 10 concurrently
for (var i = 0; i < 10; i++)
{
    _ = connection.InvokeAsync("Send", i.ToString());
}

Client

SignalR supports multiple clients in multiple languages including .NET, JavaScript/TypeScript, Java and C++. In these implementations, client events are not run concurrently.

var connection = new HubConnectionBuilder()
                .WithUrl("/chat")
                .Build();

connection.On("Receive", async (string message) => 
{
     // This will block *all* call backs while the delay 
     await Task.Delay(1000);
     Console.WriteLine(message)
});

await connection.StartAsync();

// Attempt to invoke 10 concurrently
for (var i = 0; i < 10; i++)
{
    await connection.InvokeAsync("Send", i.ToString());
}

NOTE: HubConnection.SendAsync ignores concurrency on the client side since it doesn't wait for a response from the server. Overwhelming the server will result in the client being disconnected.

davidfowl commented 1 year ago

cc @BrennanConroy

Rick-Anderson commented 1 year ago

@wadepickett make sure this makes it into the right SignalR doc.

olegon commented 1 year ago

Amazing, @davidfowl! I didn't know that Hubs works this way, it's a kind of "serializable" guarantee for each client! Your explanation must become an official doc as @Rick-Anderson said!

When there's one single thread mutating state, things become easier, so I'm building rooms of clients where each command is processed through a queue. I'm using channels to implement it.

davidfowl commented 1 year ago

@olegon there's another option and that's using Grains to manage concurrency, even when you're not scaled out. Though some of those steps may look complex, it allows you to write a class that when instantiated via a specific factory, prevents multi-threaded access to it. That allows you to use grain state without worrying about concurrency or thread safety.

grzesiek-galezowski commented 7 months ago

@davidfowl Does MaximumParallelInvocationsPerClient apply to connection lifetime events such as OnDisconnectedAsync ? In other words, can OnDisconnectedAsync be fired while another hub method from the same client is running? The documentation says "By default a client is only allowed to invoke a single Hub method at a time" but It's still not clear to me whether OnDisconnectedAsync counts as a hub method.

Vlad66M commented 7 months ago

But if I want to send a message to a certain client, who might have many connections. And I want to use as a key in the ConcurrentDictionary his actual Id (user Id, not connection Id, and his Id might be tied with many connection ids). What would be a better approach for this?