Understanding Sendable checking

I’m working on migrating a very large project to full Swift 6 concurrency checking. I’m struggling to handle all the warnings, so I’ve been making small projects to try to build up a better intuition of how to do this.

Below is one of the test files I wrote. I compiled it with complete concurrency checking in the Swift 6 language made. I didn’t have automatic @MainActor isolation on, but I don’t think that’s relevant for this example.

This code compiles successfully, but I didn’t expect it to. Here are a few things I thought would cause problems:

  1. How can Model be Sendable if it has a mutable variable and is non-final? It’s isolated to TestActor, but…I don’t see how instances of Model can be safely used across actor boundaries.
  2. The MainActor method accepts and returns an instance of Model, even though instances are supposed to be isolated to TestActor.
@globalActor
actor TestActor: GlobalActor {
  static let shared = TestActor()
  
}

@TestActor
class Model: Sendable {
  var name: String
  
  init(name: String) {
    self.name = name
  }
}

actor ModelCache {
  private var cache: [Int: Model] = [:]
  
  var models: [Model] {
    Array(cache.values)
  }
  
  func add(index: Int, model: Model) {
    cache[index] = model
  }
  
  func get(index: Int) -> Model? {
    cache[index]
  }
}

@MainActor
class Tester {
  var model: Model? = nil
  
  func testMe(cache: ModelCache, newModel: Model) async -> Model {
    await cache.add(index: 0, model: newModel)
    
    await cache.add(index: 3, model: Model(name: "Alice"))
    await cache.add(index: 4, model: Model(name: "Bob"))
    
    if let y = await cache.get(index: 3) {
      await print(y.name)
    }
    
    self.model = await cache.models.first!
    return self.model!
  }
}
1 Like

the compiler's static isolation checking is intended to ensure usage across isolation boundaries is safe, typically by requiring an await to access actor-isolated mutable state.

since the Model type is a 'global actor isolated type', it is implicitly Sendable (though in your example the conformance is also explicitly added), so it can be freely passed into different isolation domains. per the docs on Sendable:

A thread-safe type whose values can be shared across arbitrary concurrent contexts without introducing a risk of data races.

if you remove the @TestActor annotation and Sendable conformance from Model, then you'd get an error like this when trying to use it in different isolations:

error: non-Sendable type '[Model]' of property 'models' cannot exit actor-isolated context
 6 | 
 7 | // @TestActor
 8 | class Model/*: Sendable*/ {
   |       `- note: class 'Model' does not conform to the 'Sendable' protocol
 9 |   var name: String
10 |   
   :
44 |     }
45 |     
46 |     self.model = await cache.models.first!
   |                              `- error: non-Sendable type '[Model]' of property 'models' cannot exit actor-isolated context
47 |     return self.model!
48 |   }
5 Likes

All state and method calls on your Model are isolated to TestActor. This means that whenever you access state on Model, TestActor will ensure exclusive access. That’s why it’s safe to use Model across actor boundaries; TestActor prevents it from having data races when access from multiple isolation domains.

It's perfectly fine to create instances of Model on any actor and return it. Whenever you access a property on Model, or when you call a method on it, that access is isolated to TestActor. Actor isolation doesn't prevent you from making instances of that object on a different actor.

To look at this from a different angle, you can create instances of an actor from anywhere. The actor just makes sure it protects its own state. You can safely create an actor instance in one isolation context and then access it from any other isolation context.

Global actor isolation works more or less in a similar way. It just means that all state and functions run on that global actor. Not that you can't create or use instances of Model in any place that's not TestActor.

3 Likes

What is @TestActor if you don’t mind me asking, as I couldn’t find anything about it online?

@asaadjaber A custom global actor, the GlobalActor doc description states the following:

A type that represents a globally-unique actor that can be used to isolate various declarations anywhere in the program.

@globalActor
actor TestActor: GlobalActor {
  static let shared = TestActor()
  
}
2 Likes

This is super helpful, thanks. I’m actually glad to learn this is how it works, as it will make my migration a little more straightforward.

I think I always interpreted the trait name Sendable literally, i.e. “Can I send this data safely across task boundaries?” Maybe a better mental model for me is “immutable and/or isolated to an actor”.

Code isolated to an actor / global-actor will receive an implicit sendable conformance since synchronization is performed through the actor.

If I may cite the GlobalActor doc once more.

When using such a declaration from another actor (or from nonisolated code), synchronization is performed through the shared actor instance to ensure mutually-exclusive access to the declaration.

Your first interpretation is exactly what it means though; Sendable marks that a value is safe to share with another task/thread/actor/dispatch queue/what have you — by passing it over, or sending it. Loosely speaking, it simply means that a value is thread-safe.

The exact mechanism by which this is enforced is thus less relevant, but it does not conclude at just being either immutable or actor-isolated. A class that has its internals protected by a lock/mutex is also sendable, for instance. Another example are collection types from the standard library: their sendability[1] is ensured by CoW and atomic refcount checks on the inner storage buffer.


  1. given that the stored elements are themselves sendable ↩︎

2 Likes