Skip to content

Skip redundant atomics on response completion - #2372

Merged
hyperxpro merged 2 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/future-completion-atomics-331c90
Nov 6, 2026
Merged

hyperxpro merged 2 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/future-completion-atomics-331c90

Conversation

@pavel-ptashyts

@pavel-ptashyts pavel-ptashyts commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Every completed HTTP/1.1 exchange paid for atomic operations that had nothing left to do, and every channelRead (every body chunk) paid for a fenced store.

Changes

  • NettyResponseFuture.cancelTimeouts(): finishUpdate cancels the timeouts and done() cancels them again, so the second getAndSet always swapped null for null. Read the field first, as takePartitionKeyLock already does; the getAndSet still decides who cancels a holder that is there.
  • NettyRequest.release(): done() offers the request back for release, but on the HTTP/1.1 path it was handed to the encoder, so the CAS always failed. No state returns to OWNED_BY_AHC, so a plain volatile read can rule the CAS out; the CAS still arbitrates the abort-vs-write race it was added for.
  • NettyResponseFuture.touch(): skip the store when the millisecond has not moved. The field stays volatile, and a skipped store would have written the value already there.

No public API change.

Benchmark

New ResponseFutureCompletionBenchmark (JMH 1.37, JDK 17.0.2, Windows, average time, 3-5 forks, base and patched runs interleaved over two rounds):

Benchmark Before After
cancelClearedTimeouts (the second cancellation) 4.5-5.0 ns 0.45 ns
releaseHandedRequest 5.1 ns 0.45 ns
touch (per channelRead) 7.2 ns 3.9 ns
completeHandedRequest (cancel + done() on a fresh future) ~225 ns ~212 ns

A release store for currentRetry in loadContent() would save another 4.6 ns there; per review it stays a volatile set. Allocation is unchanged at 216 B/op. What remains of touch is System.currentTimeMillis() itself. perfasm was not available on this Windows host.

Testing

  • NettyRequestTest: release() after markHandedToChannel() leaves the buffer to the encoder; a released request cannot be handed to the channel.
  • NettyRequestTest: release() and markHandedToChannel() raced on two threads in lockstep for 20,000 rounds leave each body with exactly one owner. Replacing the CAS in release() with a check and a store fails it in the first rounds.
  • NettyResponseFutureTest: completion cancels the timeouts holder exactly once.
  • mvnw.cmd clean verify ran locally on JDK 17.0.2 (no JDK 11 on the host): 1861 tests, 0 failures, Revapi clean. CI covers JDK 11.

Claude Code on behalf of Pavel Ptashyts

🤖 Generated with Claude Code

Every completed HTTP/1.1 exchange paid for atomic operations that had
nothing left to do, and every channel read paid for a fenced store:

- finishUpdate cancels the timeouts and done() cancels them again, so
  the second getAndSet always swapped null for null. Read the field
  first, as takePartitionKeyLock already does; the getAndSet still
  decides who cancels a holder that is there.
- done() offers the request back for release, but on the HTTP/1.1
  path it was handed to the encoder, so the CAS always failed. No
  state returns to OWNED_BY_AHC, so a plain volatile read can rule
  the CAS out; the CAS still arbitrates the race it was added for.
- touch() runs on every channelRead and stored the clock even when
  the millisecond had not moved. Skip the store when the value is
  already there; the field stays volatile and a skipped store would
  have written the same value.
- loadContent publishes "no more retries" with a release store. The
  only other access is an atomic increment, which reads the latest
  value regardless of the store's ordering, and the completion that
  follows happens-after it.

ResponseFutureCompletionBenchmark, JMH 1.37, JDK 17.0.2 on Windows,
3-5 forks: the second timeout cancellation 4.5-5.0 -> 0.45 ns, the
release of a handed request 5.1 -> 0.45 ns, touch 7.2 -> 3.9 ns,
cancel + done on a fresh future 225 -> 206 ns. Allocation unchanged.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review feedback on AsyncHttpClient#2372. The release store for currentRetry saved
about 5 ns per response and would have been the only relaxed store in
NettyResponseFuture, so it goes back to a plain volatile set.

The read before the CAS in NettyRequest.release() leaves the CAS as
the only thing that keeps release() and markHandedToChannel() from
both claiming the body, yet no test raced the two: replacing the CAS
with a check and a store kept NettyRequestTest green. The new test
starts both calls on two threads in lockstep for 20,000 rounds and
checks that each body ends with exactly one owner. With the CAS
swapped for a check and a store it fails in the first rounds.

Local clean verify ran on JDK 17.0.2; CI covers JDK 11.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hyperxpro
hyperxpro merged commit fe1979c into AsyncHttpClient:main Nov 6, 2026
13 checks passed
@pavel-ptashyts
pavel-ptashyts deleted the claude/future-completion-atomics-331c90 branch November 6, 2026 19:40
pavel-ptashyts added a commit to maygemdev/async-http-client that referenced this pull request Nov 6, 2026
Keeps the branch current with main at f9eafb9 (includes AsyncHttpClient#2372 and
AsyncHttpClient#2380). Merged without conflicts.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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.

2 participants