Open olegon opened 1 year ago
As we iterate, I'll enumerate some of the details (I'm not sure we have this documented anywhere).
SignalR Hubs are transient, that is, they exist for as long as an invocation between the server and single client:
OnConnectedAsync
is called. OnDisconnectedAsync
is called.After the specific method invocation is complete, the hub is disposed. Any usage of the hub properties will throw an ObjectDisposedException
.
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.
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();
}
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.
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.
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());
}
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.
cc @BrennanConroy
@wadepickett make sure this makes it into the right SignalR doc.
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.
@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.
@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.
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?
Help us make content visible
concurrency
andsignalr
termsDescribe 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.