Search code examples
swiftasync-awaitconcurrency

In Swift async/await, can I use Lock or Semaphore


This is not a issue, this is a question that wants to ask for help as well as professional guidance.

According to the documentation, Sendable types can be safely passed in Swift Concurrency. In old project not all types are Sendable,and may use Cocoa Types, but they are thread safe, for example :

class DemoPerson: NSObject {

  ...

  private var _phoneNumbers: NSMutableArray = NSMutableArray()
  private var lock = NSLock
  var phoneNumbers: NSArray {
     return _phoneNumbers.copy()
  }

  ...

  func removePhoneNumber(at index: Int) {
    lock.lock()
    ...
    lock.unlock()
  }


  func addPhoneNumber() {
    lock.lock()
    ...
    lock.unlock()
  }

  ...

In addition, there are system classes that are also thread-safe, such as UserDefaults. Perhaps they use techniques like locks, semaphores, atoms, and so on.

I think those are Internally Synchronized Reference Types, and can be safety used in concurrent.

In Sendable document, Apple Say

To declare conformance to Sendable without any compiler enforcement, write @unchecked Sendable. You are responsible for the correctness of unchecked sendable types, for example, by protecting all access to its state with a lock or a queue. Unchecked conformance to Sendable also disables enforcement of the rule that conformance must be in the same file.

But Apple also not recommend using synchronization mechanisms such as locks in new concurrency, which can cause unpredictable problems. I agree with that, because lock can cause the current thread to hang, async/await is characterized by not blocking the current thread. And lock makes priority processing very difficult.

So can I mark them as @unchecked, treat them like normal Sendable types, without causing async/await to produce unpredictable bugs?


Solution

  • You ask:

    In Swift async/await, can I use Lock or Semaphore

    In short, you can, but do so with extreme care. You can use locks to synchronize some quick access to some mutable property. But do not use locks to manage dependencies between separate Swift concurrency tasks or for anything that potentially could be slow (e.g., writing to persistent store, performing network requests, etc.).


    In WWDC 2021 video Swift concurrency: Behind the scenes, they warn us that locks are permissible, but must be used with “caution”, i.e., in very specific use-cases:

    Primitives like os_unfair_locks and NSLocks are also safe but caution is required when using them. Using a lock in synchronous code is safe when used for data synchronization around a tight, well-known critical section.

    So, if it is for a very quick thread-safe synchronization for some state property, locks can be used (but with care). In fact, in WWDC 2023’s Beyond the basics of structured concurrency, they explicitly contemplate that scenario, using a final class that is Sendable:

    While actors are great for protecting encapsulated state, we want to modify and read individual properties on our state machine, so actors aren't quite the right tool for this.

    Furthermore, … we could use a dispatch queue or locks.

    Now, in their example, they encapsulated the property in a ManagedAtomic, so they did not need to use @unchecked, but if you use a lock around a mutable variable, you would designate as such.

    E.g., here is an @unchecked Sendable rendition using locks:

    final class DemoPerson: @unchecked Sendable {
        private var _phoneNumbers: Array<PhoneNumber> = []
        private let lock = NSLock()
        
        var phoneNumbers: Array<PhoneNumber> {
            lock.withLock { _phoneNumbers }
        }
        
        @discardableResult
        func removePhoneNumber(at index: Int) -> PhoneNumber {
            lock.withLock { _phoneNumbers.remove(at: index) }
        }
        
        func addPhoneNumber(_ phoneNumber: PhoneNumber) {
            lock.withLock { _phoneNumbers.append(phoneNumber) }
        }
    }
    

    Bottom line, a final class that is @unchecked Sendable can use locks. It is a little brittle and we would generally prefer Swift concurrency primitives that offer compile-time checks. But, a lock is compatible with Swift concurrency (as long as it is limited to “tight, well-known critical section”). It is the whole purpose of providing @unchecked Sendable pattern: It allows us to gracefully transition to Swift concurrency at our own pace. But, we bear the burden for ensuring the thread-safety of our code.


    A couple of minor observations:

    • Note that the original code snippet is not thread-safe, as the phoneNumber computed property was not synchronized; with @unchecked Sendable, we lose the compile-time checking of our code; I have fixed that, above, but it is a great illustration why we shy away from @unchecked patterns, as it makes it too easy to have some edge-case slip past us undetected;

    • Rather than manually lock and unlock, we might prefer withLock, which guarantees that the locking and unlocking is automatically balanced; in this case, the logic is so trivial that it is less critical, but if you have more complicated code flow, manually locking and unlocking can become more complicated to follow and is more brittle; besides, withLock reduces a little syntactic noise in our code snippet, makes the intent of the lock more explicit, etc.

    A few unrelated observations:

    • I would make the lock an immutable (let) property;

    • I would prefer to use value types (e.g., Swift Array, etc.) rather than NSMutableArray … also, while you can use NSMutableArray, be careful about the objects in that array … note that copy only performs a shallow copy of the array and you can easily end up with unintended sharing and unsynchronized interaction with the objects in that array; and

    • If you need NSObject interface, feel free to do that, but generally we do not do that in our Swift codebases; I have removed it in the above example, but if you need NSObject subclass for some compelling reason, feel free to do so.