We've maintained a package to close some gaps in async/testing in Swift, and are pretty hopeful to deprecate many of them with this release!
nonisolated(let)
deprecates ourUncheckedSendable
typeMutex
will hopefully deprecate ourLockIsolated
type, andwithTaskExecutorPreference
should deprecatewithMainSerialExecutor
On that final point, with the Xcode 16 beta I finally took task executor preference for a spin to see if we can stop depending on swift_task_enqueueGlobal_hook
in withMainSerialExecutor
, and things seem to work mostly as expected with this (quickly sketched-out) version:
public final class MainExecutor: SerialExecutor, TaskExecutor {
public static let shared = MainExecutor()
public func enqueue(_ job: consuming ExecutorJob) {
job.runSynchronously(
isolatedTo: MainActor.sharedUnownedExecutor,
taskExecutor: asUnownedTaskExecutor()
)
}
}
@MainActor
public func withMainSerialExecutor(
operation: @MainActor @Sendable () async throws -> Void
) async rethrows {
if #available(iOS 18, *) {
return try await withTaskExecutorPreference(MainExecutor.shared, operation: operation)
}
// Fall back to `swift_task_enqueueGlobal_hook`...
}
The one exception in our test suite is a task group test, which uses Task.yield()
to partition the odd values before the even ones:
func testSerializedExecution_YieldEveryOtherValue() async {
let xs = LockIsolated<[Int]>([]) // Mutex<[Int]>
await withMainSerialExecutor {
await withTaskGroup(of: Void.self) { group in
for x in 1...1000 {
group.addTask {
if x.isMultiple(of: 2) { await Task.yield() }
xs.withValue { $0.append(x) }
}
}
}
}
XCTAssertEqual(
(0...499).map { $0 * 2 + 1 } + (1...500).map { $0 * 2 },
xs.value
)
}
While swift_task_enqueueGlobal_hook
makes this fully deterministic and accumulated an array prefixed by all the odd values in order, then all the even values in order, using withTaskExecutorPreference
seems to schedule work less deterministically. Are we missing something crucial here in our implementation? Are the required tools not available yet?