Is this a thread safe Int counter?

Hello,

I started reading about atomic values and memory order and confused myself. Is this counter thread safe? Multiple threads can read and add, and it will be fine?

let queue = DispatchQueue(label: "...")
private var counter: Int = 0
func read() -> Int { queue.sync { counter } }
func add(_ n: Int) { queue.sync { counter += n } }

The advantage of something like UnsafeAtomic<Int> (from https://github.com/apple/swift-se-0282-experimental) is performance, correct?

2 Likes

Yep, you're correct!

1 Like

It's thread-safe because you're not reading/writing simultaneously from different threads. Both queue.sync calls use the same queue (which by default isn't a concurrent queue), so only one will be executing at any one time. Non-concurrent queues are like their own little streams of causality, which are not synchronised with other queues and can hop between different OS threads. If the reader and writer were on different queues, it wouldn't be thread-safe and the value in counter would be undefined.

Atomics give you the ability to truly read/write memory concurrently (across different queues or OS threads). If you had two threads (or two dispatch queues/one concurrent dispatch queue):

let queueOne = DispatchQueue(label: "...")
let queueTwo = DispatchQueue(label: "...")
private var counter: Int = 1

queueOne.async {
  sleep(1) // Wait a little bit
  counter *= 2
}
queueTwo.async {
  sleep(1) // Wait a little bit
  counter += 5
}

If counter were atomic, the result will definitely be either (1 + 5) * 2 = 12, or (1 * 2) + 5 = 7. Atomics don't define which one happens first, but it does define that both operations will occur, and that every other atomic operation it executes on counter from that point, even on a different core, will agree that it occurred.

If counter is not atomic, the result might be 12 or 7, or it might be 2 or 6 (both threads see counter = 1), or it might be something else entirely - maybe one CPU core was partially writing the result from one thread in to cache when the other one read it, and your result is "torn" somewhere. It's undefined behaviour - it could also crash or send a flock of flamingos to your house every Monday at 4pm :flamingo:. So you definitely want to avoid that; the car-washing costs aren't worth it.

Memory order basically tells the compiler what restrictions it has when optimising around atomics.

let canAccess = AtomicInt(0)
var storage   = [Item]()

func append(newItem: Item) {
  while canAccess.atomicCompareExchange(expected: 0, desired: 1, .acquire).exchanged == false {
    // Wait.
  }
  storage.append(newItem)
  canAccess.atomicStore(0, .release)
}

In this case, it would really suck if the compiler re-ordered the call to storage.append(newItem) before we acquired the canAccess lock (or to after we released it). Within those bounds, it can reorder and optimise things as it likes.

3 Likes

Thank you for the detailed explanation. I think I understand enough to write decent concurrent code using DispatchQueue, but I'm still curious how it works...

If the problems are caused by two or more threads using a variable simultaneously, then I'm guessing that "simultaneous" is this context isn't literal time (does it even exist? :slightly_smiling_face:), but rather some process in the digital circuits. So, if the two conflicting actions are separated by some nanoseconds, there might still be a problem.

In other words, what's to prevent this:

serialQueue.async { counter *= 2 }
serialQueue.async { counter += 5 }

... from (for example) running the first block on CPU core #1, writing the result to cache memory, then running the second block a few nanoseconds later on CPU core #2, and reading stale results from it's cache, or from non-cached memory?

1 Like

@robnik Let me get this for you.

What happens when you put .async on any queue is that, it will never wait or block the current running thread, rather move ahead and execute whenever resource is free.

For the thing you have asked, even if you are just writing to the counter variable, it doesn't guarantee what would be the value of counter variable in each of the async blocks. It is not only related to reading and writing at the same time. So, if you want to achieve that both should be self value aware, they should be in sync block instead. If you want to dig more into these type of things try reading about semaphores and mutex locks.

It doesnt work the way like switching cores, the process determines that on which thread it will run and see if it has to create more threads or not depending on so many other factors. A whole lot of computer science going on here. The process doesnt decide on which core it has to run.

I suggest playing https://deadlockempire.github.io/ for a from-scratch introduction to the issues of concurrency

Hi Avinash. Are you saying the order of the two blocks is not defined? I don't think that's true. Aren't blocks executed in the order received by the serial DispatchQueue? I see that mentioned in other places online but I can't find it in the Apple docs.

As to your last paragraph, I'm not sure what you mean. I'm aware that the application code doesn't decide on which core it runs. My question was that if the queue does use different cores for the two tasks, then how does it make sure the second one is synced with the first one? My guess is there is some hardware instruction to take care of this (to sync their caches, etc.), and it must be issued by GCD when it moves the queue to a different core. But maybe this is getting off topic for a Swift forum.

You might find this article interesting, particularly the part about cache coherency: