Skip to content

perf: replace object locks with Lock type for efficient synchronization#5219

Merged
thomhurst merged 2 commits intomainfrom
perf/object-to-lock-type
Mar 22, 2026
Merged

perf: replace object locks with Lock type for efficient synchronization#5219
thomhurst merged 2 commits intomainfrom
perf/object-to-lock-type

Conversation

@thomhurst
Copy link
Owner

Summary

  • Replace object lock fields with System.Threading.Lock across the entire codebase for more efficient synchronization
  • Introduce ConsoleLineBuffer class in TUnit.Core to encapsulate line buffer + Lock, solving cross-assembly polyfill type mismatch while keeping all locking internal
  • Simplify OptimizedConsoleInterceptor by removing inline lock blocks in favor of ConsoleLineBuffer method calls

Changed files

File Change
TUnit.Core/Logging/ConsoleLineBuffer.cs New — encapsulates StringBuilder + Lock with Append/Drain/Flush operations
TUnit.Core/Context.cs Replace raw StringBuilder+object buffer with ConsoleLineBuffer
TUnit.Core/AotCompatibility/GenericTestRegistry.cs objectLock
TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs Use ConsoleLineBuffer methods instead of inline lock blocks
TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs Return ConsoleLineBuffer instead of tuple
TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs Return ConsoleLineBuffer instead of tuple
TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs objectLock
TUnit.Playwright/BrowserTest.cs objectLock
TUnit.Mocks.Http/MockHttpHandler.cs objectLock
TUnit.Engine.Tests/ThreadSafeOutput.cs objectLock

Why Lock > object

The Lock type (polyfilled on pre-.NET 9) generates Lock.EnterScope() instead of Monitor.Enter/Exit when used with lock(). This avoids the Monitor slow path overhead that showed up as 1.62% exclusive time in CPU profiling.

Test plan

  • All TFMs build (netstandard2.0, net8.0, net9.0, net10.0)
  • TUnit.Profile: 1013/1013 tests pass
  • CI pipeline passes

…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.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Lazy if there's evidence that a meaningful fraction of Context instances never write to console (in which case the deferral saves the StringBuilder allocation). 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 in WriteLine() — always returns content (even "" for empty buffer), preserving the behavior of Console.WriteLine() which always emits a line.
  • FlushIfNonEmpty() → used in Flush() — returns null when empty, avoiding unnecessary sink routing.

Both are consistent with RouteToSinks's null+empty guard. Good.


Summary

The objectLock 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
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@thomhurst thomhurst enabled auto-merge (squash) March 22, 2026 17:22
@thomhurst thomhurst merged commit 1bf7ad4 into main Mar 22, 2026
15 of 16 checks passed
@thomhurst thomhurst deleted the perf/object-to-lock-type branch March 22, 2026 17:41
@claude claude bot mentioned this pull request Mar 23, 2026
1 task
This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to
1.21.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.21.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.6 -->

## What's Changed
### Other Changes
* perf: replace object locks with Lock type for efficient
synchronization by @​thomhurst in
thomhurst/TUnit#5219
* perf: parallelize test metadata collection for source-generated tests
by @​thomhurst in thomhurst/TUnit#5221
* perf: use GetOrAdd args overload to eliminate closure allocations in
event receivers by @​thomhurst in
thomhurst/TUnit#5222
* perf: self-contained TestEntry<T> with consolidated switch invokers
eliminates per-test JIT by @​thomhurst in
thomhurst/TUnit#5223
### Dependencies
* chore(deps): update tunit to 1.21.0 by @​thomhurst in
thomhurst/TUnit#5220


**Full Changelog**:
thomhurst/TUnit@v1.21.0...v1.21.6

## 1.21.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.0 -->

## What's Changed
### Other Changes
* perf: reduce ConcurrentDictionary closure allocations in hot paths by
@​thomhurst in thomhurst/TUnit#5210
* perf: reduce async state machine overhead in test execution pipeline
by @​thomhurst in thomhurst/TUnit#5214
* perf: reduce allocations in EventReceiverOrchestrator and
TestContextExtensions by @​thomhurst in
thomhurst/TUnit#5212
* perf: skip timeout machinery when no timeout configured by @​thomhurst
in thomhurst/TUnit#5211
* perf: reduce allocations and lock contention in ObjectTracker by
@​thomhurst in thomhurst/TUnit#5213
* Feat/numeric tolerance by @​agray in
thomhurst/TUnit#5110
* perf: remove unnecessary lock in ObjectTracker.TrackObjects by
@​thomhurst in thomhurst/TUnit#5217
* perf: eliminate async state machine in
TestCoordinator.ExecuteTestAsync by @​thomhurst in
thomhurst/TUnit#5216
* perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync
by @​thomhurst in thomhurst/TUnit#5215
* perf: consolidate module initializers into single .cctor via partial
class by @​thomhurst in thomhurst/TUnit#5218
### Dependencies
* chore(deps): update tunit to 1.20.0 by @​thomhurst in
thomhurst/TUnit#5205
* chore(deps): update dependency nunit3testadapter to 6.2.0 by
@​thomhurst in thomhurst/TUnit#5206
* chore(deps): update dependency cliwrap to 3.10.1 by @​thomhurst in
thomhurst/TUnit#5207


**Full Changelog**:
thomhurst/TUnit@v1.20.0...v1.21.0

## 1.20.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.20.0 -->

## What's Changed
### Other Changes
* Fix inverted colors in HTML report ring chart due to locale-dependent
decimal formatting by @​Copilot in
thomhurst/TUnit#5185
* Fix nullable warnings when using Member() on nullable properties by
@​Copilot in thomhurst/TUnit#5191
* Add CS8629 suppression and member access expression matching to
IsNotNullAssertionSuppressor by @​Copilot in
thomhurst/TUnit#5201
* feat: add ConfigureAppHost hook to AspireFixture by @​thomhurst in
thomhurst/TUnit#5202
* Fix ConfigureTestConfiguration being invoked twice by @​thomhurst in
thomhurst/TUnit#5203
* Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by
@​thomhurst in thomhurst/TUnit#5204
### Dependencies
* chore(deps): update dependency gitversion.tool to v6.6.2 by
@​thomhurst in thomhurst/TUnit#5181
* chore(deps): update dependency gitversion.msbuild to 6.6.2 by
@​thomhurst in thomhurst/TUnit#5180
* chore(deps): update tunit to 1.19.74 by @​thomhurst in
thomhurst/TUnit#5179
* chore(deps): update verify to 31.13.3 by @​thomhurst in
thomhurst/TUnit#5182
* chore(deps): update verify to 31.13.5 by @​thomhurst in
thomhurst/TUnit#5183
* chore(deps): update aspire to 13.1.3 by @​thomhurst in
thomhurst/TUnit#5189
* chore(deps): update dependency stackexchange.redis to 2.12.4 by
@​thomhurst in thomhurst/TUnit#5193
* chore(deps): update microsoft/setup-msbuild action to v3 by
@​thomhurst in thomhurst/TUnit#5197


**Full Changelog**:
thomhurst/TUnit@v1.19.74...v1.20.0

## 1.19.74

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.74 -->

## What's Changed
### Other Changes
* feat: per-hook activity spans with method names by @​thomhurst in
thomhurst/TUnit#5159
* fix: add tooltip to truncated span names in HTML report by @​thomhurst
in thomhurst/TUnit#5164
* Use enum names instead of numeric values in test display names by
@​Copilot in thomhurst/TUnit#5178
* fix: resolve CS8920 when mocking interfaces whose members return
static-abstract interfaces by @​lucaxchaves in
thomhurst/TUnit#5154
### Dependencies
* chore(deps): update tunit to 1.19.57 by @​thomhurst in
thomhurst/TUnit#5157
* chore(deps): update dependency gitversion.msbuild to 6.6.1 by
@​thomhurst in thomhurst/TUnit#5160
* chore(deps): update dependency gitversion.tool to v6.6.1 by
@​thomhurst in thomhurst/TUnit#5161
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5163
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5162
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5166
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5167
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5168
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5169
* chore(deps): update dependency coverlet.collector to 8.0.1 by
@​thomhurst in thomhurst/TUnit#5177

## New Contributors
* @​lucaxchaves made their first contribution in
thomhurst/TUnit#5154

**Full Changelog**:
thomhurst/TUnit@v1.19.57...v1.19.74

Commits viewable in [compare
view](thomhurst/TUnit@v1.19.57...v1.21.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant