Summary
Swift 6 strict concurrency requires careful handling of callback-based C APIs, especially when integrating with private frameworks like MultitouchSupport. The challenge arises from uncontrolled thread execution in callbacks, leading to potential data races and concurrency violations. This postmortem explores the root cause, real-world impact, and solutions for bridging such APIs with Swift’s structured concurrency.
Root Cause
- Uncontrolled callback threads: The private framework’s callback (
MTContactCallbackFunction) fires on an internal thread (mt_ThreadedMTEntry), bypassing Swift’s concurrency checks. - Non-Sendable C function types: The callback type cannot be marked as
@Sendable, making it incompatible with Swift’s concurrency model. - Shared mutable state: State like
currentFingerCountis accessed from both the callback thread and the main thread, leading to data races.
Why This Happens in Real Systems
- Legacy C APIs: Many frameworks, especially private ones, rely on callback-based patterns that predate modern concurrency models.
- Lack of thread control: Developers cannot dictate the thread on which callbacks execute, forcing them to implement custom synchronization mechanisms.
- Swift’s strict concurrency: Swift 6 enforces stricter rules for
Sendableconformance, exposing concurrency issues in legacy code.
Real-World Impact
- Data races: Unsynchronized access to shared state (
currentFingerCount) leads to unpredictable behavior and crashes. - Concurrency violations: Using
@unchecked Sendablewithout proper synchronization can cause silent failures or undefined behavior. - Maintenance overhead: Workarounds like
NSLockintroduce complexity and potential performance bottlenecks.
Example or Code (if necessary and relevant)
final class MultitouchManager: @unchecked Sendable {
private let fingerCountLock = NSLock()
private var _currentFingerCount: Int = 0
var currentFingerCount: Int {
get { fingerCountLock.lock(); defer { fingerCountLock.unlock() }; return _currentFingerCount }
set { fingerCountLock.lock(); defer { fingerCountLock.unlock() }; _currentFingerCount = newValue }
}
}
How Senior Engineers Fix It
- Use
Actorfor state isolation: Encapsulate shared state within an actor to ensure thread-safe access. - Bridge callbacks with
DispatchQueue: Dispatch callback execution to a dedicated queue and useasync/awaitfor structured concurrency. - Leverage
UnsafeContinuation: For synchronous callback processing, useUnsafeContinuationto bridge the callback with async code. - Replace locks with
Atomics: For low-level synchronization, useAtomicsinstead of locks for better performance and safety.
Why Juniors Miss It
- Overreliance on
@unchecked Sendable: Juniors often misuse@unchecked Sendablewithout understanding its implications, leading to hidden concurrency bugs. - Lack of understanding of
Actor: Failure to recognizeActoras the idiomatic way to manage shared state in Swift concurrency. - Ignoring thread-safety primitives: Not exploring alternatives like
AtomicsorDispatchQueuefor synchronization.
Key Takeaway: Always isolate shared state using Actor and bridge legacy callbacks with structured concurrency primitives to ensure Swift 6 compliance.