Java multithreading basics-12: Detailed explanation of CAS algorithm

The full name of CAS is Compare And Swap, which is an important concept in concurrent programming. This article combines Java's multi-threaded operation to explain the CAS algorithm.

The advantage of the CAS algorithm is that it can ensure thread safety without locking, thereby reducing competition and overhead between threads.

Table of contents

1. Contents of CAS algorithm

1. Basic ideas and steps

2. CAS pseudocode (if you imagine CAS as a function)

2. Application of CAS algorithm

1. Implement the atomic class

*Pseudo code to implement atomic class

2. Implement spin lock

*Pseudocode to implement spin lock

3. ABA issues of CAS

1. BUG caused by ABA problem

2. Solve the ABA problem-use version number


1. Contents of CAS algorithm

1. Basic ideas and steps

The basic idea of ​​the CAS algorithm is to first compare the value in memory M with the value in register A (old expected value, expectValue) to see if they are equal. If they are equal, then write the value in register B (new value, swapValue) into the memory. ; If not equal, no operation is performed. The entire process is atomic and will not be interrupted by other concurrent operations.

Although it involves the "exchange" of memory and register values, more often than not we don't care about the value stored in the register, but more about the value in the memory (the value stored in the memory is the value of the variable). Therefore, the "exchange" here does not need to be regarded as an exchange, but can be regarded directly as an assignment operation, that is, the value in register B is directly assigned to memory M.

A CAS operation consists of three operands: a memory location (usually a shared variable), the expected value, and the new value. The execution process of CAS operation is as follows:

  1. Read the current value of a memory location.
  2. Compares the current value to the expected value for equality.
  3. If equal, the new value is written to the memory location; otherwise, the operation fails.

2. CAS pseudocode (if you imagine CAS as a function )

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

The code given above is just pseudo code and not the real CAS code. In fact, the CAS operation is an atomic hardware instruction supported by CPU hardware . This single instruction can complete the function of the above code.

The biggest difference between "an instruction" and "a piece of code" is atomicity. The above pseudocode is not atomic, and thread safety issues may occur with thread scheduling during operation; however, atomic instructions will not have thread safety issues.

At the same time, CAS will not have the problem of memory visibility. Memory visibility is equivalent to the compiler adjusting a series of instructions, adjusting the instructions for reading memory into instructions for reading registers. But CAS itself is an operation of reading memory at the instruction level, so there will be no thread insecurity issues caused by memory visibility.

Therefore, CAS can ensure thread safety to a certain extent without locking. This leads to a series of operations based on the CAS algorithm.


2. Application of CAS algorithm

CAS can be used to implement lock-free programming. Implementing atomic classes and implementing spin locks are two ways of lock-free programming.

1. Implement the atomic class

There are many classes in the standard library java.util.concurrent.atomic package that use very efficient machine-level instructions ( instead of using locks) to ensure the atomicity of other operations .

For example , the Atomiclnteger class provides methods incrementAndGet, getAndIncrement  , decrementAndGet, and getAndDecrement, which atomically increment or decrement an integer respectively.

        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
                //num++
                num.getAndIncrement();
                //++num
                num.incrementAndGet();
                //num--
                num.getAndDecrement();
                //--num
                num.decrementAndGet();
        });

For example, you can safely generate a numeric sequence as follows :

import java.util.concurrent.atomic.AtomicInteger;

public class Test2 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //num++
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //num++
                num.getAndIncrement();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(num.get());
    }
}

Running result: The final value of num is exactly 100000

This is because  the getAndIncrement()  method obtains the value of num atomically and increments num . That is , the operation of getting a value, incrementing and setting it , and then generating a new value does not break . It is guaranteed that even if multiple threads access the same instance concurrently, the correct value will be calculated and returned .

By looking at the source code, we can find that the getAndIncrement() method does not use locking (synchronized):

But then entering the getAndAddInt method you can find that the CAS algorithm is used:

After entering the compareAndSwapInt method, you will find that this is a method modified by native. The implementation of the CAS algorithm relies on the atomic operation support provided by the underlying hardware and operating system, so it is a more low-level operation. 

Addendum - A contrasting thread-unsafe case is:

The following is an example of thread unsafety. In this code, a counter variable is created, and two threads t1 and t2 are created respectively, so that these two threads can increment the same counter 50,000 times.

class Counter {
    private int count = 0;
 
    //让count增加
    public void add() {
        count++;
    }
 
    //获得count
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
 
        // 创建两个线t1和t2,让这两个线程分别对同一个counter自增5w次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
 
        t1.start();
        t2.start();
 
        // main线程等待两个线程都执行结束,然后再查看结果
        t1.join();
        t2.join();
 
        System.out.println(counter.get());
    }
}

Logically speaking, the final result of outputting counter should be 100,000 times. But after we ran the program, we found that not only was the result not 10w, but the result was different every time it was run - the actual result looked like a random value.

Due to the random scheduling of threads, the count++ statement is not atomic. It is essentially composed of 3 CPU instructions:

  1. load. Read data in memory into CPU registers.
  2. add. Perform +1 operation on the value in the register.
  3. save. Write the value in the register back to memory.

The CPU needs to complete this self-increment operation in three steps. If it is single-threaded, there is no problem with these three steps; but in multi-threaded programming, the situation will be different. Since the multi-thread scheduling order is uncertain, during the actual execution process, the order of instructions for the count++ operations of the two threads will have many different possibilities:

The above only lists a very small part of the possibilities, in reality there are many more possible situations. Under different arrangement orders, the results of program execution may be completely different! For example, the execution process of the following two situations:

Therefore,  since the actual scheduling order of threads is disordered, we cannot be sure what the two threads have experienced during the auto-increment process, nor can we be sure how many instructions are "sequentially executed" and how many instructions are "Staggered execution". The final result becomes a changing value. The count must be less than or equal to 10w.

(From the article: Java Multithreading Basics-6: Thread Safety Issues and Solutions

*Pseudo code to implement atomic class

code:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

In the above code, although it seems that just after value is assigned to oldValue, value and oldvalue are compared to see if they are equal, the comparison results may still be unequal. Because this is in a multi-threaded environment. value is a member variable. If two threads call the getAndIncrement method at the same time, inequality may occur. In fact, the CAS here is to confirm whether the current value has changed. If it has not changed, it can be incremented; if it has changed, the value must be updated first, and then it can be incremented.

The previous threads were unsafe. A big reason is that one thread cannot detect the modification of memory by another thread in time:

The thread was not safe before because t1 was read first and then incremented. At this time, before t1 is incremented, t2 has already been incremented, but t1 is still incremented based on the initial value of 0. Problems will occur at this time.

However, the CAS operation causes t1 to first compare whether the register and the value in the memory are consistent before executing the auto-increment. Only if they are consistent, the auto-increment will be executed. Otherwise, the value in the memory will be re-synchronized to the register.

This operation does not involve blocking waiting, so it will be much faster than the previous locking solution.

2. Implement spin lock

Spin lock is a busy waiting lock mechanism. When a thread needs to acquire a spin lock, it will repeatedly check whether the lock is available instead of being blocked immediately. If the lock acquisition fails (the lock is already occupied by another thread), the current thread will immediately try to acquire the lock again, and continue to spin (idle) waiting for the lock to be released until the lock is acquired. The first attempt to acquire the lock fails, and the second attempt will come within a very short time. This ensures that once the lock is released by other threads, the current thread can obtain the lock as soon as possible. Generally, in the case of optimistic locking (the probability of lock conflict is low), it is more appropriate to implement spin lock.

*Pseudocode to implement spin lock

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有
        // 如果这个锁已经被别的线程持有, 那么就自旋等待
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程
        while(!CAS(this.owner, null, Thread.currentThread())){
        
        }
   }
    public void unlock (){
        this.owner = null;
   }
}


3. ABA issues of CAS

The ABA problem of CAS is a classic problem encountered when using CAS.

It is known that the key to CAS is to compare the values ​​in the memory and the register to see whether they are the same. It is through this comparison to determine whether the value in the memory has changed. However, what if the comparison is the same, but in fact the value in the memory has not changed, but changed from the A value to the B value and then back to the A value?

At this time, there is a certain probability that something will go wrong. This situation is called an ABA problem. CAS can only compare whether the values ​​are the same, but cannot determine whether the value has changed in the middle.

This is like buying a mobile phone from a certain fish website, but we can't tell whether the mobile phone is a new mobile phone that has just left the factory, or a refurbished phone that has been used and refurbished by others.

1. BUG caused by ABA problem

In fact, in most cases, ABA problems have little impact. However, some special cases cannot be ruled out:

Assume that Xiao Ming has a deposit of 100. He wants to withdraw 50 yuan from the ATM. The cash machine creates two threads and executes them concurrently -50

(Debit 50 yuan from the account) This operation.

We expect that one of the two threads will execute -50 successfully and the other thread will fail -50. If you use CAS to complete this deduction process, problems may arise.

normal process

  1. Deposit 100. Thread 1 gets the current deposit value to be 100, and the expected value is updated to 50; Thread 2 gets the current deposit value to be 100, and the expected value is updated to 50.
  2. Thread 1 performs the deduction successfully and the deposit is changed to 50; Thread 2 is blocked and waiting.
  3. It is thread 2's turn to execute, and it is found that the current deposit is 50, which is different from the 100 read before , and the execution fails.

abnormal process

  1. Deposit 100. Thread 1 gets the current deposit value to be 100, and the expected value is updated to 50; Thread 2 gets the current deposit value to be 100, and the expected value is updated to 50.
  2. Thread 1 successfully executed the deduction and the deposit was changed to 50. Thread 2 is blocked and waiting.
  3. Before thread 2 is executed, Xiao Ming’s friend just transferred  50 to Xiao Ming. At this time, the balance of Xiao Ming’s account became 100 again!
  4. It is Thread 2's turn to execute, and it is found that the current deposit is 100,  which is the same as the 100 read before , and the deduction operation is performed again. 

At this time , the deduction operation was performed twice! This is all caused by ABA issues.

2. Solve the ABA problem-use version number

The key to the ABA problem is that the value of the shared variable in memory jumps repeatedly. If it is agreed that the data can only change in one direction, the problem will be solved.

This introduces the concept of "version number". It is agreed that the version number can only be incremented (each time a variable is modified, a version number will be added). And every time CAS compares, the comparison is not the value itself, but the version number. In this way, other threads can check whether the version number has changed when performing CAS operations, thereby avoiding the occurrence of ABA problems.

(The version number is used as the basis, not the variable value. It is agreed that the version number can only be incremented, so there will be no repeated horizontal jumps like ABA.)

However, in actual situations, in most cases, even if you encounter ABA problems, it does not matter. Just knowing the version number can be used to solve ABA problems.

Guess you like

Origin blog.csdn.net/wyd_333/article/details/131710655