Configuring pyserial for High-Throughput Instrument Polling
High-throughput instrument polling demands deterministic latency, strict buffer management, and explicit error boundaries — properties the default pyserial constructor does not give you, because it is tuned for interactive terminal sessions rather than sustained automated acquisition. This page covers one narrow problem: how to configure a serial.Serial object and drive it so a single control thread can issue commands and drain fixed-size response frames at hundreds of hertz without dropping bytes, blocking indefinitely, or spinning the CPU on a virtual COM port. It is the readiness-driven counterpart to the timeout policy in Timeout Handling & Retry Logic and sits directly under PySerial Configuration & Tuning, which holds the full parameter matrix this page draws on.
The scope is deliberately tight. It applies to byte-oriented links — native RS-232, RS-485, and USB-CDC virtual COM ports exposed through FTDI, CP210x, or CH340 bridges — carrying fixed-length binary or delimited ASCII frames from DAQ cards, source-measure units, motion controllers, and benchtop meters. It assumes a request-response instrument (you write a command, the device answers) rather than a free-running telemetry stream, and it assumes 8N1 framing throughout. Instruments reached over VISA or a VISA Resource Manager session use a different transport layer and are out of scope here, though the readiness discipline transfers directly.
Poll Period Bounded by OS Receive-Buffer Fill Time
The governing constraint of high-rate polling is that the operating system’s receive buffer must be drained before it overflows. On an 8N1 line every byte costs 10 bit-times (1 start + 8 data + 1 stop), so the effective byte rate is the baud rate divided by ten. If the kernel receive buffer holds B_os bytes, the time for a continuously transmitting instrument to fill it from empty is:
The loop that reads the port must therefore drain it with a period T_poll strictly shorter than t_fill, or bytes are lost the moment in_waiting crosses the buffer ceiling and the kernel silently discards the overrun (or raises OSError: [Errno 11] Resource temporarily unavailable). The safe operating condition is:
At 115200 baud with the common 4096-byte kernel buffer, t_fill is roughly 355 ms — comfortable. At 921600 baud it collapses to about 44 ms, and any GC pause or scheduler preemption longer than that drops data. This is why the poll loop must be non-blocking and bounded rather than sleep-driven: a fixed time.sleep() interval that happened to work at one baud rate silently corrupts data at the next.
A second timing quantity governs latency rather than loss. A response frame of F bytes takes t_frame = 10F / baud to arrive on the wire once the instrument begins transmitting. The readiness loop should sample in_waiting at an interval far smaller than t_frame so per-cycle latency is dominated by the instrument’s processing delay, not by the host’s sampling granularity — but not so small that it busy-waits and thrashes a USB bridge. A micro-yield on the order of 0.5 ms is the practical floor on USB-CDC ports, where each in_waiting query crosses the USB stack. The design goal is a loop whose worst-case added latency is bounded and known, so it composes predictably when fed by Async Command Queuing Systems.
A Bounded, Readiness-Driven Poller
Three configuration choices make the constructor fit for automated polling. Set timeout=0 for non-blocking reads so read(n) returns immediately with whatever is buffered rather than parking the control thread. Keep xonxoff=False unconditionally — software flow control injects 0x11/0x13 control bytes into binary payloads and corrupts them silently. Enable rtscts=True only when the instrument firmware genuinely drives the RTS/CTS lines; on a virtual COM port that lacks physical handshake routing it deadlocks. Finally, set exclusive=True on POSIX to take a TIOCEXCL advisory lock so a stray second open() cannot race the poller — the flag is POSIX-only and raises ValueError on Windows, where a COM port already opens exclusively by default.
The implementation below wraps those choices in a context manager, flushes stale buffers on entry, and drives a bounded readiness loop with a hard monotonic deadline. Each command-response cycle either returns a complete frame or fails fast; it never blocks on phantom data.
from __future__ import annotations
import time
from types import TracebackType
from typing import Optional
import serial
class PollTimeoutError(Exception):
"""Raised when a full frame does not arrive within the response deadline."""
class HighThroughputPoller:
"""Bounded, readiness-driven serial poller for high-rate instrument acquisition.
Configures a ``serial.Serial`` object for non-blocking reads and drives a
fixed-size request/response cycle whose worst-case latency is bounded by
``response_timeout``. Intended for 8N1 request-response instruments on
RS-232/RS-485 or USB-CDC virtual COM ports.
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
frame_size: int = 64,
max_retries: int = 3,
response_timeout: float = 0.05,
write_timeout: float = 0.5,
poll_interval: float = 0.0005,
) -> None:
self.port = port
self.baudrate = baudrate
self.frame_size = frame_size
self.max_retries = max_retries
self.response_timeout = response_timeout # hard per-cycle read deadline
self.write_timeout = write_timeout # fail-fast write ceiling
self.poll_interval = poll_interval # micro-yield between in_waiting checks
self._ser: Optional[serial.Serial] = None
def __enter__(self) -> "HighThroughputPoller":
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0, # non-blocking reads
write_timeout=self.write_timeout,
rtscts=False, # enable only if firmware drives RTS/CTS
xonxoff=False, # never inject control chars into binary streams
exclusive=True, # POSIX advisory lock; drop on Windows if needed
)
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
return self
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
if self._ser is not None and self._ser.is_open:
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
self._ser.close()
def poll_frame(self, command: bytes) -> bytes:
"""Issue one command and return exactly ``frame_size`` response bytes.
Retries up to ``max_retries`` times, flushing the input buffer between
attempts so a partial frame from a failed cycle cannot corrupt the next.
Raises ``PollTimeoutError`` if every attempt misses the deadline.
"""
if self._ser is None or not self._ser.is_open:
raise RuntimeError("Serial port not initialized or already closed.")
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
try:
self._ser.write(command)
self._ser.flush() # block until the OS hands the command to the UART
deadline = time.monotonic() + self.response_timeout
buffer = bytearray()
while time.monotonic() < deadline:
waiting = self._ser.in_waiting
if waiting:
buffer.extend(self._ser.read(waiting))
if len(buffer) >= self.frame_size:
return bytes(buffer[: self.frame_size])
time.sleep(self.poll_interval) # yield; avoid CPU thrash on USB-CDC
# Deadline hit without a complete frame — discard the partial.
self._ser.reset_input_buffer()
last_error = PollTimeoutError(
f"Frame incomplete: {len(buffer)}/{self.frame_size} bytes "
f"within {self.response_timeout:.3f}s"
)
except (serial.SerialException, OSError) as exc:
self._ser.reset_input_buffer()
last_error = exc
time.sleep(0.005 * (attempt + 1)) # linear inter-attempt backoff
raise PollTimeoutError(
f"Poll failed after {self.max_retries} attempts: {last_error}"
)
Two properties make this deterministic. in_waiting never blocks, so the loop’s timing is set entirely by the monotonic deadline, not by driver behaviour; and the input buffer is flushed on every failure path, so a partial frame from one cycle can never bleed into the next and desynchronise framing. When the buffer accumulates more than frame_size bytes, only the first frame_size are returned and the remainder is discarded on the next entry — appropriate for strict request-response instruments where a surplus signals an echo or a stale reply.
Validating Poll Timing Against a Live Instrument
Confirm the poller before trusting it in a run. The cheapest check is a loopback: short pins 2 and 3 on a DE-9 (or use a socat PTY pair on Linux) so every written command echoes back, and assert the round-trip returns the bytes you sent within the deadline.
# socat -d -d pty,raw,echo=0 pty,raw,echo=0 → gives two linked /dev/pts/N devices
with HighThroughputPoller("/dev/pts/5", baudrate=115200, frame_size=8) as poller:
echo = poller.poll_frame(b"\x01\x02\x03\x04\x05\x06\x07\x08")
assert echo == b"\x01\x02\x03\x04\x05\x06\x07\x08"
Against real hardware, three indicators tell you the timing budget holds. Time a batch of poll_frame calls and divide: the achieved rate should sit at or just under 1 / (t_instrument + t_frame), where t_instrument is the device’s command-processing delay from its datasheet. A large shortfall means the loop is blocking somewhere it should not. Second, watch ser.in_waiting sampled just before each write — it should read zero on a healthy request-response link; a persistent nonzero value means responses are arriving faster than they are drained and you are approaching the t_fill ceiling. Third, on Linux, run cat /proc/tty/driver/serial or attach strace -e trace=read,ioctl -p <pid> and confirm ioctl(FIONREAD) (the syscall behind in_waiting) never returns a value near your kernel buffer size; if it does, lower the poll period or raise frame_size alignment. Frames that clear this stage flow onward to Binary & ASCII Format Parsing and, for binary payloads carrying a checksum trailer, through Checksum & CRC Validation before they are trusted.
Failure Modes Specific to High-Rate Polling
These are the faults that surface only under sustained polling — distinct from the connect-time and configuration errors covered on the parent PySerial Configuration & Tuning page.
USB-CDC scheduling jitter starves a fast loop. USB bridges coalesce interrupts and buffer against a latency timer — FTDI defaults to 16 ms — so a single frame is often delivered as two host reads, and per-poll latency spikes far above the wire time. Lower the FTDI latency timer to 1–2 ms (echo 1 | sudo tee /sys/bus/usb-serial/devices/ttyUSB0/latency_timer on Linux) and disable USB selective suspend for the hub (echo on > /sys/bus/usb/devices/.../power/control). Confirm the fix by logging the count of in_waiting iterations per frame: it should drop toward one after the timer is lowered.
OS receive buffer overflow at high baud. When T_poll exceeds t_fill — a GC pause, a logging call, or a scheduler preemption inside the loop — the kernel buffer overruns and bytes vanish, showing up downstream as a frame that fails its checksum rather than as an exception here. Diagnose by sampling in_waiting and alarming when it climbs past half the kernel buffer size; the sustainable fix is to shorten the loop body, raise thread priority, or move the drain to a dedicated thread. Do not paper over it by enlarging frame_size alone.
Control-character injection from stray XON/XOFF. If xonxoff is enabled anywhere in the driver stack, a 0x13 (XOFF) byte inside a binary payload silently pauses transmission and a 0x11 (XON) resumes it, corrupting the stream without any error. Log a hex dump of the first 16 bytes of each frame; recurring 0x11/0x13 at frame boundaries is the signature. Assert xonxoff=False on the open port and verify the underlying tty flags with stty -a -F /dev/ttyUSB0 (the ixon/ixoff flags must be off).
Driver-level stall masked as instrument silence. Python’s non-blocking read cannot distinguish a device that has gone quiet from a driver that has wedged. Wrap the port in a select.poll() loop (POSIX) to separate genuine instrument silence from OS scheduling delay, and treat a repeated deadline miss as a transport fault to be classified, not retried blindly. Route those faults through Error Code Categorization so a wedged bridge is escalated while a one-off timeout is retried under the exponential backoff policy for serial timeouts rather than hammered.
Related
- PySerial Configuration & Tuning — the full parameter and latency-calibration matrix this page draws its constructor settings from.
- Timeout Handling & Retry Logic — how repeated deadline misses become bounded retries instead of dropped data.
- Async Command Queuing Systems — feeding the bounded poller from an event loop so it composes with concurrent device I/O.
- Error Code Categorization — separating a wedged USB bridge from a transient timeout in the recovery path.
- Checksum & CRC Validation — verifying the integrity of the frames this loop drains before they reach storage.
← Back to PySerial Configuration & Tuning
References
- pyserial API documentation —
in_waiting,reset_input_buffer,write_timeout, and the POSIX-onlyexclusiveflag. - Linux serial driver
latency_timerinterface — lowering FTDI/CDC coalescing latency for tight polling loops.