0

Pseudocode (Assuming that there is no synchronization of any kind):

count = 0; // Global

Thread 1:

count = count + 1;

suppose that after reading count "captured" the 0 and context switch occurred. It went to another thread and the following happened:

Thread 2:

count = count + 1; // Count = 1. Finished all without context switching

Decides to return to thread 1:

after finishing thread 1, will count be worth 1 because it read 0 when it swapped, or will it be worth 2? That is,

does the context switch retain the value of the variables when it resumes the task?

  • 3
    Context switches are irrelevant. In C++, it's ***synchronization*** that specifies how changes made in one execution thread are observed by other execution threads. Nothing in the question involves synchronization of anything, so the pedantic answer here would be "it's unspecified". – Sam Varshavchik Feb 08 '23 at 19:13
  • 4
    If you don't have synchronization, you don't have defined behavior. The rule is that if more than one thread accesses a shared variable, and at least one of those threads is a writer, then all access needs to be protected with some sort of synchronization technique. – NathanOliver Feb 08 '23 at 19:20
  • hi @NathanOliver +1 then, knowing whether the state of the variables is preserved, is irrelevant as Sam Varshavchik says, right? –  Feb 08 '23 at 19:58
  • 1
    @Coder23 Yes, without synchronization your code has undefined behavior. That means any result will be "correct". – NathanOliver Feb 08 '23 at 20:04
  • 2
    If it _didn't_ keep the state of variables, then how could any program ever work? Context switches happen _without warning,_ Without the program even having any way to know that they happened. – Solomon Slow Feb 08 '23 at 21:36
  • hello @Solomon Slow +1, so in the **specific example** I put in the question when the threads are finished running `count` will be 1 because the first thread read `count` (an old value) when it was `0`, right? –  Feb 08 '23 at 21:54
  • 3
    No. A conforming compiler could set count to 0, 1, or any other number, or the complete works of Shakespeare. Undefined behavoir is totally undefined. _Anything_ can happen. – Mike Vine Feb 08 '23 at 22:50
  • 2
    @Coder23: If this was `count = 0xFE;` (254) and then one thread ran `count+=3` at the same time another thread ran `count += 1`, then one possible result on some CPUs you might end up with `count == 0x1FF` (511), due to the byte writes being interwoven. Undefined behavior. – Mooing Duck Feb 08 '23 at 22:57
  • hi @Mooing Duck +1 , so, 2 updates **literally** at the same time on the **same variable** is undefined, anything could happen, right? –  Feb 08 '23 at 23:00
  • 1
    @Coder23: Yes, except "literally at the same time" can be "any time even kinda sorta vaguely near each other" – Mooing Duck Feb 08 '23 at 23:04
  • Thanks @Mooing Duck +1 with a critical section (mutex) you ensure that there is **never** that "literally at the same time" and thus avoid unwanted behavior, right? –  Feb 08 '23 at 23:11
  • 1
    @Coder23: Correct. mutexes tell the compiler to generate code that tells the CPU to forcibly synchronize, thus preventing these issues. – Mooing Duck Feb 08 '23 at 23:32
  • What happens if the cache lines are dirty and have write-back enabled? On a context switch won't the dirty cache lines be written to main memory? If count++ finishes with Thread 1, the cached memory is marked as write-through, and then it gets context switched out, won't count be updated in main memory? There is no guarantee that 2 threads wouldn't overwrite each others increments of count of course. Only synchronization helps with that. – Gray Feb 09 '23 at 20:31
  • thanks @Gray +1 excellent answer, you mean that for a update made by a thread on a global (shared) variable to be visible in other threads there has to be a memory barrier, e.g. a mutex, right? –  Feb 10 '23 at 13:24

2 Answers2

2

To actually answer your question... The following can (and will!) indeed happen:

Thread 1: Load "count" into CPU register "A". Result: A = 0
Thread 1: Increment CPU register A. Result: A = 1

(Context switch)

Thread 2: Load "count" -> 0
Thread 2: Increment -> 1
Thread 2: Store 1 in "count"

(Context switch)

Thread 1: Store 1 from register A in "count"

Afterwards, count is still 1, and one increment has been "lost". In practice, pretty much anything can happen, including the counter suddenly going backwards or reading totally nonsensical values.

What's preserved across a context switch is the CPU's registers. In order to operate on a variable, the CPU first has to load the variable from memory into a register. Then it can operate on the copy of the variable in its register. When it's done, it'll write the value from the register back into memory (potentially immediately after the increment, or potentially much later - it's unspecified). If another thread changes the variable in the meanwhile, the value in the CPU's register will be outdated and your program breaks.

There are so-called atomic variables (i.e. std::atomic<int>) that allow "read-modify-write" operations that can't be interrupted by a context switch (and can also be done concurrently on multiple different CPU cores).

On some CPU architectures (ones without coherent caches), data written to memory by one thread isn't even visible to other threads until a so-called release operation has been executed that makes this data available to other threads. This means that, if there isn't proper synchronization, different threads could read totally different values from a variable even when all write accesses to it have already completed. In general, "release" operations make data available to threads that perform an "acquire" operation. These operations could be the acquiring and releasing of a mutex, but it could also be an access to an atomic variable with suitable semantics (these translate to special machine instructions that not only access the variable but also control cache coherence).

Note that acquire/release semantics are also barriers for the compiler: In the absence of barriers (and atomics), the compiler is free to re-order memory accesses as long as the program executes "as-if" it was unmodified, not taking threads into account. This means that a compiler can re-order, omit, and duplicate memory reads and writes as it pleases. A release barrier (i.e. mutex release) prevents the compiler from moving accesses "down" after the barrier, so that the barrier makes previous writes available to other threads. An acquire barrier (i.e. mutex acquire) prevents the compiler from moving accesses "up" before the barrier, so that the barrier makes other threads' writes visible to the thread that executed the barrier. When a release on one thread matches up with an acquire on another (i.e. they both use the same mutex or the same atomic variable), data written by the releasing thread becomes visible to the acquiring thread, and you can transfer data between threads safely without everything blowing up.

For more info on all of this atomics / lock-free stuff, I'd recommend you to watch this presentation by Herb Sutter at CppCon 2014:

Part 1: https://www.youtube.com/watch?v=c1gO9aB9nbs

Part 2: https://www.youtube.com/watch?v=CmxkPChOcvw

Jonathan S.
  • 1,796
  • 5
  • 14
  • 1
    hi @Jonathan S +1 . "When it's done, it'll write the value from the register back into memory" - It means that a thread has its own copy that it can work with and therefore other threads can work with the original, but when a thread terminates, the calculations on the register are reflected in the original variable, right? THANKS IN ADVANCE –  Feb 08 '23 at 19:49
  • 2
    No, the thread will write the value back to memory whenever it wants to (potentially never), or when a release-synchronization (i.e. mutex release) happens. That's what acquire and release semantics are for: Acquire makes values from other threads visible to the acquiring thread, and release makes values from the releasing thread visible to other (acquiring) threads. – Jonathan S. Feb 08 '23 at 20:10
  • 1
    thanks @Jonathan S. + 1 - "If another thread changes the variable in the meanwhile, the value in the CPU's register will be outdated and your program breaks" - the processor registers could be "refreshed" if I read the global variable in question again, right? –  Feb 09 '23 at 01:51
  • hi @Jonathan S. +1 excellent answer, you mean that for a change (update) made by a thread on a global (shared) variable to be visible in other threads there has to be a memory barrier, e.g. a mutex, right? –  Feb 10 '23 at 13:23
  • 2
    @GeorgeMeijer Not necessarily, I've updated the answer. Some architectures (ones with coherent caches) don't need explicit memory barriers for memory writes from one thread to become visible to other threads. x86 is such an arch with strong coherency guarantees. On other archs, like ARM, you need explicit memory barriers for visibility (acquire/release operations). In any case, you may need a mutex to prevent simple data races, no matter whether you have coherent caches or not. – Jonathan S. Feb 10 '23 at 22:11
  • 2
    @Coder23 You could of course read the variable twice, but you'll still have a window for a race condition to occur - it would just have to happen between the second read and the write. No matter how often you read the variable, if you don't have proper synchronization, there will always be a race window. I'd recommend you to watch this presentation (both parts): https://www.youtube.com/watch?v=c1gO9aB9nbs – Jonathan S. Feb 10 '23 at 22:14
  • excellent @Jonathan S +1 , using mutex would gain **portability** as many recommend to avoid bugs, i.e. it is healthier, right? –  Feb 10 '23 at 22:22
  • 2
    @Coder23 No, using a mutex just makes your program work in the first place, it has nothing to do with portability. Atomics are also portable - if you want to use them instead, you can. – Jonathan S. Feb 10 '23 at 22:25
  • great @Jonathan S. +1 , now I understand that it was about memory barriers when you said the register might have outdated data, DEFINITELY MARKED AS BEST ANSWER. These comments you gave today and the reply edit I was waiting for you to close the reply as the best, you were just in time. –  Feb 10 '23 at 22:28
  • thanks @Jonathan S. +2 - you are one of the few I have seen that give details on this, many people just say UB (undefined behavior) but one sometimes needs to know some details. –  Feb 10 '23 at 22:40
0

You can simply inspect the assembly code that count = count + 1 compiles to (assuming x86-64 architecture):

mov     eax, DWORD PTR count[rip]
add     eax, 1
mov     DWORD PTR count[rip], eax

It is clear to see that the final value of count will be 1 no matter whether the first context switch happens after the first or the second line.

wtz
  • 426
  • 4
  • 15
  • 2
    In other words, in *this particular implementation*, the current value of `count` (ie 0) is being captured (into `eax`) before the 1st task switch, and then `capture + 1` (ie 0+1) is being assigned back to `count` after the 2nd task switch. – Remy Lebeau Feb 08 '23 at 19:30
  • hi @Remy Lebeau +1, so, the `count`'s value at the end of the 2 threads will be 1, which means that we "missed an update", right? Thanks in advance –  Feb 08 '23 at 19:43
  • hello @wtz +1 , then, the state of the variables if preserved during the life of the thread and thus there will be old data, right? THANKS IN ADVANCE –  Feb 08 '23 at 19:54
  • @Coder23 To fully understand, I would suggest that you learn what is actually happening inside the processor rather than stick to high-level concepts like variable, state, etc. – wtz Feb 08 '23 at 20:34
  • @wtz no you do not have to do that Knowing low level details may help in some situations but is not required to write correct programs at all. Actually quite often it is quite opposite - especially as quite few programmers deduce language rules from produced assembly - thats the worst. – Slava Feb 08 '23 at 23:36
  • I agree with @Slava –  Feb 10 '23 at 22:32
  • @Coder23 If you only wanted to write *correct* programs then why did you post this question at all? Programs relying on race conditions are undoubtedly *incorrect*. – wtz Feb 11 '23 at 05:40