(Study Notes - Process Management) How to resolve multi-thread conflicts

For shared resources, if there is no lock, in a multi-threaded environment, it is very likely to overturn.


competition and cooperation

In a single-core CPU system, in order to realize the illusion of multiple programs running at the same time, the operating system usually uses time slice scheduling to allow each process to execute a time slice at a time. When the time slice is used up, the next process is switched to run. Since the time of this time slice is very short, a concurrency phenomenon occurs

 In addition, the operating system also creates the illusion of a huge, private virtual memory for each process. This abstraction of the address space makes each program seem to have its own memory, but in fact the operating system secretly makes multiple address spaces Multiplexing physical memory or disk.

If a program has only one execution flow, it also means it is single-threaded. Of course, a program can have multiple execution processes, which is the so-called multi-threaded program. Thread is the basic unit of scheduling, and process is the basic unit of resource allocation.

Therefore, threads can share process resources, such as code segments, heap space, data segments, open files and other resources, but each thread has its own independent stack space.

 Then the problem comes, if multiple threads compete for shared resources, if effective measures are not taken, it will cause confusion of shared data.

Experiment: Create two threads, and they respectively execute 10,000 times of incrementing the shared variable i by 1 , as shown in the following code:

 Logically speaking, the i variable should be 20000 in the end , however, the actual result is as follows:

 After running it twice, it is found that the value of i may be 20000 or other results.

Each run not only produces errors, but also gives different results. It cannot be tolerated in the computer. Although it is an error with a small probability, it will definitely happen with a small probability.

Why does this happen?

In order to understand why this happens, it is necessary to understand the code sequence generated by the compiler to update the counter i variable, that is, the order in which the assembly instructions are executed.

In this example, we just want to add the number 1 to i, so the execution process of its corresponding assembly instruction is as follows:

 It can be found that simply adding the number 1 will actually execute an instruction when the CPU is running . i  3 

Suppose thread 1 enters this code area, it loads the value of i (assuming it is 50 at this time) from memory into its register, and then it adds 1 to the register, and the value of i in the register is 51 at this time.

Now, something unfortunate happens: a clock interrupt occurs . Therefore, the operating system saves the state of the currently running thread to the thread's thread control block TCB.

Now something even worse happens: thread 2 is scheduled to run, and enters the same code. It also executes the first instruction, fetching the value of i from memory and putting it into a register. At this time, the value of i in memory is still 50, so the value of i in thread 2's register is also 50. Assuming that thread 2 executes the next two instructions, the value of i in the register is +1, and then the value of i in the register is saved to the memory, so the value of the global variable i is 51 at this time.

Finally, another context switch occurs and Thread 1 resumes execution. Remember that it has executed two assembly instructions and is now ready to execute the next one. Earlier, the value of i in the thread 1 register was 51, so after executing the last instruction, saving the value to memory, the value of the global variable i is set to 51 again.

In simple terms, the code that increases i (value 50) is run twice, logically, the final i value should be 52, but due to uncontrollable scheduling , the final i value is 51.

For the execution process of thread 1 and thread 2 above 2, it can be expressed as:

 mutually exclusive

The situation shown above is called a race condition , when multiple threads compete with each other to manipulate a shared variable, due to bad luck, i.e. a context switch occurs during execution, we get wrong results, in fact, every run may get Different results, so there is uncertainty in the output results .

Because multi-threaded execution of this code that operates shared variables may lead to a race condition, we call this code a critical section, which is a code fragment that accesses shared resources and must not be executed by multiple threads at the same time .

We hope that this code is mutually exclusive, that is to say, when a thread is executed in the critical section, other threads should be prevented from entering the critical section , that is, only one thread can appear at most during the execution of this code.                                                                 

 In addition, mutual exclusion is not only for multithreading. When multiple threads compete for shared resources, mutual exclusion can also be used to avoid resource confusion caused by resource competition.

Synchronize

Mutual exclusion solves the problem of using critical sections by concurrent processes/threads. This interaction based on critical section control is relatively simple. As long as a process/thread joins the critical section, other processes/threads trying to enter the critical section will be blocked until the first process/thread leaves the critical section.

In multi-threading, each thread is not necessarily executed sequentially. They basically move forward at an independent and unpredictable speed, but sometimes we hope that multiple threads can cooperate closely to achieve a common goal. Task

For example, thread 1 is responsible for reading data, while thread 2 is responsible for processing data. These two threads cooperate and depend on each other. When thread 2 does not receive the wake-up notification from thread 1, it will always block and wait. When thread 1 finishes reading the data and needs to pass the data to thread 2, thread 1 will wake up thread 2 and hand over the data to thread 2 for processing.

The so-called synchronization means that concurrent processes/threads may need to wait for each other and communicate with each other at some key points. This kind of mutual restriction of waiting and communicating information is called process/thread synchronization .

PS: Synchronization and mutual exclusion are two different concepts:

  • Synchronization: [operation A should be performed before operation B], [operation C must be performed after both operation A and operation B are completed], etc.
  • Mutual exclusion: [operation A and operation B cannot be executed at the same time];

Implementation and use of mutual exclusion and synchronization

In the process of concurrent execution of processes/threads, there is a cooperative relationship between processes/threads, such as mutual exclusion and synchronization.

In order to achieve correct cooperation between processes/threads, the operating system must provide measures and methods to achieve process cooperation. There are two main methods:

  • Lock : lock and unlock operations
  • Semaphore : P, V operation

Both of these can easily realize process/thread mutual exclusion, and semaphores are more powerful than locks, and it can also easily realize process/thread synchronization.

Lock

Using lock operation and unlock operation can solve the mutual exclusion problem of concurrent threads/processes.

Any thread that wants to enter the critical section must first perform a locking operation. If the locking operation is successfully passed, the thread can enter the critical area; after completing the access to the critical resource, the unlocking operation is performed to release the critical resource.

 Depending on the implementation of the lock, it can be divided into [busy waiting lock] and [no busy waiting lock].

Implementation of busy-wait lock

Before explaining the implementation of the busy waiting lock, you need to understand the special atomic operation instruction provided by the modern CPU architecture --Test-and-Set instruction .

If the Test-and-Set instruction is expressed in C code, the form is as follows:

The test and set directive does the following:

  • Update old_ptr to the new value of new
  • returns the old value of old_ptr

The key is that these codes are executed atomically . Because you can test the old value and set the new value, we call this instruction [test and set].

Atomic operations: either all of them are executed, or none of them are executed, and there cannot be an intermediate state that is half executed .

We can use the Test-and-Set command to achieve [busy waiting lock], the code is as follows:

 Let's understand why this lock works:

  • Scenario 1: First assume that a thread is running, calling lock() , no other thread holds the lock, so flag is 0. When the TestAndSet(flag, 1) method is called and 0 is returned, the thread will jump out of the while loop and acquire the lock. At the same time, the flag will be atomically set to 1, indicating that the lock is already held. When the thread leaves the critical section, call unlock() to clear the flag to 0.
  • Scenario 2: When a thread already holds a lock (that is, flag is 1). This thread calls lock() , and then calls TestAndSet(flag, 1), which returns 1 this time . As long as another thread keeps holding the lock, TestAndSet() will return 1 repeatedly, and this thread will be busy waiting . When the flag is finally changed to 0, the thread will call TestAndSet() , return 0 and atomically set it to 1, thereby acquiring the lock and entering the critical section.

Obviously, when the lock cannot be obtained, the thread will always loop while doing nothing, so it is called [busy waiting lock], also known as spin lock .

This is the simplest kind of lock, spinning all the time, using CPU cycles until the lock is available. On a single processor, a preemptive scheduler is required (that is, one thread is constantly interrupted by the clock to run other threads.) Otherwise, spin locks cannot be used on a single CPU, because a spinning thread will never give up the CPU.

Implementation without waiting locks

No waiting lock: When the lock cannot be acquired, no spin is required. When the lock is not acquired, put the current thread into the lock waiting queue, then execute the scheduler, and let the CPU be executed by other threads

This time, only two implementations of simple locks are proposed. Of course, in a specific operating system, it will be more complicated, but it is also inseparable from the two basic elements in this example.

amount of signal

A semaphore is a method provided by the operating system to coordinate access to shared resources.

Usually the semaphore represents the number of resources , and the corresponding variable is an integer (sem) variable.

In addition, there are two atomic operation system call functions to control the signal , namely:

  • P operation: decrement sem by 1 , after the subtraction, if sem < 0 , the process/thread enters the blocking wait, otherwise continue, indicating that the P operation may be blocked;
  • V operation: add 1 to sem , after adding, if sem <= 0, wake up a waiting process/thread, indicating that the V operation will not block;

TIP Why is sem <= 0 in V operation

Example: If sem = 1, there are three threads that perform the P operation:

  • After the first thread P operates, sem = 0; the first thread continues to run
  • After the operation of the second thread P, sem = -1; sem < 0 The second thread blocks and waits
  • After the third thread P operates, sem = -2; sem < 0 The third thread blocks and waits

At this time, after the first thread executes the V operation, sem=-1; sem <=0, so it is necessary to wake up the second or third thread that is blocked and waiting.

The P operation is before entering the critical area, and the V operation is after leaving the critical area. These two operations must appear in pairs .

To give an analogy, the semaphore of two resources is equivalent to two train tracks, and the PV operation process is as follows:

 How does the operating system realize PV operation?

The algorithm of semaphore data structure and PV operation is described as follows:

The functions of PV operations are managed and implemented by the operating system, so the operating system has made the execution of PV functions atomic.

How is the PV operation used?

The semaphore can not only realize the mutual exclusion access control of the critical section, but also synchronize the events between threads.

Semaphore realizes mutual exclusion access of critical section

A semaphore s is set for each type of shared resource , and its initial value is 1 , indicating that the critical resource is not occupied.

As long as the operation entering the critical section is placed between P(s) and V(s) , the process/thread mutual exclusion can be realized:

 At this time, any thread that wants to enter the critical area must first perform the P operation on the mutex semaphore, and then perform the V operation after completing the access to the critical resource. Since the initial value of the mutex semaphore is 1, the value of s becomes 0 after the first thread executes the P operation, indicating that the critical resource is free and can be allocated to the thread to enter the critical section.

If a second thread wants to enter the critical section at this time, it should also execute the P operation first, and the result makes s become a negative value, which means that the critical resource has been occupied, so the second thread is blocked.

And, until the first thread executes the V operation, releases the critical resource and restores the value of s to 0, then wakes up the second thread and makes it enter the critical section. After it finishes accessing the critical resource, it executes the V operation again, so that s returns to the initial value 1.

For two concurrent threads, the value of the mutex semaphore only takes three values ​​of 1, 0 and -1, which respectively represent:

  • If the mutex semaphore is 1, it means that no thread enters the critical section
  • If the mutex semaphore is 0, it means that a thread has entered the critical section
  • If the mutex semaphore is -1, it means that one thread enters the critical section and another thread waits to enter

Through the mutual exclusion semaphore, it can be guaranteed that only one thread is executing in the critical section at any time, and the effect of mutual exclusion is achieved.

Semaphore implements event synchronization​​​​​​

The way to synchronize is to set a semaphore whose initial value is 0 .

 When the mother first asked her son if he wanted to cook, what he did was to ask his son if he needed to eat. Since the initial value was 0, it changed to -1 at this time, indicating that the son did not need to eat, so the mother thread entered the waiting state . P(s1)  s1  s1 

When the son is hungry, it is executed , so that the semaphore changes from -1 to 0, indicating that the son needs to eat at this time, so the blocked mother thread is awakened, and the mother thread starts cooking. V(s1) s1 

Then, the son thread is executed , which is equivalent to asking the mother if the meal is finished. Since   the initial value is 0,   it becomes -1 at this time, indicating that the mother has not finished cooking, and the son thread is waiting. P(s2)s2s2

Finally, the mother finally finished cooking, so she executed it , and the semaphore changed from -1 back to 0, so she woke up the waiting son thread. After waking up, the son thread can start eating V(s2)s2 

producer-consumer problem

 Producer-consumer problem description:

  • After the producer generates data, it is placed in a buffer
  • The consumer fetches data from the buffer for processing
  • At any time, only one producer or consumer can access the buffer

From the analysis of the problem, it can be concluded that:

  • Only one thread can operate the buffer at any time, indicating that the operation of the buffer is a critical code that requires mutual exclusion ;
  • When the buffer is empty, the consumer must wait for the producer to generate data; when the buffer is full, the producer must wait for the consumer to fetch data. Explain that producers and consumers need to be synchronized .

Then we need three semaphores, namely:

  • Mutual exclusion semaphore mutex : used for mutually exclusive access to the buffer, the initialization value is 1;
  • Resource semaphore fullBuffers : used for consumers to ask whether there is data in the buffer, and read the data if there is data, the initial value is 0 (indicating that the buffer is empty at the beginning);
  • Resource semaphore emptyBuffers : It is used by the producer to ask whether there is space in the buffer, and if there is space, the data will be generated, and the initialization value is n (buffer size);

The specific implementation code:

If the consumer thread executes P(fullBuffers)  at the beginning , since the initial value of the semaphore fullBuffers is 0, the value of fullBuffers changes from 0 to -1 at this time, indicating that there is no data in the buffer, and the consumer can only wait.

Then, it is the producer's turn to execute P(fullBuffers) , which means reducing one empty slot. If no other producer thread is currently executing code in the critical section, then the producer thread can put the data in the buffer. After putting it, execute V(fullBuffers) , the semaphore fullBuffers changes from -1 to 0, indicating that a consumer thread is blocking and waiting for data, so the blocked waiting consumer thread will be awakened.

After the consumer thread is woken up, if no other consumer thread is reading data at this time, it can directly enter the critical section and read data from the buffer. Finally, leave the critical section and add 1 to the number of empty slots.


classic synchronization problem

dining philosophers problem

 Philosopher Problem Statement:

  • 5 philosophers eating noodles around a round table
  • This table has only 5 forks, one fork is placed between every two philosophers
  • Philosophers are not thinking together, and they want to eat when they are hungry during the thinking
  • However, these philosophers need two forks to be willing to eat noodles, that is, they need to get the forks on the left and right sides to eat
  • Put the fork back in place after eating

Question: How to ensure that the philosophers' actions are carried out in an orderly manner, so that no one will never get the fork?

Option One

We use the semaphore method, that is, the PV operation to try to solve:

 The above procedure seems very natural: pick up the fork and use P to operate, which means to use it directly if there is a fork, and wait for other philosophers to put the fork back if there is no fork.

 However, there is an extreme problem with this solution: assuming that five philosophers pick up the forks on the left at the same time, there will be no forks on the table, so that no one can get the forks on their right, which means that every philosopher will The statement P(fork[(i+1)%N]) is blocked, and it is obvious that a deadlock has occurred

Option II

Since plan 1 will cause a deadlock caused by competing for the left fork at the same time, then we add a mutex semaphore before taking the fork. The code is as follows:

 The function of the mutual exclusion semaphore in the above program is that as long as a philosopher enters the critical area, that is, when he is about to take a fork, other philosophers cannot move. A philosopher dines .

Solution 2 allows philosophers to eat in order, but only one philosopher can eat at a time, and there are 5 forks on the table. It is reasonable that two philosophers can eat at the same time, so from the perspective of efficiency , which is not the best solution.

third solution

The problem with Solution 1 is that there will be a possibility that all philosophers can hold the left knife and fork at the same time, so we can prevent philosophers from taking the left knife and fork at the same time, adopt a branch structure, and take different actions according to the number of philosophers .

That is, let even-numbered philosophers [take the left fork first and then the right fork], and odd-numbered philosophers [take the right fork first and then the left fork] .

 In the above program, when operating P, the order of picking up the left and right forks is different according to the number of the philosopher. In addition, the V operation does not require branching, because the V operation does not block.

 Option three means that there will be no deadlock and two people can eat at the same time

Option four

Here is another feasible solution, using an array state to record the three states of each philosopher, which are eating state, thinking state, and hungry state (trying to take a fork) .

Then a philosopher can enter the eating state only if both neighbors are not eating .

The neighbors of the i-th philosopher are defined by the macros LEFT and RIGHT:

  • LEFT:(i+5-1) % 5
  • RIGHT:(i+1) % 5

For example, if i is 2, then LEFT is 1 and RIGHT is 3.

The specific implementation code is as follows:

 The above program uses an array of semaphores, one for each philosopher, so that philosophers who want to eat are blocked while the required fork is occupied.

Note that each process/thread  smart_person runs the function as the main code, while the others   and   are just normal functions, not separate processes/threads.take_forksput_forkstest

 reader-writer problem

The preceding Dining Philosophers Problem is useful for modeling processes such as competing problems with limited exclusive access, such as I/O devices.

In addition, there is a well-known "reader-writer" problem, which establishes a model for database access.

Readers can only read data and will not modify data, while writers can either read or modify data.

Reader-writer problem description:

  • "Read-read" allows: at the same time, multiple readers are allowed to read at the same time
  • "Read-write" mutual exclusion: readers can read when there is no writer, and writers can write when there are no readers
  • "Write-write" mutual exclusion: a writer can only write when there are no other writers

Option One

Use semaphores to solve the problem:

  • Semaphore wMutex: Mutual exclusion semaphore controlling write operation, initial value is 1;
  • Reader count rCount: the number of readers who are reading, initialized to 0;
  • Semaphore rCountMutex: Controls the mutually exclusive modification of the rCount reader counter point, the initial value is 1;

Implementation of the code:

 The implementation of the above is a reader-first strategy, because as long as there are readers who are reading, subsequent readers can directly enter. If readers continue to enter, the writer will be in a state of starvation.

Option II

Since there is a reader-first strategy, there is naturally a writer-first strategy:

  • As long as a writer is ready to write, the writer should perform the write operation as soon as possible, and subsequent readers must block
  • Readers are starving if there are writers who keep writing

Add the following variables on the basis of Scheme 1:

  • Semaphore rMutex : The mutual exclusion semaphore that controls the reader to enter, the initial value is 1;
  • Semaphore wDataMutex : Mutual exclusion semaphore that controls the writer's write operation, the initial value is 1;
  • Writer count wCount : record the number of writers, the initial value is 0;
  • Semaphore wCountMutex : Control wCount mutual exclusion modification, the initial value is 1;

The implementation code is as follows:

 Note that the role of rMutex here is that there are multiple readers reading data at the beginning, and they all enter the reader queue. At this time, a writer comes. After executing P(rMutex) , subsequent readers cannot enter because they are blocked on rMutex The reader queue, and when the writer arrives, they can all enter the writer queue, so the priority of the writer is guaranteed.

At the same time, after the first writer executes P(rMutex) , he cannot start writing immediately. He must wait until all readers entering the reader queue have finished reading operations, and wake up the writer's writing operation through V(wDataMutex) .

third solution

Since both the reader-first strategy and the writer-first strategy will cause starvation, let's implement a fair strategy.

Fair strategy:

  • same priority;
  • Writers and readers mutually exclusive access;
  • Only one writer can access the critical section;
  • Multiple readers can access critical resources at the same time;

Specific code implementation:

 Comparing the reader-first strategy of Scheme 1, it can be found that in the reader-first strategy, as long as subsequent readers arrive, readers can enter the reader queue, while writers must wait until no readers arrive.

If no reader arrives, the reader queue will be empty, that is, rCount = 0. At this time, the writer can enter the critical section to perform the write operation.

The role of the flag here is to prevent this special permission of the reader (as long as the reader arrives, he can enter the reader queue).

For example: some readers read data at the beginning, and they all enter the reader queue. At this time, a writer comes and performs an operation, so that subsequent readers are blocked  and cannot enter the reader queue, which will gradually make the reader queue empty. , which is   reduced to 0. P(falg) flag rCount

The writer cannot start writing immediately (because the reader queue is not empty at this time), and will be blocked on the semaphore. After all the readers in the reader queue have finished reading, the last reader process will execute , wake up the writer just now, and write Otherwise, the write operation continues. wDataMutex  V(wDataMutex)

Guess you like

Origin blog.csdn.net/qq_48626761/article/details/132210855