Compare-and-Set: Mastering Atomic Operations in Concurrency

In the realm of concurrent programming, managing shared resources efficiently and safely is paramount. One crucial technique for achieving this, especially in non-blocking algorithms, is compare-and-set (CAS). Essentially, compare-and-set is an atomic operation that checks if a variable’s value matches an expected value. If they match, it updates the variable with a new value; otherwise, it leaves the variable unchanged. While the concept might initially seem intricate, it becomes remarkably clear with a deeper exploration. Let’s delve into the world of compare-and-set and understand its significance in modern concurrency.

You might also encounter the abbreviation CAS when reading about concurrency. Rest assured, this typically refers to the compare-and-set operation we’re discussing here.

The Check-Then-Act Problem in Concurrent Programming

A common pattern in concurrent algorithms is the “check-then-act” sequence. This pattern arises when code first examines the state of a variable and then performs an action based on that observed state. Consider this simplified, yet problematic, lock implementation as an example:

 public class ProblematicLock {
     private volatile boolean locked = false;

     public void lock() {
         while(this.locked) {
             // busy wait - until this.locked == false
         }
         this.locked = true;
     }

     public void unlock() {
         this.locked = false;
     }
 }

This ProblematicLock class, as its name suggests, isn’t a robust, thread-safe lock. It’s intentionally flawed to highlight the challenges that compare-and-set effectively addresses.

The lock() method embodies the “check-then-act” pattern. It checks if the locked variable is false within the while loop. If locked is indeed false, the method exits the loop and proceeds to act by setting locked to true.

However, in a multithreaded environment, this approach is vulnerable. Imagine two threads, Thread A and Thread B, attempting to acquire the lock simultaneously.

If Thread A checks locked and finds it false, it prepares to exit the while loop and set locked to true. Critically, if Thread B also checks locked before Thread A completes its action of setting locked to true, Thread B will also observe locked as false and proceed to exit its own while loop. This scenario leads to a classic race condition. Both threads believe they have acquired the lock, potentially leading to data corruption and unpredictable behavior.

Atomicity: The Solution to Race Conditions

To function correctly in concurrent applications and prevent race conditions, “check-then-act” operations must be atomic. Atomicity implies that the “check” and “act” steps are executed as a single, indivisible unit. Once a thread begins executing an atomic block, it will complete the entire block without interruption from other threads. No other thread can execute the same atomic block concurrently.

In Java, a straightforward way to achieve atomicity for a code block is by using the synchronized keyword. Here’s how we can modify the ProblematicLock to make the lock() method atomic using synchronized:

 public class ProblematicLock {
     private volatile boolean locked = false;

     public **synchronized** void lock() {
         while(this.locked) {
             // busy wait - until this.locked == false
         }
         this.locked = true;
     }

     public void unlock() {
         this.locked = false;
     }
 }

By declaring the lock() method as synchronized, we ensure that only one thread can execute it at any given time on the same ProblematicLock instance. The lock() method now operates atomically, effectively preventing the race condition we discussed earlier.

The Cost of Blocking: Synchronization Overhead

While synchronized provides atomicity, it introduces a concept called blocking. When multiple threads attempt to enter a synchronized block simultaneously, only one thread is granted access. The other threads are blocked, waiting for the currently executing thread to exit the synchronized block. Once the executing thread exits, one of the waiting threads is then allowed to proceed.

Entering a synchronized block is relatively inexpensive if access is readily granted. However, the blocking of threads, when contention occurs, can be costly in terms of performance.

Furthermore, there’s no guaranteed order in which blocked threads are unblocked. The operating system or execution platform manages thread unblocking, and while it’s typically fast, there’s still a potential delay where a blocked thread remains idle when it could be processing. This overhead is visualized below:

Hardware-Level Compare-and-Set: A Non-Blocking Approach

Modern CPUs offer built-in support for atomic compare-and-set operations at the hardware level. These operations provide an alternative to synchronized blocks and other blocking mechanisms in certain scenarios. The CPU guarantees that a compare-and-set operation executes atomically, even across multiple CPU cores. We’ll see code examples of this shortly.

Utilizing hardware-level compare-and-set instead of OS or platform-level synchronization eliminates the need for the OS to manage thread blocking and unblocking. This significantly reduces the waiting time for threads attempting to access shared resources, leading to less contention and higher overall throughput. This non-blocking approach is illustrated here:

As depicted, threads using compare-and-set don’t experience full blocking. They continuously attempt the compare-and-set operation until it succeeds, gaining access to the shared resource. This minimizes the delay before a thread can proceed.

It’s worth noting that if a thread repeatedly fails in its compare-and-set attempts due to high contention, it might consume CPU cycles in retries. However, in many practical scenarios, shared resources are not held for extended periods. Therefore, prolonged contention is often not a major issue. In contrast, blocked threads in traditional synchronization remain completely idle, relinquishing CPU time.

Compare-and-Set in Java: The Atomic Classes

Since Java 5, developers have access to CPU-level compare-and-set functionality through the java.util.concurrent.atomic package. This package provides a suite of atomic classes, including:

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference

These classes provide methods like compareAndSet(), which leverage the underlying hardware’s compare-and-set instructions.

The key advantage of using Java’s atomic classes is that they directly utilize the CPU’s native compare-and-set capabilities. This results in significantly faster and more efficient concurrent code compared to software-based implementations.

Compare-and-Set as a Lock Guard

Compare-and-set can be effectively employed as a lock guard, preventing multiple threads from entering a critical section simultaneously. Let’s revisit our lock() method and reimplement it using AtomicBoolean and compare-and-set:

 public class CompareAndSwapLock {
     private AtomicBoolean locked = new AtomicBoolean(false);

     public void unlock() {
         this.locked.set(false);
     }

     public void lock() {
         while(!this.locked.compareAndSet(false, true)) {
             // busy wait - until compareAndSet() succeeds
         }
     }
 }

Notice that locked is now an AtomicBoolean instead of a simple boolean. AtomicBoolean offers the crucial compareAndSet() method. This method compares the current value of the AtomicBoolean with an expected value. If they match, it atomically sets the AtomicBoolean to a new value and returns true. If they don’t match, it returns false, indicating the swap did not occur.

In our CompareAndSwapLock example, compareAndSet(false, true) attempts to set locked to true only if its current value is false.

Because the compareAndSet() operation is atomic, only one thread at a time can successfully observe locked as false and swap it to true. Consequently, only one thread can exit the while loop and acquire the “lock” for each unlock() operation that sets locked back to false.

Compare-and-Set for Optimistic Locking

Compare-and-set also forms the foundation for optimistic locking mechanisms. Optimistic locking allows multiple threads to proceed into a critical section concurrently, but only permits one thread to successfully commit its changes at the end.

Consider this concurrent counter implementation using optimistic locking:

 public class OptimisticLockCounter{
     private AtomicLong count = new AtomicLong();

     public void inc() {
         boolean incSuccessful = false;
         while(!incSuccessful) {
             long value = this.count.get();
             long newValue = value + 1;
             incSuccessful = this.count.compareAndSet(value, newValue);
         }
     }

     public long getCount() {
         return this.count.get();
     }
 }

The inc() method retrieves the current count from the AtomicLong count. It then calculates the incremented newValue. The crucial step is compareAndSet(value, newValue).

This compareAndSet() operation checks if the current value of count is still the value that was initially read. If it is, meaning no other thread has modified count in the meantime, the update to newValue is performed atomically, and compareAndSet() returns true, setting incSuccessful to true and exiting the loop.

However, if another thread has incremented count since the initial read, the compareAndSet() will fail because the expected value no longer matches the actual value in AtomicLong. In this case, compareAndSet() returns false, incSuccessful remains false, and the while loop iterates again. The process repeats, reading the updated count, recalculating, and attempting the compare-and-set until it succeeds. This optimistic approach ensures that increments are performed correctly even under concurrency, without resorting to blocking synchronization for every increment operation.

In conclusion, compare-and-set is a fundamental building block for constructing efficient and scalable concurrent algorithms and data structures. Its atomic nature and non-blocking characteristics make it a powerful tool for managing shared state in multithreaded applications, offering performance advantages over traditional blocking synchronization techniques.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *