Skip to content

Add the net9 Interlocked byte and short overloads - #607

Merged
SimonCropp merged 1 commit into
mainfrom
interlocked-narrow
Sep 10, 2026
Merged

Add the net9 Interlocked byte and short overloads#607
SimonCropp merged 1 commit into
mainfrom
interlocked-narrow

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Eight members, all net9: Exchange and CompareExchange over byte, sbyte, short and ushort. The shortlist said "Interlocked.Exchange", singular; the release added the narrow-integer family as a set, and CompareExchange is the more useful half.

The generic And<T>/Or<T> (net11) were already polyfilled, so this closes the type apart from the nuint overloads.

The implementation choice, settled by measurement

There is no 8- or 16-bit interlocked primitive below net9, so something has to give. The textbook trick is to widen: find the containing aligned 32-bit word and run a CompareExchange loop on it. I did not use it, for two reasons — and the first one only became clear by testing the BCL rather than reasoning about it.

Widening writes the neighbouring bytes back. A CAS on the containing word rewrites all four bytes, so a concurrent plain write to an adjacent byte can be lost. That would only be acceptable if the BCL behaved the same way. It does not: on net11, 33 million iterations of a plain write to buffer[1] racing Interlocked.Exchange(ref buffer[0], …) produced zero lost writes, so the runtime is using a genuine byte-width atomic instruction. Widening would therefore have been a silent correctness downgrade, not merely a slowdown — exactly the failure mode that shows up under load and nowhere else.

It also needs the address. Locating the aligned word requires pointer arithmetic, which can read and write outside the allocation for a location at the end of one, and would restrict the whole thing to consumers who set AllowUnsafeBlocks — leaving the API present for some consumers and absent for others.

So: a lock

It keeps each operation indivisible with respect to every other one, and — the point that makes this defensible — these members do not exist at all below net9, so every byte or short interlocked operation on those targets necessarily goes through this file. There is nothing outside it to interoperate with.

It also writes only the target byte, so neighbours are untouched, matching the BCL. That is not asserted by inspection: DoesNotDisturbTheAdjacentByte reproduces the adjacency experiment as a test and passes on the polyfill as well as on the BCL.

The one real divergence is that a concurrent plain write to the same location can be lost, where the BCL's operation is indivisible. A plain write racing an interlocked operation is already a data race, but it is a genuine difference and is //Note:d on all eight members, along with the lock.

Tests

Four tests on every target framework, so net9.0 and later check the BCL and everything below checks the polyfill:

  • return values and conditional replacement for all four types, including that CompareExchange returns the original whether or not the comparand matched, and the sign-boundary cases (sbyte.MinValue, ushort.MaxValue)
  • the adjacency property above
  • four threads running a CompareExchange increment loop over a ushort, asserting that no update is lost — the contract these members actually have to keep

Verification

Solution clean in Release, Consume clean across all 22 TFMs, tests green on net11.0 (1749), net10.0 (1749), net9.0 (1749), net8.0 (1746), net462 (1695), plus PublicTests, EmbeddedTests, UnsafeTests, NoRefsTests and NoExtrasTests.

API count 1162 → 1170.

Eight members: Exchange and CompareExchange over byte, sbyte, short and ushort.

The implementation choice was the whole question, and it was settled by measurement
rather than reasoning. Widening to a CompareExchange on the containing 32 bit word is
the usual trick, but it writes the neighbouring bytes back, so a concurrent plain write
to an adjacent byte can be lost. The BCL demonstrably does not do that: 33 million
iterations of a plain write to buffer[1] racing Interlocked.Exchange on buffer[0]
produced zero lost writes on net11, so it uses a real byte width atomic. Widening would
have been a silent downgrade, not a slowdown. It also needs the address to find the
aligned word, so it can touch memory outside the allocation.

A lock is used instead. It keeps each operation indivisible against every other one,
and since these members do not exist at all below net9 there is nothing outside this
file to interoperate with. Writing only the target byte leaves neighbours alone, which
is asserted by a test that reproduces the adjacency experiment and passes on the
polyfill as well as the BCL.

The one real divergence, that a concurrent plain write to the same location can be lost
where the BCL operation is indivisible, is noted on all eight.

API count 1162 -> 1170.
@SimonCropp SimonCropp added this to the 11.3.0 milestone Sep 10, 2026
@SimonCropp
SimonCropp merged commit b759ce1 into main Sep 10, 2026
4 of 6 checks passed
@SimonCropp
SimonCropp deleted the interlocked-narrow branch September 10, 2026 10:41
This was referenced Sep 10, 2026
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