Search code examples
swiftconcurrency

Swift concurrency confusing Sendability error


I have a simple non-Sendable type here:

class Foo {
  var foo = 3
  init() {}
}

All the following are called in viewDidLoad:

Case 1: This compiles fine:

DispatchQueue.global().async {
  Task {
    let foo = Foo()
    Task { @MainActor in
      print(foo)
    }
  }
}

Case 2: compile error

let foo = Foo()
DispatchQueue.global().async {
  Task {
    Task { @MainActor in
      print(foo)
    }
  }
}    

Case 3: compile error

DispatchQueue.global().async {
  let foo = Foo()
  Task {
    Task { @MainActor in
      print(foo)
    }
  }
}

I was expecting all cases to fail. But why does case 1 pass? What's the difference between 1 and 2/3?

Edit: I found 2 even more confusing cases:

case 4: This gives warning, instead of error.

let foo = Foo()
DispatchQueue.global().async {
  Task { @MainActor in
    print(foo)
  }
}

Case 5: compiles fine:

DispatchQueue.global().async {
  let foo = Foo()
  Task { @MainActor in
    print(foo)
  }
}

Why is case 4 warning, rather than error? and why is case 5 fine?

Edit 2:

Case 4 warning:

enter image description here


Solution

  • In Swift 6, we have “region based isolation”. As SE-0414, says:

    Swift Concurrency assigns values to isolation domains determined by actor and task boundaries. Code running in distinct isolation domains can execute concurrently, and Sendable checking defines away concurrent access to shared mutable state by preventing non-Sendable values from being passed across isolation boundaries full stop. In practice, this is a significant semantic restriction, because it forbids natural programming patterns that are free of data races.

    In this document, we propose loosening these rules by introducing a new control flow sensitive diagnostic that determines whether a non-Sendable value can safely be transferred over an isolation boundary. This is done by introducing the concept of isolation regions that allows the compiler to reason conservatively if two values can affect each other. Through the usage of isolation regions, the language can prove that transferring a non-Sendable value over an isolation boundary cannot result in races because the value (and any other value that might reference it) is not used in the caller after the point of transfer.

    And this is expanded in SE-0440 which says that:

    This proposal extends region isolation to enable the application of an explicit sending annotation to function parameters and results. A function parameter or result that is annotated with sending is required to be disconnected at the function boundary and thus possesses the capability of being safely sent across an isolation domain or merged into an actor-isolated region in the function's body or the function's caller respectively.

    And if you look at the definition of the Task initializer, it is:

    public init(
        priority: TaskPriority? = nil, 
        operation: sending @escaping @isolated(any) () async -> Success
    )
    

    I draw your attention to the sending qualifier of operation.

    So, in Swift 6, this is fine:

    let foo = Foo()
    Task { @MainActor in
        print(foo)                  // ✅
    }
    

    This why your case 1 and 5 are ok. This is just standard region based isolation behaviors.

    But the following is not OK:

    let foo = Foo()
    DispatchQueue.global().async {
        print(foo)                  // ❌
    }
    

    This is why your cases 2 and 4 are failing: It is not because foo is a local var of a non-isolated function or anything like that; it is just because the closure parameter of GCD’s async method is not marked sending (as outlined in SE-0440). The Task.init closure parameter is marked as sending, but the GCD API is not.

    That only leaves case 3, where you are passing the non-Sendable object across two different isolation contexts (i.e., a Task within another Task). It is interesting that this is not permitted, but does not seem entirely surprising (given the complexity this entails). As an aside, I experimentally noticed that it works it apparently works if both Task closures were isolated to the main actor, but in your example, only one is, and that is apparently a bridge too far (for now, at least).

    In short, region based isolation allows us to pass a non-Sendable objects across a single actor boundary in very specific, narrow cases, namely where (a) the closure is marked as sending; and (b) you do not reference the non-Sendable object after passing it to the the closure.

    This region based isolation of SE-0414/SE-0440 really is not intended as generalized “get out of jail free” card for sending non-Sendable objects. It is just eliminating one friction point in the process. If you really are going to be sending these objects across isolation domains a lot, you might want to consider whether you might just want to make them Sendable, if possible.


    You said:

    I was expecting all cases to fail.

    In fact, if you run with in Xcode 15.4 (Swift 5.10), before we had any region based isolation, that is exactly what happens (with the “Strict concurrency checking” build setting of “Complete”). If we do that, we can see that in the absence of region based isolation, all five cases generate warnings!

    errors

    In Swift 6, if you can enjoy transfer of a non-Sendable object in very specific situations.


    For future readers unfamiliar with how Sendable works, I might refer you to the Eliminate data races using Swift Concurrency video.