asyncio vs Threading for Serial I/O in Lab Control
Choosing between a thread-per-port model and a single asyncio event loop for blocking pyserial transactions is the host-concurrency decision that governs whether a rig of RS-232 and USB-CDC instruments stays responsive as port count climbs. This guide resolves that tie for serial I/O specifically, under the Interface Selection and Concurrency decision layer, with two implementations behind an identical query() API so you can measure rather than guess.
Why the GIL Does Not Block Serial Overlap
The reflex objection to threads in Python — “the GIL serializes everything” — is wrong for I/O-bound serial work, and understanding why decides the whole design. serial.Serial.read() and .write() are thin wrappers over the platform’s read(2)/write(2) syscalls in pyserial’s C-backed path. CPython releases the GIL around those blocking syscalls, so while one thread sits parked in a kernel read waiting for bytes from a UART, every other thread runs freely. Sixteen threads each blocked on their own port genuinely overlap their waits; the GIL is only contended during the microseconds of Python bytecode that frame each transaction, not during the milliseconds of driver-latency-timer and wire time that dominate a serial round trip. This is the same GIL-release behavior that lets the parent guide recommend a blocking thread pool for VISA transports, which also block in C.
The failure the GIL does cause is CPU-bound work on the I/O threads: parsing a large binary record, computing a CRC, or running numpy on the reply holds the GIL and stalls every other port’s Python frame. The rule that falls out is strict — worker threads must be I/O-only, handing raw bytes off to a separate parse stage. Keep that boundary and threads scale cleanly to the tens of ports a typical bench rig presents.
The Coroutine Trap and What run_in_executor Actually Buys
An asyncio event loop is a single thread that never blocks: every await yields control so the loop can service another ready coroutine. Drop a synchronous serial.Serial.readline() inside a coroutine and that contract shatters — the C read parks the only thread the loop owns, and every other instrument’s coroutine misses its deadline until the read returns. There is no partial degradation; one blocking read at 200 ms freezes all sixteen ports for 200 ms. Running with PYTHONASYNCIODEBUG=1 surfaces this as a “coroutine took too long” warning, the unmistakable fingerprint of a blocking call smuggled into the loop.
Two escapes exist. The first is a genuinely non-blocking driver — pyserial-asyncio, which registers the file descriptor with the loop’s selector and delivers bytes through SerialTransport/Protocol callbacks; no read ever blocks because the loop only reads when the selector reports the fd readable. This is the model that earns asyncio’s scaling advantage. The second is loop.run_in_executor, which pushes the blocking readline() onto a thread pool and awaits its result. The loop stays free, but the transaction still costs a thread for its full duration. run_in_executor is a bridge, not the payoff: if you give each port its own single-thread executor to preserve ordering, you are running one thread per port and have re-created the threaded model with an event loop bolted on top. The scaling win only materializes with a native async driver; the executor bridge buys correctness, not thread economy.
Overhead Scaling: Stacks and Switches vs Ready-Callbacks
The two models diverge in cost the way their runtimes differ. N OS threads each reserve a stack (commonly 8 MB of virtual address space, with resident memory growing on touch) and impose a scheduler that must consider all runnable threads, so switch overhead grows with the count of active threads:
A native-async event loop runs one thread with one stack and holds each port as a lightweight coroutine frame; per iteration it dispatches only the callbacks the selector reports ready, not one per registered port:
where s_task is a coroutine frame (kilobytes, not megabytes) and R is the number of ports readable on a given tick. At N = 4 the difference is noise; at N = 200 the threaded model reserves gigabytes of stack space and pays a kernel context switch per ready port, while the loop pays a cheap Python callback only for ports that actually have data. This is why the decision is a function of port count: below roughly a dozen serial ports threads are simpler and just as fast, and only past that does a native async driver’s O(R)-per-tick economy justify its added complexity. Note that the run_in_executor bridge sits in the threaded column of this table — it inherits M_thread(N) because every in-flight transaction still holds a thread.
Both models overlap serial waits; threads reserve a stack per port while the native event-loop path holds coroutine frames and wakes only ready ports — the run_in_executor bridge (amber) reintroduces the per-transaction thread cost.
Two Implementations Behind One query() Contract
Both classes below expose the same intent — submit a payload, get the reply bytes, one writer per port — so a driver layer can swap between them without changing calling code. ThreadedSerialPort runs a single owner thread fed by a queue.Queue; AsyncSerialPort exposes an awaitable query() that pushes the blocking transaction to a dedicated executor and serializes writers with an asyncio.Lock. The single-writer guarantee is not optional: a serial port has no framing above the byte level, so two concurrent writers interleave bytes mid-command, a constraint enforced at the async command queuing layer too.
from __future__ import annotations
import asyncio
import queue
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Optional
import serial # pyserial >= 3.5
class ThreadedSerialPort:
"""One OS thread owns one UART; ``query()`` is a blocking synchronous call.
A single worker thread is the sole reader and writer of the port, so
concurrent callers can never interleave bytes on the wire. Each request
carries a ``concurrent.futures.Future`` the worker resolves with the reply.
"""
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0) -> None:
self._ser = serial.Serial(port, baudrate, timeout=timeout)
self._requests: "queue.Queue[tuple[bytes, Future[bytes]]]" = queue.Queue()
self._stop = threading.Event()
self._worker = threading.Thread(
target=self._run, name=f"serial-{port}", daemon=True
)
def start(self) -> None:
self._worker.start()
def query(self, payload: bytes, timeout: Optional[float] = None) -> bytes:
"""Block the calling thread until the reply for ``payload`` arrives."""
fut: "Future[bytes]" = Future()
self._requests.put((payload, fut))
return fut.result(timeout=timeout)
def _run(self) -> None:
while not self._stop.is_set():
try:
payload, fut = self._requests.get(timeout=0.1)
except queue.Empty:
continue # poll the stop flag on an idle port
if not fut.set_running_or_notify_cancel():
continue
try:
self._ser.reset_input_buffer()
self._ser.write(payload) # blocking C write; GIL released
reply = self._ser.readline() # blocking C read; GIL released
if not reply:
raise TimeoutError(f"no reply to {payload!r}")
fut.set_result(reply)
except Exception as exc: # transport fault → surface to caller
fut.set_exception(exc)
def close(self) -> None:
self._stop.set()
self._worker.join(timeout=2.0)
self._ser.close()
class AsyncSerialPort:
"""One coroutine-facing owner of a UART with the same ``query`` contract.
The blocking pyserial transaction is pushed to a dedicated single-thread
executor so it never stalls the event loop. An ``asyncio.Lock`` keeps one
writer at a time; for true single-thread scaling replace this with a
native ``pyserial-asyncio`` transport.
"""
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0) -> None:
self._ser = serial.Serial(port, baudrate, timeout=timeout)
self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix=f"io-{port}"
)
self._lock = asyncio.Lock()
async def query(self, payload: bytes, timeout: Optional[float] = None) -> bytes:
"""Await the reply for ``payload`` without blocking the event loop."""
loop = asyncio.get_running_loop()
async with self._lock: # serialize: the port has no framing
return await asyncio.wait_for(
loop.run_in_executor(self._executor, self._blocking_txn, payload),
timeout=timeout,
)
def _blocking_txn(self, payload: bytes) -> bytes:
self._ser.reset_input_buffer()
self._ser.write(payload)
reply = self._ser.readline()
if not reply:
raise TimeoutError(f"no reply to {payload!r}")
return reply
def close(self) -> None:
self._executor.shutdown(wait=True)
self._ser.close()
The dedicated single-thread executor per port is deliberate: routing every port through the loop’s default executor caps concurrency at that pool’s size (min(32, os.cpu_count()+4) on CPython), so on a 16-port rig the seventeenth in-flight read queues behind a saturated pool while the loop looks idle. A per-port executor sidesteps that but, as the scaling section warned, spends one thread per port — the honest signal that on a blocking driver the two models converge. Baud, latency-timer, and read-terminator settings that make readline() return promptly belong to PySerial Configuration & Tuning, and the wait-before-retry curve after a TimeoutError belongs to Timeout Handling & Retry Logic.
Measuring Latency Jitter at 1 vs 16 Ports
The models are indistinguishable at one port; the decision is visible only under load. Drive each implementation against a bank of loopback or emulated ports (socat -d -d pty,raw pty,raw pairs, or a firmware echo stub) and record per-transaction latency, then compare the tail. Jitter — the spread between the median and the 99th percentile — is the metric that exposes a stalled loop or scheduler contention long before mean latency moves.
import statistics
import time
from typing import Callable
def latency_profile(query: Callable[[bytes], bytes], n: int = 500) -> dict[str, float]:
"""Return p50/p99 latency and jitter (ms) for a query callable."""
samples: list[float] = []
for i in range(n):
t0 = time.perf_counter()
query(f"MEAS{i}?\n".encode())
samples.append((time.perf_counter() - t0) * 1e3)
samples.sort()
p50 = statistics.median(samples)
p99 = samples[int(0.99 * (n - 1))]
return {"p50_ms": p50, "p99_ms": p99, "jitter_ms": p99 - p50}
def test_jitter_stays_bounded() -> None:
ports = [ThreadedSerialPort(f"/dev/pts/{i}") for i in range(16)]
for p in ports:
p.start()
prof = latency_profile(ports[0].query)
# A healthy thread-per-port rig holds jitter near the single-port baseline;
# a stalled event loop shows p99 spiking toward N × single-port latency.
assert prof["jitter_ms"] < 5 * prof["p50_ms"]
for p in ports:
p.close()
Read the numbers this way: with threads, per-port jitter stays close to its single-port baseline as you scale, because each read blocks independently. With an event loop that has accidentally swallowed a blocking call, single-port jitter looks fine but 16-port p99 balloons toward N times the single-port latency — every port waits behind the one currently blocking the loop. That signature is the empirical proof of the coroutine trap, and it is the same test you run to confirm a pyserial-asyncio migration actually restored non-blocking behavior.
Failure Modes Specific to Serial Concurrency
- Blocking read stalls the event loop. A raw
serial.Serial.readline()(or apyvisaquery()) left inside a coroutine parks the loop’s only thread; every port’s p99 latency rises together andPYTHONASYNCIODEBUG=1logs “coroutine took too long”. Route the call throughrun_in_executoror migrate to a native async transport — neverawaitaround a synchronous read. - Executor pool exhaustion. Sending all ports through the default
ThreadPoolExecutorcaps in-flight reads at the pool size; the(pool_size + 1)-th transaction blocks until a slot frees, presenting as latency that steps up sharply past a fixed device count. Size a dedicated executor to the port count, or move to one executor per port, and never rely on the shared default for device I/O. - Thread explosion at high port counts. A thread-per-port rig fanning out to 200+ UARTs reserves gigabytes of stack address space and pays a kernel context switch per ready port, showing up as rising RSS and scheduler time under
perf. Past a few dozen ports, consolidate onto a native-async event loop whose per-tick cost scales only with ready ports, not registered ones. - Shared-port write races. Two threads (or two coroutines without the lock) writing one UART interleave bytes mid-command; the instrument replies to a corrupted frame or with a parse error on garbage. Enforce exactly one writer per port — the queue-fed worker or the
asyncio.Lockabove — and prove it with a concurrent-write stress test that must never interleave.
Related
- Interface Selection and Concurrency — the decision layer that sends latency-bound serial rigs here after computing
N_max. - GPIB vs USB-TMC for High-Throughput Polling — the sibling decision for the high-throughput VISA transports, where the trade is bus bandwidth, not thread economy.
- Async Command Queuing Systems — the single-writer dispatch layer both models feed, including building async command queues with asyncio.
- PySerial Configuration & Tuning — latency-timer and terminator settings that make blocking reads return promptly under either model.
- Timeout Handling & Retry Logic — the bounded-delay recovery curve applied when a transaction times out.
← Back to Interface Selection and Concurrency
References
- Python
asyncio— running blocking code in executors —loop.run_in_executorsemantics and default-executor sizing. pyserial-asynciodocumentation — the native non-blocking serial transport and protocol callbacks.- CPython global interpreter lock notes — why the GIL is released around blocking I/O.