1

This code always seems to work like magic. Without any locking, the output is 1,2.

class Counter {
    var count = 0
    func increment() -> Int {
        count += 1
        return count
    }
}

class ViewController: UIViewController {
    
    var tasks = [Task<Void, Never>]()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let counter = Counter()
        
        tasks += [
            Task.detached {
                print(counter.increment())
            }
        ]

        tasks += [
            Task.detached {
                print(counter.increment())
            }
        ]
    }
}

This code is from https://www.andyibanez.com/posts/understanding-actors-in-the-new-concurrency-model-in-swift/

I'm expecting it to work inconsistently. I've tried switching it to not use detached tasks, but still it always returns 1,2. What is going on?

Rob
  • 415,655
  • 72
  • 787
  • 1,044
WishIHadThreeGuns
  • 1,225
  • 3
  • 17
  • 37

2 Answers2

5

In short, no, this is not thread safe. And, no, unstructured concurrency (with Task {…} or Task.detached {…}) is not guaranteed to execute in the order they were created. From your question, above, it is unclear which of these two concerns is your primary issue, so I will attempt to address both, below.


There are two issues:

  1. This code is not thread-safe.

    You have multiple detached tasks incrementing the same non-synchronized mutable object.

    If you do this in an Xcode project it will likely warn you. If not, turn on “Strict Concurrency Checking”, and it will definitely warn you.

    enter image description here

    If you do this, it will warn you that Counter is not Sendable (i.e., it is not thread-safe). Note, this is an “opt in” feature right now, but reportedly Swift 6 is likely to make this a hard error.

    Capture of 'counter' with non-sendable type 'Counter' in a @Sendable closure

    Alternatively, you can turn on TSAN (and do more iterations), and it can warn you of the misuse.

    But, either way, this code simply is not thread-safe but is simply not performing enough iterations to manifest the problem, and you should hesitate to draw any conclusions from it.

    If you want to manifest the problems arising from the lack of thread-safety here, bump the number of iterations up and try again. For example, when I did 10m iterations in a macOS target, the resulting count was 9,927,088 (!).

    func experiment() async {
        let counter = Counter()
    
        await withTaskGroup(of: Void.self) { group in
            for _ in 0 ..< 10_000_000 {
                group.addTask { counter.increment() }
            }
        }
    
        print(counter.count)  // 9,927,088
    }
    

    In this trivial case, the race doesn’t manifest a problem very often. But in more complicated objects, and the data race risk can be greatly magnified. But either way, in the spirit of Apple’s adage, “there is no such thing as a benign race”, just add synchronization and this problem completely disappears.

  2. The order of execution of tasks is not guaranteed.

    This is not a GCD queue, where tasks will launch in a FIFO manner. If you create multiple tasks (especially detached tasks, but even non-detached tasks) they will tend to run in the order that you create them, but there is no such formal assurances. But, again, doing it twice is unlikely to manifest the problem. Do it thousands or millions of times, and you are more likely to see this non-FIFO behavior.

    So, if you solve the prior data race problem (by making Counter an actor or adding your own synchronization), the Counter will now increment appropriately due to its synchronization. But the tasks you launch, themselves, will not necessarily run in that order. (Generally, that is to be expected and is desirable. This is a “concurrency” system and we’re not looking for serial behavior.) But if you are concerned about the order, just appreciate that this is not a FIFO system. You might not manifest this with only a few tasks, but add a few thousands/millions, and they will largely execute in order they were created, but not entirely so.

    But if you want to prove that they’re not running in the same order, just printing the count won’t demonstrate that. Let’s say, for example, that you create three task and the third runs before the other two: The count will still be incremented from 1, to 2, to 3 (even though the order that the tasks was not the same that you created them).

    In short, tasks (even actor-isolated ones) may not execute in the order that they were created. If you want them to run in order, you have to have each await the prior one or use some AsyncSequence (e.g., an AsyncStream or AsyncChannel).

    Consider this example, which compares the count value to the index of the loop:

    actor Counter {
        var count: Int = 0
    
        func increment() -> Int {
            count += 1
            return count
        }
    }
    
    @MainActor
    func experiment() {
        let s = Counter()
    
        for i in 1 ... 10_000 {    // perhaps even 1_000_000 in release builds on device
            Task { [i] in
                let result = await s.increment()
                if i != result {
                    print(i, "does not match", result)
                }
            }
        }    
    }
    

    And, in this case, you will see that they do not all execute in the order that one may have otherwise suspected. The same is true if it were a detached task, but I just wanted to illustrate that the issue is not simply which thread is making the call, but more fundamentally the non-FIFO nature of tasks, detached or otherwise.


Probably needless to say, but you probably want to run this test from an app on a physical device, as the cooperative thread pool on the simulator is highly constrained.

Also, I would advise avoiding playgrounds because:

  • the Playground warnings are not nearly as robust as Xcode’s compile-time checking;
  • it has all sorts of limitations and performance characteristics that may skew the results.

Playgrounds and simulators can be used, but the best results come on a capable, physical device.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
4

First, on Simulator, this only uses a single thread (*), so you won't see it get out of order with this code. On device, it uses multiple threads, but you probably won't see it out of order very often for just 2 tasks. If you re-write this with 100 tasks and run on device, it's very easy to show what you're looking for.

    for _ in 1...100 {
        tasks += [
            Task.detached {
                print(counter.increment())
            }
        ]
    }
1
5
7
8
9
10
11
...

(*) I've been told by folks I trust that Simulator uses 2 threads, at least now (I haven't run tests to check). This still causes the same symptom, since one is dedicated to main.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • 1
    Interesting! I'm always saying not to use playgrounds for testing threading and now I have to say don't use the simulator either? – matt Mar 10 '23 at 19:16
  • 1
    yeah...at least for Tasks it does seem to be that way. – Rob Napier Mar 10 '23 at 20:22
  • For other threading experiments the simulator seems adequate. It's all a bit odd, but good to know that I can replicate this issue. Thanks @RobNapier – WishIHadThreeGuns Mar 11 '23 at 09:44
  • 1
    Structured concurrency is not the same as GCD. The simulator isn't limited to one thread. But structured concurrency serializes on the simulator (at least currently; I would not be surprised if they change this in the future to support 2 threads to help people suss out concurrency bugs). – Rob Napier Mar 11 '23 at 14:33
  • 2
    I came here from https://stackoverflow.com/questions/75965500/whats-happening-at-runtime-when-mutating-a-struct-from-multiple-tasks-running-i and I actually don't believe this answer is correct: when I run 100 detached tasks in the iOS simulator and print `Thread.current` from each, I see those tasks evenly split across _10_ different threads. Your code in the simulator also shows results out of order for me. I think the real issue is that with a sample of `n = 2` and such short-running tasks, OP will pretty much never see them interleave. – Itai Ferber Apr 08 '23 at 14:02
  • 1
    At least, the number of threads in the cooperative thread pool can vary between devices and depending on how the OS is currently scheduling, so you may see different results. I just don't think this is correct in the general case. – Itai Ferber Apr 08 '23 at 14:04
  • This appears to be evolving (but I've not yet seen 10): https://stackoverflow.com/questions/67978028/maximum-number-of-threads-with-async-await-task-groups Are you saying that perhaps 14.3 has changed it again? – Rob Napier Apr 08 '23 at 14:09
  • I don't currently have access to older versions of Xcode, so I can't check to confirm — but I'd be surprised if something has changed in 14.3. From memory, at least, I've never seen Swift Concurrency be limited to only 1 or 2 steps in the Simulator; what makes the biggest difference, as far as I'm aware, is the current load on the system and how the executor's scheduler decides to schedule jobs on the cooperative thread pool. It may _choose_ to use fewer threads if the system is under contention, so you may see very variable behavior. – Itai Ferber Apr 08 '23 at 23:58
  • Because the Sim has to share the cooperative thread pool with the rest of the Mac, and because Xcode and the Sim itself put a good amount of resource strain on the rest of the machine, I think it's not surprising that you don't have quite as much access to resources within that environment, so you may end up being constrained in surprising ways. (But I'm not aware of hard _caps_ on threads in the Sim in this manner.) – Itai Ferber Apr 08 '23 at 23:59
  • 1
    FWIW, this simulator limitation on the cooperative thread pool (which I verified as recently as Xcode 14.2) appears to now be removed in Xcode 14.3. Curiously, there is not a peep about this in the 14.3 [release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-14_3-release-notes). I'm re-downloading 14.2 just to make sure Apple isn't gaslighting me (or, perhaps to prove they are). – Rob Apr 10 '23 at 17:06
  • 1
    Yep, Xcode 14.2 on simulator on Mac Studio, the cooperative thread pool maxed out at three threads. On Xcode 14.3, it maxed out at 20 threads (the number of threads available on this Mac). They definitely changed the behavior in 14.3. I just wish they acknowledged the change in the release notes. – Rob Apr 10 '23 at 17:25