AQS implementation principle of concurrent programming series

Posted by shaun112 on Mon, 07 Mar 2022 09:04:41 +0100

AQS implementation principle of concurrent programming series

1. What is AQS?

AQS (AbstractQueuedSynchronizer), an abstract queue synchronizer, is the basis of many Lock locks and synchronization components in juc, such as CountDownLatch, ReentrantLock, ReentrantReadWriteLock, Semaphore, etc. it provides interfaces or specific implementations for resource occupation, release, thread waiting, wake-up, etc., which can be used in various scenarios that need to control resource competition.

2. Clarify the overall structure of AQS

Take a look at the source code of AbstractQueuedSynchronizer in the juc package:

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
	//Head node
    private transient volatile Node head;
    //Tail node
    private transient volatile Node tail;
    //volatile modified semaphore
    private volatile int state;
    
    // Node of linked list
 static final class Node {
        volatile Node prev;
        volatile Node next;
        volatile Thread thread;
    }
	// ...
}

// AbstractQueuedSynchronizer parent class
public abstract class AbstractOwnableSynchronizer
    implements java.io.Serializable {
 private transient Thread exclusiveOwnerThread;
	// ...
}

AQS is mainly composed of the following components, the most important of which are semaphore stata and CHL queue

2.1 state semaphore

In the source code, state is modified by volatile keyword. In the previous study, we can know that the three characteristics of concurrent programming are atomicity, orderliness and visibility. The variable modified by volatile can ensure order and visibility.

volatile ensures visibility

  • Write the state value, and the volatile feature will synchronize the data to the main memory
  • Read the state value, and the volatile feature will reload data from the main memory into the working memory

This involves JMM. Unfamiliar readers can turn to my previous blog

The order of the instructions and the execution of the programs depends on the memory, so the order of the instructions and the code can be avoided.

The reason for adding volatile has been explained earlier, and then there is another feature, atomicity. Volatile can only guarantee the atomicity of a single variable, but can not guarantee the atomicity of a series of operations, so it is not thread safe. Then how can thread safety be guaranteed in AQS? Then how to implement it in the AQS source code? Turn down the source code and find two places:

Here is a very common CAS lock, which uses the unsafe api and the atomicity of cpu operation to ensure the atomicity of this operation

protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

This method directly sets a newStata, because it modifies a variable. When directly assigning values to variables of basic types, atomicity can be guaranteed

protected final void setState(int newState) {
    state = newState;
}

2.2 FIFO queue

An AQS document given by AQS author Doug Lea: http://gee.cs.oswego.edu/dl/papers/aqs.pdf AQS is described in detail in, which can be read by readers with good English level

CHL queue is a core of AQS. FIFO queue, that is, first in first out queue. The main function of this queue is to store waiting threads. Assuming that many threads grab locks, most threads can't grab locks. Therefore, these threads need a queue to store them. This queue is CHL queue, which also follows FIFO rules.

The internal structure of CHL queue is a data structure of a two-way linked list. The basic component is the Node node. In the queue, head and tail are used to represent the head Node and tail Node respectively. Both of them point to an empty Node during initialization. The thread of the head Node can hold the lock, and then the subsequent threads queue for waiting, as shown in the figure

2.3,exclusiveOwnerThread

AbstractQueuedSynchronizer class extends AbstractOwnableSynchronizer class. AbstractOwnableSynchronizer has a thread object defined as exclusiveOwnerThread, which represents the holder of the synchronizer in exclusive mode.

3. AQS implementation principle

We have a clear understanding of the overall design of AQS. The main focus is on semaphore state and CHL queue, and then we can follow the source code implementation, @ see Java util. concurrent. locks. Abstractownablesynchronizer, you don't need to look at the source code a little. Just look at the main methods and classes and clarify the implementation principle of the framework. AQS mainly focuses on * acquire * and * release * these methods can be divided into two types: one is exclusive and the other is shared. Shared methods are added with a shared suffix

  • Acquire, acquire shared: defines the logic of resource contention. If you don't get it, wait
  • tryAcquire, tryacquishared: the actual operation that occupies resources is implemented by specific AQS users
  • Release and releasedShared: define the logic of resource release. After the resource is released, adjust the queue and continue to fight for the subsequent nodes.
  • tryRelease, tryrereleaseshared: the actual operation of resource release is implemented by AQS users

3.1 exclusive

Let's follow the AQS source code. Pay attention to the difference between exclusive and shared implementations

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

General idea of acquire method:

  • The tryAcquire() method attempts to obtain resources. If successful, it returns directly
  • If the acquisition fails, the current thread is wrapped as a Node and inserted into the tail of the CLH queue through the addWaiter method, and the exclusive thread is marked as Node EXCLUSIVE
  • The acquirequeueueueued method blocks the thread in the waiting queue until the resource is obtained. In the whole waiting process, if the thread is interrupted, it returns true, otherwise it returns false. After the resource is obtained and interrupted, it will call selfInterrupt to make up the interruption

The tryAcquire method is implemented by a specific user class. If it is not implemented, an exception will be thrown, so it must be implemented

protected boolean tryAcquire(int arg) {
   throw new UnsupportedOperationException();
}

The addWaiter method is used to wrap the current thread as a Node and insert it into the tail of the CLH queue

private Node addWaiter(Node mode) {
	// Encapsulate the current thread as a Node node
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    // The tail node cannot be empty. The queue has been initialized
    if (pred != null) {
        // The front prev pointer of the Node node points to the tail Node
        node.prev = pred;
        // cas lock operation to ensure thread safety
        if (compareAndSetTail(pred, node)) {
        	// The tail Node pointer points to the Node
            pred.next = node;
            return node;
        }
    }
    // The queue has not been initialized. Call enq method
    enq(node);
    return node;
}

Follow the enq method

private Node enq(final Node node) {
    // Infinite loop, similar to while(true), spin operation
    for (;;) {
         // Tail node
        Node t = tail;
        if (t == null) { // Must initialize
        	// Create a new node and set it as the head node
            if (compareAndSetHead(new Node()))
            	// The head node is assigned to the tail node
                tail = head;
        } else {
       		// If the tail Node is not empty, point the precursor of the Node node to the tail Node
            node.prev = t;
            // cas, set the node node as the tail node
            if (compareAndSetTail(t, node)) {
            	// The next pointer of the tail Node points to the Node node
                t.next = node;
                return t;
            }
        }
    }
}

Look up at Java util. concurrent. locks. Acquirequeueueued method of abstractqueuedsynchronizer #acquire method. As mentioned earlier, acquirequeueueueued method is used to queue threads until resources are obtained

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // Spin, infinite cycle
        for (;;) {
             // Find node precursor node
            final Node p = node.predecessor();
            // The precursor node is the head node, tryAcquire
            if (p == head && tryAcquire(arg)) {
            	// The resource is obtained successfully. Set the node node to the new head node
                setHead(node);
                // The original head node is set to null and waits for GC garbage collection
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // p is not the head node, or tryAcquire() failed to obtain resources
            // shouldParkAfterFailedAcquire judge whether it can be park
            // parkAndCheckInterrupt determines whether park and Interrupt are successful
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

3.2 shared

Shared, that is, shared resources can be occupied by multiple threads at the same time

First look at the acquireShared method at the top level:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

The tryAcquireShared method is implemented by a specific user class, so first look at the doAcquireShared source code:

private void doAcquireShared(int arg) {
    // Encapsulated as Node and marked as Node SHARED
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        // Infinite cycle, spin
        for (;;) {
            // Find precursor node
            final Node p = node.predecessor();
            // Is it a head node
            if (p == head) {
            	// Try to grab resources
                int r = tryAcquireShared(arg);
                if (r >= 0) { // Successful resource grabbing
                    // Set as head node
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
             // p is not the head node, or tryAcquire() failed to obtain resources
            // shouldParkAfterFailedAcquire judge whether it can be park
            // parkAndCheckInterrupt determines whether park and Interrupt are successful
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

This logic is basically similar to the exclusive type, except that the queued nodes are marked as SHARED shared, and tryacquiresshared is implemented by specific classes

reference material