.NET Core中IOptionsMonitor<T>依赖其他选项时的竞态条件处理
IOptionsMonitor依赖其他选项时的竞态问题及无ConfigurationRoot解决方案
问题场景
在服务中使用依赖其他选项的IOptionsMonitor<T>时,OnChange事件出现竞态条件:配置重载时选项异步初始化,导致依赖方选项(如SecondOptions)在被依赖选项(如FirstOptions)完成更新前就完成初始化,最终OnChange事件中会先出现旧值,再出现新值,产生延迟。
示例代码
public class FirstOptions { public string FirstValue { get; set; } } public class SecondOptions { public string SecondValue { get; set; } } internal class Worker : IHostedService { private readonly IDisposable _disposable; private readonly string _filePath = "Config.ini"; public Worker(IOptionsMonitor<SecondOptions> options) { Console.WriteLine($"Ctor: \"{options.CurrentValue.SecondValue}\""); _disposable = options.OnChange(OnOptionsChanged); } private void OnOptionsChanged(SecondOptions options, string? arg2) { Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} OnChange: Second: {options.SecondValue}"); } public async Task StartAsync(CancellationToken cancellationToken) { for (int i = 0; i < 20; i++) { await Task.Delay(2000); WriteFirstValue(_filePath, i.ToString()); } } public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } private static void WriteFirstValue(string filePath, string value) { Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Property changed to " + value); File.WriteAllText(filePath, "[FirstSection]" + Environment.NewLine + $"FirstValue=\"{value}\""); } } internal class Program { private static void Main(string[] args) { IConfigurationRoot configurationRoot = new ConfigurationBuilder() .SetBasePath(Environment.CurrentDirectory) .AddIniFile("config.ini", false, true) .Build(); var provider = configurationRoot.Providers.OfType<IniConfigurationProvider>().First(); IHostBuilder builder = Host.CreateDefaultBuilder(args); builder.ConfigureServices(services => { services.AddHostedService<Worker>(); services.AddOptions<FirstOptions>().Bind(configurationRoot.GetSection("FirstSection")); services.AddOptions<SecondOptions>() .Bind(configurationRoot.GetSection("SecondSection")) .Configure<IOptionsMonitor<FirstOptions>>((secondOpt, firstOpt) => secondOpt.SecondValue = firstOpt.CurrentValue.FirstValue); //.Configure(secondOpt => secondOpt.SecondValue = configurationRoot.GetSection("FirstSection").GetValue<string>("FirstValue")); }); IHost host = builder.Build(); host.Run(); } }
异常输出
15:53:00.677 Property changed to 0 15:53:00.957 OnChange: Second: 0 15:53:01.222 OnChange: Second: 0 15:53:02.703 Property changed to 1 15:53:02.970 OnChange: Second: 0 15:53:03.222 OnChange: Second: 1 15:53:04.713 Property changed to 2 15:53:04.975 OnChange: Second: 1 15:53:05.228 OnChange: Second: 2 15:53:06.727 Property changed to 3 15:53:06.994 OnChange: Second: 2 15:53:07.259 OnChange: Second: 3
解决方案(不依赖ConfigurationRoot)
问题根源是配置变更时,FirstOptions和SecondOptions的重新初始化是并行的,导致SecondOptions可能先完成初始化并拿到旧值。我们可以通过监听FirstOptions的变更,主动清除SecondOptions的缓存,迫使它在FirstOptions更新完成后重新初始化,确保拿到最新值。
修改ConfigureServices代码
在服务注册中添加一个后台服务,监听FirstOptions的变更并清除SecondOptions的缓存:
builder.ConfigureServices(services => { services.AddHostedService<Worker>(); services.AddOptions<FirstOptions>().Bind(configurationRoot.GetSection("FirstSection")); services.AddOptions<SecondOptions>() .Bind(configurationRoot.GetSection("SecondSection")) .Configure<IOptionsMonitor<FirstOptions>>((secondOpt, firstOpt) => secondOpt.SecondValue = firstOpt.CurrentValue.FirstValue); // 添加监听FirstOptions变更、清除SecondOptions缓存的后台服务 services.AddSingleton<IHostedService>(sp => { var firstMonitor = sp.GetRequiredService<IOptionsMonitor<FirstOptions>>(); var secondCache = sp.GetRequiredService<IOptionsMonitorCache<SecondOptions>>(); var disposable = firstMonitor.OnChange((_, __) => secondCache.TryRemove(Options.DefaultName)); return new CacheCleanupBackgroundService(disposable); }); });
添加缓存清理后台服务类
在Program类内部添加以下嵌套类:
private class CacheCleanupBackgroundService : IHostedService { private readonly IDisposable _changeSubscription; public CacheCleanupBackgroundService(IDisposable changeSubscription) { _changeSubscription = changeSubscription; } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) { _changeSubscription.Dispose(); return Task.CompletedTask; } }
原理说明
- 当
FirstOptions发生变更时,IOptionsMonitor<FirstOptions>的OnChange事件会触发,调用IOptionsMonitorCache<SecondOptions>.TryRemove清除SecondOptions的默认实例缓存。 - 缓存清除后,下一次访问
SecondOptions(包括OnChange触发的重新初始化)会重新执行配置逻辑,此时FirstOptions已经完成更新,能拿到最新的FirstValue。 - 最终
SecondOptions的OnChange事件只会触发一次,且值为最新的FirstValue。
补充说明
ASP.NET 5.0文档曾提示“不要在Startup.ConfigureServices中使用IOptions或IOptionsMonitor,服务注册顺序可能导致选项状态不一致”,虽然该说明在后续版本文档中移除,但本质问题是选项初始化的顺序和异步特性导致的,上述方案通过主动控制缓存生命周期,规避了竞态条件。
内容的提问来源于stack exchange,提问作者Filip Procházka
相关产品推荐
相关产品推荐

