Ensuring _any_ isolation of a non-Sendable type

I'm working on a TaskStore type that I'd love to be usable in any actor-isolated context (i.e., if someone wants to store view-layer tasks, they could isolate it to the MainActor, or it could be used for database transactions). Here's a simplification of my current attempt at this:

final class BasicTaskStore<Key: Hashable & Sendable> {
  private(set) var tasks: [Key: Task<Void, Never>] = [:]
  private var taskIDs: [Key: UUID] = [:]

  func addTask(
    key: Key,
    operation: @escaping @Sendable () async -> Void
  ) -> Task<Void, Never> {
    let oldTask = tasks[key]
    let id = UUID()
    let newTask = Task {
      await oldTask?.value
      await operation()
      if self.taskIDs[key] == id { // ERROR: Capture of 'self' with non-sendable type 'BasicTaskStore<Key>' in a `@Sendable` closure
        tasks.removeValue(forKey: key)
      }
    }
    tasks[key] = newTask
    taskIDs[key] = id
    return newTask
  }
}

This almost works, but as we can see, accessing self at the end of the Task causes a compiler error.

I would love if there was a solution where I could ensure at compile-time that self would be isolated to the same context as when the method is called, but I'm not aware of any way of doing that (I tried various combinations of using #isolation and @isolated(any)).

One alternative would be using a lock, but it would be nice if I could avoid that, as it seems logically like it should be possible even if it's not with the current tools available in the language.

Any ideas?

I'm highly suspecting that this would only be possible if there was a facility to (polymorphically) isolate the whole instance to a certain actor (pseudocode syntax):

final class BasicTaskStore<Key: Hashable & Sendable, #isolated A: Actor = #context>

because there's no way to ensure or express that it'll "stay" on the actor that addTask inherits. To see this more clearly, imagine the existence of a second function

func alsoAddTask(
    isolation: isolated any Actor,
    key: Key,
    operation: @escaping @Sendable () async -> Void
 )

(or even, consider the original function just be called two times with two different actors passed to isolation) — there's no way to guarantee that the actor will be the same, so the epilogue in Task may race.

But this theoretical feature also introduces various problems, like tying the lifetime of the actor generic parameter to the lifetime of self, which is incredibly annoying and complex, and only leaves actors with indefinite lifetime as an option, wich are your global actors.

TL;DR just use locks :slight_smile:

EDIT: My bad, I have forgotten that a non-sendable type won't be callable from different actors anyway, even if it's in a @MainActor static let. Indeed, similar to what's happening here, this has to do with newTask not inheriting the isolation, so it would be otherwise possible to capture a non-sendable self into non-isolated tasks.

The following compiles for me:

import Foundation
final class BasicTaskStore<Key: Hashable & Sendable> {
    private(set) var tasks: [Key: Task<Void, Never>] = [:]
    private var taskIDs: [Key: UUID] = [:]
    
    func addTask(
        key: Key,
        isolation: isolated (any Actor)? = #isolation,
        operation: @escaping @Sendable () async -> Void
    ) -> Task<Void, Never> {
        let oldTask = tasks[key]
        let id = UUID()
        let newTask = Task {
            let _ = isolation
            await oldTask?.value
            await operation()
            if self.taskIDs[key] == id {
                tasks.removeValue(forKey: key)
            }
        }
        tasks[key] = newTask
        taskIDs[key] = id
        return newTask
    }
}

— the isolation actor just has to be captured into the task using e.g. let _ = until the closure isolation control proposal is accepted.

2 Likes

Aha, there's the right combination of compiler magic I was looking for; thank you kindly!

apologies if i'm missing something obvious, but in both of these formulations, wouldn't there still be potential races on the underlying dictionary storage if addTask were called from two different isolation contexts?

Unless I'm missing something weird with region-based isolation (I haven't really dived into how that works yet), no, because this class is not Sendable, so it cannot cross isolation boundaries and must remain in the isolation in which it was created. So if it's created on the main actor, it has to stay there. If it was created on a database actor, it has to stay there.

2 Likes

I initially assumed the same, but looks like there are enough isolation checks to make this impossible.

While the region-based isolation rules curiously don't seem to have an example with isolated arguments specifically, it indeed becomes impossible to send the object after an actor has touched such a function:


actor A {
    func test() -> sending BasicTaskStore<Int> {
        let s = BasicTaskStore<Int>()
        return s // ok!
    }

    func testAgain() -> sending BasicTaskStore<Int> {
        let s = BasicTaskStore<Int>()
        let _ = s.addTask(key: 123) { }
        return s // Sending 's' risks causing data races; this is an error in the Swift 6 language mode
    }
}

— notice the empty closure: it doesn't capture anything; we also get the error even if it's a non-escaping, non-sendable closure — the isolated parameter makes all the difference.


Found it: the precise specification for this in the proposal is as follows:

The parameters of an actor method or a global actor isolated function are considered to be within the actor's region. This is since a caller can pass actor isolated state as an argument to such a method or function. This implies that parameters of actor isolated methods and functions can not be transferred like other values in actor isolation regions.

Given that the signature of addTask can be rewritten as

func addTask<Key: Hashable & Sendable>(
    self: BasicTaskStore<Key>,
    key: Key,
    isolation: isolated (any Actor)? = #isolation,
    operation: @escaping @Sendable () async -> Void
) -> Task<Void, Never>

self becomes isolated to the actor under this rule.

3 Likes