Technical Women

033

Touch the Sky by Brave

Today

Synchronization Primitives
  • Critical sections

  • Spinlocks

  • Locks

$ cat announce.txt

  • Most of the website is up.

Review: Unless Shown Otherwise…​

Concurrency forces us to relax any assumptions that we may want to make about how any particular thread executes.

Unless explicitly synchronized, threads may:
  1. Be run in any order,

  2. Be stopped and restarted at any time,

  3. Remain stopped for arbitrary lengths of time.

  • Generally these are good things—the operating system is making choices about how to allocate resources.

  • When accessing shared data these are challenges that force us to program more carefully.

The Bank Example

Consider the following code snippet.

void giveGWATheMoolah(account_t account, int largeAmount) {
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
Assume I have $1,000 and that two of you are trying to make deposits concurrently:
  • One of you is depositing $1,000 (this person gets a B).

  • One of you is depositing $2,000 (A- material).

Things Go Well

A- Student B Student Balance

 

 

$1000

int gwaHas = get_balance(account);
gwaHas = gwaHas + $2000;
put_balance(account, gwaHas);

 

$3000

 

int gwaHas = get_balance(account);
gwaHas = gwaHas + $1000;
put_balance(account, gwaHas);

$4000

Things Go Less Well

A- Student B Student Balance

 

 

$1000

int gwaHas = get_balance(account);
gwaHas = gwaHas + $2000;

 

 

 

int gwaHas = get_balance(account);
gwaHas = gwaHas + $1000;

 

 

put_balance(account, gwaHas);

$2000

put_balance(account, gwaHas);

 

$3000

Things Go Very Badly

A- Student B Student Balance

 

 

$1000

int gwaHas = get_balance(account);
gwaHas = gwaHas + $2000;

 

 

 

int gwaHas = get_balance(account);
gwaHas = gwaHas + $1000;

 

put_balance(account, gwaHas);

 

$3000

 

put_balance(account, gwaHas);

$2000

Race Conditions

A race condition is "when the output of a process is unexpectedly dependent on timing or other events."

Note that the definition of a race depends on what we expected to happen:
  • We expected me to have $4,000 after both deposits. (Otherwise we are not observing the Law of the Conversation of Money, probably important to banks except during bailouts.)

Concurrency v. Atomicity

Concurrency: the illusion that multiple things are happening at once.
  • Requires stopping or starting any thread at any time.

Atomicity: the illusion that a set of separate actions occurred all at once.
  • Requires not stopping certain threads at certain times or not starting certain threads at certain times, i.e. providing some limited control to threads over their scheduling.

Critical Sections

A critical section contains a series of instructions that only one thread can be executing at any given time.
  • This set (or sets) of instructions will look atomic with respect to other threads executing code within the critical section.

Critical Sections

void giveGWATheMoolah(account_t account, int largeAmount) {
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
In order to implement the previous example correctly:
  1. What is local state private to each thread? gwaHas

  2. What is the shared state that is being accessed by giveGWATheMoolah? account

  3. What lines are in the critical section? 2-4

Critical Section Requirements

  • Mutual Exclusion: this is the most basic property. Only one thread should be executing in the critical section at one time.

  • Progress: all threads should eventually be able to proceed through the critical section.

  • Performance: we want to keep critical sections as small as possible without sacrificing correctness.

Implementing Critical Sections

  • Two possible approaches. Don’t stop, or don’t enter.

  • On uniprocessors a single thread can prevent other threads from executing in a critical section by simply not being descheduled.

    • In the kernel we can do this by masking interrupts. No timer, no scheduler, no stopping.

    • In the multicore era this is only of historical interest. (This design pattern is usually broken.)

  • More generally we need a way to force other threads—potentially running on other cores—not to enter the critical section while one thread is inside. How do we do this?

Atomic Instructions

Software synchronization primitives utilize special hardware instructions guaranteed to be atomic across all cores:

  • Test-and-set: write a memory location and return its old value.

int testAndSet(int * target, int value) {
  oldvalue = *target;
  *target = value;
  return oldvalue;
}
  • Compare-and-swap: compare the contents of a memory location to a given value. If they are the same, set the variable to a new given value.

bool compareAndSwap(int * target, int compare, int newvalue) {
  if (*target == compare) {
    *target = newvalue;
    return 1;
  } else {
    return 0;
  }
}
  • Load-link and store-conditional: Load-link returns the value of a memory address, while the following store-conditional succeeds only if the value has not changed since the load-link.

y = 1;
__asm volatile(
    ".set push;"     /* save assembler mode */
    ".set mips32;"   /* allow MIPS32 instructions */
    ".set volatile;" /* avoid unwanted optimization */
    "ll %0, 0(%2);"  /*   x = *sd */
    "sc %1, 0(%2);"  /*   *sd = y; y = success? */
    ".set pop"       /* restore assembler mode */
    : "=r" (x), "+r" (y) : "r" (sd));
if (y == 0) {
  return 1;
}

Atomic Instructions

  • Many processors provide either test and set or compare and swap.

  • On others equivalents can be implemented in software using other atomic hardware instructions.

Aside: Shared-Memory Multiprocessing

  • As the number of cores on typical machines has continued to grow, some memory-based synchronization mechanisms have not scaled well.

  • Building more scalable synchronization primitives for cache-coherent shared-memory machines is an open research problem.

    • (The architecture of these machines itself is an open research problem.)

The Bank Example: Test and Set

Let’s modify our earlier example to use a test and set:

void giveGWATheMoolah(account_t account, int largeAmount) {
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
+int payGWA = 0; // Shared variable for our test and set.

void giveGWATheMoolah(account_t account, int largeAmount) {
+ testAndSet(&payGWA, 1); # Set the test and set.
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
+ testAndSet(&payGWA, 0); # Clear the test and set.
  notifyGWAThatHeIsRich(gwaHas);
  return;
}

Does this work? No! How do I tell if another thread has already set payGWA?

The Bank Example: Test and Set

Let’s try again:

void giveGWATheMoolah(account_t account, int largeAmount) {
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
+int payGWA = 0; // Shared variable for our test and set.

void giveGWATheMoolah(account_t account, int largeAmount) {
+ if (testAndSet(&payGWA, 1) == 1) {
+   // But then what?
+ }
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
+ testAndSet(&payGWA, 0); # Clear the test and set.
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
  • But what should I do if the payGWA is set?

The Bank Example: Test and Set

void giveGWATheMoolah(account_t account, int largeAmount) {
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
+int payGWA = 0; // Shared variable for our test and set.

void giveGWATheMoolah(account_t account, int largeAmount) {
+ while (testAndSet(&payGWA, 1) == 1) {
+   ; // Test it again!
+ }
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
+ testAndSet(&payGWA, 0); # Clear the test and set.
  notifyGWAThatHeIsRich(gwaHas);
  return;
}

Busy Waiting

A- Student B Student Balance

 

 

$1000

while (testAndSet(&payGWA, 1));
int gwaHas = get_balance(account);

 

 

 

while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));
while (testAndSet(&payGWA, 1));

 

When two threads race

Everybody loses…​

The Bank Example: Test and Set

int payGWA = 0; // Shared variable for our test and set.

void giveGWATheMoolah(account_t account, int largeAmount) {
  while (testAndSet(&payGWA, 1) == 1) {
   ; // Test it again!
  }
  int gwaHas = get_balance(account);
  gwaHas = gwaHas + largeAmount;
  put_balance(account, gwaHas);
  testAndSet(&payGWA, 0); # Clear the test and set.
  notifyGWAThatHeIsRich(gwaHas);
  return;
}
What are the problems with this approach?
  • Busy waiting: threads wait for the critical section by "pounding on the door", executing the TAS repeatedly.

  • Bad on a multicore system. Worse on a single core system! Busy waiting prevents the thread in the critical section from making progress!

Locks

Locks are a synchronization primitive used to implement critical sections.
  • Threads acquire a lock when entering a critical section.

  • Threads release a lock when leaving a critical section.

Spinlocks

What we have implemented today is known as a spinlock:
  • lock for the fact that it guards a critical section (we will have more to say about locks next time), and

  • spin describing the process of acquiring it.

Spinlocks are rarely used on their own to solve synchronization problems.

Spinlocks are commonly used to build more useful synchronization primitives.

Next Time

  • When to sleep and when not to sleep

  • Problems with Synchronization Primitives

  • Solving Sample Synchronization Problems