====== Lecture 8 Scribe Notes ======
Synchronization II - Finding Critical Sections
Scribes:
Rahul Vaidya, Raja Radwan, Tara McBride
=====Sequential Consistency=====
Two or more sequences of operations are sequentially consistent if, for any execution, the same observable results as that execution could be obtained by executing the operations one at a time in some sequential order.
In Eddie's bank balance example, let us define two application-level operations: **withdraw**(int amt), **deposit**(int amt)
| ^ Eddie's Mom ^ Eddie ^
|1| withdraw(10) | withdraw(10) |
|2| deposit(5) | withdraw(20) |
|3| deposit(5) | withdraw(4) |
|4| deposit(5) | deposit(1) |
|5| withdraw(15)| |
We know we’ve achieved consistency if the observable results while running in parallel are the same as running the operations one at a time.
What are the observable results? The observable results are boolean values which indicate whether or not a withdrawal succeeds.
Starting Balance: 10
| ^ Eddie's Mom ^ Eddie ^
|1| withdraw(10) | withdraw(10) |
Assume that if one withdraw operation successfully completes, the next withdraw operation will do nothing and return false. What are the possible withdraw return value combinations, and do they support sequential consistency?
^ Eddie's Mom ^ Eddie ^ Sequentially Consistent? ^
| true | true | X Not sequentially consistent |
| false | true | ? Cannot tell if it is sequentially consistent |
| true | false | ? Cannot tell if it is sequentially consistent |
| false | false | X Not sequentially consistent |
Both the true/true combination and the false/false combination results cannot be obtained by any possible order, so you know that it is sequentially inconsistent. As far as proving sequentially consistency goes, you cannot tell if an ordering is sequentially consistent from just one execution, or even many executions. It would have to be sequentially consistent for every execution, which is impossible to test. For this reason, the difficult thing about trying to achieve sequential consistency is that you don’t know when you’ve gotten it right; you only know when you’ve gotten it wrong.
In order to develop sequentially consistent software, sequentially consistent hardware is required. If the hardware is not sequentially consistent, we cannot write a stable program. Sequential consistency must be ensured from the ground up.
=====Synchronization=====
Synchronization is the act of building sequential consistency for APPLICATION operations using the sequential consistency provided by lower level operations.
Synchronization requires using sequential consistency from a higher level to implement sequential consistency in a lower level.
Why is it hard to achieve synchronization?
CORRECTNESS. It is very hard to detect bugs since there are far too many things that could go wrong. This is an example of OS ROBUSTNESS.
An improper implementation of synchronization produces race conditions, and race condition bugs are usually difficult to find and fix.
Synchronization also tends to be costly, and there is a trade-off between an easy, correct implementation and utilizing the full performance.
To implement synchronization, we need 2 things:
- Application Operations (operations to implement synchronization on)
- Hardware Consistency (sequentially consistent hardware and low level operations)
Let's look at some more examples:
uint23_t balance(){
return eddie;
}
void embezzle(){ /* sets balance to 0*/
eddie = 0; // movl $0, eddie
}
The balance function simply returns the balance, so there is no need to worry about synchronization since we are not modifying any stored data. However, in the embezzle function, we are modifying the global variable 'eddie' which means that there is a possibility that we may lose sequential consistency.
When thinking about sequential consistency, we can consider the possible observable results. For example if Eddie was just calling the balance function and the bank was just calling the embezzle function as shown:
Starting Balance: 10 cents
^ Eddie ^ Bank ^
| balance( ) | embezzle( ) |
| balance{ } | embezzle( ) |
| balance( ) | embezzle( ) |
| balance( ) | embezzle( ) |
We know that we are not allowed to have a balance of 10 cents //after// a balance of zero has occurred and we are not allowed some other unexpected value for balance, such as π. If we keep this in mind, it will help us to recognize sequential inconsistency.
In the following version of embezzle, our code no longer provides sequential consistency:
void embezzle(){
while(eddie>0)
eddie--;
}
To see why this code no longer provides sequential consistency, we'll take a closer look at the assembly versions of the balance and embezzle functions. We'll begin by labeling each instruction in the assembly version of our functions. Normally, we would just execute the embezzle function (E1-E6) and then call the balance function (B1) to check the balance in the account. However, if we execute instructions E1-E5 followed by B1 and then E6, we would get considerably different results. Rather than the balance being instantly reset to 0, it would only appear as though the total balance had been decremented by one.
movl eddie, %ead B1
loop: movl eddie, %ead E1
cmpl %eax, $0 E2
jr OUT E3
decl %eax E4
movl %eax, eddie E5
jmp loop E6
Local file systems have write-to-read consistency, which means that the result of a read equals the most recent write at the time of the read. To achieve the desired results from executing the following example, we need write-to-read consistency. This is achieved when the execution of read returns from memory the most recent write.
^ E ^ B ^
| balance() | embezzle() |
| balance() | embezzle() |
| balance() | |
| balance() | |
=====Hardware Sequential Consistency (x86)=====
An **ATOMIC** operation is an operation that cannot be split apart. The operation executes indivisibly, so no other thread can view an intermediate state.
Since registers are private to a thread, register-only instructions are inherently atomic.
The following are examples of instructions that are atomic:
movl $0, %eax
addl $1, %ead
====Memory====
As far as memory goes, read operations within a single CACHE LINE and write operations within a single CACHE LINE are atomic.
What is a cache line? Main memory is much too slow for a processor to work with, so caching basically takes something from far away (out of main memory) and makes a local copy of it so that it is faster and closer. Modern machines almost always have two levels of cache, L1 and L2. Cache lines are 64-byte cache divisions within the processor's L1 and L2 caches. L1 cache is very fast but also very expensive. On the other hand, L2 cache is not as expensive as L1 cache but it also isn't as fast either. In order to preserve sequential consistency, atomic hardware instructions should only use one cache line. Operations spanning multiple cache lines are not guaranteed to be atomic.
{{notes:lec8-cache.jpg|}}
=====Hardware Synchronization (x86)=====
====Synchronization Instructions====
The following functions are examples of atomic instructions. The purpose of these functions is to atomically perform actions that would otherwise fail to satisfy our requirement of sequential consistency. Unsurprisingly, the compare_and_swap function is used to atomically compare the value of old_value with the contents of *a. If they are equal, a new value is copied into the memory location pointed to by *a.
Test and Set - xchgl
uint32_t test_and_set(uint32_t *a, uint32_t v) {
uint32_t o = *a;
*a = v;
return o;
}
Compare and Swap - cmpxchg
int compare_and_swap(uint32_t *a, uint32_t old, uint32_t new) {
if(*a == old) {
*a = new;
return 1;
}
else return 0;
}
=====Critical Sections=====
A critical section is a set of instructions where sequential consistency requires that at most one thread's instruction pointer is in the set at any moment.
We prefer to find the minimal (smallest) critical section possible. This gives the best performance and utilization.
How do we find the minimal critical sections? First, start with writes to shared state, then expand to include dependencies.
In the following code examples, the critical minimal sections are highlighted via comments:
bool withdraw(uint32_t amt){
if(amt <= eddie){ //CMS
eddie -= amt; //CMS
return true;
}else
return false;
}
void deposit(uint32_t amt){
eddie += amt; //CMS
}
The most obvious examples of critical minimal sections are the sections of the code where a shared value is changed. In both the withdraw and deposit functions, the lines of code in which the value of 'eddie' is being changed are the critical minimal sections. In the withdraw function, we can see that the if statement is also included as part of the critical minimal section. This is because the execution of this statement is dependent on the value of 'eddie' which can result in a different outcome depending on the order of execution.
How do we enforce our critical sections? We enforce them by creating a MUTUAL EXCLUSION or LOCK, which is a synchronization object that allows no more than one thread to execute a section of code.