9

Let's say I have an object for which multiple threads can read/write to the state and someValue variables. Do I need to add locking if these variables are types like int, double, enums etc.?

enum State: String {
  case one
  case two
}

class Object {
  var state: State
  var someValue: Double
}
Ahmad F
  • 30,560
  • 17
  • 97
  • 143
weuhi
  • 183
  • 1
  • 8
  • 2
    Related: [Are Swift variables atomic?](https://stackoverflow.com/questions/24157834/are-swift-variables-atomic) – Martin R Jul 04 '17 at 09:13

5 Answers5

6

Yes you do.

Imagine the situation where two threads are trying to add 1 to someValue. A thread does this by:

  1. read someValue into a register
  2. Add 1
  3. write someValue back

If both threads do operation 1 before either does operation 3, you will get a different answer than if one thread does all three operations before the other thread does operation 1.

There are also more subtle issues, in that an optimising compiler might not write the modified value back from the register for some time - if at all. Also, modern CPUs have multiple cores each with its own cache. The CPU writing a value back to memory doesn't guarantee it gets to memory straight away. It may just get as far as the core's cache. You need what's called a memory barrier to ensure that everything gets neatly written back to main memory.

On the larger scale, you'll need locking to ensure consistency between the variables in your class. So, if the state is meant to represent some property of someValue e.g. is it an integer or not, you'll need locking to ensure everybody always has a consistent view i.e.

  1. modify someValue
  2. test the new value
  3. set state accordingly.

The above three operations have to appear to be atomic, or if the object is examined after operation 1 but before operation 3, it will be in an inconsistent state.

JeremyP
  • 84,577
  • 15
  • 123
  • 161
  • Unless I am mistaken, even reading/writing a single property is not necessarily atomic, so that one thread could read garbage if another thread is writing at the same time. – Martin R Jul 04 '17 at 09:08
  • @MartinR I don't know. My thought is that, with a 64 bit data bus, `Double` would be written in one memory write cycle, no idea about an `enum` value though. However, with issues like the CPU cache, compiler optimisation and out of order execution, I think the question is moot. You need some form of synchronisation, anyway. – JeremyP Jul 04 '17 at 09:20
  • @MartinR: Reading the same data from multiple threads is fine, as long as nobody tries changing the data (on Intel and ARM processors). Reading while writing is a problem because the reader _may_ read a mixture of old and new data which would be complete rubbish. – gnasher729 Jul 04 '17 at 09:35
  • "Reading while writing is a problem because the reader may read a mixture of old and new data which would be complete rubbish" didn't think that is possible, thanks for the clarifications – weuhi Jul 04 '17 at 11:44
  • @weuhi it certainly was possible for 32 bit values on most 16 bit processors or 64 bit values on most 32 or 16 bit processors as they could be interrupted by a context switch between reading the first part of the value and the next part of the value. Whether 32bit iOS ever ran on hardware which did this I do not know. – Pete Kirkham Jul 04 '17 at 12:32
4

“need locking” needs to be quallified with what you expect to be safe from. If you need to update more than one value in a coordinated manner, you certainly need to lock. If you do a read/modify/write on more than one thread, you needto lock or use special speculative code that can note the interruption of another thread. For simple use of single values, you can use special atomic operations. Sometimes just setting a value doesn’t need locking, but that depends on the situation.

JDługosz
  • 5,592
  • 3
  • 24
  • 45
  • Say thread A writes to someValue every X milliseconds and there are multiple threads that want to read someValue. – weuhi Jul 04 '17 at 11:42
  • In C++ I know I can do that without locking. Writing a primitive value to memory writes the whole thing, not changing some bytes before others, if properly aligned. You describe a common, useful case. Document your assumptions! – JDługosz Jul 04 '17 at 11:50
2

What JeremyP says, but you also need to consider higher levels: Your "state" and "someValue" could be related. So if I change the state, then someValue, the contents of the whole object just after I change "state" might be rubbish because the new state doesn't match the old someValue.

Simple solutions are googling how to do "@synchronized" in Swift, or dispatch to the main thread, or dispatch to a serial queue.

gnasher729
  • 51,477
  • 5
  • 75
  • 98
1

For the purpose of simulating your issue, I traced the following code snippet (iOS App environment):

import UIKit

func delay (
    _ seconds: Double,
    queue: DispatchQueue = DispatchQueue.main,
    after: @escaping ()->()) {

    let time = DispatchTime.now() + Double(Int64(seconds * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
    queue.asyncAfter(deadline: time, execute: after)
}

class ViewController: UIViewController {
    var myValue = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        addOneThousand()
        addOneThousand()
        addOneThousand()

        // calling this is just for logging the value after a delay
        // just for making sure that all threads execution is completed...
        delay(3.0) {
            print(self.myValue)
        }
    }

    func addOneThousand() {
        DispatchQueue(label: "com.myapp.myqueue").async {
            for _ in 0...999 {
                self.myValue += 1
            }

            print(self.myValue)
        }
    }
}

For the first look, the expectation would be: the value of myValue should be 3000 since addOneThousand() has been called three times, but after running the app 10 times -on my machine (simulator)- sequentially, the output was:

1582 1582 1582 1582

3000 3000 3000 3000

2523 2523 2523 2523

2591 2591 2591 2591

1689 1689 1689 1689

1556 1556 1556 1556

1991 1991 1991 1991

1914 1914 1914 1914

2416 2416 2416 2416

1889 1889 1889 1889

The most important thing that the fourth value for each result (the output after waiting for the delay) is most of the times is unexpected (not 3000). If I am not mistaking, I assume that what we are facing here is a race condition.


An appropriate solution for such a case is to let the execution of the threads to be serialized; after editing addOneThousand() (sync instead of async. You might want to check this answer):

func addOneThousand() {
    DispatchQueue(label: "com.myapp.myqueue").sync {
        for _ in 0...999 {
            self.myValue += 1
        }

        print(self.myValue)
    }
}

the output for 10 sequential runs was:

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

1000 2000 3000 3000

That represents the expected result.

I hope it helpful.

Ahmad F
  • 30,560
  • 17
  • 97
  • 143
0

Strictly speaking, no (although "yes" would also be a valid answer, and arguably a more correct one for the general case).

Depending on what you need/want, you may very well just use atomic operations using functions like e.g. OSAtomicIncrement32 or OSAtomicCompareAndSwapPtr which is not locking.

Note, however, that even if one operation is atomic, two individually atomic, consecutive operations are not atomic in their entirety. Thus, if for example, you want to update both state and someValue consistently, then going without a lock is sheer impossible if correctness matters (unless, by coincidence, they're small enough so you can cheat and squeeze them into a single larger atomic type).

Also note that even though you either need to lock or use atomic operations for program correctness, you can occasionally very well "get away" without doing so. That's because on the majority of platforms, ordinary loads and stores to properly aligned memory addresses are atomic anyway.
However, do not get tempted, this is not as good as it sounds, and indeed not a good thing at all -- relying that things will just work (even if you "tested", and it works fine by all means) creates the kind of program which works fine during development and then causes a thousand support tickets a month after shipping, and there's no obvious indication to what's gone wrong.

Damon
  • 67,688
  • 20
  • 135
  • 185