Search code examples
swiftswift-concurrency

Swift concurrency: Why is this method async?


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() {
    }
}

Solution

  • 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() {}
    }