Thread-Safe VISA Sessions for Multi-Instrument Access
A VISA session is a stateful conversation, not a stateless RPC: a write arms the instrument for exactly one matching read, and any second thread that slips a transaction between them corrupts both. When a worker pool drives a shared bench, the failure is a garbled read — thread A’s MEAS:VOLT? answered by the reply to thread B’s *IDN? — and it appears only under load. This guide covers sharing PyVISA sessions safely across threads over the VISA Resource Manager: one session per instrument guarded by its own mutex, handed out from a pool, with lock ordering that makes multi-instrument transactions deadlock-free.
Scope: Serializing Access to Long-Lived Sessions
The narrow problem is concurrency, not discovery or vendor normalization — those belong to the parent VISA Resource Manager Setup guide and its companion on structuring a PyVISA resource manager for multi-vendor labs. This page assumes sessions are already opened, validated, and profiled; it solves what happens when several threading.Thread workers reach for those sessions at once. The target is Python 3.10+ with pyvisa 1.13 or newer, a single process-wide ResourceManager, and any mix of TCPIP/LXI, USB-TMC, GPIB, and ASRL message-based sessions. The governing rule is one session per instrument plus one mutex per session: a session is a single-writer resource, and concurrency is expressed by owning different instruments in parallel, never by two threads touching one instrument at once.
A crucial and counterintuitive point: the GIL does not protect you. A blocking VISA read — session.read() waiting on a slow sweep — releases the GIL so other Python threads keep running, which is exactly what you want for throughput but exactly why the interpreter provides no implicit serialization. The write and its read are two separate C calls with a GIL-releasing wait between them; another thread is free to interleave its own write in that gap. Only an explicit lock spanning the entire write-read transaction makes it atomic.
Mechanism: Per-Session Mutex, Pool Registry, and Lock Ordering
Three primitives compose the design. First, a per-session mutex (threading.RLock) whose scope is one full request-response transaction, not one I/O call — the lock is held across write then read, or across a single query, so nothing interleaves. An RLock is reentrant so that a thread already holding a session can call a helper that re-acquires it without self-deadlock. Second, a registry mapping each instrument id to its (session, lock) pair, so the pool hands out a context that acquires the right mutex with a timeout rather than exposing the raw handle. Third — and this is what makes multi-instrument transactions safe — a total order over session ids, acquired strictly ascending.
VISA itself offers session.lock(AccessModes.exclusive_lock) / session.unlock(), which serializes access at the driver level across processes and hosts. That is the correct tool when a second process or a remote client might touch the same instrument; it is heavier than a Python mutex and can raise VI_ERROR_RSRC_LOCKED / time out when contended. Within one process, the per-session RLock is the fast path; the VISA exclusive lock is reserved for cross-process exclusion, layered inside the mutex-guarded region so the two never fight.
Deadlock arises only when a thread needs two sessions at once — a synchronized measurement reading a source and a meter together — and two threads grab them in opposite orders. The classic remedy is a total order. Assign every session a stable, comparable id and require that any thread acquiring a set S of session locks acquires them in ascending id order:
Under this invariant a circular wait is impossible: a cycle in the wait-for graph would require some thread holding a higher id while requesting a lower one, contradicting the ordering. Formally, acquisition order embeds into the total order (<) on ids, and a strict total order has no cycles — so the Coffman circular-wait condition cannot hold, and the system is deadlock-free regardless of thread count or timing.
N threads draw per-instrument locked contexts from one shared pool; a two-instrument transaction acquires locks in ascending id order, which is what makes the wait-for graph acyclic.
Production Implementation: A Locked SessionPool
The pool opens each instrument once, stores its (session, RLock) under a stable id, and hands callers a context manager that acquires the mutex with a timeout — never the bare session. A single instrument uses acquire; a synchronized multi-instrument transaction uses acquire_many, which sorts ids so acquisition is always ascending. The VISA exclusive lock is available per acquisition for cross-process exclusion, nested strictly inside the mutex.
from __future__ import annotations
import logging
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterator, Sequence
import pyvisa
from pyvisa.constants import AccessModes
from pyvisa.errors import VisaIOError
from pyvisa.resources import MessageBasedResource
logger = logging.getLogger(__name__)
class SessionBusyError(TimeoutError):
"""Raised when a session mutex cannot be acquired within the timeout."""
@dataclass
class _Entry:
"""One instrument: its session, its guarding mutex, and its ordering id."""
order_id: int
session: MessageBasedResource
mutex: threading.RLock = field(default_factory=threading.RLock)
class SessionPool:
"""Thread-safe registry handing out per-instrument locked contexts.
One session per instrument, one reentrant mutex per session, all built over
a single shared ResourceManager. Concurrency comes from owning *different*
instruments in parallel; a session is never touched by two threads at once.
Multi-instrument transactions acquire locks in ascending order_id to stay
deadlock-free.
"""
def __init__(self, resource_manager: pyvisa.ResourceManager) -> None:
self._rm = resource_manager
self._entries: dict[str, _Entry] = {}
self._registry_lock = threading.Lock() # guards _entries mutation only
self._next_order = 0
def register(self, name: str, resource: str, *, timeout_ms: int = 5000) -> None:
"""Open one instrument and file it under a stable name and ordering id."""
with self._registry_lock:
if name in self._entries:
raise ValueError(f"instrument {name!r} already registered")
session: MessageBasedResource = self._rm.open_resource(resource)
session.timeout = timeout_ms
session.read_termination = "\n"
session.write_termination = "\n"
self._entries[name] = _Entry(self._next_order, session)
self._next_order += 1
logger.info("registered %s -> %s (id=%d)", name, resource, self._next_order - 1)
@contextmanager
def acquire(
self,
name: str,
*,
timeout_s: float = 10.0,
visa_exclusive: bool = False,
) -> Iterator[MessageBasedResource]:
"""Yield a session with its mutex held for the whole transaction.
Hold the returned session only inside the `with` block; issue a full
write-then-read (or a single query) so nothing interleaves. Set
`visa_exclusive` to also take a driver-level lock for cross-process
exclusion. Raises SessionBusyError if the mutex is contended past
`timeout_s`.
"""
entry = self._entries[name]
if not entry.mutex.acquire(timeout=timeout_s):
raise SessionBusyError(f"{name!r} busy: mutex not acquired in {timeout_s}s")
try:
if visa_exclusive:
try:
entry.session.lock(
timeout=int(timeout_s * 1000),
requested_key=None,
access_mode=AccessModes.exclusive_lock,
)
except VisaIOError as exc: # VI_ERROR_RSRC_LOCKED / timeout
raise SessionBusyError(
f"{name!r} VISA exclusive lock failed: {exc}"
) from exc
try:
yield entry.session
finally:
if visa_exclusive:
entry.session.unlock()
finally:
entry.mutex.release()
@contextmanager
def acquire_many(
self, names: Sequence[str], *, timeout_s: float = 10.0
) -> Iterator[dict[str, MessageBasedResource]]:
"""Yield several sessions at once, acquiring locks in ascending id order.
Sorting by order_id enforces the total order that makes a circular wait
impossible, so no two `acquire_many` calls can deadlock regardless of the
order the caller lists the instruments.
"""
ordered = sorted({n for n in names}, key=lambda n: self._entries[n].order_id)
held: list[_Entry] = []
try:
for name in ordered:
entry = self._entries[name]
if not entry.mutex.acquire(timeout=timeout_s):
raise SessionBusyError(
f"{name!r} busy while composing a multi-instrument transaction"
)
held.append(entry)
yield {n: self._entries[n].session for n in ordered}
finally:
for entry in reversed(held): # release in reverse acquisition order
entry.mutex.release()
def close_all(self) -> None:
"""Close every session. Call once at shutdown, from a single thread."""
with self._registry_lock:
for name, entry in self._entries.items():
try:
entry.session.close()
except Exception as exc: # noqa: BLE001 - log and continue teardown
logger.error("close failed for %s: %s", name, exc)
self._entries.clear()
Validation: Hammering One Instrument From N Threads
The property to prove is that no two transactions on one session ever interleave. Point N threads at a single instrument through the pool, have each run a write-then-read whose reply must echo a per-thread token, and assert every reply matches its own request. A garbled read — thread A receiving B’s token — is the exact symptom of a missing or too-narrow lock, and it will fire within seconds under contention if the mutex scope is wrong.
import concurrent.futures as cf
def test_no_interleaved_transactions(pool: SessionPool) -> None:
"""N threads pound one session; every reply must match its own request."""
n_threads, iterations = 16, 200
mismatches: list[tuple[int, str]] = []
def worker(tid: int) -> None:
for i in range(iterations):
token = f"{tid}-{i}"
with pool.acquire("scope", timeout_s=15.0) as sess:
sess.write(f'DIAG:ECHO "{token}"') # arms a one-shot echo
reply = sess.read().strip().strip('"') # must be *this* token
if reply != token:
mismatches.append((tid, reply))
with cf.ThreadPoolExecutor(max_workers=n_threads) as pool_exec:
list(pool_exec.map(worker, range(n_threads)))
assert not mismatches, f"interleaved transactions detected: {mismatches[:5]}"
For an instrument lacking a DIAG:ECHO, substitute any deterministic query whose answer is a function of the immediately preceding write — set a marker register, read it back — so a crossed transaction produces a detectable mismatch. On a live rig, watch the instrument’s error queue: read SYST:ERR? after the run and confirm a clean 0,"No error", since interleaved framing typically leaves -410,"Query INTERRUPTED" or -420,"Query UNTERMINATED" behind. This connects to the same error surface catalogued in Error Code Categorization and categorizing SCPI error codes for automated recovery.
Failure Modes Specific to Shared Sessions
Two threads sharing one session — garbled reads. The signature failure: a write and its read are guarded separately (or not at all), so a second thread injects a transaction between them and each thread reads the other’s answer. It manifests as sporadic -410 Query INTERRUPTED errors and values that belong to the wrong measurement, never on a single thread and always under load. Diagnose with the N-thread echo harness above; fix by widening the mutex to span the whole write-read pair via a single acquire context, never one lock per I/O call.
Deadlock from inconsistent lock order. A synchronized read of a source and a meter deadlocks when thread A takes source-then-meter while thread B takes meter-then-source and each holds one, waiting on the other. The pipeline freezes with two threads parked in RLock.acquire and no CPU activity. Reproduce by having two threads request the same pair in opposite listed order under load; fix by routing every multi-instrument transaction through acquire_many, which sorts by order_id so acquisition is always ascending and the wait-for graph stays acyclic.
VISA exclusive-lock timeout under contention. With visa_exclusive=True, session.lock(AccessModes.exclusive_lock) competes at the driver level with other processes or remote clients and raises VI_ERROR_RSRC_LOCKED or times out when another owner holds the lock. It surfaces as a SessionBusyError originating in the VISA call, not the mutex. Distinguish a cross-process holder (another script, a bench GUI still connected) from genuine slowness; reserve the exclusive lock for true cross-process exclusion and keep its timeout above the longest legitimate transaction, or drop back to the mutex-only fast path when only one process owns the instrument.
A ResourceManager per thread exhausting handles. Constructing pyvisa.ResourceManager() inside each worker — instead of sharing one — spawns duplicate backend sessions that leak OS handles until the bus reports VI_ERROR_RSRC_BUSY and locks out every thread, the same accumulation the parent guide warns against. Diagnose by counting live handles (lsof | grep -i visa) as thread count grows; fix by constructing exactly one ResourceManager, passing it to a single SessionPool, and sharing that instance process-wide.
Under sustained polling the blocking, GIL-releasing reads that these locks serialize are better driven from an event loop than from a thread pool — see Async Command Queuing Systems and building async command queues with asyncio for lab devices. The commands each locked transaction carries should conform to one dialect through SCPI Command-Set Standardization.
Related
- VISA Resource Manager Setup — the parent guide on discovery, validation, session lifecycle, and the singleton-per-host backend rule these locks build on.
- How to Structure a PyVISA Resource Manager for Multi-Vendor Labs — opening and normalizing the sessions this pool then guards.
- Async Command Queuing Systems — the event-loop alternative to a thread pool for serializing instrument I/O.
- SCPI Command-Set Standardization — the dialect each locked transaction should speak.
- Error Code Categorization — classifying the
-410/-420query faults that interleaving leaves behind.
← Back to VISA Resource Manager Setup
References
- PyVISA Documentation — resource locking,
lock/unlock, and session attribute semantics. - Python
threading— RLock objects — reentrant-lock acquisition, timeouts, and release semantics.