Concurrency

Concurrency

This page documents concurrency in the RawCullAIModels branch of RawCull and in the first-party Swift packages checked out beside it. The audit covers production source in the RawCull app, DecodeEncodeGeneric, ParseRsyncOutput, PhotoAIKit, PhotoAnalysisKit, RawCullCore, RawParserKit, RsyncArguments, RsyncProcessStreaming, and RsyncAnalyse.

RawCull uses concurrency to keep SwiftUI responsive while it scans folders, reads RAW metadata, extracts and caches previews, calculates sharpness, creates similarity artifacts, groups bursts, runs AI models, downloads model assets, and streams rsync output.

The rule: isolate work, do not guess about threads

A Swift Task is not a thread. Swift schedules tasks on executors, and executors use threads. At an await, a task may suspend and later resume on a different worker thread even when it resumes on the same actor. RawCull must never depend on the worker thread being unchanged.

In this document, hop means a change of actor, executor, or dispatch queue that is required or explicitly requested by the source code:

  • Calling an actor-isolated method from outside that actor hops to the actor’s executor.
  • @MainActor, MainActor.run, and Task { @MainActor in ... } hop to the main actor when the caller is elsewhere.
  • @concurrent and Task { @concurrent in ... } explicitly move non-UI work off inherited actor isolation and onto the concurrent executor.
  • A task-group child is independently scheduled so that sibling work can run in parallel.
  • Task.detached creates an unstructured task that does not inherit actor isolation.
  • DispatchQueue.async submits work to the named GCD queue.

None of await, Task.sleep, task priority, or cancellation alone requests an executor hop. They may cause suspension and a later change of worker thread, but that is scheduler behavior and must not be observed as application logic.

Every executor hop in RawCull must be intentional. Add an explicit isolation annotation or concurrency primitive and document the reason. A change of worker thread after suspension is neither an error nor a behavior RawCull may rely on.

Compiler concurrency model

TargetLanguage and isolation settingsConsequence
RawCull appSwift 6, Approachable Concurrency, default MainActor isolationSwift 6 enforces data-race safety, and unannotated app declarations commonly begin main-actor isolated. Background work must opt out deliberately. The test target additionally spells out SWIFT_STRICT_CONCURRENCY = complete.
RawCullCoreSwift 6, default MainActor, InferIsolatedConformances, NonisolatedNonsendingByDefaultPure models and algorithms are explicitly nonisolated so they can run in the caller’s non-UI domain.
RawParserKitSwift 6, default MainActor, InferIsolatedConformances, NonisolatedNonsendingByDefaultParser utilities opt out with nonisolated; stateful loaders and limiters use actors.
PhotoAnalysisKitSwift 6, InferIsolatedConformances, NonisolatedNonsendingByDefaultAnalysis APIs are not main-actor-owned; explicit workers and task groups provide concurrency.
PhotoAIKitSwift 6Model runtimes, stores, and indexes use actors; workflows use bounded structured concurrency.
Remaining packagesSwift 6 toolchains; no package-level default actor isolationIsolation is supplied by declarations such as @MainActor or actor.

swift-tools-version selects the PackageDescription API and minimum toolchain. It is not, by itself, proof of the package’s Swift language mode; packages that declare swiftLanguageModes: [.v6] do so explicitly.

Concurrency architecture

flowchart TD
    UI["SwiftUI and @MainActor state"]
    APP["RawCull background actors"]
    GROUPS["Bounded task groups and async let"]
    PARSER["RawParserKit actors and ImageIO bridge"]
    ANALYSIS["PhotoAnalysisKit workers"]
    AI["PhotoAIKit model and storage actors"]
    CORE["RawCullCore nonisolated Sendable values"]
    RSYNC["Rsync callbacks, StreamAccumulator actor, GCD drain queue"]
    GCD["GCD worker threads for blocking framework and pipe work"]

    UI -->|"await: intentional actor hop"| APP
    UI -->|"@concurrent: intentional exit from MainActor"| GROUPS
    APP --> GROUPS
    APP -->|"await"| PARSER
    GROUPS --> ANALYSIS
    GROUPS --> AI
    APP --> CORE
    UI --> RSYNC
    PARSER --> GCD
    RSYNC --> GCD
    GCD -->|"Task @MainActor"| UI

1. Main-actor concurrency

The main actor owns SwiftUI and user-visible mutable state. This is correctness isolation, not a way to make work parallel.

Main-actor ownerResponsibility
RawCullViewModelCatalog lifecycle, selection, culling state, thumbnail and burst coordination
CullingModelRatings and saved culling state
SharpnessScoringModel, FocusMaskModelObservable scoring options, results, calibration, and focus-mask presentation
SimilarityScoringModelSimilarity artifacts, semantic search, burst groups, ranking, and progress
RawCullAISettingsModel, DeepAIReviewFeatureModel availability/download UI and deep-review UI state
SettingsViewModelSettings snapshots and persistence coordination
MemoryViewModel, MemoryDiagnosticsViewModel, SimilarityDiagnosticsViewModelDiagnostic state presented by SwiftUI
GridThumbnailViewModel, ComparisonGridImageCoordinatorView-specific selection and image-loading state
ExecuteCopyFiles, Params, ArgumentsSynchronize, RemoteDataNumbersrsync UI state and parameters
RawCullAIIntegrationMain-actor capability snapshot and service selection
CreateStreamingHandlers, ReadSavedFilesJSONApp-facing callbacks and state application

SwiftUI .task modifiers and button-created Task values inherit main-actor isolation when their synchronous prefix reads or mutates view state. That entry is intentional. They then hop only when awaiting a background actor or an explicitly concurrent function, and resume on the main actor before changing UI state.

DispatchQueue.main.async is used in the comparison filmstrip and vertical image table to defer scrollTo by one run-loop turn so lazy view IDs have entered scroll geometry. These calls do not exist merely to reach the main thread; they intentionally delay already-main-actor UI work.

2. Actors that serialize mutable state

An actor hop is wanted because it establishes exclusive access to the actor’s state. It may suspend the caller, but it does not promise a particular worker thread.

RawCull app actors

GroupActorsSerialized state or operation
Catalog and batch workScanFiles, ScanAndCreateThumbnails, ScanAndExtractJPGs, ExtractAndSaveJPGsScan state, stored batch task, progress counters, ETA, and cancellation
Thumbnail flowThumbnailLoader, RequestThumbnailAdmission slots, waiters, settings snapshots, one-time setup, and per-request cache flow
Cache and persistenceSharedMemoryCache, DiskCacheManager, FullSizeJPGDiskCache, BurstAnalysisCache, PerFileAnalysisArtifactStore, WriteSavedFilesJSON, SaveJPGImageCache configuration, disk-cache operations, atomic JSON/artifact commits, schema validation, and writes
DiagnosticsSimilarityDiagnosticsLogOrdered diagnostic log writes
AI resources and downloadsRawCullAIModelResourceManager, RawCullManagedBackgroundAssetsModelDownloadService, RawCullAIModelDownloadCoordinator, RawCullAIModelLicenceAcceptanceFileStoreProvider reuse, model download state, licence acceptance, and file-backed records
AI recoveryRawCullCLIPFailureRecorder, RawCullRecoveringCLIPArtifactProviderOrdered failure collection and primary-to-fallback provider decisions

SharedMemoryCache deliberately exposes its NSCache instances through nonisolated(unsafe): NSCache supplies its own thread safety. Synchronous counters, eviction history, and memory-pressure samples use OSAllocatedUnfairLock. This avoids an actor hop in synchronous cache delegate callbacks while retaining an explicit synchronization invariant.

Package actors

PackageActorsPurpose of isolation
RawParserKitRawImageLoader, DecodeConcurrencyLimiterCoalesce duplicate image/metadata tasks and bound full-size and thumbnail decodes
PhotoAnalysisKitVisionFeaturePrintBackendSerialize its Vision feature-print request state
PhotoAIKitCoreAICLIPProvider, CoreAISAM3Provider, VisionFeaturePrintBackend, SegmentationService, SubjectMaskCatalogIndex, SubjectMaskMemoryStore, SubjectMaskDiskStore, private ProgressRecorderOwn non-Sendable model runtimes, lazy model loads, repositories, cache inventory, disk/memory masks, and progress
RsyncProcessStreamingStreamAccumulatorPreserve partial stdout lines, errors, and counters across callback arrivals
RsyncAnalyseActorRsyncOutputAnalyserSerialize access to the parsed-output cache while returning Sendable analysis values

PhotoAIKit’s Core AI model providers are actors because model loading and inference resources are shared and not safe to use concurrently without ownership. An await from a RawCull workflow to a provider is therefore an intentional provider-actor hop. The pinned coreai-models implementation may use its own async execution internally; RawCull does not assume which thread it uses.

3. Structured parallelism

Structured child tasks inherit cancellation and cannot outlive their scope. RawCull uses them when operations are independent and results must be joined.

SitePrimitiveWork and concurrency limitWhy independent scheduling is wanted
ScanFiles.scanFileswithTaskGroupMetadata and native focus-point read per fileIndependent files can be decoded concurrently.
ScanAndCreateThumbnails, ScanAndExtractJPGs, ExtractAndSaveJPGswithTaskGroupPreview/cache/export work capped by activeProcessorCount * 2Bounded parallel file work improves throughput without unbounded bitmap allocation.
PhotoAnalysisKit.analyzeBatch and input loadingwithTaskGroupSliding window, caller-configured limit, default 8Loading and scoring different images are independent; output is restored to input order.
PhotoAnalysisKit calibrationwithTaskGroupSliding window of image energy calculationsCalibration samples are independent and cancellation discards partial output.
PhotoAIKit.EmbeddingIndexer, SimilarityArtifactIndexerwithThrowingTaskGroupSliding window controlled by maximumConcurrentTasksDecode/provider/store work overlaps while progress remains completion ordered.
Cache settingsfour async let childrenThumbnail, full-size preview, similarity-artifact, and burst-cache usageFour unrelated actor queries can complete in parallel.
AI capability refreshthree async let childrenSAM3 and two CLIP resource managersModel bundles are validated independently.
AI settings refreshtwo async let childrenCapability refresh and saved-evidence scanIndependent inputs are joined before one UI update.

Task-group children are scheduled independently of the actor running the group. That is the explicit parallelism boundary. Each child captures Sendable values and calls the actor or provider that owns mutable state.

4. Unstructured tasks and lifetime

RawCull creates unstructured tasks to bridge synchronous UI/callback entry points, coalesce duplicate requests, and retain cancellable long-running work.

PatternRepresentative ownersLifetime rule
Main-actor workflow taskRawCullViewModel, SharpnessScoringModel, SimilarityScoringModel, DeepAIReviewFeature, RawCullAISettingsModelStore the task, cancel the previous generation, check cancellation/generation before publishing results.
Actor-owned single-flight taskRawImageLoader, RequestThumbnail, SharedMemoryCacheReuse an in-flight task for the same setup or resource and remove it after awaiting the value.
Actor-owned batch taskscan, preload, export, catalog-index actorsActor serializes replacement; cancellation is propagated to child work.
SwiftUI .taskthumbnail, zoom, settings, diagnostics, and search viewsSwiftUI cancels it with view identity/lifetime; stored sub-tasks are cancelled on replacement.
Fire-and-forget notificationpreload/extraction progress and synchronous cancellation entry pointsThe task contains a single explicit actor call and owns no result needed by its creator.

Important stored tasks include catalog load, thumbnail preload, full-size cache warming, zoom extraction, burst analysis, similarity and semantic hydration, scoring, semantic search, model download, deep review, memory sampling, mask loading, and image-source loading. Their exact properties live with the owning view model or actor; ownership, not thread identity, defines their lifetime.

A bare Task { ... } inherits the surrounding actor. It does not hop away merely because it is asynchronous. This is wanted when its synchronous prefix accesses actor-owned state. If a new task has no actor work before its first await, use Task { @concurrent in ... } or call an explicitly @concurrent function so that leaving the main actor is visible in source.

5. Explicit concurrent-executor work

@concurrent is the Swift 6.2 spelling RawCull uses for work that must not inherit the caller’s actor. It is an executor hop, not a guarantee that a new OS thread will be created.

SiteWork moved off inherited isolationWhy the hop is wanted
CreateOutputforView.createOutputForViewParse collected rsync linesParsing is CPU work with no UI state.
ScanFiles.sortFilesSort immutable file snapshotsSorting may be large and does not need the scan actor or main actor.
RawCullSavedBurstEvidenceScanner.scanDirectory enumeration and JSON decodingCapability evidence scanning is file/CPU work, not UI work.
RawCullSemanticSearchService.rankText encoding and candidate rankingRanking can be substantial and publishes only Sendable progress/results.
RawCullDeepReviewImageDecoder.image, RawCullDeepAIReviewPipeline.reviewRAW/Core Image decode and review pipelineExpensive review must not occupy inherited main-actor isolation.
SubjectMaskFocusScorer.scoreMask-weighted focus scoringPure image analysis is non-UI CPU work.
SimilarityScoringModel workersArtifact indexing, semantic search, burst grouping, pairwise distance workMain-actor model owns publication; computation runs off it.
RawCullViewModel+DiagnosticsDiagnostic extractionFile diagnostics are computed away from UI and returned through MainActor.run.
RawCullAIModelDownloadService progress taskConsume asset status updatesThe stream wait is not UI-owned; its callback explicitly hops to @MainActor.
PhotoAnalysisKit.FocusMaskEngine workerSynchronous Core Image/Vision operationThe operation must not inherit UI isolation; cancellation cancels the worker.
PhotoAIKit.SubjectMaskDiskStore and storage-key readsSynchronous disk, identity, PNG, and JSON workExplicitly leave the store actor so the actor is not occupied during file work.

The PhotoAIKit disk-store tasks intentionally leave actor isolation, but @concurrent still uses Swift’s cooperative executor. It is appropriate for short bounded operations; any operation shown by profiling to block for a long time should use a dedicated blocking-I/O bridge like RawParserKit’s GCD continuation bridge.

6. Detached tasks and blocking work

Task.detached is used only where losing actor inheritance is deliberate. It does not inherit actor isolation or task-local values, and cancellation is not automatically structured unless the parent explicitly awaits and/or cancels it.

AreaDetached workReason
Catalog discovery and sidecar readsDirectory enumeration, Data(contentsOf:)Synchronous file work must not run on the main or scan actor.
Thumbnail and preview cachesImage decode, JPEG conversion, disk reads/writes, size calculation, pruningImageIO and filesystem work should not occupy cache actors.
RawParserKit.RawImageLoaderSidecar/preview decode and metadata readsExpensive synchronous ImageIO is separated from loader serialization.
Settings and burst cacheJSON reads/writes, cache usage, clearBlocking persistence is performed outside UI/actor isolation.
Memory diagnosticsMach and VM statisticsSystem calls are sampled away from the main actor, followed by MainActor.run.
Zoom/full-size previewPreview load and sharpeningPixel work is kept off UI isolation.

RawParserKit’s CancellableImageIOWork is the stronger escape hatch for blocking framework calls. It uses withCheckedThrowingContinuation and submits the operation to DispatchQueue.global(qos:). This is an intentional queue hop: blocking ImageIO/CoreImage work runs on a GCD worker rather than occupying a Swift cooperative-pool thread. ImageIOCancellationToken and WorkState use OSAllocatedUnfairLock so cancellation can race safely with exactly-once continuation resumption.

The queue submission may run on a different worker thread, but GCD does not promise a fresh thread or a stable thread identity. The guarantee RawCull relies on is queue/executor separation, not a thread number.

7. Async streams, callbacks, and continuations

BoundaryMechanismIsolation behavior
Memory settings timerAsyncStream plus a producer taskTask.sleep suspends between ticks; it does not request a hop. SwiftUI owns and cancels the consumer, and termination cancels the producer.
Copy progressAsyncStream<Int>Rsync callbacks yield Sendable progress; the main-actor owner consumes and presents it.
Managed model downloadframework AsyncSequenceA Task { @concurrent in ... } consumes updates, then awaits an explicitly main-actor progress closure.
Thumbnail/decode admissionchecked continuations plus cancellation handlersActors park callers without blocking a thread and resume each continuation exactly once when a slot is transferred or cancelled.
ImageIO bridgechecked throwing continuationA GCD callback resumes the Swift task; the waiting task resumes on its own required executor, not on the callback’s thread by contract.
rsync pipes and PTYFileHandle/DispatchSourceRead callbacksCallbacks arrive outside main-actor isolation; Task { @MainActor in ... } explicitly returns to UI/process state.

8. GCD, locks, and framework callbacks

GCD remains where the source API is callback-based or blocking:

  • RsyncProcessStreaming uses a named serial termination queue to wait briefly, drain stdout/stderr, clear handlers, and then creates Task { @MainActor in ... } to finalize process state. The first hop avoids blocking the main actor; the return hop protects main-actor process/UI state.
  • PTY and pipe callbacks create main-actor tasks before accessing RsyncProcess.
  • A short DispatchQueue.main.asyncAfter in PTY cleanup deliberately defers final drain/close; it is a timing boundary, not an accidental thread correction.
  • RawParserKit’s ImageIO bridge uses a global GCD queue specifically for blocking decode work.
  • The two SwiftUI DispatchQueue.main.async calls deliberately defer scrolling by one run loop.

Locks are limited to synchronous callback-compatible state:

  • SharedMemoryCache and CacheDelegate use OSAllocatedUnfairLock for counters, pressure state, and eviction history.
  • ImageIOCancellationToken uses it for the cancellation bit.
  • WorkState uses it to enforce exactly-once continuation completion.

Code must never hold one of these locks across await, call arbitrary client code while holding it, or use it as a substitute for actor ownership of asynchronous mutable state.

9. Sendable and nonisolated data flow

Values crossing task and actor boundaries are immutable or explicitly Sendable:

  • RawCullCore declares its catalog, EXIF, saliency, burst grouping, boundary, ranking, and review values nonisolated and Sendable. Its algorithms are synchronous and stateless; concurrency is supplied by callers.
  • RawParserKit exposes Sendable metadata/value types and actor-owned loaders. Images are consumed inside their owning isolation domain or encoded to Data before crossing a detached-task boundary.
  • PhotoAnalysisKit batch requests contain @Sendable async input providers. FocusMaskEngine is @unchecked Sendable under the documented invariant that its CIContext has no mutable model/UI state and every operation receives immutable configuration.
  • PhotoAIKit contracts are Sendable values and @Sendable provider/progress closures. CoreAISAM3Provider temporarily marks upstream runtime types @unchecked Sendable; the documented invariant is that they never leave that actor, and the conformances should be removed when upstream supplies correct annotations.
  • CachedThumbnail, rsync ProcessHandlers, and lock/continuation bridge state use @unchecked Sendable only with a local immutability or synchronization invariant.

nonisolated does not mean “background thread.” It means the declaration is not owned by an actor. A synchronous nonisolated function runs on the calling thread; an async one follows its declared/caller isolation rules. Use @concurrent when execution must explicitly leave inherited actor isolation.

Package coverage

PackageConcurrency present in production code
DecodeEncodeGenericAsync URLSession decode APIs. await URLSession.data(from:) suspends without promising a thread hop; the package owns no actor or task.
ParseRsyncOutputThe stateful parser class is @MainActor; parsing is serialized with its app-facing state. RawCull’s CreateOutputforView separately moves bulk line transformation off main actor.
PhotoAIKitEight actors, bounded throwing task groups, actor-owned catalog task, explicit concurrent disk/identity tasks, Sendable contracts, cancellation checks, and async progress callbacks.
PhotoAnalysisKitVision actor, bounded task groups, explicit concurrent cancellable Core Image/Vision worker, Sendable inputs/results.
RawCullCoreNo tasks or actors. Explicitly nonisolated, Sendable value models and pure algorithms support safe use from any isolation domain.
RawParserKitLoader and limiter actors, task coalescing, cancellation-aware admission continuations, detached ImageIO tasks, and a GCD/continuation bridge for blocking work.
RsyncProcessStreamingMain-actor process manager, accumulator actor, pipe/PTY callbacks, main-actor return tasks, a serial drain queue, dispatch source, timer, and Sendable handler boundary.
RsyncArgumentsNo asynchronous work, tasks, actors, queues, or locks; it builds argument values synchronously.
RsyncAnalyseActorRsyncOutputAnalyser protects its mutable analysis cache. Its parsing methods are synchronous actor operations and return nested Sendable result, statistics, change, and flag values. It is adjacent to RawCull but is not a direct package reference of the app target.

The app also pins external packages through PhotoAIKit, including Apple’s coreai-models. Their source is not part of the first-party checkouts under /Users/thomas/GitHub/RawCull; RawCull treats their async APIs as suspension points and does not make thread-affinity claims about their internals.

Cancellation, stale results, and backpressure

Cancellation is cooperative. Long-running loops check Task.isCancelled or Task.checkCancellation() before expensive steps and after suspension points. Task groups call cancelAll() when abandoning partial work. Continuation-based limiters remove and resume cancelled waiters. Framework work receives an explicit cancellation token where the underlying synchronous API cannot observe Swift task cancellation directly.

Cancellation alone does not prevent an old operation from publishing late. Main-actor workflows also use task identity, generation counters, selected catalog IDs, source IDs, or current-query checks before applying results. RawCullViewModel.cancelCatalogLoad() cancels catalog-related tasks, tells actors to cancel their inner work, resets dependent analysis state, and releases the active security-scoped resource.

Backpressure is intentional:

  • RAW thumbnail decoding is capped at 6 and full-size decoding at 2 in RawParserKit.
  • App batch extraction uses max(1, activeProcessorCount * 2).
  • PhotoAnalysisKit and PhotoAIKit batch APIs maintain a sliding window rather than enqueueing every file at once.
  • ThumbnailLoader parks excess callers with continuations instead of blocking threads.

Hop audit checklist

Before adding or changing concurrent work, answer these questions in code review:

  1. Who owns the mutable state? Use @MainActor for UI state and an actor for shared background state.
  2. Does the synchronous prefix need that actor? Keep a bare inherited Task only when work before the first await uses actor-owned state. Otherwise make the exit explicit with @concurrent.
  3. Is parallelism structured? Prefer async let or a bounded task group. Store an unstructured task only when a synchronous entry point or independently cancellable lifetime requires it.
  4. Is the operation blocking? An actor or @concurrent prevents main-actor occupation but does not make blocking safe for Swift’s cooperative pool. Use the GCD continuation bridge for long ImageIO/CoreImage or pipe-drain work.
  5. What causes each hop? Name the actor call, @concurrent, detached task, group child, MainActor.run, or dispatch queue. Do not write “await moves to a background thread.”
  6. How does work return? UI publication must occur through main-actor isolation; continuation waiters resume on their required executor automatically.
  7. What crosses the boundary? Transfer Sendable values. Encode non-Sendable images to Data, or consume them inside the actor that owns them.
  8. How does it stop? Define task ownership, cancellation checks, continuation cleanup, and stale-result rejection.
  9. Is a thread change being observed? Remove correctness logic based on Thread.current or thread IDs. Thread logging is diagnostic only.
  10. Is every hop wanted? If no correctness, responsiveness, or parallelism reason can be written beside the boundary, remove the boundary rather than relying on an accidental scheduling effect.

Last modified August 1, 2026: update (9c803af)