Why Is It Legal to Mutate an Actor's Nonsendable Property

why is it legal to mutate an actor's nonSendable property?

You are right that such access is unsafe and Swift 5.5 today does not prevent this, unless you pass the -warn-concurrency flag explicitly.

Please refer to the Staging in Sendable checking proposal (and forums post which discuss the rollout plan of the checking feature).

You can also read about the general plan with regards to concurrency safety between now in Swift 5.5 and Swift 6 in the roadmap update here: Concurrency in Swift 5 and 6.

CoreData and Swift's Actor and Sendable

By now I understand the situation a little better.

LocalStore is declared as Sendable, i.e. it should be possible to be passed around thread-safe. Now assume its property localPersistentContainer had been declared as follows:

let localPersistentContainer = NSPersistentContainer()  

NSPersistentContainer has some properties that can be set, like persistentStoreDescriptions. It is thus possible that such a property can be changed within LocalStore, as well as by anything that has been passed LocalStore to, i.e. this would not be thread-safe. In this case the warning

Stored property 'localPersistentContainer' of 'Sendable'-conforming struct 'LocalStore' has non-sendable type 'NSPersistentContainer'  

is justified.

However, in my case localPersistentContainer has been declared as follows:

private let localPersistentContainer = NSPersistentContainer()  

This means that anything that receives a LocalStore cannot access the private property localPersistentContainer, and thus the compiler warning is not justified, since the compiler is of course aware of that.

As the other compiler warning suggested,

@_predatesConcurrency import CoreData  

suppresses all Sendable warnings created by the CoreData module. While this works, it is to my mind dangerous because it suppresses also justified warnings.

Is it safe to make a struct containing a closure Sendable?

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.

Swift, actor: Actor-isolated property 'scanning' can not be mutated from a non-isolated context


How can I use an actor to store/read state information correctly so the MyObj can use it to read and set state?

You cannot mutate an actor's instance variables from outside the actor. That is the whole point of actors!

Instead, give the actor a method that sets its own instance variable. You will then be able to call that method (with await).

Apple's description of reference and value types with multiple threads

As others have pointed out, reference types always pass a pointer to the object, which is ideal where you want a "shared, mutable state" (as that document you referenced said). Clearly, though, if you're mutating/accessing a reference type across multiple threads, make sure to synchronize your access to it (via a dedicated serial queue, the reader-writer pattern, locks, etc.).

Value types are a little more complicated, though. Yes, as the others have pointed out, if you pass a value type as a parameter to a method that then does something on another thread, you're essentially working with a copy of that value type (Josh's note regarding the copy-on-write, notwithstanding). This ensures the integrity of that object passed to the method. That's fine (and has been sufficiently covered by the other answers here).

But it gets more complicated when you are dealing with closures. Consider, for example, the following:

struct Person {
var firstName: String
var lastName: String
}

var person = Person(firstName: "Rob", lastName: "Ryan")

DispatchQueue.global().async {
Thread.sleep(forTimeInterval: 1)
print("1: \(person)")
}

person.firstName = "Rachel"
Thread.sleep(forTimeInterval: 2)
person.lastName = "Moore"
print("2: \(person)")

Obviously, you wouldn't generally sleep, but I'm doing this to illustrate the point: Namely, even though we're dealing with a value type and multiple threads, the person you reference in the closure is the same instance as you're dealing with on the main thread (or whatever thread this was running on), not a copy of it. If you're dealing with a mutable object, that's not thread-safe.

I've contrived this example to illustrate this point, where the print statement inside the closure above will report "Rachel Ryan", effectively showing the state of the Person value type in an inconsistent state.

With closures using value types, if you want to enjoy value semantics, you have to change that async call to use a separate variable:

let separatePerson = person
queue.async {
Thread.sleep(forTimeInterval: 1)
print("1: \(separatePerson)")
}

Or, even easier, use a "capture list", which indicates what value type variables should be captured by the closure:

queue.async { [person] in
Thread.sleep(forTimeInterval: 1)
print("1: \(person)")
}

With either of these examples, you're now enjoying value semantics, copying the object, and the print statement will correctly report "Rob Ryan" even though the original person object is being mutated on another thread.

So, if you are dealing with value types and closures, value types can be shared across threads unless you explicitly use capture list (or something equivalent) in order to enjoy value semantics (i.e. copying the object as needed).



Related Topics



Leave a reply



Submit