Home > OS >  Swift concurrency: Why is this method async?
Swift concurrency: Why is this method async?

Time:12-20

I'm trying to wrap my head around how to integrate Swift concurrency with old code that is using block-based things like Timer. So when I build the code below, the compiler tells me on the line self.handleTimer() that the Expression is 'async' but is not marked with 'await'

Why is it async? It is not marked async and is not doing anything. When I call it without the timer I don't need await. Does actor-isolation mean that every call to a member is "async" from outside that context?

@MainActor
class MyClass {
    
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            Task {
                self.handleTimer() // "Expression is 'async' but is not marked with 'await'"
            }
        }
    }
    
    func handleTimer() {
    }
}

CodePudding user response:

Does actor-isolation mean that every call to a member is "async" from outside that context?

Yes.

You can remove the warning by telling the task to do its work on the main actor, like so:

Task { @MainActor in
    self.handleTimer()
}

Or by marking handleTimer() as nonisolated, if it has no side effects that would break actor isolation.

CodePudding user response:

It is because of the @MainActor (only use to update UI) you get the same effect if you use actor instead of class.

actor MyClass {

Actors are isolated, concurrent pieces

The actor allows only one task at a time to access its mutable state

Meaning that anything that is done within handleTimer will be "paused" if something else is mutating the actor.

If handleTimer will not mutate anything inside the actor you can add the nonisolated keyword.

@MainActor
class MyClass {
    
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            Task {
                self.handleTimer() 
            }
        }
    }
    
    nonisolated func handleTimer() {
    }
}

But that Task inside of a closure is an issue by itself. Concurrency is all about organizing and knowing what is going on at all times. That Task break that, it gets called an then it is just left there. There is no way of stopping it or keeping track of when it is done or when it has failed.

I suggest rethinking your setup so it uses a pure concurrent setup, closures and concurrency are full of issues and leaks.

@MainActor
class MyClass {
    func startTimer(withTimeInterval interval: TimeInterval, upperLimit: Int = .max) async throws {
        for _ in 0...upperLimit{
            try await Task.sleep(for: .seconds(interval))
            handleTimer()
        }
    }
    
    func handleTimer() {
        print(#function)
    }

}

Then use it something like below.

let task = Task{
    do{
        let c = await MyClass()
        try await c.startTimer(withTimeInterval: 1, upperLimit: 10)
    }catch{
        print(error)
    }
}

With this setup you can cancel the timer/task with

task.cancel()

CodePudding user response:

I think the way to understand this situation is to build up to it in stages. Let's start with no async/await markings of any kind:

class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            self.handleTimer()
        }
    }
    func handleTimer() {}
}

Fine, so we all know we're allowed to talk like that; we've been doing it for years. Now let's mark MyClass as @MainActor:

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            self.handleTimer() // warning -> error
        }
    }
    func handleTimer() {}
}

Now self.handleTimer() elicits a warning that, we are told, will evolve into an error when Swift concurrency comes to full fruition in Swift 6: "Call to main actor-isolated instance method 'handleTimer()' in a synchronous nonisolated context."

Clearly there is something about this line that the compiler regards with suspicion. But what? As you have said yourself, handleTimer seems innocuous enough. But let's take a step back. All these years, we've become accustomed to the notion that things in general tend to happen "on the main thread". As long as that's universally true, there aren't any threading issues. If we call handleTimer from a view controller, everything is fine:

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        MyClass().handleTimer()
    }
}

No problem. But not so fast. There is no problem, only because ViewController, as a UIViewController, is also marked @MainActor (implicitly). As soon as we introduce a class with no such marking, things rapidly fall apart:

class OtherClass {
    func test() {
        MyClass().handleTimer() // BLAAAAAAAAAAP!
    }
}

Whoa! Everything about that line turns out to be illegal. Not only can we not call handleTimer, we can't even call the MyClass initializer!!!

Why is there an issue here? Let's look at the matter historically. While you were sleeping, Apple quietly attached a @MainActor designation to all your favorite UIKit classes, and turned on the beginnings of Swift concurrency. But because Apple attached this designation to all your favorite UIKit classes, you didn't even notice; it's just a bunch of methods calling one another on the same actor (namely the main actor), so the compiler is perfectly happy.

In such a world, however, our OtherClass is a stranger. It is not declared @MainActor. So when it talks to MyClass, it crosses actor contexts — and the compiler brings down upon us the full force of its wrath.

Obviously we can solve the problem by bringing OtherClass into our happy @MainActor world:

@MainActor
class OtherClass {
    func test() {
        MyClass().handleTimer() // no problem :)
    }
}

We could alternatively solve it introducing full-on async/await, which is what you do when you want to talk across actor contexts:

class OtherClass {
    func test() {
        Task {
            await MyClass().handleTimer()
        }
    }
}

In that code, we are making no guarantees about what actor (if any) OtherClass is tied to, or what actor (if any) the Task is tied to. But we can behave coherently just by crossing the actor contexts correctly, namely by saying await inside an async-context world, namely the Task.

Okay! So now we're ready to grapple with your original problem. Everything was fine, as we've shown, until you said @MainActor on MyClass. At that point, a question arises: what's the actor status of the line self.handleTimer()?

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            self.handleTimer() // warning -> error
        }
    }
    func handleTimer() {}
}

The compiler is saying: "This line belongs to the Timer (and the runtime), not to you. This self.handleTimer() call is coming from outside MyClass. But MyClass is bound to the main actor, so you can't do that! I (the compiler) am going to forgive you for now, because if I didn't, all your Timer code in every UIKit class in all your existing projects would break. But I'm warning you, I'm not going to be so easy-going in the future!"

To solve the problem, one approach is to guarantee that self.handleTimer() will be itself on the main actor. One way to do that is to wrap the call in a MainActor block. But we can't do that in a non-async context; this, for example, doesn't compile at all:

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            MainActor.run { // No way!!!!
                self.handleTimer()
            }
        }
    }
    func handleTimer() {}
}

Instead, we have to use the lessons we just learned. We need to get into an async context — which, since the Timer block doesn't even belong to us, we can do only by introducing a Task. We can then mark the inside of that task as tied to the main actor, and the whole issue goes away:

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            Task { @MainActor in
                self.handleTimer()
            }
        }
    }
    func handleTimer() {}
}

The alternative, obviously, would be, instead of saying @MainActor inside the task, just to use full-on async/await talk so we can cross the actor contexts:

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            Task {
                await self.handleTimer()
            }
        }
    }
    func handleTimer() {}
}

Last but not least, we can do what jrturton has said: mark handleTimer itself as innocuous. This is tricky, because you're telling the compiler that you know more than it does, which is always risky (like when you cast down with as!). But since handleTimer is currently empty,, we can certainly get away with it:

@MainActor
class MyClass {
    func startTimer() {
        _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
                self.handleTimer()
            }
    }
    nonisolated func handleTimer() {}
}
  • Related