Skip to content

Commit c181443

Browse files
committed
Initialize the property mapping collection exactly once
Address the review remark on PropertyMappingsInternal: the lock-free CompareExchange pattern allowed racing threads to each run the property mapper and build their own PropertyMappingCollection, with only one of the builds ever being published. Since the mapper may be user-supplied and the collection is the mutable object callers manipulate through PropertyMappings, initialization now takes a lock on the cold path so the mapper runs exactly once. The warm path is unchanged - a single Volatile.Read with no lock and no allocation (re-verified at 0 B per access on a warmed-up mapping). The other lazy members keep the lock-free pattern; their factories are idempotent and produce values that are never mutated afterwards, so a duplicate run is unobservable there. The comment on the member now spells out this distinction. Adds a test racing 64 threads on first access, asserting the mapper runs exactly once and all threads observe the same collection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PtLPvgYW89N5hTebTKPQBk
1 parent 6e27eca commit c181443

2 files changed

Lines changed: 55 additions & 11 deletions

File tree

src/Hl7.Fhir.Base/Introspection/ClassMapping.cs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ internal static bool TryCreate(ModelInspector parent, Type type, [NotNullWhen(tr
187187
// This list is created lazily. This not only improves initial startup time of
188188
// applications but also ensures circular references between types will not cause loops.
189189
private PropertyMappingCollection? _mappings;
190+
private readonly object _mappingsLock = new();
190191

191192
// Note: this member - like the other lazily initialized members below, and the equivalent
192193
// members in PropertyMapping and PropertyMappingCollection - deliberately does not use
@@ -197,11 +198,21 @@ internal static bool TryCreate(ModelInspector parent, Type type, [NotNullWhen(tr
197198
// cached by the compiler in a static field, so the LazyInitializer calls elsewhere in this
198199
// assembly cost nothing per access and are left alone.)
199200
//
200-
// The pattern used instead has the same semantics as LazyInitializer for reference types: the
201-
// factory may run more than once when threads race, but only a single instance is ever
202-
// published and every caller receives that one instance. The Volatile.Read on the fast path
203-
// provides the same acquire semantics, so a caller that observes the field also observes the
204-
// fully constructed object on weakly ordered architectures.
201+
// On the warm path all of these members are a single Volatile.Read, whose acquire semantics
202+
// guarantee that a caller observing the field also observes the fully constructed object on
203+
// weakly ordered architectures. The cold paths differ:
204+
//
205+
// This member builds the mutable collection that callers may manipulate through
206+
// PropertyMappings, and it runs the (possibly user-supplied) property mapper to do so. Racing
207+
// threads must therefore not each run the mapper and build their own copy - the lock, taken
208+
// only until the field is first published, makes initialization run exactly once.
209+
//
210+
// The other lazy members (CreateInstance/CreateList below, and the equivalents in
211+
// PropertyMapping and PropertyMappingCollection) have idempotent factories producing values
212+
// that are never mutated afterwards, so they keep LazyInitializer's lock-free semantics
213+
// instead: when threads race, the factory may run more than once, but only a single result is
214+
// ever published (via Interlocked.CompareExchange) and every caller receives that one
215+
// instance - a duplicate run is unobservable there.
205216
private PropertyMappingCollection PropertyMappingsInternal
206217
{
207218
get
@@ -210,12 +221,18 @@ private PropertyMappingCollection PropertyMappingsInternal
210221

211222
PropertyMappingCollection createCollection()
212223
{
213-
var properties = propertyMapper(this).ToList();
214-
if(properties.FirstOrDefault(m => m.DeclaringClass != this) is {} errorMapping)
215-
throw new InvalidOperationException($"PropertyMapping '{errorMapping.Name}' is already used for another ClassMapping '{errorMapping.DeclaringClass.Name}'.");
216-
217-
var created = new PropertyMappingCollection(properties);
218-
return Interlocked.CompareExchange(ref _mappings, created, null) ?? created;
224+
lock (_mappingsLock)
225+
{
226+
if (_mappings is { } existing) return existing;
227+
228+
var properties = propertyMapper(this).ToList();
229+
if(properties.FirstOrDefault(m => m.DeclaringClass != this) is {} errorMapping)
230+
throw new InvalidOperationException($"PropertyMapping '{errorMapping.Name}' is already used for another ClassMapping '{errorMapping.DeclaringClass.Name}'.");
231+
232+
var created = new PropertyMappingCollection(properties);
233+
Volatile.Write(ref _mappings, created);
234+
return created;
235+
}
219236
}
220237
}
221238
}

src/Hl7.Fhir.Support.Tests/Introspection/ClassMappingTest.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,33 @@ public void LazyMembersAreSafeToReadConcurrently()
313313
lists.Should().OnlyHaveUniqueItems();
314314
lists.Should().AllSatisfy(l => l.Count.Should().Be(0));
315315
}
316+
317+
[TestMethod]
318+
public void PropertyMapperRunsExactlyOnceUnderConcurrentFirstAccess()
319+
{
320+
// The property mapper may be user-supplied and builds the mutable PropertyMappings
321+
// collection, so racing threads must not each run it and build their own copy: the
322+
// first access initializes it exactly once and all threads see that single result.
323+
const int threads = 64;
324+
var mapperRuns = 0;
325+
326+
var mapping = new ClassMapping(ModelInspector.Base, "test", typeof(FhirBoolean), _ =>
327+
{
328+
System.Threading.Interlocked.Increment(ref mapperRuns);
329+
// Keep the factory busy for a while so racing threads pile up on the
330+
// still-uninitialized field instead of hitting the warm path.
331+
System.Threading.Thread.Sleep(50);
332+
return [];
333+
});
334+
335+
var collections = new ICollection<PropertyMapping>[threads];
336+
var result = Parallel.For(0, threads, new ParallelOptions { MaxDegreeOfParallelism = threads },
337+
i => collections[i] = mapping.PropertyMappings);
338+
339+
result.IsCompleted.Should().BeTrue();
340+
mapperRuns.Should().Be(1);
341+
collections.Should().AllSatisfy(c => c.Should().BeSameAs(collections[0]));
342+
}
316343
}
317344

318345

0 commit comments

Comments
 (0)