C# gRPC server-side asset 如何保存长期内存状态
结论
将需要长期保存在内存中的状态放到独立的业务类里,再通过 ASP.NET Core 的依赖注入注册为 Singleton。gRPC 服务仍按请求创建,只处理 RPC,并通过构造函数使用这个单例。
注册代码可以放在 builder.Services.AddGrpc() 附近:
builder.Services.AddSingleton<HardwareDataCache>();
应用运行期间,依赖注入容器会一直持有该实例,GC 不会提前回收它。应用关闭后,内存中的状态也会随之消失。如果状态需要在进程重启后继续保留,就要写回硬件、数据库或其他持久化存储。
最简单的整数示例
先定义一个保存共享状态的类。多个 gRPC 请求可能会并发执行,因此读写操作需要保证线程安全:
public sealed class SharedCounter
{
private int _value;
public int Get()
{
return Volatile.Read(ref _value);
}
public void Set(int value)
{
Volatile.Write(ref _value, value);
}
public int Increment()
{
return Interlocked.Increment(ref _value);
}
}
在 Program.cs 中注册:
using GrpcManipulator.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddSingleton<SharedCounter>();
var app = builder.Build();
app.MapGrpcService<ManipulatorAbbSocketServerService>();
app.MapGet("/", () =>
"Communication with gRPC endpoints must be made through a gRPC client.");
app.Run();
接着通过构造函数将它注入 gRPC 服务:
public sealed class ManipulatorAbbSocketServerService
: Manipulator.ManipulatorBase
{
private readonly SharedCounter _counter;
public ManipulatorAbbSocketServerService(SharedCounter counter)
{
_counter = counter;
}
public override Task<GetValueReply> GetValue(
GetValueRequest request,
ServerCallContext context)
{
return Task.FromResult(new GetValueReply
{
Value = _counter.Get()
});
}
public override Task<SetValueReply> SetValue(
SetValueRequest request,
ServerCallContext context)
{
_counter.Set(request.Value);
return Task.FromResult(new SetValueReply
{
Value = _counter.Get()
});
}
}
即使框架创建了多个 gRPC 服务实例,注入其中的仍是同一个 SharedCounter。
缓存硬件数据
在实际项目中,可以将硬件访问和缓存更新封装到同一个单例里。以下示例假设数据快照是不可变对象,硬件也支持成本较低的版本检查:
public sealed record DeviceSnapshot(long Version, string Payload);
public interface IHardwareDevice
{
Task<bool> HasChangedAsync(
long currentVersion,
CancellationToken cancellationToken);
Task<DeviceSnapshot> ReadSnapshotAsync(
CancellationToken cancellationToken);
}
缓存类负责检查数据是否变化,并在需要时重新读取和替换快照:
public sealed class HardwareDataCache
{
private readonly IHardwareDevice _device;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private DeviceSnapshot? _snapshot;
public HardwareDataCache(IHardwareDevice device)
{
_device = device;
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
var snapshot =
await _device.ReadSnapshotAsync(cancellationToken);
Volatile.Write(ref _snapshot, snapshot);
}
public async Task<DeviceSnapshot> GetAsync(
CancellationToken cancellationToken)
{
var current = Volatile.Read(ref _snapshot)
?? throw new InvalidOperationException(
"Hardware cache has not been initialized.");
if (!await _device.HasChangedAsync(
current.Version,
cancellationToken))
{
return current;
}
await _refreshLock.WaitAsync(cancellationToken);
try
{
current = Volatile.Read(ref _snapshot)
?? throw new InvalidOperationException(
"Hardware cache has not been initialized.");
// 取得锁后再次检查,避免多个并发请求重复读取设备。
if (await _device.HasChangedAsync(
current.Version,
cancellationToken))
{
var updated =
await _device.ReadSnapshotAsync(cancellationToken);
Volatile.Write(ref _snapshot, updated);
current = updated;
}
return current;
}
finally
{
_refreshLock.Release();
}
}
}
注册硬件访问类和缓存:
builder.Services.AddGrpc();
builder.Services.AddSingleton<IHardwareDevice, HardwareDevice>();
builder.Services.AddSingleton<HardwareDataCache>();
如果需要在第一个请求到达前完成读取,可以在 Build() 之后、映射端点之前初始化缓存:
var app = builder.Build();
var cache = app.Services.GetRequiredService<HardwareDataCache>();
await cache.InitializeAsync(CancellationToken.None);
app.MapGrpcService<ManipulatorAbbSocketServerService>();
await app.RunAsync();
如果初始化失败,应用就不会在缓存不可用时开始提供 gRPC 服务。初始化还涉及重试、定期轮询或关闭清理时,可以将相关逻辑放入 IHostedService 或 BackgroundService。
gRPC 服务直接调用缓存即可:
public sealed class ManipulatorAbbSocketServerService
: Manipulator.ManipulatorBase
{
private readonly HardwareDataCache _cache;
public ManipulatorAbbSocketServerService(
HardwareDataCache cache)
{
_cache = cache;
}
public override async Task<GetDeviceDataReply> GetDeviceData(
GetDeviceDataRequest request,
ServerCallContext context)
{
var snapshot =
await _cache.GetAsync(context.CancellationToken);
return new GetDeviceDataReply
{
Version = snapshot.Version,
Payload = snapshot.Payload
};
}
}
为什么单例不会被 GC 回收
应用的根依赖注入容器会持有通过 AddSingleton<T>() 注册的对象。只要应用和容器仍在运行,该对象就是可达的,GC 不会回收它。应用关闭时,容器才会释放单例。如果单例实现了 IDisposable 或 IAsyncDisposable,容器也会调用相应的清理方法。
因此,无需再创建额外的 static 字段来保持对象存活。
并发和生命周期注意事项
gRPC 可以同时处理多个调用,单例中的可变状态必须保证线程安全:
- 单个整数的读取、写入和递增可以使用
Volatile、Interlocked。 - 多个字段需要作为一个整体保持一致时,可以使用
lock、SemaphoreSlim,也可以创建不可变快照,再以原子方式替换引用。 - 不要修改旧快照中的部分字段后再让其他请求读取。应先构造完整的新快照,然后一次性替换引用。
- 如果硬件驱动不支持并发访问,版本检查和数据读取也需要放在同一把锁中执行。
- 单例不能直接依赖
Scoped服务。需要使用短生命周期依赖时,应通过IServiceScopeFactory显式创建作用域。 - 内存单例只在当前进程内共享。多实例部署、容器扩容或运行多个服务器进程时,每个进程都有自己的副本,无法依靠单例保持全局一致。
- 进程重启会清空缓存。需要永久保存的数据仍应以外部设备或其他持久化存储为准。
一般不必将 gRPC 服务类本身注册为单例。服务类保持轻量,长期状态和同步逻辑则集中在独立的单例中。这样更便于测试,也能明确区分 RPC 层和设备访问逻辑。
备注:内容仅供参考。
