Skip to content

feat(transport-tcp): replace NIO selector with per-connection virtual-thread blocking I/O#2612

Open
LivingLikeKrillin wants to merge 3 commits into
apache:developfrom
LivingLikeKrillin:feature/tcp-vthread-blocking-io
Open

feat(transport-tcp): replace NIO selector with per-connection virtual-thread blocking I/O#2612
LivingLikeKrillin wants to merge 3 commits into
apache:developfrom
LivingLikeKrillin:feature/tcp-vthread-blocking-io

Conversation

@LivingLikeKrillin

Copy link
Copy Markdown

Summary

Replaces the per-connection NIO Selector in TcpTransportInstance with one virtual thread per
connection doing blocking SocketChannel.read() / write(). Stays fully Netty-free. Follows the
SPI3 transport layer (commit 372501287d); @chrisdutz greenlit a redesign of this transport and
offered bench testing on real devices — this is that redesign, TCP-only as a first step.

Scope is confined to plc4j/transports/tcp (TcpTransportInstance). No public API / SPI / driver
changes.

Motivation (verified against current code)

  1. A vthread blocked in Selector.select() does not release its carrier on Java 21. Two reasons:
    (a) pre-JEP-491 the selection path synchronizes on the selector monitor (a monitor pin, fixed in
    JDK 24); and (b) more fundamentally, select()'s native poll is not a carrier-unmounting /
    poller-managed operation, so the carrier stays blocked even after JEP 491. The scheduler
    compensates up to maxPoolSize (default 256) → we pay vthread overhead but get platform-thread
    behavior plus a hidden ~256 ceiling, and this does not improve on newer JDKs.
  2. Write backpressure is a busy-wait: on a full send buffer, write() registers OP_WRITE,
    wakeup()s, then Thread.sleep(1) in a loop while holding writeLock (never consumes the event).
  3. Selector + per-connection bookkeeping (interestOps, reEnableReadIfNeeded, OP_READ toggling).

On Java 21 a vthread blocked in a blocking-mode SocketChannel.read()/write() parks and
releases its carrier (JDK parks it on the shared NIO poller) — no pin. So one-vthread-per-connection
blocking reads is both simpler and avoids the ceiling.

What changed

  • select() loop → runReadLoop() doing blocking read() into the existing RingBuffer.
  • Removed Selector, SelectionKey, interestOps, reEnableReadIfNeeded, and the OP_WRITE +
    Thread.sleep(1) write path. Blocking write() now provides natural backpressure.
  • Full ring buffer → backpressure (parkNanos park-and-retry, bounded to free space), never a
    disconnect (only the codec knows frame boundaries; COTP can legitimately drain cross-thread).
  • close() is lock-free CAS (AtomicBoolean): closing the channel is what unblocks a parked
    read/write; AsynchronousCloseException with open==false is treated as a normal shutdown.
  • Listener invocation guarded (safeRun) so a misbehaving listener can't silently kill the read loop.

Zero downstream impact (audited)

Public surface, readLock, RingBuffer, and the AsyncTransportInstance callback contract are
unchanged. readLock is deliberately kept because CotpTransportInstance calls the read-side
methods cross-thread during the S7/COTP handshake — it is a load-bearing guard, not removable.

Consumer Affected?
MessageCodecBase + driver codecs (read-thread) No — contract/semantics preserved
ConnectionBase.startReceiving (registerDataListener) No — read vthread invokes the listener exactly as the selector loop did
CotpTransportInstance (cross-thread read-side, concurrent) No — readLock + read-side thread-safety preserved
OpcuaConnection (instanceof + getRemoteAddress) No — concrete class + surface preserved

Evidence

  • TcpTransportInstanceTest (31 tests) passes unmodified on the new implementation → behavior-equivalent.

  • Scaling — carrier (OS) threads for 200 idle connections (measured; @Disabled probe, run manually):

    model JDK 21 JDK 25
    selector (before) 201 201
    blocking (after) 2–3 2–3

    The selector inflates to ~1 carrier per connection on both JDKs, so JEP 491 (JDK 24, removes
    synchronized pinning) does not help here — the cost is select() being a non-unmounting blocking
    call, not monitor pinning. The blocking model stays flat (bounded by CPU count, not connection
    count), so the win does not erode as Java advances. (All four cells reproduced with the same probe:
    selector CARRIER_COUNT=201 and blocking CARRIER_COUNT=2 for 200 connections, on JDK 21 and
    JDK 25; no pinned-thread traces for the blocking model under -Djdk.tracePinnedThreads=full.)

  • End-to-end regression: ModbusDockerIT (pymodbus container) — all cases green. The
    modbus-tcp://, modbus-rtu:tcp://, and modbus-ascii:tcp:// cases (~38) exercise the new
    TcpTransportInstance over a real socket; the UDP and TLS cases use the separate UDP / TLS
    transports and are unaffected by this change.

Scope / non-goals

  • TCP only. UDP (shares the selector pattern) is the natural follow-up; serial/TLS already use
    different reader-thread models.
  • No SPI contract reshape. The AsyncTransportInstance callback is intentionally kept as a thin shim
    on the blocking core; migrating to a blocking-pull contract is intentionally out of scope.

Testing notes

  • TcpTransportInstanceScalingTest is an @Disabled evidence probe (opens 200 sockets + sleeps; not a
    CI regression test) — run manually, ideally with -Djdk.tracePinnedThreads=full.
  • mvn -pl :plc4j-transports-tcp -am verify is green (tests + apache-rat + jacoco).
  • Bench validation on real devices welcome, as offered.

…-thread blocking I/O

Each connection runs a blocking SocketChannel.read() loop on its own virtual thread
instead of a per-connection NIO Selector. On Java 21 blocking-mode reads/writes park the
virtual thread and release the carrier, so the selector (which pins the carrier in
select()) and the OP_WRITE + Thread.sleep(1) write busy-wait are removed. A full ring
buffer applies backpressure (park-and-retry) instead of toggling OP_READ.

Public surface, readLock, RingBuffer, and the AsyncTransportInstance callback contract are
unchanged: the existing TcpTransportInstanceTest (31 tests) passes unmodified.

Scaling probe (TcpTransportInstanceScalingTest): 200 idle connections use 2 carrier
threads with the blocking model vs 201 with the selector model.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR redesigns the plc4j TCP transport implementation to replace the per-connection NIO Selector loop with a per-connection virtual-thread read loop using blocking SocketChannel.read() / write(), simplifying the transport while preserving the existing AsyncTransportInstance callback contract.

Changes:

  • Replaced selector-driven async I/O with a blocking read loop on a per-connection virtual thread, writing into the existing RingBuffer and invoking the data listener.
  • Simplified write-side behavior by relying on blocking SocketChannel.write() for natural backpressure (removing OP_WRITE + sleep loop).
  • Added an @Disabled scaling/probe test to help validate carrier-thread usage characteristics under high idle-connection counts.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstance.java Replaces selector loop with blocking vthread read loop; updates close/write behavior and listener guarding.
plc4j/transports/tcp/src/test/java/org/apache/plc4x/java/transport/tcp/TcpTransportInstanceScalingTest.java Adds a disabled probe test for observing carrier-thread scaling with many idle connections.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +281 to +283
}
} finally {
writeLock.unlock();
LOGGER.debug("TCP connection closed");
getAuditLog().write(AuditLogEventType.CLOSE, "Closed");

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 469e1f5. Moved the "TCP connection closed" debug line and the CLOSE audit event into the success path so they only fire when socketChannel.close() succeeds; on failure the catch reports the ERROR event and rethrows. readThread.join() stays in finally so the read loop is always awaited.

Comment on lines +118 to +120
// tiny holder so the daemon accept loop can observe a stop flag without a field on the test
private static final class Flag { volatile boolean running; }
private final Flag volatileFlag = new Flag();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 469e1f5. Reworded the comment to describe the holder accurately (it carries a volatile stop flag the daemon accept loop polls); the previous "without a field" wording was inaccurate since the holder is itself a field.

Move the "TCP connection closed" debug line and CLOSE audit event out of
the finally block and into the success path of close(). Previously they
ran even when socketChannel.close() threw and the method rethrew, so a
failed close logged both an ERROR audit event and a misleading CLOSE
"Closed" event. The readThread.join() stays in finally so the read loop
is always awaited. Also correct an inaccurate comment in the scaling
test (the stop-flag holder is a field, not a way to avoid one).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines 231 to 236
while (writeBuffer.hasRemaining()) {
int written = socketChannel.write(writeBuffer);
if (written == -1) {
open = false;
open.set(false);
throw new TransportException("Connection closed while writing");
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a1e480. Removed the dead written == -1 branch — the blocking write loop now just calls socketChannel.write(writeBuffer) until the buffer is drained. A broken or closed connection still surfaces as IOException/AsynchronousCloseException, both already handled below.

Comment on lines +91 to +95
long carriers = Thread.getAllStackTraces().keySet().stream()
.filter(t -> !t.isVirtual())
.filter(t -> t.getName().contains("ForkJoinPool"))
.count();
long total = Thread.getAllStackTraces().size();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a1e480. Now takes a single Thread.getAllStackTraces() snapshot and derives both carriers and total from it, so the two counts come from the same instant. (Also excluded ForkJoinPool.commonPool workers from the carrier count so unrelated parallel-stream workers cannot inflate it.)

- write(): blocking SocketChannel.write() never returns -1 (that signals
  read EOF), so the `written == -1` branch was dead code. A broken or closed
  connection already surfaces as IOException/AsynchronousCloseException, both
  handled below. Remove the check.
- constructor: errorMsg already embeds e.getMessage(), so the second ERROR
  audit event duplicated the first. Emit a single event.
- constructor: start the read-loop virtual thread last (after the INFO log
  and CONNECT audit), so an unchecked throw from logging/audit cannot leak an
  already-running read thread and the open SocketChannel — the catch only
  handles IOException and does not stop the read loop.
- close(): skip readThread.join() when close() runs on the read thread itself
  (a disconnect/data listener calling close()), since joining yourself only
  stalls for the timeout and the loop already exits once open is false.
- scaling test: take one Thread.getAllStackTraces() snapshot so carriers and
  total are counted from the same instant instead of two separate calls.
- scaling test: exclude ForkJoinPool.commonPool workers from the carrier
  count so unrelated parallel-stream workers cannot inflate it.
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