Edit 2: I added a screen recording below so the issue is even clearer. I am not satisfied with the current answers because I don’t understand why bindings (async) update differently when dealing with arrays, and much more reliably when dealing with single structs (even if they are of the same type)
—
Edit: I updated the code (removed the ForEach and added a couple of print()) to make the lack of thread safety array issue even more obvious.
—
I’m writing a basic countdown timer on iOS using try await Task.sleep(for: .seconds(1))
before updating a @State variable. When the variable MyStruct
, it seems to update fine, but if I place a few MyStruct
-s in an Array then I get strange timings for updates that break the functionality of a basic timer counting down.
Here is my isolated example – you might have to refresh the app a few times or swap out my onAppear with a Button to see how inconsistent this behavior is.
import SwiftUI
struct ContentView: View {
@State var first = MyStruct(setTime: 15)
@State var second = MyStruct(setTime: 10)
@State var third = MyStruct(setTime: 5)
@State var array = [MyStruct(setTime: 15), MyStruct(setTime: 10), MyStruct(setTime: 5)]
@State var hasNotAppearedYet = true
var body: some View {
VStack {
Text("Seconds: (Int(array[0].currentTime))").padding().font(.title3)
Text("Seconds: (Int(array[1].currentTime))").padding().font(.title3)
Text("Seconds: (Int(array[2].currentTime))").padding().font(.title3)
Divider()
Text("Seconds: (first.currentTime)").padding().font(.title3)
Text("Seconds: (second.currentTime)").padding().font(.title3)
Text("Seconds: (third.currentTime)").padding().font(.title3)
}
.padding()
.onAppear(){
if(hasNotAppearedYet){
$array[0].startTimer(name: "arrayElement0")
$array[1].startTimer(name: "arrayElement1")
$array[2].startTimer(name: "arrayElement2")
$first.startTimer(name: "FIRST")
$second.startTimer(name: "SECOND")
$third.startTimer(name: "THIRD")
hasNotAppearedYet = false
}
}
}
}
struct MyStruct {
var setTime: Double
var currentTime: Double = 0
}
extension Binding<MyStruct> {
func startTimer(name: String){
Task {
wrappedValue.currentTime = wrappedValue.setTime
print(name, wrappedValue.currentTime)
while (wrappedValue.currentTime > 0) {
try await Task.sleep(for: .seconds(1))
try Task.checkCancellation()
wrappedValue.currentTime -= 1
print(name, wrappedValue.currentTime)
}
}
}
}
#Preview {
ContentView()
}
Same weird results on Simulator and real iPhone
2
Answers
I think what you are seeing is the natural behavior of
async await
the "actor" decides what to do and when to do it.In this case the actor is juggling the timers and the UI updates.
Here are some things you can do to stabilize the
while
Get rid of the
Binding
extension
becauseBinding
is notSendable
and therefore not safe to use insideasync/await
. You can turn on strict concurrency to see the warning.Binding
by itself can introduce data races.Use
.task
instead ofTask
so you can hold on to and stabilize the "timer".Set the priority for the
task
it won’t be a perfect second but it should be much better. Theawait
is a second but there are other lines of code so the true time is likely 1+ seconds.While there are lots of issues in this code, the fundamental problem is that you have multiple threads updating individual value type instances in the same array at the same time.
Specifically,
startTimer
is a non-isolatedasync
function which (by virtue of SE-0338) does not run on the main thread. So you have multiple threads updating the individual values within the same array, without the sufficient synchronization. It is hard to be specific re the manifested behavior (as it is dependent upon a lot of Foundation implementation details), but it is not remotely surprising that it is problematic to mutate multiple values within the same array from separate threads at the same time.FWIW, isolating
startTimer
to a global actor addresses the immediate problem. In this trivial example, I would isolate it to the main actor as you updating a property used by theView
.But this is not the whole answer: At a bare minimum, I would advise changing the “Strict Concurrency Checking” build setting to “Complete” and review/resolve all of the warnings it raises.