Building Async Command Queues with asyncio for Lab Devices

An asyncio command queue serializes traffic to a lab instrument that shares a single physical bus, decoupling non-blocking command submission from strictly ordered I/O execution. This guide builds a single-worker queue that drains commands in FIFO order, applies a per-command timeout budget, bounds retries, and guarantees that one malformed response or timeout never corrupts the command that follows it. It is the concrete implementation behind the paradigms surveyed on the parent Async Command Queuing Systems page.

Where a Single-Worker asyncio Queue Applies

The scope here is narrow: one instrument reachable over one command-response transport that cannot multiplex — a pyserial port, a PyVISA session, or a GPIB address — where every command must be written, then its reply read back, before the next command may go out. This is the near-universal shape of SCPI-style hardware: source-measure units, oscilloscopes, temperature controllers, and syringe pumps all speak a request/reply protocol with no native pipelining. As established across the Serial, USB, and GPIB Communication Workflows domain, three constraints dictate the architecture: hardware receive buffers are finite and non-preemptive, the command-response handshake is strictly sequential, and firmware may silently drop or reorder frames under bus contention.

Two assumptions hold throughout. First, exactly one coroutine owns the transport — concurrent writers are the single most common source of interleaved, unparseable replies, so the queue makes the worker a singleton by construction. Second, callers submit from within a running event loop and await a Future for the result, rather than blocking on the port directly. If your driver is a synchronous pyserial object, its blocking read/write calls must be pushed to a thread with asyncio.to_thread so they never stall the event loop; native-async transports attach directly. Baud, parity, and latency-timer configuration that govern how bytes actually arrive belong to PySerial Configuration & Tuning and must be settled before the queue is started.

CommandEnvelope lifecycle state machine An envelope submitted onto the asyncio.Queue starts PENDING. The worker moves it to EXECUTING, which writes the payload then awaits the read under wait_for. A read that returns within budget transitions to COMPLETED and resolves the caller's Future with the reply bytes. A wait_for expiry transitions to TIMEOUT; while attempts remain at or below max_retries the command loops back to EXECUTING for another try, and once the budget is exhausted it terminates as FAILED with a TimeoutError. Any other transport-level exception during EXECUTING transitions straight to FAILED and resolves the Future with that exception. COMPLETED and FAILED are the two terminal states that resolve the Future. submit() PENDING in asyncio.Queue EXECUTING write → wait_for(read) COMPLETED set_result(bytes) TIMEOUT attempts ≤ max_retries? FAILED set_exception(err) record_attempt() reply read wait_for expires can_retry → retry budget spent → TimeoutError transport fault transition / retry timeout / fault

Serialized Execution Model and Worst-Case Latency Bound

The queue’s correctness rests on a single invariant: at most one command is in flight on the transport at any instant, and commands leave the queue in the exact order they were enqueued. Submission is non-blocking — submit() only appends an envelope and returns a Future — while the worker coroutine imposes total order at the I/O boundary. This separation is what lets an experiment sequencer fire off a batch of commands concurrently without ever risking a reordered or spliced reply.

Determinism extends to timing. Because the worker never overlaps commands, the wall-clock time until a command enqueued at depth k completes is bounded by the sum of the service times of every command ahead of it plus its own retry budget:

where r_i is the retry ceiling for command i, δ is the fixed inter_command_delay inserted after each write to respect firmware turnaround, and t_read,i is capped by that command’s timeout budget. This bound is what makes queue latency predictable enough to reason about in a real assay: a slow query at the head of the queue delays everything behind it by a computable amount rather than an unbounded one. Retries are counted, not randomized — reproducible execution traces matter more here than the thundering-herd avoidance that motivates jittered backoff in distributed systems. When a command does time out, the retry delay itself should follow the deterministic curve documented in Timeout Handling & Retry Logic; the queue owns ordering and lifecycle, while that page owns how long to wait before the next attempt.

A Production asyncio Device Queue

The implementation below is a single self-contained module. Each command is wrapped in a CommandEnvelope carrying its identity, payload, timeout, retry ceiling, and the asyncio.Future that resolves the caller. The AsyncDeviceQueue owns one worker task that drains the queue, enforces the timeout budget with asyncio.wait_for, applies bounded retries, and — critically — never lets an exception escape the execution boundary: every failure is captured, logged, and routed to the originating Future.

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Awaitable, Callable, Optional

logger = logging.getLogger(__name__)

ReadFn = Callable[[], Awaitable[bytes]]
WriteFn = Callable[[bytes], Awaitable[None]]


class CommandStatus(Enum):
    PENDING = "pending"
    EXECUTING = "executing"
    COMPLETED = "completed"
    FAILED = "failed"
    TIMEOUT = "timeout"


@dataclass
class CommandEnvelope:
    """A single command plus the state needed to serialize and retry it.

    The ``response_future`` is created by the queue on submission so the
    envelope is never bound to an event loop at import time.
    """

    cmd_id: str
    payload: bytes
    timeout: float
    max_retries: int = 1
    status: CommandStatus = CommandStatus.PENDING
    response_future: Optional["asyncio.Future[bytes]"] = None
    _attempts: int = field(default=0, init=False)

    @property
    def can_retry(self) -> bool:
        return self._attempts <= self.max_retries

    def record_attempt(self) -> None:
        self._attempts += 1


class AsyncDeviceQueue:
    """FIFO command queue with a single serialized I/O worker.

    One worker coroutine owns the transport, guaranteeing that commands are
    written and read back in submission order. Callers ``submit()`` a payload
    and ``await`` the returned future for the raw response bytes.
    """

    def __init__(
        self,
        read_callback: ReadFn,
        write_callback: WriteFn,
        *,
        max_queue_size: int = 128,
        default_timeout: float = 5.0,
        inter_command_delay: float = 0.0,
    ) -> None:
        self._read = read_callback
        self._write = write_callback
        self.default_timeout = default_timeout
        self.inter_command_delay = inter_command_delay
        self.queue: "asyncio.Queue[CommandEnvelope]" = asyncio.Queue(maxsize=max_queue_size)
        self._worker_task: Optional["asyncio.Task[None]"] = None
        self._shutdown = asyncio.Event()
        self._log = logging.getLogger(f"{__name__}.AsyncDeviceQueue")

    async def start(self) -> None:
        """Launch the single worker task; refuses to start a second one."""
        if self._worker_task and not self._worker_task.done():
            raise RuntimeError("Queue worker is already running")
        self._shutdown.clear()
        self._worker_task = asyncio.create_task(self._worker_loop(), name="device_queue_worker")

    async def stop(self, *, drain: bool = True) -> None:
        """Signal shutdown and await the worker.

        With ``drain=True`` in-flight and queued commands finish or time out
        before the transport is released; with ``drain=False`` the worker is
        cancelled after the current command.
        """
        self._shutdown.set()
        if not self._worker_task:
            return
        if not drain:
            self._worker_task.cancel()
        try:
            await self._worker_task
        except asyncio.CancelledError:
            pass

    async def submit(
        self,
        cmd_id: str,
        payload: bytes,
        *,
        timeout: Optional[float] = None,
        max_retries: int = 1,
    ) -> "asyncio.Future[bytes]":
        """Enqueue a command and return a future that resolves to its reply.

        Raises ``asyncio.QueueFull`` immediately if the bounded queue is at
        capacity, surfacing backpressure to the caller instead of hiding it.
        """
        if self._shutdown.is_set():
            raise RuntimeError("Queue is shutting down; refusing new commands")
        loop = asyncio.get_running_loop()
        envelope = CommandEnvelope(
            cmd_id=cmd_id,
            payload=payload,
            timeout=timeout if timeout is not None else self.default_timeout,
            max_retries=max_retries,
            response_future=loop.create_future(),
        )
        self.queue.put_nowait(envelope)  # raises QueueFull rather than blocking
        assert envelope.response_future is not None
        return envelope.response_future

    async def _worker_loop(self) -> None:
        self._log.info("Queue worker started")
        while not self._shutdown.is_set():
            try:
                envelope = await asyncio.wait_for(self.queue.get(), timeout=0.1)
            except asyncio.TimeoutError:
                continue  # poll the shutdown flag on an empty queue
            except asyncio.CancelledError:
                break
            try:
                await self._execute(envelope)
            except Exception:  # a bug here must not kill the loop silently
                self._log.critical("Worker crashed on %s", envelope.cmd_id, exc_info=True)
                if not envelope.response_future.done():
                    envelope.response_future.set_exception(
                        RuntimeError(f"Worker crashed while executing {envelope.cmd_id}")
                    )
            finally:
                self.queue.task_done()
        self._log.info("Queue worker terminated")

    async def _execute(self, envelope: CommandEnvelope) -> None:
        while envelope.can_retry:
            envelope.record_attempt()
            envelope.status = CommandStatus.EXECUTING
            try:
                await self._write(envelope.payload)
                if self.inter_command_delay > 0:
                    await asyncio.sleep(self.inter_command_delay)
                raw = await asyncio.wait_for(self._read(), timeout=envelope.timeout)
                envelope.status = CommandStatus.COMPLETED
                if not envelope.response_future.done():
                    envelope.response_future.set_result(raw)
                return
            except asyncio.TimeoutError:
                envelope.status = CommandStatus.TIMEOUT
                self._log.warning(
                    "Timeout on %s (attempt %d/%d)",
                    envelope.cmd_id, envelope._attempts, envelope.max_retries + 1,
                )
                if not envelope.can_retry and not envelope.response_future.done():
                    envelope.response_future.set_exception(
                        asyncio.TimeoutError(
                            f"{envelope.cmd_id} timed out after {envelope._attempts} attempts"
                        )
                    )
                    return
            except Exception as exc:  # transport-level fault: do not retry blindly
                envelope.status = CommandStatus.FAILED
                if not envelope.response_future.done():
                    envelope.response_future.set_exception(exc)
                return

The worker uses a short queue.get() timeout so it stays responsive to the shutdown flag instead of blocking forever on an empty queue. Deferring Future creation to submit() avoids event-loop binding issues at import time and keeps submission safe to call from any coroutine on the loop. Note the deliberate asymmetry in the except clauses: an asyncio.TimeoutError is retryable and consumes the retry budget, whereas any other exception is treated as a transport fault and fails the command immediately — retrying a SerialException on a yanked USB cable only wedges the loop harder.

Verifying Deterministic Draining Against Live Hardware

Correct behavior is observable, not assumed. The following harness swaps in a scripted transport so ordering, retry, and timeout semantics can be proven before any real instrument is attached, then the same assertions apply against live hardware:

import asyncio

async def _demo() -> None:
    replies = asyncio.Queue()
    for r in (b"OK1\n", b"OK2\n", b"OK3\n"):
        replies.put_nowait(r)

    async def fake_write(_: bytes) -> None: ...
    async def fake_read() -> bytes: return await replies.get()

    q = AsyncDeviceQueue(fake_read, fake_write, inter_command_delay=0.01)
    await q.start()
    futures = [await q.submit(f"c{i}", f"MEAS{i}?\n".encode()) for i in range(3)]
    results = await asyncio.gather(*futures)
    assert results == [b"OK1\n", b"OK2\n", b"OK3\n"]  # FIFO order preserved
    await q.stop(drain=True)

asyncio.run(_demo())

Against real hardware, three signals confirm the queue is healthy. First, ordering: log every cmd_id at the point of set_result and confirm the completion order matches the submission order exactly — any inversion means a second writer is touching the transport. Second, backpressure: instrument the queue with counters for commands_submitted, commands_completed, commands_failed, and sample queue.qsize(); a qsize that climbs monotonically means the instrument cannot keep up with the submission rate and you are heading for QueueFull. Third, physical completion: for SCPI instruments, follow any actuation command with an *OPC? query — because the queue guarantees serialized execution, *OPC? returns 1 only after every preceding command has physically completed, giving you a hardware-side proof that the queue drained in order. Enabling PYTHONASYNCIODEBUG=1 surfaces any coroutine that blocks the loop longer than 100 ms, which is the fingerprint of a synchronous driver call that should have been wrapped in asyncio.to_thread.

Failure Modes Unique to asyncio Command Queues

These are the failures specific to a single-worker async queue, distinct from the generic transport errors catalogued under Error Code Categorization.

Symptom Root cause Diagnosis and remediation
Futures never resolve; worker appears hung A synchronous read/write blocks the event loop Run with PYTHONASYNCIODEBUG=1 and watch for “coroutine took too long”; wrap the driver in asyncio.to_thread or move to a native-async transport
Replies interleave / parse as garbage A second coroutine writes to the transport outside the queue grep the codebase for direct await port.write(/.read( calls; route every command through submit() so the worker is the sole writer
asyncio.QueueFull raised on submit() Submission rate exceeds instrument service rate Sample queue.qsize() over time; raise inter_command_delay, throttle the producer, or apply an asyncio.Semaphore at the call site
TimeoutError storms on known-good commands Firmware turnaround shorter than inter_command_delay=0, or bus contention Add a small inter_command_delay, verify RTS/CTS flow control, and confirm the read terminator — see PySerial Configuration & Tuning
Worker exits, whole pipeline stalls silently An unhandled exception escaped _execute The _worker_loop guard now fails the current future and logs critical; alert on that log line and restart via start() after a transport reset

The most damaging of these is the orphaned future. If the worker task dies and no one is awaiting self._worker_task, callers block forever on futures that will never resolve. The guard clause in _worker_loop closes the common cases by failing the in-flight future, but you should still attach a done-callback to the worker task itself so a dead worker raises an alert rather than degrading into a silent hang. Treat a crashed worker as a framing/lifecycle fault, not a transient error: reset the transport and call start() fresh rather than reusing a queue whose ordering guarantee may already be compromised.

Upstream, an experiment sequencer should only ever await queue.submit(...) and await the returned future — never poll queue.empty() or inspect worker state from outside the loop. Downstream, the raw bytes a future resolves to are unvalidated: instrument replies carrying a non-zero status byte are protocol errors, not transport failures, and must pass through Error Code Categorization before any recovery logic fires. When the payload is binary telemetry rather than a short SCPI reply, hand it to Checksum & CRC Validation before parsing. For labs coordinating several instruments through a shared session layer, allocate transports through a VISA Resource Manager and give each instrument its own AsyncDeviceQueue, since the single-worker guarantee is per-transport, not global.

← Back to Async Command Queuing Systems

References