Detailed explanation of synchronization mode between threads

Time:2022-1-14

The article has been included in my warehouse:Java learning notes and free books to share

Inter thread synchronization mode

introduction

Access to critical zone resources between different threads may cause common concurrency problems. We want threads to execute a series of instructions atomically, but due to interrupts on a single processor, we must find some other ways to synchronize threads. This paper introduces some synchronization methods between threads.

mutex

Mutexes (also known as mutexes) emphasize the mutual exclusion of resource access: mutexes are used for multi-threaded and multi task mutexes. When a thread occupies a resource, other threads cannot access it until the thread is unlocked, and other threads can start to use the resource.

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

Pay attention to understand the trylock function, which is different from the ordinary lock function. The ordinary lock function will be blocked when the resource is locked until the lock is released.

The trylock function is a non blocking call mode, that is, if the mutex is not locked, the trylock function will lock the mutex and obtain access to shared resources; If the mutex is locked, the trylock function will not block the wait, but directly return EBUSY, indicating that the shared resource is busy. In this way, some extreme situations such as deadlock or starvation can be avoided.

Explore the bottom layer and realize a lock

The implementation of a lock must be supported by hardware, because we must ensure that the lock is also concurrent and safe, which requires hardware support to ensure that the lock is implemented atomically.

It is easy to think of maintaining a global variable flag. When the variable is 0, the thread is allowed to lock and the flag is set to 1; Otherwise, the thread must hang and wait until the flag is 0

typedef struct lock_t {
    int flag;
}lock_t;

void init(lock_t &mutex) {
    mutex->flag = 0;
}

void lock(lock_t &mutex) {
    while (mutex->flag == 1) {;} // Spin waits until the variable is 0
    mutex->flag = 1;
}

void unlock(lock_t &mutex) {
    mutex->flag = 0;
}

This is based on the preliminary implementation of the software. The initialization variable is 0, and the thread can enter only when the spin waiting variable is 0. It seems that there is nothing wrong, but after careful consideration, it is problematic:

Detailed explanation of synchronization mode between threads

When the thread just passes the while determination, it falls into an interrupt. At this time, the flag is not set to 1, and another thread breaks in. At this time, the flag is still 0. It enters the critical area through the while determination. At this time, the interrupt returns to the original thread, the original thread continues to execute, and also enters the critical area, which causes the synchronization problem.

In the while loop, it is not enough to only set mutex – > flag = = 1. Although it is a primitive, we must have more code. At the same time, when we introduce more code, we must ensure that these codes are also atomic, which means that we need hardware support.

Let’s think about why the above code failed? The reason is that when exiting the while loop, the flag is still 0 at this time, which gives other threads the opportunity to rush into the critical area.

The solution is also intuitive – when exiting while, use hardware support to ensure that the flag is set to 1.

Test and lock (TAS)

We write the following functions:

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

Also reset the while loop:

void lock(lock_t &mutex) {
    while (TestAndSet(mutex->flag, 1) == 1) {;} // Spin waits until the variable is 0
    mutex->flag = 1;
}

Here, we use hardware to ensure that the testandset function is executed atomically. Now the lock can be used correctly. When the flag is 0, we have set the flag to 1 when passing the while test, and other threads can no longer enter the critical area.

Compare and exchange (CAS)

We write the following functions:

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected) {
        *ptr = new;
    }
    return actual;
}

Similarly, the hardware should also support CAS primitives to ensure the internal security of CAS. Now reset while:

void lock(lock_t &mutex) {
    while (CompareAndSwap(mutex->flag, 0, 1) == 1) {;} // Spin waits until the variable is 0
    mutex->flag = 1;
}

Now the lock can be used correctly. When the flag is 0, we have set the flag to 1 when passing the while test, and other threads can no longer enter the critical area.

In addition, you may find that CAS needs more registers to study in the futuresynchronozationYou will find its beauty.

Another question, too much spin?

You may find that although a thread fails to obtain a lock, it is still in a while loop to occupy CPU resources. One way is to enter sleep to release CPU resources (condition variable) when the thread fails to obtain the lock. When a thread releases the lock, wake up a sleeping thread. However, this also has disadvantages. It takes time to enter sleep and wake up a lock. When a thread can release the lock soon, more and so on are better choices than falling into sleep.

Linux adopts two-stage lock. In the first stage, the thread spins for a certain time or times to wait for the release of the lock. When it reaches a certain time or a certain number of times, it enters the second stage, and the thread enters sleep.

Back to mutex

Mutexes provide the basic guarantee of concurrency security. Mutexes are used to ensure safe access to resources in critical areas. However, when to access resources is not something that mutexes should consider, which may be something that conditional variables should consider.

If threads lock and unlock frequently, the efficiency is very inefficient, which is a point we must consider.

Semaphore

It is important to understand that semaphores are not used to transmit resources, but to protect shared resources. The meaning of semaphore s isMaximum number of threads allowed to access resources at the same time, which is a global variable.

Semaphores can also be used in processes. The understanding of semaphores is not much different from that in threads. They are used to protect resources. For more understanding of semaphores, see this article:Java learning notes / interprocess communication mode.

Consider the above simple example: two threads modify at the same time and cause errors. We don’t consider the reader but only the writer process. In this example, shared resources allow at most one thread to modify resources, so we initialize s to 1.

At the beginning, a writes the resource first. At this time, a calls P (s) and subtracts s by one. At this time, s = 0, and a enters the shared area to work.

At this time, thread B also wants to enter the shared area to modify resources. It calls P (s) and finds that s is 0, so it suspends the thread and joins the waiting queue.

When a finishes working, it calls V (s). It finds that s is 0 and detects that the waiting queue is not empty, so it randomly wakes up a waiting thread and adds s to 1. Here, it wakes up B.

B wakes up and continues to perform P operation. At this time, s is not 0. B successfully sets s to 0 and enters the work area.

At this point, C wants to enter the workspace

It can be found that at any time, only one thread can access the shared resources, which is what the semaphore does. It controls the maximum number of processes entering the shared area, which depends on the value of initialization s. After that, the P operation is invoked before entering the shared area, and the V operation is invoked after the shared area is sent. This is the idea of semaphore.

Named semaphore

Famous semaphores exist in the form of files. Even threads between different processes can access the semaphore. Therefore, it can be used for mutual exclusion and synchronization between multiple threads between different processes.

Create open semaphore

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
//Successfully return semaphore pointer; Failure return SEM_ Failed, set errno

Name is the file pathname, and value is set to the initial value of the semaphore.

Turn off the semaphore and call it when the process terminates

int sem_ close(sem_t *sem);    // Successfully returned 0; Failure returns - 1, setting errno

Delete the semaphore, delete the semaphore name immediately, and destroy it when other processes close it

int sem_unlink(const char *name);

Wait for semaphore, test the value of semaphore, and wait (block) if its value is less than or equal to 0; Once its value becomes greater than 0, it is subtracted by 1 and returned

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
//Successfully returned 0; Failure returns - 1, setting errno

When the value of semaphore is 0, SEM_ Trywait returns immediately and sets errno to eagain. If interrupted by a signal, SEM_ Wait will return prematurely and set errno to Eintr

Emit a semaphore, add 1 to its value, and then wake up the process or thread waiting for the semaphore

int sem_post(sem_t *sem);

Successfully returned 0; Failure returns – 1 without changing its value. Set errno. This function is asynchronous signal safe and can be called in the signal handler

Unknown semaphore

Anonymous semaphores exist in the virtual space in the process and are invisible to other processes. Therefore, anonymous semaphores are used for mutual exclusion and synchronization among threads in a process. The following APIs are used:

(1)sem_ Init function: used to create a semaphore and initialize the value of the semaphore. Function prototype:

int sem_init (sem_t* sem, int pshared, unsigned int value);

Function passed in value: SEM: semaphore. Pshared: determines whether semaphores can be shared among several processes. Since Linux has not yet implemented the amount of information shared between processes, this value can only be taken as 0.

(2) Other functions

int sem_wait       (sem_t* sem);
int sem_trywait   (sem_t* sem);
int sem_post       (sem_t* sem);
int sem_getvalue (sem_t* sem);
int sem_destroy   (sem_t* sem);

Function:

sem_ Wait and SEM_ Trywait is equivalent to p operation. Both of them can reduce the value of semaphore by one. The difference between the two is that if the value of semaphore is less than zero, SEM_ Wait will block the process, while SEM_ Trywait will return immediately.

sem_ Post is equivalent to V operation. It increases the semaphore value by one and sends a wake-up signal to the waiting thread.

sem_ GetValue gets the value of the semaphore.

sem_ Destroy destroy semaphore.

If a memory based semaphore is synchronized between different processes, the semaphore must be stored in the shared memory area, which exists as long as the shared memory area exists.

summary

Nameless semaphores exist in memory and famous semaphores exist on disk. Therefore, nameless semaphores are faster, but they are only applicable to threads in an independent process; Famous semaphores can speed up, but they can synchronize threads between different processes. This is realized through shared memory, which is a way of communication between processes.

You may find that when the value s of the semaphore is 1, the semaphore has the same effect on the mutex. The mutex can only allow one thread to enter the critical area, while the semaphore allows more threads to enter the critical area, depending on the value of the semaphore.

Conditional variable

What are conditional variables?

In the mutex, the thread waits for the flag to be 0 before entering the critical area; P operation in semaphore also needs to wait, s is not 0 In multithreading, it is common for a thread to wait for a certain condition. In the section on mutex implementation, do we have a more specialized and efficient way to realize conditional waiting?

It’s a conditional variable! Condition variable is a mechanism for synchronization by using global variables shared among threads. It mainly includes two actions: a thread waits for a condition to be true and suspends itself; The other thread sets the condition to true and notifies the waiting thread to continue.

Since a condition is a global variable, theConditional variables often use mutexes to protect(this is necessary and mandatory).

When condition variables are used with mutexes, threads are allowed to wait for specific conditions to occur in a non competitive manner.

Threads can use condition variables to wait for a condition to be true. Note that it is not true to wait for condition variables. Condition variables (cond) are common methods used to implement “wait — > wake up” logic in multithreaded programs. They are used to maintain a condition (different from the concept of condition variables). It does not mean that the waiting condition variables are true or false。 The condition variable is an explicit queue. When the condition is not met, the thread will add itself to the waiting queue and release the mutex held at the same time; When a thread changes the condition, it can wake up one or more waiting threads (note that the condition is not necessarily true at this time).

There are two basic operations on condition variables:

  • Wait: a thread is dormant in the waiting queue. At this time, the thread will not occupy the mutex. When the thread is awakened, it will regain the mutex lock (possibly competing with multiple threads) and regain the mutex.
  • Signal / notify: when the condition changes, another thread sends a notification to wake up the thread in the waiting queue.

correlation function

1. Initialization

The data type of condition variable is pthread_ cond_ t. , it must be initialized before use. There are two ways:

Static: directly set the condition variable cond to constant pthread_ COND_ INITIALIZER。

Dynamic: pthread_ cond_ Init function is to use pthread before releasing the memory space of dynamic condition variables_ cond_ Destroy clean it.

int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
//0 is returned for success and the error number is returned for error

Note: the space occupied by the condition variable is not released.

Cond: condition variable to initialize; Attr: generally null.

2. Waiting conditions

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restric mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
//0 is returned for success and the error number is returned for error

These two functions are blocking waiting and timeout waiting respectively. Blocking waits until it enters the waiting queue and sleeps until the condition is modified and wakes up; Timeout wait automatically wakes up after a certain period of sleep.

When waiting, the thread releases the mutex, and when awakened, the thread regains the lock.

3. Conditions of notice

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
//0 is returned for success and the error number is returned for error

These two functions are used to notify the thread that the condition has been modified, and call these two functions to send signals to the thread or condition.

Usage and thinking

Conditional variable usage template:

pthread_ cond_ t cond;  // Conditional variable
mutex_ t mutex;    // mutex 
int flag; // condition

//A thread
void threadA() {
    Pthread_ mutex_ lock(&mutex);  // Protect critical resources because threads modify the global condition flag
    While (flag = = 1) // wait for a condition to hold
        Pthread_ cond_ wait(&cond, &mutex);  // If not, join the queue to sleep and release the lock
    ....dosomthing
    .... Change flag // the condition is modified
    Pthread_ cond_ signal(&cond); // Send a signal to notify that the condition has been modified
    Pthread_ mutex_ unlock(&mutex); // Release the lock as quickly as possible after releasing the signal, because the awakened thread will try to obtain the lock
}


//B thread
void threadB() {
    Pthread_ mutex_ lock(&mutex);  // Protect critical resources
    While (flag = = 0) // wait for a condition to hold
        Pthread_ cond_ wait(&cond, &mutex);  // If not, join the queue to sleep and release the lock
    ....dosomthing
    .... Change flag // the condition is modified
    Pthread_ cond_ signal(&cond); // Release the lock as quickly as possible after releasing the signal, because the awakened thread will try to obtain the lock
    Pthread_mutex_unlock(&mutex);
}

Through the above example, we should well understand the difference between conditional variables and conditions. Conditional variables are a logic. They are not bool statements in the while loop. I believe many beginners have such a misunderstanding that conditional variables are the conditions that threads need to wait for. A condition is a condition. A thread waits for a condition rather than a condition variable. A condition variable makes a thread more efficient. The condition variable is a set of wait wake logic.

Note that the while loop waiting condition is still used here. You may think that it has been locked and other threads cannot be forced in. In fact, when thread a falls into sleep, it will release the lock, and when it is awakened, it will try to obtain the lock. When it is trying to obtain the lock, another thread B now attempts to obtain the lock, grabs the lock and enters the critical area, and then modifies the conditions so that the conditions of thread a are no longer valid, and thread B returns. At this time, thread a finally obtains the lock and enters the critical area, But at this time, the condition of thread a is not tenable at all. He should not enter the critical area!

In addition, being awakened does not mean that the conditions are true. For example, the above code thread B modifies flag = 3 and wakes up thread A. here, the conditions of thread a do not meet at all, so the determination conditions must be repeated. Examples of mutexes and conditional variables tell us:When waiting for conditions, always use while instead of if!

It is also meaningful that a thread falling into sleep must release the lock. If the lock is not released, other threads cannot modify the conditions at all, and the dormant thread will never wake up!

Practice – reader writer lock

Read lock – shared; Write lock – exclusive. That is, multiple read threads can be added, while there can only be one write thread, and readers and writers cannot work at the same time.

In this case, since it is efficient to allow multiple readers to share the critical area, let’s consider the implementation: if only one writer is allowed to work, a mutex or binary semaphore must be required to maintain it, which is called writer lock; Since readers and writers cannot work at the same time, the first reader must try to obtain the writer lock. Once the number of readers is greater than 1, subsequent readers can enter directly without trying to obtain the writer lock. Note that there is a global reader quantity variable here. Therefore, readers also need a lock to maintain the global reader quantity. The last reader who exits must be responsible for releasing the reader lock.

Know the principle, go and realize a reader / writer lock by yourself!

Under Linuxpthread_rwlockFunction family implementation.