perf: replace object locks with Lock type for efficient synchronization#5219
perf: replace object locks with Lock type for efficient synchronization#5219
Conversation
…ization Replace `object` lock fields with `System.Threading.Lock` across the codebase. The Lock type (available via Polyfill on pre-.NET 9 targets) uses optimized `Lock.EnterScope()` instead of `Monitor.Enter/Exit`, reducing synchronization overhead in hot paths. For the console interceptor, introduce `ConsoleLineBuffer` in TUnit.Core to encapsulate the StringBuilder + Lock together, avoiding cross-assembly polyfill type mismatches while keeping all locking internal to the buffer class.
There was a problem hiding this comment.
Code Review: perf: replace object locks with Lock type for efficient synchronization
Overall this is a well-executed performance improvement. The changes are correct, the design is clean, and the encapsulation solves a real problem (cross-assembly polyfill type mismatch). A few observations below.
What's working well
ConsoleLineBuffer abstraction is well-designed. The semantically-named methods (Drain, AppendAndDrain, FlushIfNonEmpty) clearly express intent vs. raw lock (bufferLock) { ... } blocks. The calling code in OptimizedConsoleInterceptor is significantly easier to read.
Correctness is maintained. I verified that RouteToSinks is null-safe (if (message is not null && message.Length > 0)), so the new Flush() calling RouteToSinks(GetLineBuffer().FlushIfNonEmpty()) where content can be null is fine — same terminal behavior as the original guarded if (buffer.Length > 0).
The cross-assembly polyfill problem is correctly solved. Keeping Lock internal to ConsoleLineBuffer in TUnit.Core prevents consumers in other assemblies from needing to reference the polyfill directly.
ConstraintKeyScheduler change is correct. Lock doesn't have thread affinity and is safe to pass as a parameter to async methods.
Minor concern: Lazy<StringBuilder> inside ConsoleLineBuffer
// ConsoleLineBuffer.cs
private readonly Lazy<StringBuilder> _buffer = new(() => new StringBuilder());Since ConsoleLineBuffer is now eagerly constructed as a field of Context (which is constructed per-test), the Lazy<T> wrapper defers exactly one small StringBuilder allocation — but adds the overhead of the Lazy<T> wrapper itself (object allocation + volatile read on access). In the hot path of console interception, this Lazy is likely always realized for tests that produce any output, making it a net negative.
Consider either:
- Direct initialization:
private readonly StringBuilder _buffer = new StringBuilder();— simpler, slightly faster, one fewer object. - Or keep
Lazyif there's evidence that a meaningful fraction ofContextinstances never write to console (in which case the deferral saves theStringBuilderallocation). But given the context of console interception tests, this seems unlikely to matter.
This is a minor nit and doesn't affect correctness — just worth considering given the perf-motivation of this PR.
Semantic note: Drain() vs FlushIfNonEmpty()
The two draining methods have distinct semantics that are correctly matched to their call sites:
Drain()→ used inWriteLine()— always returns content (even""for empty buffer), preserving the behavior ofConsole.WriteLine()which always emits a line.FlushIfNonEmpty()→ used inFlush()— returnsnullwhen empty, avoiding unnecessary sink routing.
Both are consistent with RouteToSinks's null+empty guard. Good.
Summary
The object → Lock changes across the codebase are all correct and straightforward. The ConsoleLineBuffer extraction is the most significant architectural change and it's a net improvement. The Lazy<StringBuilder> inside ConsoleLineBuffer is worth reconsidering given the performance focus of this PR, but it's not blocking.
- Replace Lazy<StringBuilder> with direct StringBuilder in ConsoleLineBuffer (removes per-access Lazy overhead on hot path) - Add null check in Flush() to match FlushAsync() consistency - Remove unnecessary WHAT-comments on self-evident Append/Drain methods
There was a problem hiding this comment.
Follow-up Review: perf: replace object locks with Lock type for efficient synchronization
Previous Feedback Addressed
My earlier concern about inside has been resolved — the current implementation uses direct initialization:
private readonly StringBuilder _buffer = new();
private readonly Lock _lock = new();This is the right choice for a perf-focused PR. No wrapper overhead, no volatile reads on hot paths.
Overall Assessment
The PR is clean and correct. A few observations on the final state:
AppendAndDrain handles null safely. StringBuilder.Append(null) is a no-op, so passing a null value when the buffer is non-empty produces the buffered content unchanged — correct behavior.
Flush() semantics preserved. FlushIfNonEmpty() returning null when the buffer is empty correctly matches the original if (buffer.Length > 0) guard — RouteToSinks is only called with actual content.
ConstraintKeyScheduler is safe. Lock has no thread affinity, so passing it across async Task boundaries is correct — unlike Monitor, which requires Enter/Exit on the same thread.
Encapsulation is a genuine improvement. Centralizing the lock inside ConsoleLineBuffer removes the possibility of a caller accidentally locking on the wrong object or forgetting to acquire the lock at all. The semantically-named methods (Drain, AppendAndDrain, FlushIfNonEmpty) are also self-documenting at each call site.
No issues found. This is ready to merge.
There was a problem hiding this comment.
Follow-up Review: perf: replace object locks with Lock type for efficient synchronization
Previous Feedback Addressed
My earlier concern about Lazy<StringBuilder> inside ConsoleLineBuffer has been resolved — the current implementation uses direct initialization:
private readonly StringBuilder _buffer = new();
private readonly Lock _lock = new();This is the right choice for a perf-focused PR. No wrapper overhead, no volatile reads on hot paths.
Overall Assessment
The PR is clean and correct. A few observations on the final state:
AppendAndDrain handles null safely. StringBuilder.Append(null) is a no-op, so passing a null value when the buffer is non-empty produces the buffered content unchanged — correct behavior.
Flush() semantics preserved. FlushIfNonEmpty() returning null when the buffer is empty correctly matches the original if (buffer.Length > 0) guard — RouteToSinks is only called with actual content.
ConstraintKeyScheduler is safe. Lock has no thread affinity, so passing it across async Task boundaries is correct — unlike Monitor, which requires Enter/Exit on the same thread.
Encapsulation is a genuine improvement. Centralizing the lock inside ConsoleLineBuffer removes the possibility of a caller accidentally locking on the wrong object or forgetting to acquire the lock at all. The semantically-named methods (Drain, AppendAndDrain, FlushIfNonEmpty) are also self-documenting at each call site.
No issues found. This is ready to merge.
Summary
objectlock fields withSystem.Threading.Lockacross the entire codebase for more efficient synchronizationConsoleLineBufferclass in TUnit.Core to encapsulate line buffer + Lock, solving cross-assembly polyfill type mismatch while keeping all locking internalOptimizedConsoleInterceptorby removing inline lock blocks in favor ofConsoleLineBuffermethod callsChanged files
TUnit.Core/Logging/ConsoleLineBuffer.csTUnit.Core/Context.csConsoleLineBufferTUnit.Core/AotCompatibility/GenericTestRegistry.csobject→LockTUnit.Engine/Logging/OptimizedConsoleInterceptor.csConsoleLineBuffermethods instead of inline lock blocksTUnit.Engine/Logging/StandardOutConsoleInterceptor.csConsoleLineBufferinstead of tupleTUnit.Engine/Logging/StandardErrorConsoleInterceptor.csConsoleLineBufferinstead of tupleTUnit.Engine/Scheduling/ConstraintKeyScheduler.csobject→LockTUnit.Playwright/BrowserTest.csobject→LockTUnit.Mocks.Http/MockHttpHandler.csobject→LockTUnit.Engine.Tests/ThreadSafeOutput.csobject→LockWhy Lock > object
The
Locktype (polyfilled on pre-.NET 9) generatesLock.EnterScope()instead ofMonitor.Enter/Exitwhen used withlock(). This avoids the Monitor slow path overhead that showed up as 1.62% exclusive time in CPU profiling.Test plan