Is dispatchPrecondition a reasonable way to implement an @unchecked Sendable type?

I'm trying to improve my understanding of when it makes sense to use @unchecked Sendable.

Take this example, where value is guaranteed to only be read or modified on the main thread using a dispatchPrecondition:

/// A wrapper that guaruntees that its value is only read or modified on the main thread.
/// For simplicity assume `T` is a value type.
final class MainThreadWrapper<T> {

  init(_ value: T) {
    dispatchPrecondition(condition: .onQueue(.main))
    _value = value
  }

  var value: T {
    get {
      dispatchPrecondition(condition: .onQueue(.main))
      return _value
    }
    set {
      dispatchPrecondition(condition: .onQueue(.main))
      _value = newValue
    }
  }

  private var _value: T

}

For the sake of this discussion let's assume the wrapped value is a value type (not a reference type), so we don't need do consider modifications that don't go through value's setter.

Is it reasonable for this type to be Sendable, using an @unchecked Sendable conformance?

// Is this reasonable, given the expectations of Sendable?
extension MainThreadWrapper: @unchecked Sendable { }

It's impossible for there to be a data race when using this type. If the type is incorrect used on the wrong thread (e.g. in a Task off the main actor), the dispatchPrecondition will fail and prevent the disallowed usage:

struct NotSendable {
  var value: String
}

let wrapper = MainThreadWrapper(NotSendable(value: "foo"))

Task {
  // Allowed by the compiler since wrapper is Sendable, 
  // but a triggers a runtime error:
  print(wrapper.value)

  await MainActor.run {
    // Safe, prints "NotSendable(value: "foo")"
    print(wrapper.value)
  }
}

Given this type can be safely passed across concurrency domains without the possibility for data races, I lean towards thinking this is a reasonable use case of @unchecked Sendable.

What do folks think? I'm especially interested in hearing any potential counter-arguments.

To clarify, it sounds like you're asking a question about the semantics of Sendable, i.e. is a type 'Sendable' if it isn't usable across threads but merely "safe" (in Swift's sense of no undefined behaviour). Correct?

Yeah, exactly.

"Language-lawyering" from Apple's docs, I'd say such a type is not Sendable, since you can't really pass the value to other concurrency domains; any attempt to use it will crash.

But I'm guessing that you're asking because there may be some utility in being able to pass a value transiently through other concurrency domains on its way back to the main thread where it's actually used?

But if that's the case, then can you not just do:

final class MainThreadWrapper<T>: @unchecked Sendable {
    init(_ value: T) {
        self.value = value
    }

    @MainActor
    var value: T
}

(or put @MainActor on the whole class if you really don't want to allow initialisation off of the main thread - either way the class is concurrency-safe)

That way any errant use is prevented at compile time rather than crashing at runtime.

Non-main-thread concurrency domains can still access the wrapped value if they want, but only via await so it's all properly serialised through the main thread. If your goal is to truly ensure they never even try to access the value… I'm not sure if there's a way to enforce that at compile time.

Tangentially, it's annoying that @unchecked is required there because the compiler seems to overlook the @MainActor attribute.

1 Like

Those docs say:

You can safely pass values of a sendable type from one concurrency domain to another

I suppose this rests on what we take "safe" to mean. Safe as in "no crashes", or "no data races" and "no undefined behavior" (and no crashes if you satisfy the preconditions -- like how Array's subscript is "safe" even though it can crash if its preconditions aren't satisfied)?

Do we have a good definition somewhere of what "safe" means here with respect to concurrency and thread safety / memory safety?

Your wrapper here does guarantee "safety" of the underlying data, but it does it by crashing if the type is ever used off the main queue. This would only be usable for a type that would be declared @MainActor...but once you annotate it that way then you already get guarantees across concurrency domains that you'll be on the main actor.

With regards to what "safe" means for you, @unchecked Sendable is a promise that your type will be usable from any concurrency domain and still protect its own state. I think it is incorrect to mark your wrapper that way. It is still only safe to use from the main queue. (It just happens to crash quickly and usefully if this isn't the case.)

2 Likes

Thanks all for the feedback. I think I agree that this sort of type shouldn't be @unchecked Sendable, because crashing on incorrect access is not the expected behavior of a Sendable type.

We've been looking at how to bridge a legacy system into Swift concurrency. This existing system implements thread safety via dispatchPrecondition(condition: .onQueue(.main)) -- it can only be used on the main thread.

The correct way to represent that would be to annotate the type as @MainActor, but we can't do that all in one go because of how involved this change would be. Instead I'm thinking MainActor.assumeIsolated is a reasonable and safe way to bridge this API into concurrency.

This is the rough shape of what I'm talking about:

protocol Handler {
  // Precondition: must be called on the main thread,
  // enforced with dispatchPrecondition(condition: .onQueue(.main))
  func handle()
}

@MainActor
final class MyHandler: Handler, Sendable {
  // This has to be `nonisolated` to fulfill the non-isolated `Handler.handle` requirement.
  // By default a nonisolated func can't actually interact with self.
  nonisolated func handle() {
    // Since we already have a precondition that `handle` is only called
    // on the main thread, we can assume we're already on the main actor:
    MainActor.assumeIsolated {
      // Now we're isolated to the main actor and can mutate self, etc.
    }
  }
}
1 Like

For what it's worth, SwiftNIO slightly disagrees with this position. More accurately, we allow runtime correctness guarantees, in the form of things like NIOLoopBound.

This type has the following properties:

  1. It can only be constructed when you are running within the isolation domain of the event loop.
  2. It can only be accessed when running within the isolation domain of the event loop.
  3. It is Sendable.

The reason for this is simple: otherwise we can't use the equivalent of DispatchQueue.async. There are some operations that may cause an isolation hop, but aren't guaranteed to do so. DispatchQueue.async is a good example: the closure argument to this function must be @Sendable, because at compile time the compiler cannot prove what queue you're running on. However, at runtime it is possible that this will not actually Send the value at all, you may be asyncing to the queue you're already on.

This is the tension NIOLoopBound seeks to address. It allows you to transform a program whose correctness cannot be proven at compile time, and lets you prove it at runtime instead, with the overhead this implies.

Note that we discourage using NIOLoopBound as storage, and tend to prefer it to be used when a capture is necessary.

2 Likes

I don't think I agree with that statement. So, when it comes to expected behaviour and incorrect access - the docs say:

You can safely pass values of a sendable type from one concurrency domain to another — for example, you can pass a sendable value as the argument when calling an actor’s methods.

So the question I would ask is whether you want to support writing actor methods such as:

actor MyActor {
  func doSomething(_ value: MainThreadWrapper<NonSendable>)
}

I'd say it's fine to do that - the implementation of doSomething obviously knows that it must access value's wrapped data from the main thread, because it's right there in the type name. That precondition is verified at runtime, so the type is safe.

The behavioural expectations that are relevant are the expectations developers have of the MainThreadWrapper type.


In general, the way I interpret Sendable is that it exists so you can curate your APIs, and have the compiler prohibit developers sending data across concurrency domains that isn't designed to support such usage. It exists so developers know what is supported and get errors when they try to do the wrong thing.

The primary requirement for Sendable is that you've thought about concurrent accesses and have some mechanism in place to prevent data races. And you have. It's fine.

Of course, the compiler has a bunch of complex rules to try to infer Sendable for data types, based on whether it can determine that data races are already taken care of. You shouldn't feel limited by those handful of cases where inference is supported.

1 Like

If the type is safe to send around to multiple threads because its instances are only ever used from a specific globally-singleton thread, that is precisely the intended use case for global actors, in this case (because the thread is the main thread) @MainActor. You ought to be able to make the initializer nonisolated if you need to construct an object outside the main actor. If you have any trouble with that, that's worth a bug.

@unsafe Sendable is an acceptable workaround if you really can't find any way to express the safety constraints you're working with, but you're going to have a much better experience overall if you can put the effort in to get the formal isolation right.

In general, the right thing to do depends on why your class is safe:

  • If your class instances can be safely referenced by multiple threads[1] because their stored properties are all immutable lets, your class can just be Sendable.

  • If your class instances can be safely referenced by multiple threads because in practice their stored properties are all immutable, but for some reason (e.g. a complex initialization pattern that completes before the object is shared across threads) some of the properties have to be declared as mutable vars, that is a perfectly reasonable use of @unchecked Sendable. Consider adding some sort of lifecycle assertion to your setters, e.g. a "this is immutable now" flag.

  • If your class instances can be safely referenced by multiple threads because their mutable storage is only actually accessed from a globally-singleton thread, your class should be associated with a global actor.

  • If your class instances can be safely referenced by multiple threads because their mutable storage is only actually accessed under a lock, that is a reasonable use of @unchecked Sendable. This is an important pattern that we're working on safe ways to express.

  • If your class instances can't generally be safely referenced by multiple threads, and in fact they aren't referenced by multiple threads and just get created, used, and destroyed on a single thread, they should not have to be Sendable at all. In this case, it's worth figuring out why you think you need to do so. It's possible that you're actually doing something dangerous, or maybe you've got some funny sort of concurrent context that Swift doesn't understand behaves like an isolated serial executor. Consider if there's an alternative way to express your pattern in a way that Swift will understand.

  • If your class instances can't generally be safely referenced by multiple threads, but instances do need to be moved between threads occasionally, you should not use @unchecked Sendable on the class. Instead, you should suppress the warning locally by "smuggling" the object across the sendability boundary: make a value of an @unchecked Sendable struct that holds the object, then send that instead. This is safe as long as you really do stop using the object in the original context (and only transfer it to one context at a time), and it's much better than pretending that any send is safe. This is a very important pattern that we're actively working on safe ways to express.

  • If your class instances can be safely referenced by multiple threads because their mutable storage is only actually accessed from one of the threads at a time, but that thread isn't globally singleton, the accesses aren't mediated by something like a lock, and you really do maintain active references on multiple threads... I mean, I'm willing to assume that you've got some justification for why this is being done safely, but this seems like a very treacherous pattern, and it's hard to imagine Swift ever finding a way to support it safely. Consider whether you can find a more structured way to express this. If not, you're going to have to just use @unchecked Sendable on the class and accept that you're losing out on concurrency safety.


  1. By "thread" I really mean any concurrent context: a thread, a queue, an actor, whatever. But it's a lot more succinct to just say "thread". ↩︎

14 Likes

Thank you very much for the thorough reply and explanation!

I'm very excited to hear that folks are working on safer ways of expressing these patterns (especially safely transferring across concurrency domains).

2 Likes