Closure + actor + task

Hello I am sorry it's me again :)

I have new questions on async/await and actor

I have my actor that look like this, really simple

actor MyActor {
  var value: Int = 0
}

so if I try to modify my value from a closure I need to do like this:

actor MyActor {
  var value: Int = 0
  
  func test_withClosureAndTaskAndModifyMyValue() {
    let closure: (Int) -> () = { value in
      Task {
        self.value = 1 // Work
        self.update(2) // Work
      }
    }
  }
  
  func update(_ value :Int) {
    self.value = value
  }
}

for me the task catch the current actor and it will be updated on the actor, So far, so good.

now if my closure is Sendable
self.value = 1 don't work anymore
and
self.update(2) need to be await

so I am not able to update my value unless I use a method

actor MyActor {
  var value: Int = 0
  
  func test_withClosureAndTaskAndModifyMyValue() {
    let closure: @Sendable (Int) -> () = { value in
      Task {
        self.value = 1 // ERROR Actor-isolated property 'value' can not be mutated from a nonisolated context
        await self.update(2) // Work
      }
    }
  }
  
  func update(_ value :Int) {
    self.value = value
  }
}

Ia am asking why but I have something even stranger.

I implemented some code without any real purpose, just to test the limits and try to more undersand

here I don't need to have await anymore for update method

actor MyActor {
  var value: Int = 0
  
  lazy var stream = AsyncStream<Int>()  { continuation in
      continuation.onTermination = { _ in
        Task {
          self.value = 2 // ERROR Actor-isolated property 'value' can not be mutated from a nonisolated context
          self.update(1) // run and not need await anymore
        }
      }
    }
  
  func update(_ value :Int) {
    self.value = value
  }
}

and even weirdest :smiley:
I can run task on mainActor it's work without to have await

actor MyActor {
  var value: Int = 0
  
  lazy var stream = AsyncStream<Int>()  { continuation in
      continuation.onTermination = { _ in
        Task {@MainActor in
          print("") //Main Actor
          self.value = 2 // ERROR Actor-isolated property 'value' can not be mutated from a nonisolated context
          self.update(1) // run and will be switch on actor and no need await
        }
      }
    }
  
  func update(_ value :Int) {
    self.value = value
  }
}

If some one can explain me plase :'(

  var value: Int = 0
  
  func test_withClosureAndTaskAndModifyMyValue() {
    let closure: @Sendable (Int) -> () = { value in
      Task {
        self.value = 1 // ERROR Actor-isolated property 'value' can not be mutated from a nonisolated context
        await self.update(2) // Work
      }
    }
  }
  
  func update(_ value :Int) {
    self.value = value
  }
}

I think I can explain the @Sendable case: marking the closure „sendable“ means: this closure can be moved between isolation domains freely and executed concurrently. By definition, it therefore cannot be bound to your actor, instead it must assume that it is executed outside of one specific actor (ie it can run anywhere). And for that reason you cannot access actor-isolated state and you need to await actor isolated functions.

3 Likes

make sense :)

Just for completeness, I'd like to add that this isn't entirely true for global actors. For example, a @MainActor @Sendable (Int) -> () closure can capture non-sendable variables in @MainActor isolation. Calling these closures in code having a different isolation requires await.

var value: Int = 0 // Top level variables have @MainActor isolation implicitly

@MainActor func test() {
    let closure: @MainActor @Sendable (Int) -> () = { v in
        value = v // This compiles
    }
}

As SE-0434 (global actor isolated types usability) infers @Sendable for global-actor-isolated closures capturing non-Sendable values, closures which are @MainActor @Sendable are more common than we're aware.

I find a good way to understand the subtle difference between a @Sendable closure and @MainActor @Sendable closure. The key is to be aware that @Sendable and isolation are orthogonal for closure. For example, a @Sendable (Int) -> () closure is non-isolated because it has no explicit isolation defined. That's why it can be called directly.

They are really weird, so I tried to simplify the code to narrow down the issue. Based on my experiments, I believe the issue is specific to closures within lazy variable initializer of custom actor. I filed a bug. I hope this reassures you (and myself as well :grinning_face:) that all behaviors either follow some reasonable rules or are bugs.

2 Likes

Thank you I saw that will be occured an error on swift 6.1 :slight_smile: