thread mutual exclusion, synchronization

Table of contents

1. Thread mutual exclusion

1.1 Introduction to related concepts

1.2 Mutex mutex

1.3 Mutex interface

1.4 Mutex implementation principle

2. Reentrancy and thread safety

2.1 Concept

2.2 Common thread unsafe situations

2.3 Common thread safety situations

2.4 Common non-reentrant situations

2.5 Common reentrant situations

2.6 The relationship between reentrancy and thread safety

3. Deadlock

4. Thread synchronization

4.1 Synchronization concepts and race conditions

4.2 Condition variables

4.2.1 Concept

4.2.2 Interface

4.2.3 Why pthread_cond_wait needs a mutex

4.2.4 Specifications for use


1. Thread mutual exclusion

1.1 Introduction to related concepts

  • Critical resources:  resources shared by multi-threaded execution streams are called critical resources
  • Critical section:  The code that accesses critical resources inside each thread is called a critical section
  • Mutual exclusion:  At any time, mutual exclusion guarantees that there is only one execution flow entering the critical section to access critical resources, which usually protects critical resources
  • Atomicity:  An operation that will not be interrupted by any scheduling mechanism. The operation has only two states: either completed or not completed

The following simulates the implementation of a ticket grabbing system. The variable that records the remaining number of tickets is defined as a global variable. The main thread creates four new threads to grab tickets. When the tickets are grabbed, these four threads automatically exit

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

const int thread_num = 4;
int tickets = 1000;

void* GetTickets(void* args) {
	while (true) {
		if (tickets > 0) {
			usleep(10000);//抢票所耗费的时间
            printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
		}
		else {
			break;
		}
	}
	printf("%s quit!\n", (char*)args);
	pthread_exit((void*)0);
}
int main()
{
    pthread_t tids[thread_num];
    pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
    pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
	pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
	pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");

    for(int i = 0;i < thread_num; ++i) {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

The results of the operation obviously did not meet expectations, and the final number of votes became negative

Reasons for negative votes:

  • After the if statement judges the condition to be true, the code can switch to other threads. usleep is used to simulate a long business process. During this business process, some threads may enter the code segment
  • --tickets operations are not atomic

--tickets operation

Performing -- on a variable actually requires three steps:

  • load: Load the shared variable tickets from memory into registers
  • update: update the value in the register, perform -1 operation
  • store: Write the new value from the register back to the memory address of the shared variable tickets

-- The assembly code corresponding to the operation is as follows:

-- The operation requires three steps to complete. It is possible that when thread1 reads the value of tickets into the CPU register, it is cut off. Suppose that the value read by thread1 is 1000 at this time, and when thread1 is cut off, the value in the register The 1000 is called the context data of thread1, so it needs to be saved, and then thread1 is suspended

Assuming that thread2 is scheduled at this time, since thread1 only executes the first step of the -- operation, thread2 sees that the value of tickets in the memory is still 1000 at this time, assuming that the system may give thread2 more time slices, and thread2 once Executed 100 times -- the operation was cut off, and finally the tickets were reduced from 1000 to 900

At this time, the system restores thread1 again, continues to execute the code of thread1 and restores the hardware context information of thread1. At this time, the value in the register is the restored 1000, and then thread1 continues to execute -- the second and third steps of the operation step, and finally write 999 back to memory

At this time, thread1 grabbed 1 ticket, thread2 grabbed 100 tickets, but the remaining number of tickets at this time is 999, which is equivalent to 100 more tickets. -- The operation is not atomic. Although --tickets looks like one line of code, this line of code is essentially three lines of assembly after being compiled by the compiler; on the contrary, performing ++ on a variable also requires three corresponding steps. That is, the ++ operation is not an atomic operation

1.2 Mutex mutex

If the data used by the thread is a local variable, the address space of the variable is in the thread stack space, the variable belongs to a single thread, and other threads cannot obtain this variable; but some variables need to be shared between threads (shared variables), which can be shared through data , to complete the interaction between threads. Concurrent operation of shared variables by multiple threads will cause some problems

To solve the above problems of the ticket grabbing system, three things need to be done:

  • The code must have mutual exclusion behavior: when the code enters the execution of the critical section, no other thread is allowed to enter the critical section.
  • If multiple threads request to execute the code in the critical section at the same time, and no thread is executing in the critical section at this time, only one thread is allowed to enter the critical section
  • If a thread is not executing in a critical section, the thread cannot prevent other threads from entering the critical section

At this time, a lock is needed, and the lock provided in Linux is called a mutex

1.3 Mutex interface

1.3.1 Initialize the mutex

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

parameter:

  • mutex: the address of the mutex that needs to be initialized
  • attr: Initialize the attribute of the mutex, generally set to nullptr

Return value: 0 is returned if the mutex is initialized successfully, and an error code is returned if it fails

The way to initialize a mutex using the pthread_mutex_init() function is called dynamic allocation, and it can also be initialized using static allocation, which is the following way:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

1.3.2 Destroy the mutex

int pthread_mutex_destroy(pthread_mutex_t *mutex);

Parameter mutex: the address of the mutex that needs to be destroyed

Return value: 0 is returned if the mutex is destroyed successfully, and an error code is returned if it fails

Notice:

  • Mutexes initialized with PTHREAD_MUTEX_INITIALIZER do not need to be destroyed
  • Do not destroy a locked mutex
  • For the mutex that has been destroyed, make sure that no thread will try to lock it later

1.3.3 Mutex locking

int pthread_mutex_lock(pthread_mutex_t *mutex);

Parameter mutex: the address of the mutex that needs to be locked

Return value: 0 is returned if the mutex is locked successfully, and an error code is returned if it fails

Notice:

  • When the mutex is in an unlocked state, this function will lock the mutex and return success at the same time
  • When initiating a function call, if other threads have locked the mutex, or there are other threads applying for the mutex at the same time, but the mutex is not competed, then the thread will be blocked inside the pthread_mutex_lock() function until the mutex is unlocked

1.3.4 Mutex unlocking

int pthread_mutex_unlock(pthread_mutex_t *mutex);

Parameter mutex: the address of the mutex that needs to be unlocked

Return value: 0 is returned if the mutex is unlocked successfully, and an error code is returned if it fails

1.3.5 Use Cases

Introduce a mutex in the above ticket grabbing system to solve the problems of printing confusion and negative votes:

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

const int thread_num = 4;
int tickets = 1000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void* GetTickets(void* args) {
	while (true) {
        pthread_mutex_lock(&mtx);
		if (tickets > 0) {
			usleep(1000);//抢票所耗费的时间
            printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
            pthread_mutex_unlock(&mtx);
            usleep(10);//避免全部为同一线程抢占锁
		}
		else {
            pthread_mutex_unlock(&mtx);
			break;
		}
	}
	printf("%s quit!\n", (char*)args);
	pthread_exit((void*)0);
}
int main()
{
    pthread_t tids[thread_num];
    pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
    pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
	pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
	pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");

    for(int i = 0;i < thread_num; ++i) {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

  • In most cases, locking itself is detrimental to performance, and it changes the multi-execution flow from parallel execution to serial execution, which is almost inevitable
  • Locking and unlocking should be performed at the appropriate position, reducing the granularity of the lock, which can reduce the performance overhead cost caused by locking
  • The protection of critical resources is a standard that all execution flows should abide by. Programmers need to pay attention when coding

1.4 Mutex implementation principle

How to reflect the atomicity after locking?

After the introduction of the mutex, when a thread applies for a lock and enters the critical section, it appears to other threads that the thread has only two states, either the lock has not been applied, or the lock has been released, because only these two states are valid to other threads. is meaningful.

For example, after thread 1 enters the critical section in the figure, from the perspective of threads 2, 3, and 4, thread 1 either does not apply for a lock, or thread 1 has released the lock, because only these two states are important to threads 2, 3, and 4. It makes sense, and when threads 2, 3, and 4 detect other states (thread 1 holds the lock), they are blocked. At this time, for threads 2, 3, and 4, the entire operation process of thread 1 is atomic

Is it possible for a thread in a critical section to be switched?

Threads within a critical section may perform thread switching. But even if the thread is cut off, other threads cannot enter the critical area for resource access, because the thread is cut off with the lock at this time, and the lock is not released, which means that other threads cannot apply for the lock, that is, Unable to enter critical section for resource access.

Other threads that want to enter the critical section for resource access must wait for the thread to execute the code in the critical section and release the lock before applying for the lock, and then entering the critical section after applying for the lock.

Does the mutex need to be protected?

The resources shared by multiple execution streams are called critical resources, and the code that accesses critical resources is called critical section. All threads must compete to apply for locks before entering the critical section, so the lock is also a resource shared by multiple execution flows, that is to say, the lock itself is a critical resource.

Since the lock is a critical resource, the lock must be protected, but the lock itself is used to protect the critical resource, so who protects the lock?

The lock actually protects itself. You only need to ensure that the process of applying for the lock is atomic , then the lock is safe

How to ensure that the lock application is atomic?

In order to realize mutual exclusion lock operation, most architectures provide swap or exchange instruction, the function of this instruction is to exchange the data of register and memory unit. Since there is only one instruction, atomicity is guaranteed.

Pseudocode for lock and unlock:

It can be considered that the initial value of mutex is 1, and al is a register in the computer

When a thread applies for a lock, it needs to perform the following steps:

  1. First clear the value in the al register to 0
  2. Then swap the values ​​in the al register and the mutex. xchgb is an exchange instruction provided by the architecture, which can complete the exchange of data between registers and memory units
  3. Finally, determine whether the value in the al register is greater than 0. If it is greater than 0, the lock application is successful, and at this time, you can enter the critical area to access the corresponding critical resources; otherwise, the lock application fails and you need to hang up and wait until the lock is released and then compete to apply for the lock again

For example, at this time, the value of mutex in the memory is 1. When the thread applies for a lock, it first clears the value in the al register to 0, and then exchanges the value in the al register with the value of mutex in the memory.

After the exchange is completed, it is detected that the value in the al register of the thread is 1, then the thread applies for the lock successfully, and can enter the critical section to access critical resources. And if the subsequent thread applies for the lock again, the value exchanged with the mutex in the memory is 0. At this time, the thread fails to apply for the lock and needs to be suspended and wait until the lock is released and then competes to apply for the lock again.

When a thread releases a lock, it takes the following steps:

  1. Set the mutex in memory back to 1. So that the next thread that applies for the lock can get 1 after executing the exchange instruction, that is, "put the key of the lock back"
  2. Wake up the thread waiting for Mutex. Wake up these threads that are suspended due to failure to apply for locks, and let them continue to compete for locks

Notice:

  • When applying for a lock, it is essentially which thread executes the exchange instruction first, then the thread applies for the lock successfully, because the value in the al register of the thread is 1 at this time. The exchange instruction is just an assembly instruction. A thread either executes the exchange instruction or does not execute the exchange instruction, so the process of applying for the lock is atomic
  • When the thread releases the lock, the value in the al register of the current thread is not cleared to 0, which will not affect, because every time the thread applies for a lock, it will first clear the value in its al register to 0, and then execute the exchange instruction
  • Each thread uses the same set of registers in the CPU. When a thread is scheduled, the context data is loaded into the register; when a thread switch occurs, the context data is saved so that the context data can be reloaded into the register the next time it is scheduled

2. Reentrancy and thread safety

2.1 Concept

  • Thread safety: when multiple threads concurrently execute the same piece of code, there will be no different results
  • Reentrancy: The same function is called by different execution flows. Before the execution of the current flow is completed, other execution flows enter again, which is called reentrancy. When a function is reentrant, the running result will not be any different or have any problems, then the function is called a reentrant function, otherwise it is a non-reentrant function.

Note: thread safety discusses whether threads are safe when executing code, and reentrancy discusses functions being reentrant

2.2 Common thread unsafe situations

  • Functions that do not protect shared variables

  • A function whose state changes as it is called

  • function returning a pointer to a static variable

  • Functions that call thread-unsafe functions

2.3 Common thread safety situations

  • Each thread has only read access to global variables or static variables, but no write access. Generally speaking, these threads are safe
  • Classes or interfaces are atomic operations for threads
  • Switching between multiple threads will not cause ambiguity in the execution results of this interface

2.4 Common non-reentrant situations

  • The malloc/free function is called, because the malloc function uses a global linked list to manage the heap
  • Standard I/O library functions are called, and many implementations of standard I/O can use global data structures in a non-reentrant manner
  • Static data structures are used in the body of the function

2.5 Common reentrant situations

  • Do not use global or static variables
  • Do not use the space created by malloc or new
  • Non-reentrant functions are not called
  • No static or global data is returned, all data is provided by the caller of the function
  • Use local data, or protect global data by making a local copy of global data

2.6 The relationship between reentrancy and thread safety

  • Thread safety is not necessarily reentrant, but reentrant functions must be thread safe (reentrant functions are a type of thread safe functions)
  • The function is not reentrant, so it cannot be used by multiple threads, which may cause thread safety issues
  • If there are global variables in a function, then this function is neither thread-safe nor reentrant
  • If the access to critical resources is locked, this function is thread-safe, but if the lock of this reentrant function has not been released, a deadlock will occur, so it is not reentrant

3. Deadlock

Deadlock refers to a permanent waiting state in which each process in a group of processes occupies resources that will not be released, but is in a permanent waiting state due to mutual application for resources that are occupied by other processes and will not be released

Single execution stream produces deadlock

A single execution flow may also cause a deadlock. If a certain execution flow applies for a lock twice in a row, the execution flow will be suspended at this time. Because the execution flow applied for the lock for the first time, the application was successful, but when the lock was applied for the second time, because the lock had already been applied, the application failed and was suspended until the lock was released, but it would not be woken up. This lock is already in my hands, and I have no way to release the lock in the suspended state, so the execution flow will never be woken up, and the execution flow is in a state of deadlock at this time

#include <iostream>
#include <pthread.h>
using namespace std;
void *Routine(void *pmtx)
{
    pthread_mutex_lock((pthread_mutex_t*)pmtx);
    pthread_mutex_lock((pthread_mutex_t*)pmtx);
    pthread_mutex_unlock((pthread_mutex_t*)pmtx);//无法执行
    pthread_exit(nullptr);
}
int main()
{
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);

    pthread_t tid;
    pthread_create(&tid, nullptr, Routine, (void *)&mtx);

    pthread_join(tid, NULL);//等待不到
	pthread_mutex_destroy(&mtx);
    return 0;
}

At this time, the main thread is blocked waiting for the new thread to exit, but the thread is blocked and enters a deadlock state

The current state of the process is sl+, where l means lock, indicating that the process is currently in a state of deadlock

Multiple execution streams generate deadlock

The order in which thread A applies for lock resources is: lock 1, lock 2; the order in which thread B applies for lock resources is: lock 2, lock 1

When thread A applies for lock 1 and is about to apply for lock 2, thread B has already applied for lock 2 and is about to apply for lock 1. At this time, both threads will be blocked because of the failure to apply for the lock, and cannot release the lock, entering a deadlock state

Conditions that cause a deadlock

  • Mutually exclusive conditions:  a resource can only be used by one execution flow at a time
  • Request and Hold Conditions:  When an execution flow is blocked by requesting resources, hold on to the obtained resources
  • No deprivation condition:  the resources obtained by an execution flow cannot be forcibly deprived before they are used up
  • Circular waiting condition:  A head-to-tail cyclic waiting resource relationship is formed between several execution flows

avoid deadlock

  • Four necessary conditions to break deadlock
  • The order of locking is the same
  • Avoid scenarios where locks are not released
  • One-time allocation of resources

4. Thread synchronization

4.1 Synchronization concepts and race conditions

Synchronization:  Under the premise of ensuring data security, allow threads to access critical resources in a specific order, thereby effectively avoiding starvation problems. This is called synchronization
race conditions:  program exceptions caused by timing problems, we call them race condition

  • Simple locking will have certain problems. If a thread has a higher priority or is more competitive, it can apply for a lock every time, but it does nothing after applying for the lock. Then this thread will always be locked. When applying for locks and releasing locks, this may cause other threads to not compete for locks for a long time, causing starvation problems
  • There is nothing wrong with simple locking. It can ensure that only one thread enters the critical section at the same time, but it does not allow each thread to use this critical resource efficiently.
  • Now add a rule, when a thread releases the lock, the thread cannot apply for the lock again immediately, and the thread must be queued at the end of the resource waiting queue for the lock
  • After adding this rule, the next thread to acquire the locked resource must be the thread at the head of the resource waiting queue. If there are ten threads, these ten threads can be allowed to access critical resources in a certain order

For example, now there are two threads accessing a critical resource, one thread writes data to the critical resource, and the other thread reads data from the critical resource. However, the thread responsible for data writing is very competitive. The thread can compete for the lock every time, so the thread has been performing the write operation at this time until the critical resource is full, and the thread has been in progress since then. Apply for locks and release locks. The thread responsible for data reading is too weak to apply for a lock every time, so it cannot read data. This problem can be solved well after the introduction of synchronization

4.2 Condition variables

4.2.1 Concept

A condition variable is a mechanism for synchronization using global variables shared between threads. A condition variable is a data description used to describe whether a certain resource is ready.

Condition variables mainly include two actions:

  • A thread is suspended waiting for the condition of a condition variable to be true
  • Another thread makes the condition true and wakes up the waiting thread

Condition variables usually need to be used with mutexes

4.2.2 Interface

Initialize condition variable

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

parameter:

  • cond: the address of the condition variable that needs to be initialized
  • attr: Initialize the attribute of the condition variable, generally set to NULL

Return value: return 0 if the condition variable initialization is successful, and return an error code if it fails

The method of initializing conditions using the pthread_cond_init() function is called dynamic allocation, and can also be initialized using static allocation, that is, the following method:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

destroy condition variable

int pthread_cond_destroy(pthread_cond_t *cond);

Parameter cond: the address of the condition variable that needs to be destroyed

Return value: 0 is returned if the condition variable is destroyed successfully, and an error code is returned if it fails

Note: Condition variables initialized with PTHREAD_COND_INITIALIZER do not need to be destroyed

Wait for a condition variable to be satisfied

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

parameter:

  • cond: the address of the condition variable that needs to wait
  • mutex: the mutex corresponding to the critical section where the current thread is located

Return value: the function call returns 0 successfully, and returns an error code if it fails

wake up wait

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
  • The pthread_cond_signal() function is used to wake up the condition variable to wait for the first thread in the queue
  • The pthread_cond_broadcast() function is used to wake up all threads in the condition variable waiting queue

Parameter cond: wake up the thread waiting under the cond condition variable

Return value: the function call returns 0 successfully, and returns an error code if it fails

4.2.3 Why pthread_cond_wait needs a mutex

  • Conditional waiting is a means of synchronization between threads. If there is only one thread, and its condition is not satisfied, it will not be satisfied if it continues to wait, so there must be a thread to change the shared variable through some operations, so that the original unsatisfied condition becomes satisfied, and the notification waits on the condition variable the rout
  • The condition will not become satisfied for no reason, and it will inevitably involve changes in shared data, so it must be protected with a mutex. Without a mutex, shared data cannot be safely obtained and modified.
  • When the pthread_cond_wait function is called, the corresponding mutex is passed in. When the thread needs to wait under the condition variable because some conditions are not met, the mutex will be automatically released. Let other threads also get the lock and enter the waiting queue of the condition variable, so that the same thread will not seize the lock multiple times
  • When the thread is woken up, the thread will then execute the code in the critical section, and will automatically obtain the corresponding mutex

wrong design

After entering the critical section and locking, if the condition is not met, unlock it first, and then wait under the condition variable

//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
	pthread_mutex_unlock(&mutex);
	//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
	pthread_cond_wait(&cond, &mutex);
	pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

Not feasible. After unlocking and before calling the pthread_cond_wait() function, if other threads obtain the mutex at this time and find that the condition is satisfied at this time, so they send a signal, then the pthread_cond_wait function will miss this signal at this time (the lock has been released and cannot be obtained again) to the lock), which may eventually cause the thread to never be woken up. The thread calling pthread_cond_wait() must hold the lock

4.2.4 Specifications for use

Code to wait on a condition variable

pthread_mutex_lock(&mutex);
while (条件为假)
	pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);

Code to wake up waiting thread

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

Guess you like

Origin blog.csdn.net/GG_Bruse/article/details/129019815
Recommended