1What is "atomic"?¶
Essentially, it varies by context, but still, some things are expected to be same anywhere.
For exemple, y = 1
is atomic in most cases, e.g. if the expression deals with a 32-bit value on a 32-bit architecture.
However, if the value is larger than the natural word size of the architecture, a simple assignment is expected not to be atomic, e.g. long long x
on a 32-bit machine, as it is a 64-bit variable.
Usually, the machine will deal with assignments in such cases by splitting them in parts i.e.upper
and lower
in our previous exemple. This incurs some concurrency issues. For exemple, say two threads want to execute x = 0
and x = -1
, respectively:
Thread 0 | Thread 1 |
---|---|
x.upper = 00000000 |
x.upper = FFFFFFFF |
x.lower = 00000000 |
x.lower = FFFFFFFF |
This may yield the following results: 0000000000000000
, 00000000FFFFFFFF
, FFFFFFFF00000000
or FFFFFFFFFFFFFFFF
.
In Java and C, assignment (of natural word size) will be atomic. For exemple, in the statement x = y + 3
, y + 3
may not be atomic, but the assignment itself (giving a value to x
) will be.
Nb: Assignment to a long
in Java is not necessarily atomic (except when using a 64-bit JVM on a 64-architecture), unless you declare volatile
. This keyword will be explored more in depth later in the course.
1.1Appearing atomic¶
Consider the statement x = y
where the value must be loaded in a register, then assigned to x
, thus being, formally, not atomic.
However, if the RHS is not being altered by another thread, it can "appear" atomic.
- Formally:
- Consider the statement
x = e
wheree
is an expression. e
has a critical reference ife
uses a variable some other threads may change or write.x = e
satisfies an "at-most-one" property if either of two properties hold:
e
has at most 1 critical reference andx
is not used by another thread.e
has no critical reference (x
may be read by another thread.)
Exemple 1: Let x = y = 0;
, the thread T0 execute x = y + 1;
and the thread T1 execute y = y + 1;
. Are any of those last two statements at-most-one?
x
is not read in the other thread, so it is allowed to have 1 critical reference. Yes, it is at-most-one.y
is read in the other thread, but this thread is the only one to altery
. Yes, it is at-most-one.
We thus have those two scenarios:
Scenario 1 | Scenario 2 |
---|---|
x = y + 1 |
y = y + 1 |
y = y + 1 |
x = y + 1 |
x : 1, y : 1 |
y : 1, x :2 |
You could look at the machine code instructions, but those two scenarios are still the only two possibilities.
Example 2: Let x = y = 0;
again, the thread T0 execute x = y + 1;
and thread T1 execute y = x + 1;
Are any of those last two statements at-most-one?
- No. The LHS is a critical reference in T1.
- No, for the same reason.
Let's look at the different scenarios at the level of single instructions:
Scenario 1 | Scenario 2 |
---|---|
x = y + 1 |
y = x + 1 |
y = x + 1 |
x = y + 1 |
x : 1, y : 2 |
y : 1, x :2 |
At the machine code level, however, things are different (remember that each thread has its own registers, either through context switching, with each CPU physically having its own set, or both):
Thread 1 | Thread 2 |
---|---|
Load r1, [y] # Load the value of y into Register 1 |
Load r1, [x] # Load the value of x into Register 1 |
Inc r1 # Increment Register 1 |
Inc r1 # Increment Register 1 |
Store r1, [x] # Store the value of Register 1 into x |
Store r1, [y] # Store the value of Register 1 into y |
Any of those 6 instructions could be executed in different orders. As such, for $n$ threads that do $m$ atomic operations, there are
$$ \frac{(nm)!}{m!^{n}} \text{ possible interleavings}$$
In our case, $n = 2$ and $m = 3$. This gives us $20$ different scenarios.
2Mutual-exclusion¶
This is when we make sure that only 1 thread at a time can execute some code, called the critical section.
|----------| enter protocol | | | | critical section (mut. ex) | code | | | | | |----------| exit protocol
2.1Ways to achieve mutual exclusion¶
There is more than one way to achieve mutual exclusion. Say we have 2 threads, each with their own thread ID (0 and 1, in our case) and they can access it (through the id
variable).
No matter the method, a common protocol has to be followed:
- Init: Which happens only once, before anything else
- Enter: In which a thread must wait before entering the critical section
- Exit: In which the thread has finished, and may allow another thread to enter the critical section
2.1.1First way¶
Phase | Instructions |
---|---|
Init | turn = 0; |
Enter | while (turn != id); // Spin |
Exit | turn = 1-turn; |
- Works.
However,
- It is over-constrained.
- There is a strict alternation that requires T0 to be the first to execute.
T0 -> T1 -> T0 -> T1
works. Doesn't work:T0 -> T0 -> T1 -> ...
2.1.2Second way¶
Trying to improve on the first way...
Phase | Instructions |
---|---|
Init | flag[0] = flag[1] = false; |
Enter | while (flag[1-id]); flag[id] == true; |
Exit | flag[id] = false; |
Does this work?
No. They could both do the check at the same time and both enter.
2.1.3Third way¶
Trying to fix the second way...
Key: signal the intention of going into c.s., rather than going in directly.
Phase | Instructions |
---|---|
Init | flag[0] = flag[1] = false; |
Enter | flag[id] = true; while (flag[1-id]); //Spin |
Exit | flag[id] = false |
Does this work?
Sort of
Thread 0 | Thread 1 |
---|---|
flag[0] = true; |
flag[1] = true; |
Check flag[1]... |
Check flag[0]... |
If those instruction happen as follows: T0.0; T1.0; T0.1; T1.1 (Z-pattern), then neither threads gets in the critical section. So:
- Enforces m.t.
- May deadlock (actually, livelock, because they're still doing work...)
2.1.4Fourth way¶
Trying to fix the third way.
Phase | Instructions |
---|---|
Init | flac[0] = flag[1] = false; |
Enter | flag[id] = true; |
- | while (flag[1-id]) { |
- | flag[id] = false; |
- | // delay |
- | flag[id] = true; |
- | } |
Exit | flag[id] = false |
This somewhat works, if delay is not identical. Otherwise both threads might wait the same amount of time and cause the previous problem.
2.1.5Fifth way: Dekkers' algorithm¶
Either I slept through this whole section, or the prof told us to look it up ourselves.
2.1.6Sixth way: Peterson 2-processes tie breaker¶
Phase | Instructions |
---|---|
Init | flac[0] = flag[1] = false; |
- | turn = 0; |
Enter | flag[id] = true; |
- | turn = id; |
- | while(turn == id && flag[1-id]); // Spin |
Exit | flag[id] = false; |
Thread 0 | Thread 1 |
---|---|
flag[0] = true; |
flag[1] = true; |
turn = 0; |
turn = 1; |
Check turn == 0 && flag[1]; |
Check turn == 1 && flag[0]; |
2.1.7Kessel's single writes alg¶
- "Split up" turn.
Peterson | Kessel |
---|---|
turn = 0 |
turn[0] = turn[1] |
turn = 1 |
turn[0] != turn[1] |
also add load[0], load[1]
Phase | T0 | T1 |
---|---|---|
Enter | flag[0] = true; |
flag[1] = true; |
- | load[0] = turn[1] |
load[1] = turn[0] |
- | turn[0] = load[0] |
turn[1] = load[1] |
- | await(flag[1] == false or load[0] != turn[1]) |
await(flag[0] == false or load[1] != turn[0]) |
T0 only writes to flag[0], turn[0], load[0]. Single writer solution (a single-writer register is a register that can be written by a single process only).
2.2Nb: Performance and patterns¶
[TODO: Decipher, image]
What we are looking for:
- Mutual exclusion
- Absence of deadlock
- No unnecessary delay
- Eventual entry (starvation-free)
1, 2 and 3 concern safety whereas 4 concerns liveness.