NetworkX Graph Synchronization Strategy for Concurrent Read-Write Workloads
0 reputation · 08 Feb 2020, 01:56 UTC
0 reputation · 08 Feb 2020, 01:56 UTC
NetworkX documents that its core graph classes—Graph, DiGraph, MultiGraph—are not thread-safe. The CPython GIL serializes bytecode, yet simultaneous mutations still race on the underlying dict structures, producing latency spikes that grow non-linearly with thread count.
Pickle-based persistence adds another dimension: interleaved dump/load calls on a shared file descriptor can leave the on-disk representation in an inconsistent state, forcing callers to retry or recover.
The library exposes no RLock, read_write_lock, or atomic snapshot API. Teams typically wrap every mutating call in a process-wide threading.Lock, but that serializes even read-only traversals and defeats the purpose of concurrency.
What synchronization pattern limits write contention while allowing parallel reads?
Which copy or snapshot method—copy(), to_undirected(), or a custom __deepcopy__—provides a consistent view without blocking writers?
Is there a supported way to make pickle round-trips atomic without external file locking?
35775 reputation · 08 Feb 2020, 09:22 UTC
readerwriterlock.RWLockFair) wrapping every public mutator (add_node, add_edge, remove_node, attribute writes) in the write lock and every traversal (neighbors, nodes, edges, algorithm calls) in the read lock.copy.deepcopy(G) (or nx.convert_node_labels_to_integers(G) for a relabeled isolate) gives a fully independent copy; G.copy() is shallow and shares attribute dicts, so concurrent attribute mutations leak into the snapshot.os.fsync(fd), then os.replace(tmp, target) (POSIX atomic rename). For durability on NFS, add os.fsync on the target directory after rename.The CPython GIL serializes bytecode but does not make dict resize/rehash atomic. Simultaneous G.add_edge(u,v) calls can corrupt the adjacency dicts. A fair RWLock lets any number of readers proceed concurrently while writers get exclusive access, eliminating the global threading.Lock bottleneck. Caution: under extreme read-heavy load writers can starve; RWLockFair mitigates this.
G.copy() duplicates the top-level adjacency dicts but reuses the same node/edge attribute dict objects. If a writer later does G.nodes[n]['weight'] = 5, the snapshot sees the change. copy.deepcopy(G) recurses into every attribute dict, producing a truly immutable view at O(N+E) time and memory. For graphs >1M nodes/edges, profile before adopting copy-on-write.
Pickle streams are not transactional. Interleaved dump/load on the same file descriptor yields truncated or concatenated streams. The temp-file + os.replace pattern is the portable POSIX way to achieve atomicity without external lock files. A dedicated writer process with a queue.Queue is an alternative that also isolates the GIL.
import threading, copy, os, tempfile, pickle
import networkx as nx
from readerwriterlock import RWLockFair
class ConcurrentGraph:
def __init__(self, graph=None):
self._graph = graph or nx.Graph()
self._lock = RWLockFair()
# Readers
def neighbors(self, n):
with self._lock.gen_rlock():
return list(self._graph.neighbors(n))
def snapshot(self):
with self._lock.gen_rlock():
return copy.deepcopy(self._graph)
# Writers
def add_edge(self, u, v, **attr):
with self._lock.gen_wlock():
self._graph.add_edge(u, v, **attr)
def set_node_attr(self, n, key, val):
with self._lock.gen_wlock():
self._graph.nodes[n][key] = val
# Atomic pickle
def save_atomic(self, path):
with self._lock.gen_rlock():
data = pickle.dumps(self._graph, protocol=pickle.HIGHEST_PROTOCOL)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.nx-')
try:
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, path) # atomic on POSIX
# optional: os.fsync(os.open(os.path.dirname(path), os.O_DIRECTORY))
@classmethod
def load(cls, path):
with open(path, 'rb') as f:
return cls(pickle.load(f))
neighbors() and M writer threads calling add_edge(); measure latency percentiles vs. a global threading.Lock baseline.snap = graph.snapshot(), mutate graph.set_node_attr(n, 'x', 99) and assert snap._graph.nodes[n].get('x') != 99.save_atomic, then verify ConcurrentGraph.load(path) loads without EOFError or UnpicklingError under concurrent readers.import networkx as nx; print(nx.__version__) — copy() and pickling behavior changed between 2.x and 3.x.What is the typical graph size (nodes + edges) and read/write ratio? If the graph exceeds ~500k edges or writes are >20% of operations, copy-on-write snapshots may introduce unacceptable latency; a separate writer process with request/response IPC becomes preferable.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.