You should know optimistic locking – an effective means of controlling thread safety

Time:2021-11-27

1. Background

Recently modifiedSeataSome problems of thread concurrency, some of which are summarized to you. Let’s briefly describe this problemSeataThere is a concept of global transaction in this distributed transaction framework. In most cases, the process of global transaction is basically sequential, and there will be no concurrency problem. However, in some extreme cases, multi-threaded access will lead to incorrect global transaction processing. As shown in the following code: in our global transactioncommitPhase, there is a code as follows:
` if (status == GlobalStatus.Begin) {

    globalSession.changeStatus(GlobalStatus.Committing);
}`

The code is omitted, that is, first judge whether the status state is the begin state, and then change the state to submitting.

In the rollback phase of our global transaction, there is a code as follows:

if (status == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
        }

Similarly, part of the code is omitted. Here, judge whether the status state is begin, and then change it to rolling. There are no thread synchronization methods in Seata’s code. If these two logics are executed at the same time (generally not, but may occur in extreme cases), unexpected errors will occur in our results. What we need to do is to solve the problem of concurrency in this extreme case.

2. Pessimistic lock

For this concurrency problem, I believe you must think of locking at the first time. In Java, we generally use the following two methods to lock:

Synchronized
ReentrantLock
We can use synchronized or reentrantlock to lock, and modify the code to the following logic:

synchronized:
`synchronized(globalSession){

        if (status == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
        }
    }

`
Reentrantlock:
`reentrantLock.lock();
try {

if  (status == GlobalStatus.Begin) {
globalSession.changeStatus(GlobalStatus.Rollbacking);
    }
}finally {
        reentrantLock.unlock();
}

`
This kind of locking is relatively simple. It is currently implemented in Seata’s go server. However, this implementation scenario ignores one of the situations mentioned above, that is, in extreme cases, that is, 99.9% of the cases may not have concurrency problems, and only% 0.1 may lead to this concurrency problem. Although our pessimistic lock takes a short time to lock, it is still not enough in this high-performance middleware, so we introduce our optimistic lock.

3. Optimistic lock

When it comes to optimistic locks, many friends will think of optimistic locks in the database. Imagine the above logic. If we do not use optimistic locks in the database, we will have the following pseudo code logic:
`select * from table where id = xxx for update;
if(status == begin){

//do other thing
update table set status = rollbacking;

}
`
The above code can be seen in many of our business logic. This code has two small problems:

1. The transaction is large. Since we lock our data at the beginning, if some time-consuming logic is interspersed between our query and update in a transaction, our transaction will lead to a large transaction. Since each transaction will occupy a database connection, it is easy to have insufficient database connection pool when the traffic is high.

2. It takes a long time to lock the data. In our whole transaction, we lock the data. If other transactions want to modify the data, they will block and wait for a long time.

Therefore, in order to solve the above problems, in many scenarios where the competition is small, we adopt the optimistic locking method. We add a field version in the database to represent the version number. We modify the code as follows:
`select * from table where id = xxx ;
if(status == begin){

//do other thing
int result = (update table set status = rollbacking where version = xxx);
if(result == 0){
    throw new someException();
}

}
`
Here, our query statement no longer has for update, and our transaction is only reduced to update. We judge by the version found in the first sentence. If the number of updated rows of our update is 0, it proves that other transactions have modified it. You can throw exceptions or do something else.

It can be seen from here that we use optimistic locks to solve the two problems of large transactions and long locks, but the corresponding cost is that if the update fails, we may throw exceptions or take some other remedial measures, and our pessimistic locks have been limited before executing the business. Therefore, we can only use optimistic lock when the concurrent processing of a piece of data is relatively small.

3.1 optimistic lock in code

We talked about the optimistic lock in the database above. Many people are asking, without the database, how can we implement the optimistic lock in our code? Students familiar with synchronized must know that synchronized optimizes it after JDK1.6 and introduces a model of lock expansion:

1. Biased lock: as the name suggests, a lock biased to a thread is applicable to a thread that can obtain the lock for a long time.

2. Lightweight lock: if the bias lock acquisition fails, CAS spin will be used to complete it. Lightweight lock is suitable for threads to enter the critical area alternately.

3. Heavyweight lock: after the spin fails, the heavyweight lock strategy will be adopted, and our thread will be blocked and suspended.

In the above level lock model, the threads applicable to lightweight locks alternately enter the critical area, which is very suitable for our scenario, because generally speaking, our global transaction will not be handled by a single thread all the time (of course, it can also be optimized into this model, but the design will be complex), In most cases, our global transactions will be handled by different threads alternately, so we can learn from the idea of lightweight lock CAS spin to complete our code level spin lock. Some friends here may ask why you don’t use synchronized? It is measured here that the CAS spin performance realized by ourselves is the highest when entering the critical region alternately, and synchronized has no timeout mechanism, which is inconvenient for us to deal with exceptions.
`class GlobalSessionSpinLock {

    
    private AtomicBoolean globalSessionSpinLock = new AtomicBoolean(true);

    public void lock() throws TransactionException {
        boolean flag;
        do {
            flag = this.globalSessionSpinLock.compareAndSet(true, false);
        }
        while (!flag);
    }


    public void unlock() {
        this.globalSessionSpinLock.compareAndSet(false, true);
    }
}

// method rollback
void rollback(){

globalSessionSpinLock.lock();
try {
    if  (status == GlobalStatus.Begin) {
    globalSession.changeStatus(GlobalStatus.Rollbacking);
        }
}finally {
    globalSessionSpinLock.unlock();
}

}
`
Above, we simply implemented an optimistic lock with CAS, but this optimistic lock has a small disadvantage that once competition occurs, it can not expand into pessimistic lock blocking waiting, and there is no expiration timeout, which may occupy a lot of our CPU. We continue to further optimize:
`public void lock() throws TransactionException {

        boolean flag;
        int times = 1;
        long beginTime = System.currentTimeMillis();
        long restTime = GLOBAL_SESSOION_LOCK_TIME_OUT_MILLS ;
        do {
            restTime -= (System.currentTimeMillis() - beginTime);
            if (restTime <= 0){
                throw new TransactionException(TransactionExceptionCode.FailedLockGlobalTranscation);
            }
            // Pause every PARK_TIMES_BASE times,yield the CPU
            if (times % PARK_TIMES_BASE == 0){
                // Exponential Backoff
                long backOffTime =  PARK_TIMES_BASE_NANOS << (times/PARK_TIMES_BASE);
                long parkTime = backOffTime < restTime ? backOffTime : restTime;
                LockSupport.parkNanos(parkTime);
            }
            flag = this.globalSessionSpinLock.compareAndSet(true, false);
            times++;
        }
        while (!flag);
    }

`
The above code is optimized as follows:

The timeout mechanism is introduced. Generally speaking, to lock the critical area, the timeout mechanism must be done well, especially in this middleware with high performance requirements.

The lock inflation mechanism is introduced. There is no cycle for a certain number of times. If the lock cannot be obtained, the thread will suspend the parktime time, and then continue to cycle to obtain it. If the lock cannot be obtained again, we will suspend our parktime in the form of exponential backoff, and gradually increase our suspension time until it times out.

summary

From our handling of concurrency control, there are many ways to achieve a goal. We need to select the appropriate method and the most efficient means to achieve our goal according to different scenarios and conditions. This article does not elaborate too much on the principle of pessimistic lock. Those who are interested here can come down and consult the materials by themselves. After reading this article, if you can only remember one thing, please remember to consider optimistic lock when implementing thread concurrency security.

To learn more about Java basics, you can join my:Java learning garden, more suitable for Xiaobai.