应使用 lazy 或带锁工厂实现线程安全的单例初始化:lazy 配合 executionandpublication 模式确保构造函数仅执行一次;依赖 di 时需用私有静态锁保护工厂委托;ihostedservice 中须用 asynclazy 避免 startasync 并发风险。

使用 Lazy<t></t> 包裹单例初始化逻辑
ASP.NET Core 默认使用多线程启动(尤其在 Kestrel + 多核 CPU 下),Program.cs 或 Startup.ConfigureServices 中的静态初始化、静态字段赋值、或单例服务的构造函数,都可能被多个线程同时触发。直接在构造函数里写耗时初始化(如加载配置、连接数据库、预热缓存)会导致重复执行甚至竞态错误。
Lazy<t></t> 是 .NET 原生线程安全的延迟初始化机制,配合 LazyThreadSafetyMode.ExecutionAndPublication 可确保仅一次执行且结果对所有线程可见。
public class ExpensiveInitializer
{
private static readonly Lazy<ExpensiveInitializer> _instance =
new Lazy<ExpensiveInitializer>(() => new ExpensiveInitializer(),
LazyThreadSafetyMode.ExecutionAndPublication);
public static ExpensiveInitializer Instance => _instance.Value;
private ExpensiveInitializer()
{
// 这段代码只会被执行一次,即使多个线程同时首次访问 Instance
Console.WriteLine("Initializing...");
Thread.Sleep(1000); // 模拟耗时操作
}
}
- 必须显式指定
LazyThreadSafetyMode.ExecutionAndPublication,否则默认模式在某些 .NET 版本下可能不保证构造函数只调一次 - 不要把
Lazy<t></t>实例放在非静态字段中——那会失去“全局唯一初始化”的意义 - 如果初始化过程可能抛异常,
Lazy<t></t>会缓存异常,后续访问直接重抛;需自行捕获并处理或改用AsyncLazy<t></t>
在 IServiceProvider 中注册时使用工厂委托 + 锁保护
当初始化逻辑依赖 DI 容器(比如需要 IConfiguration 或 ILogger),就不能用纯静态 Lazy<t></t>,而应在 ConfigureServices 中用工厂方法注册,并手动加锁控制首次执行。
注意:不能用 lock 锁住 this 或类型对象(如 typeof(MyService)),因为此时 IServiceCollection 还未构建完成,锁对象可能不唯一;推荐用私有静态对象。
private static readonly object _initLock = new object();
private static bool _initialized = false;
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IMyService>(sp =>
{
lock (_initLock)
{
if (!_initialized)
{
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILogger<MyService>>();
// 执行初始化:读配置、建连接池、预热等
logger.LogInformation("Running one-time init...");
InitializeOnce(config);
_initialized = true;
}
}
return new MyService();
});
}
- 该方式适用于初始化必须依赖 DI 服务的场景,但要注意:锁会阻塞其他服务注册线程,应尽量缩短临界区(只包真正需要同步的部分)
- 避免在锁内调用异步方法或等待 I/O(如
await),否则会阻塞整个容器构建流程 - 如果初始化本身是异步的(如
await LoadCacheAsync()),需改用AsyncLazy<t></t>或信号量(SemaphoreSlim)+ 异步锁模式
警惕 IHostedService.StartAsync 的并发调用风险
很多开发者误以为把初始化移到 IHostedService 就天然串行,其实 ASP.NET Core 会并发调用所有已注册的 IHostedService.StartAsync ——除非你显式控制顺序或同步。
常见错误:多个 IHostedService 都尝试初始化同一资源(如 Redis 连接、内存缓存项),导致重复连接或覆盖。
- 若必须用
IHostedService,应在实现类内部用Lazy<task></task>或AsyncLazy<t></t>包装初始化逻辑 - 不要在
StartAsync中直接写if (!_inited) { DoInit(); _inited = true; }—— 多个实例可能同时通过判断,造成竞态 - 更稳妥的做法是:让初始化逻辑集中在一个专用的
IHostedService中,并通过IServiceProvider注入它所初始化的资源,其他服务只消费不初始化
为什么不用 ConcurrentDictionary.GetOrAdd?
有人尝试用 ConcurrentDictionary<string object>.GetOrAdd("init", _ => { ... })</string> 实现一次性初始化,这看似可行,但存在隐患:
-
GetOrAdd的 valueFactory 可能被多次调用(虽然只有一次胜出),若初始化逻辑有副作用(如发 HTTP 请求、写日志、修改静态状态),就会意外触发多次 - 无法区分“初始化成功”和“初始化失败后重试”,异常会被吞掉或重复抛出
- 不如
Lazy<t></t>语义清晰、行为确定,.NET 团队也明确推荐Lazy<t></t>用于一次性初始化场景
真正需要防止并发初始化的地方,核心就两条:用对机制(Lazy<t></t> 或带锁工厂),并且把“是否已完成”的状态绑定到线程安全的存储上——而不是靠条件判断或字典试探。










