HNSW-Sharp

Thread safety

SmallWorld<TItem, TDistance> is thread-safe by default. A reader-writer lock around the graph lets multiple KNNSearch calls run concurrently while serialising mutating operations (AddItems, OptimizeIfNeeded, ResizeDistanceCache).

What's protected

Operation Lock acquired
AddItems Write lock — exclusive.
KNNSearch Read lock — concurrent with other reads.
GetItem, Items Read lock.
SerializeGraph Read lock.
OptimizeIfNeeded, DisableDistanceCache, ResizeDistanceCache Internal locking on the underlying graph core.
UnsafeItems No lock — see below.

That's enough for the standard producer-consumer pattern: one writer adds items, many readers query in parallel.

When to disable the lock

Pass threadSafe: false to the constructor:

var graph = new SmallWorld<float[], float>(
    distance, generator, parameters,
    threadSafe: false);

This is appropriate when:

  • You build the entire graph upfront, then query it from many threads — and never call AddItems again. The library doesn't need a lock if there are no concurrent mutations.
  • You wrap the graph in your own synchronisation primitive (e.g. your application already holds a per-tenant SemaphoreSlim).
  • You measured the lock overhead and confirmed it's significant for your workload — typically only true on very small graphs where reads are sub-microsecond.

It is not safe to disable the lock if any code path calls AddItems while another thread might be reading. Concurrent reads + writes corrupt the graph silently.

UnsafeItems vs Items

The library exposes two accessors for the underlying item list:

  • Items — returns a copy under a read lock. Safe everywhere; costs an allocation and a copy.
  • UnsafeItems — returns the underlying IReadOnlyList<TItem> directly. Cheap, but you must guarantee no AddItems runs concurrently.

UnsafeItems is appropriate in read-mostly hot paths where you can prove no writer is in flight — for example, inside a request handler when the graph is built at startup and never grown.

Deserialisation and threading

DeserializeGraph is a static method that returns a fresh, fully-initialised SmallWorld<TItem, TDistance>. Pass threadSafe: false on deserialisation if you intend to use the reloaded graph in read-only mode:

var (graph, missing) = SmallWorld<float[], float>.DeserializeGraph(
    vectors,
    distance,
    generator,
    stream,
    threadSafe: false);

This is the typical pattern when shipping pre-built indexes: build offline with threadSafe: true, serialise, and reload with threadSafe: false in the read-only consumer.

Performance impact

The internal lock is a ReaderWriterLockSlim. Cost is small but not zero — a few hundred nanoseconds per acquisition. For graphs where each query takes microseconds, that's a noticeable fraction; for graphs where each query takes hundreds of microseconds, it's noise.

Measure before disabling. The correctness cost of getting it wrong is higher than the latency cost of leaving it enabled.

Common pitfalls

Don't pass the lock to other types

Calling KNNSearch inside a callback registered with the runtime's GC or the thread-pool can run on a thread that doesn't own the lock context cleanly. Keep SmallWorld access on application threads.

Long-running enumerations

Items returns a snapshot copy — safe to enumerate even if the writer adds more items concurrently. UnsafeItems returns a live reference — enumerating it while a writer mutates the underlying list throws.

Referenced by

© 2026 HNSW-Sharp. All rights reserved.