Sync wrapper¶
anyserial is async-first. The anyserial.sync.SerialPort class is a thin
blocking wrapper around anyserial.SerialPort for scripts and
test benches that do not want to run an event loop themselves.
Async-first is the promise
The sync wrapper shares 100 % of its I/O code with the async port —
every blocking call is implemented as
portal.call(async_method, ...). Performance characteristics track
the async path (see Performance); behavioural
semantics are identical.
Quickstart¶
from anyserial.sync import SerialPort
with SerialPort.open("/dev/ttyUSB0", baudrate=115200) as port:
port.send(b"ping\n")
reply = port.receive(1024, timeout=1.0)
Every method that blocks on I/O accepts an optional timeout= keyword;
snapshot-style calls (input_waiting, output_waiting, property
accesses) do not dispatch through the portal and return immediately.
How it works¶
Every sync port in the process shares one
anyio.from_thread.BlockingPortalProvider, which owns a
single background thread running an AnyIO event loop. The provider is
refcounted — the first SerialPort.open(...) spawns the event-loop
thread; the last close() tears it down. Opening multiple sync ports
does not spawn multiple event-loop threads.
Each sync method forwards to its async counterpart via
portal.call(coroutine, *args); optional timeouts wrap the coroutine in
anyio.fail_after.
┌──────────────────── caller thread(s) ─────────────────────┐
│ port.send(b"x", timeout=1.0) │
└────────────────┬───────────────────────┬──────────────────┘
│ portal.call │ portal.call
▼ ▼
┌────────── portal event-loop thread (one) ─────────────────┐
│ async_port.send(b"x") ←─ AnyIO coroutine │
│ anyio.wait_writable(fd) / os.write(fd, ...) │
└───────────────────────────────────────────────────────────┘
Choosing the AnyIO backend¶
The default is plain asyncio. To use uvloop, winloop, or Trio, call
anyserial.sync.configure_portal before opening
the first sync port:
from anyserial.sync import configure_portal, SerialPort
configure_portal(backend="asyncio", backend_options={"use_uvloop": True})
with SerialPort.open("/dev/ttyUSB0") as port:
...
configure_portal raises RuntimeError if called after the portal
thread has started.
Per-call timeouts¶
Timeouts are per-call keyword arguments on every blocking method.
TimeoutError (the stdlib one, which AnyIO's fail_after raises
directly) surfaces unchanged when they fire; the port stays usable for
subsequent calls.
Some methods take both a behavioural-timing parameter and the
portal-level timeout — for example send_break(duration=0.25,
timeout=1.0): duration is how long the BREAK condition is asserted,
timeout bounds the whole call at the portal.
Concurrency¶
The portal's event loop is single-threaded, so calls from multiple OS
threads dispatch through one serialization point. Full-duplex send+
receive from different threads works cleanly; concurrent send calls
from multiple threads are not guaranteed to serialize — the async
port's ResourceGuard can still fire (anyio.BusyResourceError) if two
coroutines enter the same guarded section. Coordinate writes from a
single thread if you need ordering guarantees.
Context managers and lifecycle¶
SerialPort supports the stdlib with protocol and is idempotently
closeable:
Leaking an open port triggers a ResourceWarning during garbage
collection, matching the async port's contract. Always close explicitly
or use with.
SerialConnectable¶
The deferred-open data class mirrors anyserial.SerialConnectable but
does not implement anyio.abc.ByteStreamConnectable — that Protocol
requires an async connect, which is incompatible with sync call sites.
Sync code that needs AnyIO connectable polymorphism should use the async
variant.
from anyserial.sync import SerialConnectable
recipe = SerialConnectable(path="/dev/ttyUSB0", config=SerialConfig(baudrate=115200))
with recipe.connect() as port:
...
API parity¶
Every non-lifecycle method and property on the async SerialPort has a
sync counterpart with a matching signature (minus async, plus an
optional timeout= on portal-dispatched methods). The parity is
regression-tested in tests/unit/test_sync_parity.py.
The only intentional differences:
| Async | Sync |
|---|---|
async def aclose() |
def close(*, timeout=None) |
async def __aenter__ / __aexit__ |
def __enter__ / __exit__ |
| N/A | def open(cls, path, config=None, /, *, timeout=None, **fields) |
When to use which¶
| Situation | Recommendation |
|---|---|
| Real app with an event loop | Async anyserial.SerialPort |
| Jupyter notebook exploration | Sync wrapper |
| Hardware-test bench, one-shot scripts | Sync wrapper |
| Integrating into an AnyIO task group | Async anyserial.SerialPort |
| Library API that callers compose with | Async (don't force an event loop) |
| Tight request/response loops where every µs counts | Async (skip the portal hop) |
| Fan-out across many ports concurrently | Async (one loop, no thread-per-port) |
The sync wrapper is a convenience; code that expects to grow into an async application should start with the async API.
Performance expectations¶
Every sync call pays one portal.call(coroutine, *args) hop — the
caller's thread submits the coroutine to the event-loop thread and
blocks until the result returns. The hop is measured on real
hardware at ~470 µs per call on a modern laptop
(reference). Two
consequences worth internalizing:
- Fine for setup, one-shot scripts, and I/O-bound calls. The hop is dwarfed by any wait on the serial port (milliseconds for USB adapters).
- Visible on tight request/response loops. A 500-iteration poll
loop using the sync wrapper takes roughly 250 ms longer than the
same loop in pure async code. For that workload, drop into an
anyio.run(...)block or structure the caller as async.
Cancellation shows the same shape: async cancellation hits p99 < 1 ms
on real USB; the sync wrapper's timeout= lands at p99 ≈ 2.6 ms
because the cancellation event itself crosses the portal. Again, fine
for most code — just know the number so you don't be surprised.