I have a few long-running services within my app. I'm looking for a sanity check that there isn't anything wrong with doing something like this to create these services:
final actor MyService {
private var serviceTask: Task<(), Error>?
func start() {
serviceTask = Task {
repeat {
do {
try await Task.sleep(nano: 1_000_000_000 * 30) // sleep for 30 seconds. This throws if task is cancelled
await makeANetworkRequest()
} catch {
break
}
} while !Task.isCancelled
}
}
func stop() {
serviceTask?.cancel()
}
}
The goal here is to do some async work with a 30 second gap between firing. This is all good and well in the world of Swift Concurrency, right? The Task will suspend while sleeping, which allows other code to execute on the same thread, allowing the thread to always make forward progress.
I've been going down some ill-informed rabbit holes and started converting these services to use Timers, but I think I'm realizing that the problems were elsewhere.
I don't see anything wrong with this approach, in fact this is very similar to code I've been writing in my apps. What's fundamental -- and you are correctly doing -- is checking Task.isCancelled or Task.checkCancellation() so that the loop breaks eventually.
I guess one thing to be aware of is that you're making the request then sleeping... if you need the requests to be periodic on the dot you might want to account the time the request takes and substract from the sleep time and things like that. But if you don't care much about the skew this'll be good enough.
What about actor reentrancy here? The Task.sleep is a suspension point, and at this point, the actor's context is free. Any other entity from a different async context can call the start function, start a new task, and override the existing one, right?
This will certainly fix the issue in some way, but at the cost of denying service to other code requesting the same service from a different context or from the same context. I just wanted to highlight the implications of the suspension point and actor reentrancy occurring here.
Your service task does not currently throw any errors (because you catch any errors that occur). As it stands now, you can make serviceTask a Task<Void, Never> to reflect this reality.
That having been said, I would be inclined to leave it as a Task<Void, any Error>, but then simplify start to:
That achieves the same thing, but with less syntactic noise, IMHO.
In AppKit/UIKit, this idea of start/stop might feel natural, but in SwiftUI, a more natural pattern might be to remain within structured concurrency, e.g.:
actor MyService {
func pollingNetworkRequest() async throws {
while true {
try await Task.sleep(for: .seconds(30))
await makeANetworkRequest()
}
}
}
Then, for a View that requires this network polling, you can do:
struct NetworkBasedView: View {
let service = MyService()
var body: some View {
VStack {…}
.task {
try? await service.pollingNetworkRequest()
}
}
}
Note, there’s no need to manually cancel the polling. When the view is dismissed, the .task view modifier is automatically canceled and, as a result, the polling will be canceled for you, too.
Bottom line, we often try to minimize the amount of unnecessary unstructured concurrency (the use of Task {…}). Especially in SwiftUI, I like to enjoy the automatic cancelation propagation, in which case the manual keeping track of a Task and ensuring its proper cancelation can be a tad brittle.
I personally don’t declare actors as final, as actors implicitly can’t be subclassed (at this point, at least). It feels a bit redundant to declare it as final. I reserve final for those cases where either:
there is something about the type that would break if subclassed; or
it is a use-case that demands static dispatch for some reason and for which I would be willing to sacrifice the flexibility of subclassing (if we even could with an actor).
The use of final is sometimes introduced with classes for Sendable conformance, but again, that is moot with an actor.
But, I confess, this is a matter of stylistic/personal preference, so take this with a grain of salt.
How will your approaches work with the app being suspended when minimised/put into the background?
I have run a similar approach within a Task that loops with a Task.isCancelled check, and with start() and stop() methods much like the earlier approaches mentioned here. With that code I had strange behaviour that seemed to occur when the app is put into the background for longer periods of time where the polling requests do not seem to continue running. I was previously attempting to stop and restart the polling tasks by monitoring for an .onChange() for scenePhase provided by SwiftUI, but still had this sporadic issue.
As it only happened very infrequently (nothing regarding regularity concretely measured, but I would usually experience the bug only after testing for a day or two of normal use opening and minimising the app), I was never quite able to debug it and started looking at other mechanisms for polling for data.
I'm curious to know people's take on how app suspension on iOS will affect these approaches?
The task implicitly strongly captures self, so if you also wanted to cancel the task when the instance of your type goes out of scope, you would also have to add [weak self] in addition to implementing the deinit.