Skip to content

Improve IOCP performance - #420

Draft
TheLingeringWill wants to merge 6 commits into
cppalliance:developfrom
TheLingeringWill:pr/iocp-perf-pass
Draft

TheLingeringWill wants to merge 6 commits into
cppalliance:developfrom
TheLingeringWill:pr/iocp-perf-pass

Conversation

@TheLingeringWill

@TheLingeringWill TheLingeringWill commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

The two allocation sites #383 names are already gone on develop: 8efad53 made the wait reactor reuse its drain buffers and moved random-access file operations onto a free list (testRafChurnIsZeroAlloc covers it). This PR takes on the rest of the pass. Compared with asio's IOCP backend, corosio made several syscalls per connection that asio does not, and the Windows benchmark report shows matching gaps against asio coroutines: about 5% on accept_churn and 3% on socket_latency pingpong. The changes below remove those calls and some smaller per-operation costs.

Changes

  • perf: read accepted IOCP TCP endpoints from the AcceptEx buffer. Each accept called getsockname and getpeername, but AcceptEx had already written both addresses into its buffer. The accept now parses them with GetAcceptExSockaddrs, looked up once next to AcceptEx, which takes no syscall. A provider that does not hand out the function keeps the old path. The local-stream acceptor is unchanged.
  • perf: fetch the local endpoint on demand after an IOCP TCP connect. A successful connect called getsockname for a local endpoint most connections never read. The first local_endpoint() call now fetches it, as the io_uring backend has done since 0b3830d.
    • One reader claims the job of filling the cache. A reader racing it fetches its own copy, so concurrent local_endpoint() calls never read the cache while it is being written. A fetch that fails caches nothing, and the next call asks again.
    • The unfetched mark can outlive the socket it described when a late completion races a close and reopen. A connect over such a socket therefore still tries the bind ConnectEx needs, and lets an "already bound" refusal through to ConnectEx. A second connect on a connected socket fails the same way as before, at ConnectEx.
    • The tcp_socket::local_endpoint() documentation now says the endpoint may be cached by the first call after a connect, rather than at the connect.
  • perf: ask the IOCP wait reactor to cancel only waits it holds. Once the wait reactor's polling thread existed, every close, cancel and open() asked it to drop the socket's wait operation. Each ask cost a lock, a byte down the wakeup channel, a wake, a table search and a poll-set rebuild, nearly always for a socket with nothing parked. Operations now carry a flag that the registering thread sets under the reactor lock and the reactor clears before completing the operation. A cancel returns early when the flag is clear.
    • Two existing fault tests used a cancel with nothing parked as a wake that always reaches the polling thread. testWakeSendFails now parks its wait again after every cancel. testWaitReactorPollFails now wakes the thread with a register.
  • perf: drop the O(n) erases and extra recv from the IOCP wait reactor.
    • Removing an entry from the middle of the table moved every entry after it. Removal now swaps the last entry into the hole. The walks that remove as they go run from the back, so the entry moved has already been examined.
    • Draining the wakeup channel took a second recv on every wake just to get WSAEWOULDBLOCK. Only one byte is ever in flight, so a short read is the end.
  • fix: arm the IOCP waitable timer under the dispatch lock. Found during this pass. update_timeout read the earliest expiry and set the waitable timer with no lock held. Two threads arming at once could set the later deadline last, which made the earlier timer fire late. The read and the arm now happen under the dispatch lock, as asio does.
  • perf: move the stop token into IOCP file operations. The stream and random-access file wrappers copied their by-value stop token on every read and write, so each call added and dropped a reference on the stop state. They now move it, as the stream handle already does.

Not in this PR

These came up in the audit but are larger, or need Windows profiling to justify:

  • Dropping the CancelIoEx that every close makes before closesocket. It would save a syscall per close, and asio's close does without it. But closesocket alone may not cancel pending I/O when a child process has inherited the socket handle, and corosio creates its sockets inheritable. It came out of this PR after review, and needs either non-inheritable sockets or a way to tell that nothing is pending.
  • Skipping SetWaitableTimer when a new earliest timer is due after the deadline already set, which would save a syscall per arm in the per-operation timeout pattern (arm, cancel, arm a later one). It came out of this PR after review. The waitable timer's relative countdown stops while the system sleeps, but steady_clock keeps running. After a sleep, a remembered deadline can look already passed while the timer still has time to run, and a timer skipped behind it fires late. Doing this correctly means keeping the deadline in unbiased interrupt time, and QueryUnbiasedInterruptTime needs Windows 7, while corosio supports Vista.
  • A thread-private post queue in win_scheduler, plus GetQueuedCompletionStatusEx batching. Every post() is a PostQueuedCompletionStatus and every handler a separate dequeue.
  • FILE_SKIP_COMPLETION_PORT_ON_SUCCESS for sockets and files. It needs an IFS-provider check and changes what release() and adoption hand back.
  • IOCTL_AFD_POLL in place of the wait reactor's polling thread.
  • A cap, or a pool shared across implementations, for the random-access file free list.
  • Reusing the resolver's wide-string and result buffers between calls.

Measurements

Not run yet. The Windows benchmark needs a maintainer to add the benchmark label.

Testing

New tests in iocp_faults:

  • testAcceptReadsAcceptExAddresses: an accept makes no getsockname or getpeername call, and both endpoints are correct.
  • testAcceptWithoutSockaddrsParser: with the GetAcceptExSockaddrs lookup failed, the accept asks the kernel (one getsockname, one getpeername) and the endpoints are still correct.
  • testConnectDefersLocalEndpoint: no getsockname at connect, one at the first local_endpoint(), none on a reread.
  • testSecondConnectFailsAtConnectEx: a second connect on a connected socket, whose endpoint was never read, makes one bind and still fails with WSAEISCONN.
  • testDeferredLocalEndpointOutlivesConnection: the deferred endpoint, first read after the peer resets the connection or after both sides close it, equals the address the peer saw. This checks that Windows getsockname still answers once the connection is gone.
  • testIdleReactorNotWokenByClose: once the polling thread runs, closes and a cancel with no wait parked send nothing down its wakeup channel.
  • testReactorRemovalKeepsMovedWait: cancelling a wait from the middle of the reactor's table leaves the wait moved into its slot parked, and still cancellable. Removal during the ready walk is reached only by testWaitReactorEntryRefused, on providers that refuse the whole poll.

testWakeSendFails and testWaitReactorPollFails changed as described above. The comment on testStaleWaitCancelsFromDestroyedObjects was updated for the new cancel rule.

No Windows toolchain was available while writing this, so Windows CI on this PR is the first build of the code.

Resolves #383

@coderabbitai

coderabbitai Bot commented Nov 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@cppalliance-bot

cppalliance-bot commented Nov 14, 2026 •

Copy link
Copy Markdown

An automated preview of the documentation is available at https://420.corosio.prtest3.cppalliance.org/index.html

If more commits are pushed to the pull request, the docs will rebuild at the same URL.

2026-11-14 04:42:32 UTC

@cppalliance-bot

cppalliance-bot commented Nov 14, 2026 •

Copy link
Copy Markdown

GCOVR code coverage report https://420.corosio.prtest3.cppalliance.org/gcovr/index.html
LCOV code coverage report https://420.corosio.prtest3.cppalliance.org/genhtml/index.html
Coverage Diff Report https://420.corosio.prtest3.cppalliance.org/diff-report/index.html

Build time: 2026-11-14 05:01:03 UTC

Each accept asked the kernel for both endpoints of the new connection,
one getsockname and one getpeername, although AcceptEx had already
written both addresses into the buffer it was given. The tcp service now
looks up GetAcceptExSockaddrs next to AcceptEx and the accept parses the
two addresses out of that buffer, which takes no syscall.

A provider that does not hand out GetAcceptExSockaddrs leaves the
accept asking the kernel as before.
A successful connect called getsockname to cache a local endpoint that
most connections never read. It now marks the endpoint pending, and the
first local_endpoint() call fetches and caches it, as the io_uring
backend does since it stopped fetching it eagerly. One reader claims
the fill and a reader racing it fetches its own copy, so concurrent
calls never read the cache while it is being written. A fetch that
fails caches nothing, and the next call asks again.

ConnectEx needs a bound socket, and an endpoint left unfetched says the
socket most likely is one. The mark can outlive the socket it described
when a late completion races a close and reopen, so a connect over it
tries the bind anyway and lets an "already bound" refusal through to
ConnectEx. A second connect on a connected socket still fails as it did
before, at ConnectEx.

The local_endpoint() documentation now says the endpoint may be cached
by the first call after a connect rather than at the connect.
Every socket close and cancel asked the wait reactor to drop the
socket's wait op, parked or not, and every open() closes first. Once the
polling thread exists, each ask took the reactor lock, sent a byte down
the wakeup channel, and made the thread wake, search its table and
rebuild its poll set, for a socket that in nearly every case had nothing
parked.

Ops now carry a flag the reactor sets when a register queues them and
clears before it completes them. A cancel reads it under the reactor
lock and returns when it is clear: the op is not held, so there is
nothing to drop. A cancel that comes before the register is still caught
there by the op's cancelled flag, and one that races the reactor letting
go of the op is dropped by the table search as before.

Two fault tests used a cancel with nothing parked as a wake that always
reaches the polling thread. testWakeSendFails now parks its wait again
after every cancel, and testWaitReactorPollFails wakes the thread with a
register.
Removing a fired, cancelled or refused wait erased it from the middle of
the table, moving every entry after it down, so k completions out of n
parked waits cost O(k*n) moves. The table has no order, so the last
entry now fills the hole; the walks that remove as they go run from the
back, so the entry moved was already examined and the slots below it
keep their poll set entries.

Draining the wakeup channel read until recv failed, which took a second
recv on every wake just to hear WSAEWOULDBLOCK. Only the sender that
turns wake_pending_ from false to true writes a byte, so the channel
holds one at a time and a short read has emptied it.
update_timeout read the earliest expiry and set the waitable timer with
no lock held. Two threads arming at once could read different expiries
and set the timer in the opposite order, leaving the later deadline set,
so the earlier timer fired late, at the later one's deadline. A thread
inserting a new earliest timer races the run thread re-arming after a
fire the same way.

The read and the arm now happen under the dispatch lock, as asio arms
its timer. A new earliest expiry is published before its notification
takes the lock, so the last thread through always arms the earliest
one.
The stream and random-access file wrappers passed their by-value stop
token on by copy, so every read and write took and dropped a reference
on the stop state. They now move it, as the stream handle already does.
@pgit

pgit commented Nov 14, 2026

Copy link
Copy Markdown

There is something weird with the unit tests in the CI:

https://github.com/cppalliance/corosio/actions/runs/43557619651/job/132334044957?pr=420#step:31:1168

          Start  87: boost.corosio.wolfssl_engine
   84/245 Test  #87: boost.corosio.wolfssl_engine ...............................   Passed    0.21 sec
          Start  88: boost.corosio.wolfssl_stream
   85/245 Test  #78: boost.corosio.udp_socket.iocp ..............................   Passed    1.49 sec
          Start  89: fault
   86/245 Test  #89: fault ......................................................   Passed    0.01 sec
          Start  90: harness:
   87/245 Test  #90: harness: ...................................................   Passed    0.01 sec
          Start  91: pthread_create
   88/245 Test  #91: pthread_create .............................................   Passed    0.01 sec

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

perf: optimization pass on the IOCP backend

3 participants