skip to Main Content

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)

Demo of suspected concurrency issue

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


  1. 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

    1. Get rid of the Binding extension because Binding is not Sendable and therefore not safe to use inside async/await. You can turn on strict concurrency to see the warning. Binding by itself can introduce data races.

    2. Use .task instead of Task so you can hold on to and stabilize the "timer".

    3. Set the priority for the task it won’t be a perfect second but it should be much better. The await is a second but there are other lines of code so the true time is likely 1+ seconds.

        import SwiftUI
    
        struct TimerTestView: 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
            @State private var date: Date = Calendar.current.date(byAdding: .second, value: 16, to: .init())!
            
            var body: some View {
                VStack { 
                    Text(date, style: .timer) //Added an Apple timer to compare
                    HStack { //Changed UI so I can see things side by side 
                        MyStructView(item: $array[0])
                        MyStructView(item: $first)
                    }
                    HStack {
                        MyStructView(item: $array[1])
                        MyStructView(item: $second)
                    }
                    HStack {
                        MyStructView(item: $array[2])
                        MyStructView(item: $third)
                    }
                }
                .padding()
                
            }
        }
    
        struct MyStruct: Identifiable, Sendable {
            let id: UUID = .init()
            var setTime: Double
            var currentTime: Double = 0
        }
    
    
        #Preview {
            TimerTestView()
        }
    
        @MainActor
        struct MyStructView: View {
            @Binding var item: MyStruct
            var body: some View {
                Text("Seconds:n (item.currentTime)").padding().font(.title3)
                // Use the id to stabilize the task
                    .task(id: item.id, priority: .userInitiated) { //Set the priority & hold on to the Task
                        do {
                            try await startTimer(name: item.id.uuidString)
                        } catch {
                            print(error)
                        }
                    }
            }
            func startTimer(name: String) async throws { 
                let start = Date()
                item.currentTime = item.setTime
                print(name, item.currentTime)
                
                while (item.currentTime > 0) {
                    try await Task.sleep(for: .seconds(1))
                    try Task.checkCancellation()
                    item.currentTime -= 1
                    print(name, item.currentTime)
                }
            }
        }
    
    Login or Signup to reply.
  2. 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-isolated async 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 the View.

    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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search