[Pitch] Safely sending non-`Sendable` values across isolation domains

Hello Swift Community!

As strict concurrency checking comes closer to being a standard, error-producing part of the Swift language, the restriction that only Sendable values can be communicated between tasks, actors, and other isolation domains is becoming increasingly problematic. For many datatypes, sacrificing mutability to attain Sendable conformance is awkward to program around. A particularly unfortunate case arises for datatypes that cannot safely be made Sendable; computation involving them is forced onto actors where it hurts performance. Here's an example:

struct LocationData : Sendable {}

class NearestNeighbors {
    class DataPoint {
        var nearestPoints: [DataPoint] = [] ...
    }
    
    var rootPoints: [DataPoint] = [] ...
}

func computeNearestNeighbors(data : LocationData) -> NearestNeighbors { ... }

@MainActor func addToDisplay(neighbors : NearestNeighbors) { ... }

func computeAndDisplayData(data : LocationData) async {
    let neighbors = computeNearestNeighbors(data: data)
    await addToDisplay(neighbors: neighbors)
}

NearestNeighbors is an example of a class that cannot be made Sendable. Instances are constructed by computeNearestNeighbors from a set of datapoints with location information, creating a graph associating each with its nearest neighbors (trust me, all that expensive computation happens in the ellipses). Since this is a certainly cyclic data structure, there's no way to build it with immutable let bindings alone, so it's bound to be non-Sendable. But eventually, we want to display it to UI, using the @MainActor to do so. Since there's no way for it to cross isolation domains, this means the expensive computeNearestNeighbors call has to happen on the main actor as well. This is bad for performance.

The SendNonSendable proposal, linked here, describes a pass that uses flow-sensitive checking, plus a cool idea called "regions", to make many patterns involving sending non-Sendable values between isolation domains safe, including the above code. To the more type-theory-inclined among you - these are linear regions.

Here's the link to the proposal:

And here's the link to the PLDI '22 paper that this work is based on in case you want even more detail than provided in the proposal. Keep in mind the system in this paper is much more complex, and includes features such as iso fields not being implemented by this proposal.

If you want to play with the current implementation yourself, it's available on the public Github version of the Swift compiler, just make sure you run with flags -enable-experimental-feature SendNonSendable -strict-concurrency=complete.

Let me know what you think :grin::thread:

25 Likes

Can you elaborate on the difference between this and moveable types? You have a section at the end, "Relying on move-only (i.e. purely linear) types", which presumably would cover this, but it's empty.

1 Like

Both this system and move-only type have in common linearity: they allow values to be consumed somehow - indicating they can't be used thereafter. This means that both move-only types and the region-based types presented in this proposal could theoretically be used to make sending non-sendable types safe; both offer a mechanism for consuming values when they're sent to another thread, preventing races from occurring against the original thread.

But that's where the similarities end. In move-only types, individual values are treated linearly. This means, for one thing, that it's impossible to make aliases of values - the act of creating the alias is either banned or consumes the original value. Trying to point a field of a struct to a value also is banned or consumes it. This is the "single ownership" semantics you get in languages like Rust with some linearity. This allows values to safely be sent to other threads without introducing data races because the reference to a value used to make the send is guaranteed to be the only one.

Single ownership is not the way region-based types like in this proposal work. Instead of banning the creation of aliases or ancillary references to a value, we allow but track them by putting them "in the same region" as the aliased or referenced value. This means that, until you reach a point that performs a concurrent operation like a send, you wouldn't even notice the presence of a region-based type system; it's doing all of its tracking "in the background". When you get to a point where a send is performed a linear consumption does take place. But not of just the value being sent, of the entire region containing that value. This is just as safe as the purely linear, single ownership semantics of move-only types (and Rust). You aren't guaranteed to be using the only existing reference to perform the send, but you know all of the other ones and can render them invalid statically.

You have to be careful with regions, a lot of things count as merging regions and a lot of things count as consuming regions (basically anything that allows it to "escape") - but it works! And you got a lot more expressivity than pure linearity in return.

tl;dr move-only types prevent aliasing from arising, region-based types allow aliasing to arise but track it when it does.

what this looks like in practice:

let x = NonSendableClass()
let y = x
Task { print (x) }
print(y)

This is a race between the calls to print(x) and print(y). Move-only types would prevent the race by either disallowing the alias y from being created, or invalidating x when it is. Region-based types like in this proposal would prevent the race by noticing x and y are "in the same region", so giving one to a Task also consumes the other.

Hope that clarifies, but let me know if not!

14 Likes

Thank you, that's very clear.

It sounds like this is superior to move-only types, at least from a concurrency perspective (I'm guessing there's still unique utility in move-only types for other purposes, maybe from an RAII angle?). As someone that respects Rust's strictness but prefers Swift's pragmatic compromises, this approach sounds very appealing.

Maybe it's even a baby step closer to broader use of flow analysis in Swift, which I'm a huge proponent of.

What's the catch? :grin:

Is the "downside", such as there may be one, just that it's hard to do this (in the compiler), or are there other caveats?

…some of the topics in 'future directions' sound like they might be pain-points, particularly the function arg & actor members aspects.

Prior art

I'm quite unfamiliar with this area of compiler theory - it sounds like this is a quite recent idea, and that's why it's not seen in other languages yet? Are there any other languages that have done this already?

Diagnostics

Thanks also for the diagnostics section with the details and examples. I feel like the impact on compiler errors & warnings is sometimes a bit overlooked in pitches.

1 Like

In general, I am a big fan of tackling this problem since we have a few holes in the current Sendable checking and once we close this it will be almost infectious and require Sendable for a bunch of things that one really only wants to transfer. I haven’t read this pitch in detail yet but will do so next week.

One thing that I like and find particularly interesting is that we do this with flow analysis which IMO is nice but the pitch states that this is enough to know if something can be transferred. I am not sure if I agree with this statement. A type could store state in thread locals and be truly not Sendable but we Analyse it to be safe due to flow control. I like how Rust achieved this by having two marker traits with Send and Sync. That gives the developer more control and all of this becomes a bit less magical.

Secondly what I think this proposal still doesn’t solve is the actor case where you want to isolate a non Sendable value to the actor for thread safety. If that non Sendable value has an async method then we will have to send it across isolation domains. I think one of your examples hinted at this but have to give it a more detailed read. We should at least call out that this proposal is not fixing this .

Lastly, could we add an example around how this interacts with child tasks in task groups. A very common scenario here is iterators from async sequences. Most async sequences only support a single iterator so it is important that this iterator is created and never accessed from two threads. This means it is marked as explicitly non Sendable but what we really want is just to make sure it is only held by a single task at a time. What I would like to see is an example where we construct an iterator, consume an element and then transfer it to a child task for further consumption. At best even getting it out of that child task again at the end by returning it.

Edit: How is this going to work across module? I am seeing a pattern with a lot of with style functions that are just glorified task groups that run a closure with access to some managed resource. Would we be able to infer that certain parameters are just transferred? I can imagine a specific transfer/send annotation to be helpful here

I will give this a more through read next week!

Franz

4 Likes

Here are three potential "catches" (roughly in decreasing order of importance)

  1. Compile-time cost: The current implementation relies on performing a fixpoint iteration to find the region partition that solves the dataflow equations of the function being analyzed. In practice, it appears to be very low, with most function with n basic blocks converging in at most 2n iterations, but it's trivial to construct pathological examples with 3 basic blocks that take an arbitrarily large number of iterations to converge. There are various strategies for mitigating this, such as strategies for reporting overly conservative fixpoints early if iteration takes too long, but the need for and viability of these has not been investigated yet.
  2. Confusing diagnostics: In the simplest case, the diagnostics from this pass take the form "you transferred x to another thread here, and then tried to access x here. There's dataflow from the former of those places to the latter, so hey, that's a race". In the more complex case, the value accessed could appear to be very different from the value transferred. A long sequence of x0 could alias x1, x1 at one point had a field assigned to x2, x3 also had a field assigned to x2, .... terminating in some x<n> for large n could yield a diagnostic saying x0 was transferred and x<n> was accessed. Leaving the programmer to think: "what the heck do these two values have to do with each other?". Answering that question in diagnostics ergonomically is an interesting problem.
  3. Eventual desire for fancier function types: The current implementation only has one function signature allowed with respect to the regions of its non-sendable args and results: all arguments including self come from a single region, that region cannot be transferred away, and results also inhabit that region. For various reasons, I believe this to be the best option if it's to be the only available option, but there are many increases in complexity of signatures that could be made available. The "transferring args" and "fresh result" sections of the proposal touch on a couple of these, but there are many more: for example, two non-sendable arguments that are both transferred away, but are not assumed to come from distinct regions at the callsite, i.e. they could alias or reference each other. If iso fields get added into the mix, then there's a real explosion of possible function signatures. I'm listing this as a catch because adoption of this regions mechanism will almost certainly lead to programmers wanting some or all of these fancier function signatures features, which would add language complexity.

thanks for you continued engagement with this proposal, and let me know any further info or opinions I can provide!

2 Likes

Thanks for all these details.

It sounds like these caveats are best judged through actual experience. There's plenty of precedence for algorithms in the compiler that can be pathologically poor-performing (most obviously type inference), and likewise for diagnostics. The key question is whether it falls within the "good enough" range.

Possibly compiler / core team members can estimate that. Or maybe it needs some real demonstrations using real-world example apps and libraries. I can't say, myself, but I look forward to seeing what others think.

2 Likes

Gave the proposal a very detailed read now and have some questions/suggestions. First, off I think the underlying problem that we are trying to solve here is very important and it boils down to the same problem that Rust is solving with Sync/Send traits.

Right now Sendable implies that a type can be safely accessed from multiple tasks/threads at a time. This marker protocol is then also further added to closures through the @Sendable annotation. I am not yet convinced that just inferring from the usage of an object if it can be safely transferred across isolation domains/tasks. However, I think the approach of usage analysis is definitely part of the solution to figure out when something is transferred and produce the correct diagnostic.

Personally, I think we should introduce the same distinction of Sync and Send from Rust to Swift and allow developers to annotate their types and closures correctly. This would solve some of the problems we have seen where we really don't want to make types Sendable but we want people to freely transfer those types across tasks. One motivating example of why distinct marker protocols make sense is that we have been going around and marking a bunch of things as explicitly not Sendable by using an unavailable extension. We must never transfer those types across tasks/isolation domains since the developer clearly marked them as not safe. However, we only marked them this way since we do not have this distinction between Sync and Send.
Another thing with the current pitch is that it doesn't state how this works across module. In a single module, we are currently inferring Sendable conformance based on the contents of a type. However, we do not make this Public API. With the proposed transfer pitch we should IMO follow the same rules where we cannot assume that types can be transferred from other modules without the developers specifically opting into this.

Additionally, to achieve the desired level of expressivity, it will be necessary to remove the source-level @Sendable annotation on the types of the arguments to these functions. Without the introduction of the SendNonSendable pass, race-freedom is attained for functions such as Task.detached only by ensuring they only take @Sendable args. This prevents entirely the passing of closures that capture non-sendable values to these functions (as such closures are necessarily @Sendable )

I think this section clearly outlines why we actually want a separate marker protocol and annotation for transferring values. Right now @Sendable is used for both closures that might be accessed from multiple tasks and for closures that are only invoked once but might be transferred to a new isolation domain before being invoked.

4 Likes

I'm with @FranzBusch and a bit worried about which changes are non-API breaking across modules once this has landed.

In addition, the proposal doesn't really mention how it treats Sendable arguments and inout arguments but only really talks about non-sendable arguments and the rules around them. Would this example be rejected:

class Memory {
    var bytes: [UInt8]
    init(pool: inout [Memory]) {
        self.bytes = []
        pool.append(self)
    }
}

var pool: [Memory] = []
let x1 = Memory(pool: &pool)
Task {
    print(x1.bytes)
}
// data race
pool[0].bytes.append(0)

Hi @FranzBusch, thanks for the detailed read! Below are my summaries of your concerns (please correct me if misinterpreted), and my responses.

At a high level, I believe it's worth clarifying one important descriptive point about this approach. Protocols such as as Sendable (which I believe is equivalent to Rust Sync) that provide type-level guarantees about the safety of offering aliases of local references to other threads are useful, but this SendNonSendable pass is meant to be a catch-all for allowing concurrent sharing of values for which no type-level information alone suffices to conclude the sharing is safe. Onto more specific responses:

Viability of usage analysis

Concern:

Summary: Should we rely on analyzing the usage of an object to determine if it's safe to send?
Quote:

Response:

Summary: the analysis is based on usage not type - I believe this is valuable in addition to type-based analysis like protocol conformance; the analysis is intended to be sound but not complete

Longer form: To be very clear, this is a conservative analysis. Let's say we reach a point where a local reference x is sent to another isolation domain. We want to determine whether this send is safe or not. By safe, we mean that there is no possible program execution resulting in a race over data accessed through x. The SendNonSendable pass does not guarantee that sends it reports as unsafe indeed can yield such a race. It instead uses static analysis to compute a region including all values that could be accessed through x at the point of the send. Namely, this transitively includes all possible aliases and references of x. This region is then marked as transferred at that program point. The send is declared safe iff dataflow analysis determines no values in that region are accessed after that program point. This analysis has nothing to do with the type of x or anything else in its region. This analysis can have false positives, it cannot have false negatives. In this sense, "type-independent inference from the usage of an object" very much can determine it can be safely transferred across isolation domains/tasks. It cannot determine that a usage is definitely unsafe, but that's par for the course.

Let's say we scrapped even the Sendable protocol, and solely relied on this flow-sensitive, region-based analysis in the SendNonSendable pass. You'd have a somewhat annoying world, in which even very surely non-mutable types like integers would need unsafe copying functions to be sent to other threads then read again. But it wouldn't be an unusable world: any functions that typecheck in current Swift and don't send values to other isolations would still typecheck, and when sends were inserted you'd just have to be very careful to only send data you really don't need in the sending thread again. This is a purely usage-based way of achieving safe concurrency, and it would work.

Sendable steps in and makes this world much better because now, everything from primitive types like Int, to more complex types like [Int], and internally-synchronized types like actors and unsafe Sendable user-defined types can be sent to other threads and still used by the sending thread. These Sendable types are types whose values are always safe to send between threads because even concurrent access after the send would not yield a race. Values of other types are not always safe to send, so usage analysis like the SendNonSendable pass steps in to ensure concurrent access after the send does not actually occur.

Swift with both the Sendable protocol and the SendNonSendable pass is thus a hybrid world in which there are two ways to safely send a value to another thread: inspect its type, and realize it's Sendable and therefore safe to concurrently access after the send; or inspect its usage, and realize it will not be concurrently accessed after the send. This is flexible and ergonomic, and I believe that offering both type-based safe sending and usage-based safe sending is a valuable approach.

Comparison to Rust Sync/Send

Concern:

Summary: Should we just lift Sync/Send from Rust to Swift as closely as we can instead?
Quote:

Response:

Summary: I think SendNonSendable is more expressive than a lifting of Sync/Send to Swift, but it's an orthogonal feature.

Longer form: I believe that the usefulness of Sync/Send in Rust, and the extent to which it's the "right" solution in their context, is directly tied to the nature of their brand of linearity: pervasive single ownership. As discussed a bit above in my response to @wadetregaskis, allowing move-only types to be safely sent between threads as long as the send is treated as a consume would be safe. Since Rust's brand of linearity closely resembles move-only types in Swift, Sync/Send would be a useful distinction for move-only types. Sendable would be Sync, and deeply move-only would be Send. These deeply move-only "Sync" types would be safe to send because their move-only nature would prevent aliases being available to the sender after the send, and the "deeply" caveat would ensure that no references accessible from the shared type are aliased in the sending context either. (deeply meaning that any stored properties of the type must also be deeply move-only)

To summarize, I believe that the truest way to lift Sync/Send to Swift would be to make only move-only types be able to conform to the equivalent of Send, and only if they are deeply move-only. My response to @wadetregaskis now nearly directly applies: we could do this, but it would inhibit natural programming patterns, and many of the examples of code that it would allow to typecheck already typecheck with SendNonSendable. It has the downsides of requiring more programmer-provided annotation, and of making move-only more prevalent in general - which makes Swift code more complex. For these reasons SendNonSendable seems more appealing to me than co-opting the ownership semantics of move-only to make more patterns of concurrency safe. Let me know what you think though!

Finally, on this point, it's worth addressing that if we wanted to add something like a Swift Transferrable protocol that is to Rust Send what Swift Sendable is to Rust Sync, and that is only applicable to deeply move-only types as mentioned above, we could! I have various personal reasons to think it's a bad idea, but it would play just as nicely with the SendNonSendable pass as Sendable currently does: values of Transferrable type would simply be ignored by the pass just like values of Sendable type currently are.

API impacts

Concern:

Summary: Does this yield issues with the ability of API designers to control downstream concurrent usage of their types?
Quote:

Response:

Please adjust my interpretation of your concern if it's off, but I believe that you're imagining a developer who wants to expose a library type T with no intention that it ever be used outside of the isolation domain it was defined in (in a sense, never "used with concurrency"). If the reason that the developer wanted this was that they could not guarantee that values of the type could be concurrently accessed without yielding races (in Rust: not Sync), or that they could not guarantee that values of the type could be transferred out of their original domain without yielding races through ancillary references accessible through the value (in rust: not Send) , then there's no issue with the SendNonSendable pass stepping in and letting downstream users of T communicate values of its type across isolations. This is because the nature of the pass, combining regions and flow-sensitivity, ensures conservatively that no values that could possibly alias or be references by the sent value are used after the send. There is thus no concern in sending values of type T even if the implementer of T never intended for them to be sent.

If you're envisioning other reasons that an API designer could want to ensure that no values of a type T they provide are ever accessed outside the isolation domain they're designed in, then that could be a useful feature to provide - e.g. a truly NonTransferrable protocol or equivalent unavailability - but I'd argue it's once again orthogonal to the SendNonSendable pass. I'd love to hear more about the specific situations you're imagining in which a developer would want this feature though, so we could prioritize whether bundling it with SendNonSendable is necessary.

Conclusion

I hope I didn't mischaracterize any of your concerns. Thanks for your time in reading this proposal. I think we all agree that something that increases the expressiveness of Swift Concurrency beyond the current strict Sendable requirement is warranted, so hopefully as a community we can converge on what that right thing is!

9 Likes

Hi @dnadoba, I attempted to address concerns about API design in my response to @FranzBusch, but let me know if any points there are still unresolved. Long story shot is I don't believe that this feature provides any new avenues for surprising API breaks: whether or not sending a value across isolations is independent of anything in the definition of its type, so changing the upstream definition of its type cannot break usages.

The quick answer to "how does this pass treat Sendable arguments" (and Sendable values in general) is that it doesn't. Sendable values are totally ignored by this pass: they don't get tracked in regions, they don't get "transferred", they don't get checked to ensure they haven't been transferred at points they're sent across isolations. As to how inout arguments are treated, I have not thought hard enough to say this with 100% certainty, but I believe that treating them as non-sendable regardless of their type would be a safe approach. The issue is preventing them from escaping into stored properties, global variables, or other isolations such as tasks, which the current tracking for non-sendable values in SendNonSendable does. It would be essentially treating them as instances of a box class instead of as their true type. But it's worth thinking more about this, so thinks for bringing it to my attention!

As to the specific code you shared, I believe that it does not illustrate a race as written? possibly due to a typo in which the pool parameter to Memory.init being unused? Feel free to explain it to me more though to help me understand the issue. Looking at the top-level code and ignoring everything about the Memory class except that it's non-sendable, the example would not be accepted by SendNonSendable. x1's initialization would cause it to be in the same region as pool, so transferring x1 to the task would transfer pool too, and the potentially racy access on the last line would yield a diagnostic.

2 Likes

I really like this framing. Safety is ultimately about how something is actually used, not necessarily an intrinsic aspect of the something's type. Or at least it's better that way - so much more flexible.

Having a way to say "this type is always safe" is a great shortcut (or way to override the compiler in cases where it isn't smart enough to determine that automatically), but as you say technically it's just an optimisation.

Sounds like it's a !Sendable. As in, a ternary - something can be explicitly Sendable, explicitly not Sendable, or indeterminate (meaning: see how it's actually used, case by case, and figure it out then).

I'm having trouble coming up with an example of something that needs to be explicitly not transferrable in this way, in its immediate incarnation. But perhaps it's an important declaration even if only from a forward-compatibility perspective: maybe type Foo happens to be safe to send today, but the owner of that type wants to reserve the right to revoke that sendability in future (and therefore, for modules with resiliency enabled, it can't be sent even now). Today they can do that by simply omitting Sendable, but with the pitched mechanism a way to opt out is required.

2 Likes

Thanks for the detailed reply!

Summary : the analysis is based on usage not type - I believe this is valuable in addition to type-based analysis like protocol conformance; the analysis is intended to be sound but not complete

If you're envisioning other reasons that an API designer could want to ensure that no values of a type T they provide are ever accessed outside the isolation domain they're designed in, then that could be a useful feature to provide - e.g. a truly NonTransferrable protocol or equivalent unavailability - but I'd argue it's once again orthogonal to the SendNonSendable pass. I'd love to hear more about the specific situations you're imagining in which a developer would want this feature though, so we could prioritize whether bundling it with SendNonSendable is necessary.

I completely agree that usage based analysis is necessary and I am not opposed to adding this. I was always worried that the only way to solve some of the current Sendable constraints where you want to say that a type is not Sendable but _transferrableis by adopting~Copyable` types. However, those types currently come with severe limitations and can't be applied to everything.
The only concern that I have left on the usage base analysis approach is that there should be a way for a developer to opt their type out of participating in this. There might be some truly weird types out there that rely on only being called on a single thread ever. Right now we suggest developers to mark those types like this.

@available(*, unavailable)
extension SomeType: Sendable { }

However, maybe this is also just not a problem since the current non-Sendable doesn't guarantee that a value is ever going to called on the same thread ever just that it is only called from a single thread at a time and that it cannot pass isolation domains. Maybe we should just add a section about this discussion in Future directions/Alternatives considered.

Additionally, to achieve the desired level of expressivity, it will be necessary to remove the source-level @Sendable annotation on the types of the arguments to these functions. Without the introduction of the SendNonSendable pass, race-freedom is attained for functions such as Task.detached only by ensuring they only take @Sendable args. This prevents entirely the passing of closures that capture non-sendable values to these functions (as such closures are necessarily @Sendable ). By implementing the SendNonSendable pass, sendable closures will still be able to be passed freely to these functions, but non-sendable closures will not be outright banned - rather they will be subject to the same flow-sensitive region-base checking as all other non-sendable values passed to isolation-crossing calls: if their region has not been transferred the call will be allowed, and otherwise it will throw an error. This change (removing @Sendable from the argument signatures of these functions) is necessary.

I find this section very interesting and it contains a bunch of complexity. From personal experience most of the @Sendable annotations on closures come from the fact that they are run inside a child task or unstructured task in the end. A common pattern is a function like this:

func withAccessToFoo(_ body: @Sendable (Foo) async -> Void) async {
    let foo = Foo()
    await withTaskGroup(of: Void.self) { group in
        group.addTask { foo.run() }
        group.addTask { body(foo) }

        await group.next()
        group.cancelAll()
    }
}

Right now this is requires the @Sendable just because group.addTask requires @Sendable. I expect that in a lot of places we could actually drop the @Sendable annotation from methods once we have a way to define that they that the body closure is transferred. I would love to see capabilities included in the initial implementation which allows users to define such methods without the requirement of @Sendable.

  1. Mark such functions with an annotation such as @IsolationCrossing at the source level where they're defined.

How does this play with the current @_inheritActorContext attribute? With this attribute Task.init inherits the current actor context and would not be isolation crossing.

The iso keyword, when placed on fields, for example as iso var contents : NonSendable above, would indicate that instead of tracking the source and target of the reference (here, self and self.contents ) as necessarily belonging to the same region, they could belong to different regions.

I don't fully see how this can be true. The following example from the pitch can never be safely transfer the contents over to the otherBox without introducing a data race since the IndecisiveBox can be reentrantly called so both the otherBox and this current box might have overlapping access to the reference of contents unless I am missing something.

actor IndecisiveBox {
  var contents : NonSendable
  
  func setContents(_ val : NonSendable) {
    contents = val
  }
  
  func swapWithOtherBox(_ otherBox : IndecisiveBox) async {
    let otherContents = await otherBox.contents
    await otherBox.setContents(contents)
    contents = otherContents
  }
}

Overall a big fan of this work!

1 Like

Yes, you are right, sorry. I have missed an pool.append(self) in the init of Memory. I have updated my post.

Sounds reasonable to me to treat inout parameters as non-sendable parameters.

1 Like

Is that true in the case of cross-module calls? The pitch as written provides 5 ways to expand regions:

There are a few things missing here, some of which fit easily and some of which do not.

The first is with functions:

public class RefcountedBuffer {
    private var bytes: UnsafeRawBufferPointer

    deinit { bytes.deallocate() }

    public func withBytes(_ body: (UnsafeRawBufferPointer) -> Void) {
        body(bytes)
    }
}

Now it would seem that your 3rd rule ("creating a reference from a non-sendable value y to a non-sendable value x merges their regions") would apply here. However, the contents of withBytes is not available to a caller from a different module, so they can't see where the argument came from.

This isn't a big deal: the conservative approach is to assume that all values given to a closure passed to a method potentially alias the value in question, and so may not be sent if that value is used elsewhere.

What I don't understand clearly is how this works with thread locals, which have no requirement to be Sendable but which can be used after the end of a given function. What is your thinking here?

done!

Indeed, TaskGroup.addTask is another function that should be modified to not require a @Sendable closure. I've updated the proposal to reflect this, but the mentioned solution @IsolationCrossing is only the best solution if there is no transferring qualifier available on arguments to functions. As discussed in the "Transferring args" section of the proposal, such a qualifier would indicate that instead of the function being expected to preserve the region of that argument, it can transfer it to another isolation domain itself. Placing this qualifier on Task.init, Task.detached, MainActor.run, TaskGroup.addTask, and other functions that indeed are not isolation-crossing calls but either transfer their argument internally (like MainActor.run) or create a new isolation domain for that argument (Task.init) is the appropriate solution.

With this change made (swapping @Sendable for transferring on the argument to TaskGroup.addTask, and also making this change to withTaskGroup's closure argument if needed) the code you provided would typecheck as is, and with the @Sendable annotation on the argument removed; provided Foo is a Sendable type. If not, the first call to group.addTask would transfer foo so the second call would be an error.

I forgot about reentrancy when drafting that example! Just updated it in the proposal to be safe in the presence of actor reentrancy. iso fields are a subtle feature, but very powerful. Unfortunately, they are likely very much out of scope for short term development of this pass, so if you're further interested in what they allow I think the linked paper really is your best bet!

Thanks for continuing to help me refine this proposal!

2 Likes

We just fixed a Sendable checking hole in the nightly compiler with this PR: [Concurrency] Diagnose non-sendable 'self' arguments crossing isolation boundaries. by hborla · Pull Request #67730 · apple/swift · GitHub

The PR was the correct thing to-do but it uncovered a bunch of correct warnings in our code. A common pattern that now produces a warning with strict checking on the latest nightlies looks like this

actor Foo {
    let stream = AsyncStream<Int>.makeStream().0

    func foo() async {
        for await element in stream { // warning: passing argument of non-sendable type 'inout AsyncStream<Int>.Iterator' outside of actor-isolated context may introduce data races

        }
    }
}

This is correct since async sequence iterators are not Sendable and we are sending it across an isolation domain here. The proposed SendNonSendable pass here wouldn't solve this since we are still using the iterator after transferring it, right?

It is probably orthogonal to this problem and only solvable with making it possible to consume async sequences on an actor's executor.

3 Likes

Why not just store the AsyncSequence instead of the AsyncSequence.Iterator?

makeStream().0 is the AsyncSequence, namely the AsyncStream. But the for await loop grabs the iterator from that value, apparently crossing async boundaries in the process. (@FranzBusch what boundary is crossed here? Iterator's isolation (default executor?) to actor's?)

Oh, I see, that is strange. It's not really taking an iterator, it's making a new one with makeIterator(). So I wonder what the problem with that is