Skip to content

perf: remove unnecessary lock in ObjectTracker.TrackObjects#5217

Merged
thomhurst merged 2 commits intomainfrom
perf/reduce-object-tracker-lock-contention
Mar 22, 2026
Merged

perf: remove unnecessary lock in ObjectTracker.TrackObjects#5217
thomhurst merged 2 commits intomainfrom
perf/reduce-object-tracker-lock-contention

Conversation

@thomhurst
Copy link
Owner

Summary

  • Remove lock (kvp.Value) in ObjectTracker.TrackObjects() that caused Monitor.Enter_Slowpath contention (~1.25% exclusive CPU during parallel execution)
  • The lock was unnecessary: TrackedObjects is per-TestContext and TrackObjects is called from a single thread per test
  • The shared state (s_trackedObjects ConcurrentDictionary) is already thread-safe via GetOrAdd
  • Also removed a stale comment in UntrackObject that referenced a non-existent lock

Test plan

  • Builds successfully (TUnit.Core and TUnit.Engine, Release, net10.0)
  • DisposableFieldTests and DisposablePropertyTests pass
  • CI passes

TrackedObjects is per-TestContext and TrackObjects is called from a
single thread per test context. The lock(kvp.Value) on each HashSet
caused Monitor.Enter_Slowpath contention (~1.25% exclusive CPU) during
parallel test execution of 1,013+ tests, with no actual concurrent
access to protect against.
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

Overall: Clean and correct performance improvement. The analysis is sound and the change is safe.

Summary of Changes

  1. **Removes in ** — the lock was guarding iteration of a that comes from .
  2. Removes a stale comment in referencing a lock that no longer existed.

Analysis: Was the lock actually necessary?

The PR's reasoning holds up under scrutiny:

  • delegates to , which constructs a fresh on every call — the kvp.Value HashSets are not shared references.
  • TrackedObjects on TestContext is per-context, not shared across tests.
  • The ObjectGraphDiscoverer class itself is documented as thread-safe (static ConcurrentBag, cached reflection), but the returned dictionary is local to each call.

So yes, the lock was guarding a locally-constructed, call-owned collection — providing no real safety benefit while introducing Monitor.Enter_Slowpath overhead under parallel test execution.


Minor Consideration

The new comment says:

// No lock needed: TrackedObjects is per-TestContext and TrackObjects is called from a single thread per test.

The "called from a single thread per test" part is an architectural contract that isn't enforced at compile time. If this invariant ever breaks (e.g., a future parallel data-provider feature), it would be a silent regression. Consider whether this guarantee should be documented more formally at the call site in the engine, or whether a Debug.Assert or thread-affinity check could serve as a guardrail. That said, this is a pre-existing condition — not introduced by this PR — and is low risk given the current architecture.


Stale Comment Removal

Correct. The removed comment ("Dispose outside the lock to avoid blocking other untrack operations") referenced a lock in UntrackObject that no longer exists. The cleanup is appropriate.


No blocking issues. The change correctly identifies and removes unnecessary synchronization overhead.

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

Overall: Clean and correct performance improvement. The analysis is sound and the change is safe.

Summary of Changes

  1. Removes lock (kvp.Value) in TrackObjects — the lock was guarding iteration of a HashSet<object> that comes from trackableObjectGraphProvider.GetTrackableObjects(testContext).
  2. Removes a stale comment in UntrackObject referencing a lock that no longer existed.

Analysis: Was the lock actually necessary?

The PR's reasoning holds up under scrutiny:

  • GetTrackableObjects delegates to ObjectGraphDiscoverer.DiscoverAndTrackObjects, which constructs a fresh Dictionary<int, HashSet<object>> on every call — the kvp.Value HashSets are not shared references.
  • TrackedObjects on TestContext is per-context, not shared across tests.
  • The ObjectGraphDiscoverer class itself is documented as thread-safe (static ConcurrentBag, cached reflection), but the returned dictionary is local to each call.

So yes, the lock was guarding a locally-constructed, call-owned collection — providing no real safety benefit while introducing Monitor.Enter_Slowpath overhead under parallel test execution.


Minor Consideration

The new comment says:

// No lock needed: TrackedObjects is per-TestContext and TrackObjects is called from a single thread per test.

The "called from a single thread per test" part is an architectural contract that isn't enforced at compile time. If this invariant ever breaks (e.g., a future parallel data-provider feature), it would be a silent regression. Consider whether this guarantee should be documented more formally at the call site in the engine, or whether a Debug.Assert or thread-affinity check could serve as a guardrail. That said, this is a pre-existing condition — not introduced by this PR — and is low risk given the current architecture.


Stale Comment Removal

Correct. The removed comment ("Dispose outside the lock to avoid blocking other untrack operations") referenced a lock in UntrackObject that no longer exists. The cleanup is appropriate.


No blocking issues. The change correctly identifies and removes unnecessary synchronization overhead.

@thomhurst thomhurst enabled auto-merge (squash) March 22, 2026 14:32
@thomhurst thomhurst merged commit 7a81c56 into main Mar 22, 2026
15 of 16 checks passed
@thomhurst thomhurst deleted the perf/reduce-object-tracker-lock-contention branch March 22, 2026 14:43
This was referenced Mar 22, 2026
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