State-Sharing Memory: Implementing Thread-Safe Shared State in Multi-Agent Systems
By Bishwambhar SenIn multi-agent systems, agents must share context to collaborate effectively. There are two primary communication models:
- Message Passing: Agents send messages directly to each other.
- Shared State (Blackboard Pattern): Agents write and read from a centralized memory database.
While message passing works well for linear pipelines, it quickly becomes unwieldy in complex workflows. Centralized shared memory simplifies communication but introduces challenges around concurrency, write conflicts, and state consistency.
State-Sharing Memory Architecture
The Architecture of Shared Memory
In a shared memory system, the entire state is represented as a structured schema (e.g., a JSON document or relational database). Multiple agents query and update this state concurrently.
To prevent race conditions, the system must enforce concurrency controls. The two main approaches are:
- Optimistic Concurrency Control (OCC): Each state update includes a version number. If the version in the database doesn't match the agent's read version, the update is rejected and the agent retries.
\text{Update Condition: } V_{current} = V_{expected}
- Pessimistic Locking: The state is locked by an agent while performing calculations, preventing other agents from reading or writing until the lock is released.
Python Code: A Thread-Safe State Store
Here is a Python class that manages a shared, thread-safe memory state using threading.Lock and versioning:
import threading
from typing import Any, Dict, Tuple
class ThreadSafeStateStore:
def __init__(self):
self._lock = threading.Lock()
self._state: Dict[str, Any] = {}
self._version = 0
def get_state(self) -> Tuple[Dict[str, Any], int]:
with self._lock:
return self._state.copy(), self._version
def update_state(self, updates: Dict[str, Any], expected_version: int) -> bool:
with self._lock:
if self._version != expected_version:
# Concurrent edit detected
return False
# Apply updates
self._state.update(updates)
self._version += 1
return True
# Example Usage
store = ThreadSafeStateStore()
state, ver = store.get_state()
success = store.update_state({"agent_alpha_status": "searching"}, expected_version=ver)
print(f"Update status: {success}, New version: {store.get_state()[1]}")
Implementing Distributed Shared Memory
In production environments, memory state is often stored in external databases like Redis or PostgreSQL:
- Redis Hash Map: Ideal for fast, key-value storage. Redis's single-threaded nature guarantees atomic updates.
- PostgreSQL JSONB: Great for queries over structured states. PostgreSQL provides robust transactional isolation levels (e.g.,
SERIALIZABLE).
The Retry Loop Is the Hard Part
Optimistic concurrency looks clean in the code above because the interesting case — the rejected write — is handled by returning False and leaving the caller to figure it out. That's where the real design work lives. A traditional database client retries a failed OCC write by re-reading and reapplying a deterministic transformation, which is cheap and safe. An agent cannot do that. Its "transformation" was an LLM call that reasoned over the state it read, so a stale version means the reasoning itself is stale. Blindly reapplying the agent's write clobbers whatever the other agent concluded; re-running the agent means paying for the inference again and hoping it converges this time.
Two agents contending on the same key with plain retry-on-conflict will livelock — each invalidates the other, both retry, neither wins. The fix is usually to stop sharing the key. Partition the blackboard so each agent owns the fields it writes and only reads the rest, and conflicts largely disappear without any locking at all. Where genuine shared mutation is unavoidable, prefer append-only structures — a shared list of findings that agents add to rather than a document they overwrite — since concurrent appends commute and never conflict.
The pessimistic alternative deserves less enthusiasm than it usually gets in this context. Holding a lock across an LLM call means holding it for seconds, sometimes tens of seconds, and an agent that crashes or times out mid-call leaves the lock held and every other agent blocked. If you do lock, lock with a TTL shorter than your agent timeout and design for the lock expiring underneath you.
One last thing that catches people: the .copy() in get_state is a shallow copy. Nested dicts and lists are still shared references, so an agent mutating state["plan"]["steps"] is writing straight through the lock into everyone else's view, silently and without a version bump. Use copy.deepcopy, or freeze the state into an immutable structure and make every change go through update_state. Shared-memory bugs in agent systems are rarely the dramatic race conditions people design against — they're aliasing mistakes like this one that produce quietly wrong behaviour weeks later.