PAYATHON 2026

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 层和设备访问逻辑。

备注:内容仅供参考。