Each ๐Ÿงถ Thread performs some task, and our ๐Ÿ—“๏ธ Scheduler is responsible for deciding which thread to run at any given time. If we have multiple threads working on some shared resource (usually memory), we need to be careful about data racesโ€”different threads accessing the same location where at least one is writing. When this is happens, in an event called a race condition, the result can be unpredictable.

Increment Data Race

As a classic example, consider two threads trying to increment the same variable. The increment operation actually consists of three instructions, something like:

LOAD sum into R0
ADD r0 r0 #1
STORE R0 into sum

If we context switch between the two threads in the middle of running these three instructions, we get unexpected behavior. For example:

Thread 0						Thread 1
-----------------------------------------------------------------
LOAD sum into R0
								LOAD sum into R0
								ADD r0 r0 #1
								STORE R0 into sum
ADD r0 r0 #1
STORE R0 into sum

Both R0 registers will read the sum in and write sum + 1 out; thus, instead of incrementing twice, our result is only incremented once.

Mutual Exclusion

To prevent concurrent threads from interfering with each other, we need to coordinate them. Our goals are to ensure that only one thread acts on a shared resourceโ€”in the part of code called the critical sectionโ€”at any time, and we also want to ensure that all threads are eventually allowed to access the resource.

There are several solutions:

  1. We can naively disable interrupts, preventing threads from being interrupted in the first place. Obviously, this is overkill and stops unrelated threads as well.
  2. Petersonโ€™s algorithm forces a thread to busy wait while another thread is accessing some resource. However, it assumes atomic operations and a specific execution order, which might be affected by the compiler or processor (if they decide it wonโ€™t affect function semantics).
  3. Lamportโ€™s Bakery algorithm works with threads: each thread picks a number higher than the number of waiting threads and busy waits until theyโ€™re done.
  4. Special instructions like TSL and CAS can perform atomic operations that allow us to check for other concurrent threads. TSL atomically writes 1 to the memory location and returns the old value. CAS atomically compares the memory location with a value and sets it to a new value if theyโ€™re the same.
  5. ๐Ÿ”’ Lock locks before the critical section and unlocks after. When a thread tries to acquire a lock, itโ€™ll block if the lock is already in use. This is the most common solution. Itโ€™s also often used with โ˜๏ธ Condition Variables.
  6. ๐Ÿ Semaphores let a certain number of threads run concurrently by internally tracking an integer state.

Monitor

On a higher level, some systems associate shared objects with monitors, which are built-in features of a language that protects functions of a class. Each instance of the class has its own lock, and the protected functions must acquire the lock before running. However, this gives us less fine-grained control over the lock, and a blocked protected function waiting on some condition would be deadlock; to avoid this, we need to use condition variables.

When a monitor uses a condition variableโ€™s signal, a thread is woken up; then, if theyโ€™re using the same lock, who gets it?

  1. Hoare monitor: signaler yields monitor lock to the released thread, guaranteeing that the released threadโ€™s condition is true.
  2. Mesa monitor: signaler continues to hold the lock, released thread waits again and canโ€™t guarantee that the condition is true. Though this is less โ€œclean,โ€ it can be more efficient and is used by most systems.

One example of a Hoare monitor is as follows, with the producer-consumer problem:

monitor PC {
	Buffer buffer;
	condition can_insert, can_remove;
	int count;
 
	void insert(int item) {
		if (count == N)
			can_insert.wait();
		buffer.add(item);
		count++;
		if (count == 1)
			can_remove.signal();
	}
 
	int remove() {
		if (count == 0)
			can_remove.wait();
		int item = buffer.get();
		count--;
		if (count == N-1)
			canInsert.signal();
	}
}