kosikowski/swift-executors
A Swift library providing custom task executors for fine-grained control over concurrency and thread management in Swift applications.
Overview
Swift Executors provides three main executor types that allow you to control how Swift tasks are executed:
- QueueTaskExecutor: A task executor backed by
NSOperationQueuefor controlling concurrency and prioritizing work - DispatchQueueTaskExecutor: A task executor backed by Grand Central Dispatch (GCD) for efficient thread management and performance
- ThreadExecutor: A serial executor and task executor that runs every job on one dedicated thread, for thread-affinity requirements
Features
- Concurrency Control: Limit the number of concurrent operations with
QueueTaskExecutor - GCD Integration: Leverage Grand Central Dispatch with
DispatchQueueTaskExecutorfor efficient thread management - Thread Affinity: Pin an actor, or a task, to one thread with
ThreadExecutorfor frameworks requiring thread-local storage - Isolation Checking:
ThreadExecutorimplementscheckIsolated()andisIsolatingCurrentContext(), soassumeIsolatedworks from callbacks on its thread (see ThreadExecutor for the one exception) - Quality of Service: Configure QoS levels for priority handling
- Swift Concurrency Integration: Seamlessly works with Swift's async/await and structured concurrency
- Apple Platforms: iOS 18+, macOS 15+, tvOS 18+, watchOS 11+ and visionOS 2+
Requirements
- Swift 6.2+ (Xcode 26+)
- iOS 18+, macOS 15+, tvOS 18+, watchOS 11+ or visionOS 2+ (task executors need the Swift runtime that ships with these releases)
Installation
Swift Package Manager
Add the following dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/Kosikowski/swift-executors.git", from: "0.2.0")
]Or add it to your Xcode project:
- File → Add Package Dependencies
- Enter the repository URL
- Select the version you want to use
Usage
QueueTaskExecutor
Use QueueTaskExecutor when you need to control concurrency, prioritize work, or isolate blocking operations from Swift's cooperative thread pool.
import SwiftExecutors
// Create an executor for file I/O operations
let ioExecutor = QueueTaskExecutor(
label: "File-IO",
maxConcurrent: 4, // Limit to 4 concurrent operations
qos: .utility
)
// Use the executor for file operations
func loadFiles(urls: [URL]) async throws -> [Data] {
try await withTaskExecutorPreference(ioExecutor) {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
// This runs on the custom executor
return try Data(contentsOf: url)
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
}DispatchQueueTaskExecutor
Use DispatchQueueTaskExecutor when you want to leverage GCD's efficient thread management, work with existing GCD-based code, or need the performance characteristics of dispatch queues.
import SwiftExecutors
// Create a concurrent executor for network operations
let networkExecutor = DispatchQueueTaskExecutor(
label: "Network",
qos: .userInitiated,
attributes: .concurrent
)
// Use it for network requests
func fetchData(urls: [URL]) async throws -> [Data] {
try await withTaskExecutorPreference(networkExecutor) {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
// This runs on the GCD queue
return try Data(contentsOf: url)
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
}
// Create a serial executor: one job at a time, in submission order
let serialExecutor = DispatchQueueTaskExecutor(
serialLabel: "SerialProcessor",
qos: .utility
)
// Use it for work that must run one item at a time
func processSequentially(_ items: [String]) async -> [String] {
await withTaskExecutorPreference(serialExecutor) {
var results: [String] = []
for item in items {
// One item at a time, in order, on the serial queue. GCD may use
// a different thread for each job, so don't rely on thread identity.
let processed = await processItem(item)
results.append(processed)
}
return results
}
}ThreadExecutor
Use ThreadExecutor when you need thread affinity for:
- Legacy C/C++/Objective-C libraries that rely on thread-local storage
- Frameworks that demand single-thread affinity (Core MIDI, Core Audio)
- Real-time loops that must never hop between cores
Back an actor with it to pin all of that actor's code to one thread:
import SwiftExecutors
actor AudioEngine {
private let executor = ThreadExecutor(name: "AudioThread", qualityOfService: .userInteractive)
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
func render() {
// Always runs on the "AudioThread" thread
}
}It is also a TaskExecutor, so nonisolated async code can prefer it:
let audioExecutor = ThreadExecutor(name: "AudioThread")
func processAudio() async {
await withTaskExecutorPreference(audioExecutor) {
// Written outside any actor, so this closure - and the nonisolated
// async functions it calls - runs on the dedicated thread
await processAudioBuffer()
}
}Write such closures outside actor-isolated code; see nonisolated(nonsending) and @concurrent.
When a thread-affine library delivers a callback on that thread, outside any Swift task, assumeIsolated lets you touch the actor's state synchronously. ThreadExecutor confirms the caller is on its thread, and traps if it is not.
The runtime only asks the executor when the process's main executable was linked against the macOS 15 / iOS 18 SDK or later, which every app built with Xcode 26 is. In a plugin loaded by an older host app, assumeIsolated from a callback can trap even on the right thread.
extension AudioEngine {
/// Called by the audio library on "AudioThread".
nonisolated func handleCallback() {
assumeIsolated { engine in
engine.render()
}
}
}Swift 6.2: nonisolated(nonsending) and @concurrent
This package enables the NonisolatedNonsendingByDefault upcoming feature (SE-0461). With it, a nonisolated async function runs on its caller's executor.
That decides where the closure you pass to withTaskExecutorPreference runs:
- Written in nonisolated code (a free function, a
nonisolatedmethod), the closure
runs on the preferred executor, and so do the nonisolated async functions it calls.
- Written in actor-isolated code (for example a
@MainActormethod), the closure stays
on that actor, and so do the nonisolated async functions it calls. Only @concurrent functions move to the preferred executor.
Mark work that should always move to the preferred executor @concurrent:
@concurrent
func resize(_ image: CGImage) async -> CGImage {
// Runs on the task's preferred executor, or the global pool if none is set
}
await withTaskExecutorPreference(imageProcessor) {
let thumbnail = await resize(image)
}Child tasks (group.addTask) and Task(executorPreference:) closures that are not isolated to an actor always run on the preferred executor.
API Reference
QueueTaskExecutor
public final class QueueTaskExecutor: TaskExecutor {
public init(
label: String = "TaskExec",
maxConcurrent: Int = OperationQueue.defaultMaxConcurrentOperationCount,
qos: QualityOfService = .default
)
}Parameters:
label: Human-readable name for debugging (shows up in Instruments/Xcode)maxConcurrent: Maximum number of simultaneous tasks (throttles throughput). Must be positive, orOperationQueue.defaultMaxConcurrentOperationCount; a limit of 0 traps, since no job would ever runqos: Quality of service for priority handling
DispatchQueueTaskExecutor
public final class DispatchQueueTaskExecutor: TaskExecutor {
public init(
label: String = "DispatchTaskExec",
qos: DispatchQoS = .default,
attributes: DispatchQueue.Attributes = [],
target: DispatchQueue? = nil
)
// Convenience initializers
public convenience init(concurrentLabel: String,
qos: DispatchQoS = .default,
target: DispatchQueue? = nil)
public convenience init(serialLabel: String,
qos: DispatchQoS = .default,
target: DispatchQueue? = nil)
}Parameters:
label: Human-readable name for debugging (shows up in Instruments/Xcode)qos: Quality of service for priority handlingattributes: Queue attributes, e.g..concurrent. The queue starts active even if.initiallyInactiveis passedtarget: Target queue for execution (nil for default)
ThreadExecutor
public final class ThreadExecutor: SerialExecutor, TaskExecutor, @unchecked Sendable {
public init(name: String = "ThreadExecutor", qualityOfService: QualityOfService? = nil)
// Lets other jobs run inside the calling job while `body` spins the run loop
public func allowingNestedJobs<T, E: Error>(_ body: () throws(E) -> T) throws(E) -> T
// Isolation checks used by assumeIsolated / preconditionIsolated
public func checkIsolated()
@available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *)
public func isIsolatingCurrentContext() -> Bool?
}Parameters:
name: Human-readable thread name for debuggingqualityOfService: The thread's quality of service.nil, the default, inherits the creating thread's. Real-time work such as audio should ask for.userInteractiverather than run at the priority of whatever happened to create the executor
The thread starts in init and stops once the executor is released, after the jobs already queued on it have run. If a callback on the thread is running the run loop in the default mode at that moment, that run is stopped once, as CFRunLoopStop would, and the thread stops once the callback returns.
Jobs run in batches, one batch per pass of the thread's run loop, so the run loop's timers, ports and other sources keep firing however busy the executor is.
Jobs never run inside one another. If a job spins the thread's run loop, as some legacy APIs do while they wait for a callback, run-loop sources and callbacks still fire, but other jobs wait until that job returns. Code on the thread that calls CFRunLoopStop does not stop the executor.
So a job that spins while it waits for another job on the same executor, for example a callback that hands its reply to an actor backed by the executor through Task { await engine.handle(reply) }, waits until it times out, or forever: that task only runs once the spinning job returns. Either reach the actor from the callback with assumeIsolated, which runs at once on the thread, await the reply rather than spin, or wrap the spinning call in allowingNestedJobs:
extension AudioEngine {
func connect() {
// Jobs on the executor - the reply's task among them - may now run
// inside this call while the legacy API spins the run loop.
executor.allowingNestedJobs {
legacyDevice.connectAndWaitForReply()
}
}
}Use allowingNestedJobs only where the calling job's state is consistent: as at an await, any code isolated to the executor, the calling actor's own methods included, may run before it returns. Jobs that run inside it do not let further jobs run inside themselves unless they call it too. It must be called on the executor's thread.
Any code on that thread may call it, a run-loop callback included. A callback that fires while a job spins the run loop and calls allowingNestedJobs lets the waiting jobs run inside that job, whether or not the job asked for it.
Every job runs isolated to the executor, so a call from it to a @concurrent function goes back through the run loop instead of continuing inline.
Examples
Background Image Processing
let imageProcessor = QueueTaskExecutor(
label: "ImageProcessing",
maxConcurrent: 2,
qos: .background
)
func processImages(_ images: [UIImage]) async throws -> [UIImage] {
try await withTaskExecutorPreference(imageProcessor) {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for image in images {
group.addTask {
// Heavy image processing on background thread
return await applyFilters(to: image)
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
}Network Operations with GCD
let networkExecutor = DispatchQueueTaskExecutor(
label: "NetworkOperations",
qos: .userInitiated,
attributes: .concurrent
)
func downloadMultipleFiles(_ urls: [URL]) async throws -> [Data] {
try await withTaskExecutorPreference(networkExecutor) {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
// Concurrent downloads using GCD's efficient thread pool
return try await downloadFile(from: url)
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
}Audio Processing with Thread Affinity
let audioThread = ThreadExecutor(name: "AudioProcessor")
func startAudioProcessing() async throws {
try await withTaskExecutorPreference(audioThread) {
// Written outside any actor, so all audio processing happens on
// the same thread. This prevents audio glitches from thread hopping
while isProcessing {
await processAudioFrame()
try await Task.sleep(for: .seconds(1.0 / sampleRate))
}
}
}Contributing
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Submit a pull request
License
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
Acknowledgments
This project demonstrates advanced Swift concurrency patterns and provides practical solutions for real-world threading requirements in Swift applications.
Package Metadata
Repository: kosikowski/swift-executors
Default branch: main
README: README.md