1

I want to have a Sendable struct, which contains a closure. This closure takes in a reference type, but returns Void, therefore the Greeter doesn't directly stores the Person reference. However, closures themselves are still references anyway.

Current code:

class Person {
    let name: String

    init(name: String) {
        self.name = name
    }
}

struct Greeter: Sendable { // <- Trying to make this Sendable
    let greet: (Person) -> Void

    init(greeting: String) {
        greet = { person in
            print("\(greeting), \(person.name)!")
        }
    }
}
let person = Person(name: "George")
let greeter = Greeter(greeting: "Hello")
greeter.greet(person)

// Hello, George!

In my actual problem (this is simplified) I don't actually know Person's implementation and so can't mark it Sendable. It's actually a MTLRenderCommandEncoder, but for simplicity we just have Person.

On the greet definition, I get the following warning:

Stored property 'greet' of 'Sendable'-conforming struct 'Greeter' has non-sendable type '(Person) -> Void'

I can make the warnings go away, but I don't think it's the safe & correct solution:

struct Greeter: Sendable {
    let greet: @Sendable (Person) -> Void

    init(greeting: String) {
        greet = { @Sendable person in
            print("\(greeting), \(person.name)!")
        }
    }
}

How can I be sure this code is safe across threads?

George
  • 25,988
  • 10
  • 79
  • 133
  • Worth adding (but not warranting an answer). Because I was interfacing with Metal and so don't know the actual implementation (that `Person` mocked in this case) and it wasn't `Sendable`, concurrency is made generally unsafe. I have a solution. I've created a pattern which works well for me, where I have an `actor` to set properties etc which is a 'mini' version of the actual Metal class. I then have some sort of `nonisolated` `make()` function which `await`s/`async let`s the actor's properties then returns the Metal class instance it creates. Hope that may be useful to others too. – George Feb 03 '22 at 03:55

1 Answers1

1

I reason that you can’t. Someone else may have a reference to Person, modify it concurrently and break your assumptions.

But you could create a PersonWrapper: @unchecked Sendable that duplicates Person if there is more than one reference or stores it as a serialized Sendable type. This may be expensive but it will be safe. You may also have to lock if you make changes, and return duplicates instead the real thing.

A trivial example:

public struct SendableURL: Sendable {
    private let absoluteString: String
    public init(_ url: URL) {
        self.absoluteString = url.absoluteString
    }
    public var url: URL {
        URL(string: absoluteString)! 
    }
}

The version that deals with non serializable objects would be:

public final class SendablePerson: @unchecked Sendable {
    private let _person: Person
    private init(_ person: Person) {
        self._person = person
    }
    public static func create(_ person: inout Person) -> SendablePerson? {
        let person = isKnownUniquelyReferenced(&person) ? person : person.copy()
        return SendablePerson(person)
    }
    public func personCopy() -> Person {
        _person.copy()
    }
}

What do you think? I reason that as long as you avoid shared mutable state you should be fine. If you are unable to copy the object you depend on it not being modified.

In practice, we do unsafe things every day (e.g. passing a Data/UIImage, etc.) through threads. The only difference is that SC is more restrictive to avoid data races in all cases, and let the compiler reason about concurrency.

I’m trying to figure out this stuff in the face of ever increasing warnings levels in Xcode, and lack of guidance. ‍♂️


Make it an actor:

public final actor SendablePerson: @unchecked Sendable {
    // ...
    public func add(_ things: [Something]) -> Person {
        _person.array.append(contentsOf: things)
        return _person
    }
}

or start every instance method with a lock()/unlock().

public final class SendablePerson: @unchecked Sendable {
    // ...
    private let lock = NSLock()

    public func add(_ things: [Something]) {
        lock.lock()
        defer { lock.unlock() }
        _person.array.append(contentsOf: things)
        return _person
    }

    // or 
    public func withPerson(_ action: (Person)->Void) {
        lock.lock()
        defer { lock.unlock() }
        action(_person)
    }
}

In both cases every method will execute fully before another method is called. If one locked method calls another locked method replace NSLock with NSRecursiveLock.

If you can’t hand Person copies, be mindful not to pass references to code that stores and mutates Person outside your wrapper.

The create/copy thing:

  • If a thread is changing the state of Person concurrently I have no guarantee that what I read from Person will still be true before I act on it. But If I hand copies, I know threads will at most modify their own copy.
  • The create is a way to create a wrapper to attempt to synchronize changes.

The root of all concurrency problems is mutable shared state. And the way to solve them is to either prevent access, make the state immutable, or provide orderly access to the state.

Jano
  • 62,815
  • 21
  • 164
  • 192
  • Really appreciate the answer. I agree with that last point, concurrency feels really early on but just confusing in general. Apple is still in the process of deciding which types are `Sendable`, so that's why in my real example with Metal I don't know yet and have to assume it isn't. The second code sample looks promising - an example usage would be really helpful (when to use `create(_:)` vs `personCopy()`). – George Feb 02 '22 at 17:06
  • For simplicity sake, pretend `Person` contains an array of something. This thing will change over time (specifically where we currently use `print()`), but I need _every_ single change to be added (no race conditions where 2 things are added at the same time causing only one to be kept). Is there a way to basically wrap this `Person` so every change is in a lock to prevent more changes until the last is finished, rather than just creating copies which could cause the data to be out of sync? – George Feb 02 '22 at 17:10
  • Swift concurrency is definitely more complicated than I initially imagined - very well designed but wildly different from basic `async`/`await` in JS haha – George Feb 02 '22 at 17:11
  • Answered above. – Jano Feb 02 '22 at 19:15
  • Thank you! Just a smaller note, I'm not sure how that `actor SendablePerson` would work as you can't store `Person` (`_person`) since it's not `Sendable`. The class with locks seems like the way to go though – George Feb 02 '22 at 19:21
  • You could do `extension Person: @unchecked Sendable {}` to suppress the warning. In any case (actor/locks) you are responsible to manage Person. – Jano Feb 02 '22 at 20:41