Multiple function calls, but single execution of async Task
Asked Answered
C

3

8

I am playing around with Apple's new WeatherKit + WidgetKit. Unfortunately I can't seem to find a solution for the combination of the following three problems:

  1. WidgetKit requires to load all data in the getTimeline function (no dynamic updating of the UI)
  2. WidgetKit for some reason calls getTimeline twice or more when loading (Developer Forums)
  3. I want to keep my WeatherKit requests at a minimum

The first two problems above force me to fetch weather data in a function I have no control over (getTimeline).

My function to get weather already caches the Weather object and makes sure to only request new data if the cache is too old.

private func getWeather() async -> Weather? {
    // if cachedWeather is not older than 2 hours return it instead of fetching new data
    if let cachedWeather = self.cachedWeather,
        cachedWeather.currentWeather.date > Date().addingTimeInterval(-7200)  {
        return cachedWeather
    }

    return try? await Task { () -> Weather in
        let fetchedWeather = try await WeatherService.shared.weather(for: self.location)
        cachedWeather = fetchedWeather
        return fetchedWeather
    }.value
}

If I call getWeather() from within getTimeline, it might get called twice or more at roughly the same time though. As long as the first Task has not finished yet, the cachedWeather is still empty/outdated. This leads to multiple executions of the Task which in turn means multiple requests are sent to Apple.

In a normal SwiftUI view in an app, I'd work with something like an ObservableObject and only trigger a request in getWeather() if none is running already. The UI would be updated based on the ObservableObject. In WidgetKit this is not possible as mentioned above.

Question: Can someone help me figure out how to trigger the Task in getWeather() on the first call and if the task is already/still running when the second getWeather() call comes in, use the already running Task instead of triggering a new one?

Clary answered 26/8, 2022 at 14:42 Comment(6)
You'd need to keep a reference to the Task (right now you're just throwing the Task away).Preindicate
Actually, can you explain why you're making a Task inside an asyc method? Just say try await directly.Preindicate
You can use the REST API or just use the cache, update the cache however you are filling that in and that way you can control the calls. But he whole thing seems off, How will you react to errors? how will the user be notified? Doesn't seem sound.Welter
@Preindicate You're right the Task inside the async method makes no sense and was an error I introduced when simplifying the code for the example 👍 Keeping a reference of the Task was also an idea I had, but did not come up with something that worked for me. How would I return the result of said task if I noticed it is running already? If I use try await alreadyRunningTask that task is triggered again instead of reusing it, right?Clary
@loremipsum The REST API could be a workaround. Might look into that if all else fails, thanks! Updating the cache is exactly the problem. I need to trigger said updating somewhere and the only place I can do that is called by iOS in getTimeline (so I can't control how often it is called). In my full code I do react to errors by simply showing placeholders in the widget (nothing else one can do really if there is no data). Widgets are static, so there is no user input. All I can do is try to get the desired information and display it.Clary
Fun. It looks like it is a xcode 14 beta bug when using iOS 16 Lock Screen widgets. As soon as I switch to "normal" widgets getTimeline is only called once. The fact that xcode adds a systemSmall widget to my homescreen when running my scheme should have tipped me off. I do still find the question I posted quite interesting from a theoretical point of view though. How can I prevent tasks from running multiple times in parallel if I call an async function from different parts of my app at roughly the same time?Clary
P
6

If I'm understanding the question correctly, this is what an actor is for. Try this:

import UIKit

actor MyActor {
    var running = false
    func doYourTimeConsumingThing() async throws {
        guard !running else { print("oh no you don't"); return }
        running = true
        print("starting at", Date.now.timeIntervalSince1970)
        try await Task.sleep(nanoseconds: 5_000_000_000) // real task goes here
        print("finished at", Date.now.timeIntervalSince1970)
        running = false
    }
}

class ViewController: UIViewController {
    let actor = MyActor()
    var timer: Timer?
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            Task { [weak self] in
                try? await self?.actor.doYourTimeConsumingThing()
            }
        }
    }
}

As you'll see, the timer tries to start the task every second, but if the task is running, the attempt is turned back; you can only start the task if it isn't already running. The actor makes all this perfectly safe and thread-coherent.


With regard to your comment:

The thing missing is, that if the timeConsumingThing is called while running, I still need the result eventually... Ideally a second call would just „subscribe“ to the same running async Task.

I think we can emulate this by adding an actual publish-and-subscribe to the mix. First, let me separate out the actual task and make it return a result; this is supposed to be your WeatherKit interaction:

func timeConsumingTaskWithResult() async throws -> Date {
    try await Task.sleep(nanoseconds: 5_000_000_000)
    return Date.now
}

Now I'll revise the actor slightly so that new callers are forced to wait for the next result to come back from the latest WeatherKit interaction:

actor MyActor {
    var running = false
    @Published var latestResult: Date?
    func doYourTimeConsumingThing() async throws -> Date? {
        if !running {
            running = true
            latestResult = try await timeConsumingTaskWithResult()
            running = false
        }
        for await result in $latestResult.values {
            return result
        }
        fatalError("shut up please, compiler")
    }
}

Finally, the test bed is much as before, but now I'm getting a result for the call made on each firing of the timer, and I'll print it when I get it:

class ViewController: UIViewController {
    let actor = MyActor()
    var timer: Timer?
    override func viewDidLoad() {
        super.viewDidLoad()
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            Task { [weak self] in
                print("calling at", Date.now)
                if let result = try? await self?.actor.doYourTimeConsumingThing() {
                    print("RESULT!", result)
                }
            }
        }
    }
}

That yields:

calling at 2022-08-28 15:35:39 +0000
calling at 2022-08-28 15:35:40 +0000
calling at 2022-08-28 15:35:41 +0000
calling at 2022-08-28 15:35:42 +0000
calling at 2022-08-28 15:35:43 +0000
calling at 2022-08-28 15:35:44 +0000
RESULT! 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:46 +0000
RESULT! 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:47 +0000
RESULT! 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:48 +0000
RESULT! 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:49 +0000
RESULT! 2022-08-28 15:35:45 +0000
calling at 2022-08-28 15:35:50 +0000
RESULT! 2022-08-28 15:35:45 +0000
RESULT! 2022-08-28 15:35:50 +0000
calling at 2022-08-28 15:35:51 +0000
calling at 2022-08-28 15:35:52 +0000
RESULT! 2022-08-28 15:35:50 +0000
calling at 2022-08-28 15:35:53 +0000
RESULT! 2022-08-28 15:35:50 +0000
calling at 2022-08-28 15:35:54 +0000
RESULT! 2022-08-28 15:35:50 +0000
calling at 2022-08-28 15:35:55 +0000
RESULT! 2022-08-28 15:35:50 +0000
calling at 2022-08-28 15:35:56 +0000
RESULT! 2022-08-28 15:35:50 +0000
RESULT! 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:35:58 +0000
RESULT! 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:35:59 +0000
RESULT! 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:36:00 +0000
RESULT! 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:36:01 +0000
RESULT! 2022-08-28 15:35:57 +0000
calling at 2022-08-28 15:36:02 +0000
RESULT! 2022-08-28 15:35:57 +0000
RESULT! 2022-08-28 15:36:02 +0000
calling at 2022-08-28 15:36:03 +0000

As you can see, someone calls into our actor every second. Every caller eventually gets a result, and they are all the same result, 2022-08-28 15:35:45, because that is the time when the time-consuming task returned. From then on, the more recent callers all start getting 2022-08-28 15:35:50, because that is the time when the next time-consuming task returned. The call to the time consuming task, as in my earlier example, is gated so that it cannot be called until it has returned from its previous call.

Preindicate answered 27/8, 2022 at 1:43 Comment(7)
This is very close to what I was searching! Thanks for sharing! The thing missing is, that if the timeConsumingThing is called while running, I still need the result eventually. Just throwing or returning will lead to an empty timeline entry in WidgetKit. Ideally a second call would just „subscribe“ to the same running async Task.Clary
In that case I would suggest using Combine framework and literally subscribing the caller.Preindicate
Added code to show how to do it.Preindicate
You are a legend! What a great response! Thank you so much!Clary
Very interesting problem. :)Preindicate
"tasks rarely need to capture weak references to values." developer.apple.com/documentation/swift/taskFluoroscopy
This is a fantastic solution. Actor worked for a different problem I had. I have an AVAudioPlayer where the user can skip time by moving a slider. It was causing the player setup to happen multiple times if the user slides and leaves the slider many time. That resulted in crashes. Using Actor to manage the AudioPlayer saved it! Thank you for the pointer.Confectioner
B
3

I want to share a different approach. Using the AsyncStream on a publisher has the side effect of publishing the first result (which is nil) and didn't work for me in the scenario I need. You can of course use dropFirst or compactMap but there is another way.

We can leverage the fact that Task has an async value property that you can wait on. If we keep a reference to our task that we only want one instance of, then we can await its result.

actor WeatherService {
    
    private var timeConsumingTask: Task<Date, Never>?
    
    func timeConsumingTaskWithResult() async -> Date {
        print("Triggering at \(Date.now)")
        defer { print("RESULT! \(date)") }

        if let timeConsumingTask {
            return await timeConsumingTask.value
        } else {
            let t = Task {
                try! await Task.sleep(for: .seconds(5))
                return Date.now
            }
            timeConsumingTask = t
            return await t.value
        }
    }
}

Using the above weather service and requesting timeConsumingTaskWithResult multiple times produced the following:

Triggering at 2023-06-02 12:20:44 +0000
Triggering at 2023-06-02 12:20:44 +0000
Triggering at 2023-06-02 12:20:45 +0000
Triggering at 2023-06-02 12:20:45 +0000
Triggering at 2023-06-02 12:20:45 +0000
Triggering at 2023-06-02 12:20:45 +0000
Triggering at 2023-06-02 12:20:45 +0000
Triggering at 2023-06-02 12:20:46 +0000
Triggering at 2023-06-02 12:20:46 +0000
Triggering at 2023-06-02 12:20:46 +0000
Triggering at 2023-06-02 12:20:46 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000
RESULT! 2023-06-02 12:20:50 +0000

You will see how the Result value is always the same.

Of course in a real example you would actually store the value from the Task but this illustrates the point. Keep a reference to your task and await its value.

Bolometer answered 2/6, 2023 at 12:27 Comment(0)
F
3

There is a secret feature of Task that multiple things can await the same result, e.g.

actor WeatherDownloader {

    private enum CacheEntry {
        case inProgress(Task<Weather, Error>)
        case ready(Weather)
    }

    private var cache: [Location: CacheEntry] = [:]

    func weather(from location: Location) async throws -> Weather? {
        if let cached = cache[location] {
            switch cached {
            case .ready(let weather):
                return weather
            case .inProgress(let task):
                return try await task.value
            }
        }

        let task = Task {
            try await downloadWeather(for: location)
        }

        cache[location] = .inProgress(weather)

        do {
            let weather = try await task.value
            cache[location] = .ready(weather)
            return weather
        } catch {
            cache[location] = nil
            throw error
        }
    }
}

From 11:59 in https://developer.apple.com/wwdc21/10133

Fluoroscopy answered 15/4 at 17:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.