Concurrency & thread safety
Native handles are C++ objects behind a thin Java wrapper. Sharing one across threads without care is a data race in C++, not just in the JVM — and that means a segfault, not a caught exception: no stack trace, no test failure, just a dead process. The good news is that the rule for staying safe is short, and most parallel image work fits it naturally. This page says exactly what is safe to share, what isn't, and how to parallelise correctly.
import scalacv.*
OpenCv.load()
The rule in one line
One owner per handle. A Mat, a Managed, an Image, a detector, a VideoCapture — keep it on one thread, or guard it with your own lock. scalacv does not use JavaCPP PointerScope, so there is no thread-local-scope subtlety to reason about: ownership is always the explicit Managed you hold, wherever it travels.
The reason this rule is livable is that detector results are plain, immutable Scala data, copied out of native memory. So the natural pattern — do the native work on a worker, hand the result back — needs no synchronisation at all. A count, a Seq[Rect], a Pose: once computed, it belongs to no thread and to every thread.
// The native work happens on one owner; the RESULT is a plain Int, safe to send anywhere.
val edgeCount: Int =
val edges = Image.blank(64, 64, Scalar.White).gray.canny(50, 150)
try edges.contours().size
finally edges.close()
edgeCount >= 0
// res1: Boolean = true
What is safe to share
| Thing | Concurrent use | Why |
|---|---|---|
Immutable results — Contour, Rect, Scalar, Pose, FaceMatch, any Seq[…] a detector returns | ✅ fully safe | plain Scala data, copied out of native memory, no pointer behind them |
Managed release — release()/close() from several threads | ✅ safe | idempotent atomic compare-and-set; frees exactly once even under a race |
A shared Mat's pixels — reads only | ✅ safe | concurrent reads of a buffer nobody writes are fine |
OpenCv.load() | ✅ safe | idempotent, internally synchronised; call it from anywhere |
A shared Mat with a concurrent write | ❌ unsafe | OpenCV's refcount ops are atomic, but pixel writes are not synchronised |
A stateful detector — MotionDetector, Odometry, ObjectTracker, LoopDetector | ❌ unsafe | mutable internal state; documented single-thread |
FaceDetectorYN, FaceRecognizer, a Dnn net, a Tracker | ❌ one per thread | stateful native objects; a second thread corrupts the in-flight call |
VideoCapture / Camera / Recorder | ❌ one owner | a decode/encode in progress is not reentrant |
Two rows carry most of the weight:
- The safe results row is why the "detect on a worker, combine on the main thread" pattern below needs no locks.
- The
Managedrelease row is subtler than it looks:release()is agetAndSet(null)compare-and-set, so even if two threads race to close the same handle, the buffer is freed exactly once and the loser is a no-op. You still shouldn't use a handle from two threads — but you can't double-free one. (See Mat lifecycle.)
"Reads are safe" means concurrent reads of a buffer nobody writes. The moment one thread writes a Mat another is reading, you are back to undefined behaviour. When in doubt, give each thread its own buffer.
The pattern: one owner per worker
The stateful native objects aren't thread-safe, but they're cheap to hold per thread. Give each worker its own end-to-end pipeline, process independently, and collect the immutable results. Here each future reads, processes, and closes its own Image, returning only a plain Int:
import scala.concurrent.{Future, ExecutionContext}
import ExecutionContext.Implicits.global
def edgeCountsInParallel(paths: Seq[String]): Future[Seq[Int]] =
Future.sequence(paths.map { path =>
Future {
// Each worker owns its Image end to end; `reading` closes it on every path,
// and the returned Int is safe to combine on any thread.
Image.reading(path)(_.gray.canny(80, 160).contours().size).getOrElse(0)
}
})
Image.reading scopes the image to the block — success, failure, or exception — so nothing native escapes the worker. Nothing crosses a thread boundary but Ints.
Sharing an expensive detector: pool, don't share
A loaded Dnn net or a FaceRecognizer is expensive to build, so the temptation is to build one and share it. Don't — a second thread entering a stateful native call corrupts the in-flight one. Instead build one per worker, or pool them with a ThreadLocal and close each when its thread retires:
import org.opencv.dnn.Net
// One net per thread: built on first use, reused by that thread, never shared across threads.
// Dnn.fromOnnx hands back a caller-owned Managed[Net]; each thread closes its own when it retires.
val perThreadNet = ThreadLocal.withInitial[Either[CvError, Managed[Net]]] { () =>
Dnn.fromOnnx("model.onnx")
}
The rule of thumb: share results, never handles. If two threads need the same model, they need two copies of it.
Splitting work over one image
To process regions of a single image concurrently, give each worker its own buffer to write into — never let two threads write the same Mat. The cleanest approach is per-worker crops, which are independent copies (not aliasing views), processed in isolation, then composited back on one thread:
// crop returns an INDEPENDENT copy, so two workers holding two crops share no buffer.
val source = Image.blank(256, 256)
val topLeft = source.copy.crop(Rect(0, 0, 128, 128))
val topRight = source.crop(Rect(128, 0, 128, 128)) // consumes `source`
// Hand each crop to its own worker; they write disjoint buffers with no lock.
topLeft.close()
topRight.close()
Note the .copy on the first crop: crop consumes its receiver (it returns a fresh buffer and releases the parent), so to take two crops from one source you branch with .copy first. Each resulting crop owns its own Mat.
Reads are different: many threads may read one shared Mat at once — measuring, hashing, encoding — with no lock, as long as nobody writes it concurrently. That is the one place shared native memory is fine.
Common mistakes
| You wrote… | What happens | Fix |
|---|---|---|
shared one Dnn/detector across futures | in-flight native call corrupted → segfault | one per thread / ThreadLocal |
collected borrowed Video.frames Mats into a Seq and processed off-thread | N references to one reused buffer; a data race the moment decode continues | Video.framesCopied for frames that outlive the loop |
two workers crop the same Image | second crop throws use-after-move (the first consumed it) | .copy before the first crop |
two threads write the same Mat | undefined behaviour | give each its own buffer |
| passed a detector result between threads and added a lock | harmless, but pointless | results are immutable — no lock needed |
Thread-pool oversubscription
OpenCV and OpenBLAS each run an internal thread pool. If your outer parallelism already saturates the cores, the inner pools fight it and everything slows down. When you fan work out yourself, cap the inner layers:
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 java -jar your-app.jar
or org.opencv.core.Core.setNumThreads(1) from code before you spread work. Conversely, for a single sequential pipeline, leave OpenCV's threading on — it parallelises the heavy kernels for you. Measure both; see Performance.
ZIO: fibers are just threads here
The ZIO module expresses ownership as Scope, so a native object is released when the scope closes — on success, failure, and interruption, which try/finally cannot promise once an interrupt is in play. Native and blocking work runs on the blocking pool, never the CPU-sized default executor.
The two borrowing contracts carry over unchanged from the synchronous world:
frameStreamborrows one reused buffer — reduce each frame inside the stream (.mapZIO(...)), don't buffer the borrowedMatacross stages.framesCopiedhands out ownedManaged[Mat]clones — consume each in the pulling fiber (.mapZIO(m => m.use(...))). A clone dropped because the fiber is interrupted before a downstreamuse/scope takes it over leaks, exactly as a droppedManagedwould in synchronous code — which is whyScope, not a bare clone, is how you keep frames in effectful code.
The end-of-stream semantics do not carry over. The synchronous readers take an attemptsPerFrame bound — how many consecutive empty reads it takes before the traversal is declared over. Video.frames defaults it to 1, which is right for a file, where the first empty read is end-of-file; the Camera helpers (foreach, take, taking, snapshot) default it to 3, so a webcam that drops one frame keeps going. frameStream has no such parameter: the first empty read ends the ZStream, and it ends it cleanly — your app sees a normal completion, not an error. See A dropped frame ends your stream for what to do about it.
"One owner per handle" is about the handle, not the concurrency primitive. A ZIO fiber is still a thread as far as native memory is concerned — a native object still belongs to exactly one fiber at a time, and Scope is how you make that ownership survive interruption.
Next
- Why release is safe under a race, and the borrowing contract in full: Mat lifecycle.
- Effect-based resource scoping and stream contracts: ZIO integration.
- Capping inner thread pools, and measuring what parallelism actually buys: Performance.