Understanding Lazy<T> thread safety modes
Andrew Lock walks through the three LazyThreadSafetyMode values for Lazy<T> and shows how each interacts with exceptions thrown by the factory method. Because exception caching is tied to the thread-safety mode rather than being a separate switch, a Lazy<T> that fails once can stay permanently broken in two of the three modes.
Key points
- None gives no thread safety; concurrent access can throw InvalidOperationException
- PublicationOnly lets threads race, publishes the first completed value and discards the rest
- ExecutionAndPublication runs the factory once and blocks all other threads until it finishes
- None and ExecutionAndPublication cache exceptions, so a single failure can leave the Lazy<T> permanently broken
- PublicationOnly does not cache exceptions; once a value is published, callers just get that value
- A proposal to separate exception caching via a cacheExceptions flag was approved, likely for .NET 12
Lazy<T>, available since .NET Framework 4.0, defers creation of an expensive object until the Value property is first read. The post compares the three LazyThreadSafetyMode values and shows they control two things at once: how concurrent access is coordinated and what happens when the factory method throws. With None there is no thread safety at all, so multi-threaded access tends to produce InvalidOperationException, and a nullable field with ??= often expresses the same intent more simply.
PublicationOnly lets every thread run the factory and race; the first thread to finish publishes its value and the losers' results are thrown away. That can beat locking when the factory is cheap, safe to repeat and contention is high. ExecutionAndPublication runs the factory exactly once and blocks the rest. The important difference: None and ExecutionAndPublication cache the exception and rethrow it to every later caller, while PublicationOnly does not.
Real-world use
- Deferring creation of an expensive config object or HTTP client until first use
- Guaranteeing a singleton service initializes once and understanding why retries keep throwing the same exception
- Choosing PublicationOnly to avoid lock contention when the factory is cheap and safe to repeat
- Using LazyInitializer.EnsureInitialized to avoid an extra Lazy<T> allocation on a hot path
Why it matters
A cached exception turns a transient initialization failure into a permanent one for the lifetime of the object, so picking a safety mode is a resilience decision as much as a performance one.