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 ourUncheckedSendabletypeMutexwill hopefully deprecate ourLockIsolatedtype, andwithTaskExecutorPreferenceshould 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?