1

Preface

This article is writing OS kernel from scratch-lock and multi-thread synchronization . That article discusses the basic principles of lock and several different types of locks and their application scenarios, but they are all just principles. It is sexual, and actually prefers the use of lock in the user state; and the lock in the kernel, especially spinlock , and the characteristics of the lock on single-core/multi-core CPUs, have not been expanded in detail. This article will start from the source and re-discuss the implementation and use of various locks under the kernel.

The root of race

Lock is used to solve the data race, so we must first ask ourselves a question, what is the root of the data race?

A common answer is: if multiple threads run on multiple CPUs at the same time, or switch and run concurrently on the same CPU, if the same data is accessed/modified, it will cause a race.

This answer may be correct in the user state; but for the kernel, it is not complete, and even in a sense, it is a little bit upside-down.

Here we put aside the multi-core CPU, first discuss the single-core situation, which is also our scroll project used. Let us correct several deficiencies and cognitive biases in the above answer.

Code interleaving (interleave)

For the kernel, in addition to the switch between threads, there is also a very important role is interrupt (note the terminology used here, interrupt refers to an external interrupt, that is, a hard interrupt), it can also interrupt a code being executed, This may lead to a data race.

Therefore, strictly speaking, the essential cause of race is the interweaving ( interleave ) of the code execution flow. In the kernel, it may be caused by threads switching, or it may be caused by interrupt. For interrupt, you must realize the following points:

  • It can happen at any time, uncontrolled and unpredictable;
  • The interrupt processing process, that is, the interrupt context ( interrupt context ), does not belong to any thread, but an independent execution flow. It just temporarily "interrupts" the current execution flow; since it does not belong to any thread, it It cannot be scheduler , which means that it cannot be yield , sleep yieldlock or blocking locks cannot be used in interrupt;

preempt and interrupt

In the user space, we usually think that the main cause of the data race is the switch of threads, which may cause concurrent access/modification of the same data. However, in the kernel, this view actually needs to be re-examined. Although threads will switch to each other in the kernel state, we need to realize that the root of this switching behavior actually comes from the kernel itself: it is the kernel's scheduler that controls the switching scheduling of threads, so here is a bit of inverted causality. . Let's think about it: If the kernel suspends the threads switch scheduling, will the data race problem caused by the threads switch no longer exist?

In terms of technical terms, the switching execution between threads is called preempting ( preempt ), which means that while threads-A is running, the kernel replaces it from the CPU and replaces it with thread-B for execution. Note that the switch here may be thread-A voluntarily giving up the CPU (for example, waiting for a resource while sleeping), or it may be forced by the kernel (for example, the time slice is exhausted). Therefore, we will use preempt to refer to the threads switch.

Here to distinguish preempt and interrupt , although they will suspend the current execution flow and switch to another, but there is an essential difference between them:

  • preempt is thread-based, and interrupt is an independent entity. Although it will interrupt the thread, it does not have any relationship with this thread;
  • preempt behavior of 06115ecac9c69a is synchronous and completely controlled by the kernel logic; interrupt is asynchronous and unpredictable;

For the Linux kernel, the most important time points for the preempt decision are:

  • When interrupt processing;
  • syscall returning from 06115ecac9c6ea;
  • When the code actively calls the scheduler, such as thread yield, sleep, exit, etc.;

Corresponding to scroll project, the most common switching point is in the timer interrupt . Here we will check whether the currently running thread time slice has been used up. If it is used up, call the scheduler to trigger the threads switch (ie preempt ) . So we usually say that the multi-threaded rotation switch is driven by timer interrupt. This statement is not wrong, but it is not strict. A more common approach is to move the call to the scheduler out of the timer interrupt handler, and postpone it to the general exit process of interrupt. For the timer interrupt, the final effect is actually no essential difference; of course, one change is that all The interrupt will call the scheduler when it exits, not just the timer.

Let's look at the modified timer interrupt processing function. It no longer calls the scheduler, but only increases the running time ticks of the thread currently running (before interrupt). If the upper limit of the time slice is reached, then set the need_reschedule = true this thread and mark This thread needs to be switched:

static void timer_callback(isr_params_t regs) {
  // ...
  tcb_t* crt_thread = get_crt_thread();
  crt_thread->ticks++;
  if (crt_thread->ticks >= crt_thread->time_silice) {
    crt_thread->need_reschedule = true;
  }
}

When interrupt exits scheduler will be called:

[EXTERN schedule]

interrupt_exit:
  ; call scheduler
  call schedule
  
  ;...

In Schedule function checks in need_reschedule , if true, then the handover occurs ( preempt ).

Turn off preempt

Just because preempt is completely controlled by the kernel itself, when we need to eliminate the data race problem caused by preempt, we can solve it from the root cause, that is, prohibit preempt: Since threads are not switched, how can data race come?

Of course, there is a price to turn off preempt. It will allow the current thread to monopolize the current CPU, which will damage the fairness of threads scheduling, so the time to turn off preempt should be as short as possible.

Close interrupt

Turning off preempt can only solve the race problem caused by threads switching, but if we need to protect a data accessed by both thread and interrupt, then just turning off preempt is not enough. For example, thread-A is running, although preempt is disabled, it is impossible for other threads to interfere, but interrupt may interrupt thread-A at any time. If thread-A holds a lock at this time, it is also required during interrupt processing. Get this lock, then interrupt will be stuck, which is absolutely not allowed, because interrupt can not sleep or yield, but at this time thread-A is interrupted and cannot continue to run and release the lock, so deadlock . The above discussion is still limited to what happens on one CPU.

All in all, once the interrupt is processed, it must be processed (and as fast as possible). When you need to acquire a lock in an interrupt, you must ensure:

  • The lock cannot be held by other people on the CPU;
  • If the lock is held on another CPU, then the interrupt can only wait in place by spin and cannot be deprived of the CPU, that is to say, yield, sleep, etc. are forbidden; you cannot postpone the interrupt processing;

However, interrupt is unpredictable, so in order to meet the first point of the above requirements, we can only do the opposite: make sure that after the lock is held, no interrupt can be inserted horizontally. In other words, if the lock may be used by interrupt, interrupt needs to be turned off when locking, so that the problems caused by interrupt can be completely eliminated.

The most typical application scenario is the driver of various input devices, such as the keyboard. Every time a new key is input, the keyboard interrupt will be triggered. Then the interrupt processing function will read the input from the hardware port and place it in the kernel. A buffer; and the user thread may be waiting to read this buffer; this is a typical producer-consumer model, this buffer must be protected by lock, it is to prevent the concurrency of the buffer between multiple user threads The race problem should also prevent the race problem between the thread and keyboard interrupt processing functions. This lock needs to be closed.

Turning off interrupt also has a price, it will reduce the system's response speed to interrupt, which is intolerable for systems with high real-time requirements, such as control systems, human-computer interaction systems, etc. Therefore, the time to close interrupt also needs to be controlled very short.

At the same time, after the interrupt is turned off, most of the trigger points of preempt are actually turned off. Because there is no interrupt, there will be no scheduler call points when the interrupt exits. But this does not mean that it is completely safe, because preempt may still happen, such as thread actively calling scheduler (this situation should be rare, I am not sure, please correct me). But anyway, the safest way is to turn off interrupt and preempt at the same time, which guarantees the complete monopoly of the CPU ( mutual exclusive ) in the current code execution flow, and completely avoids the data race.

Look at spinlock again

Let's re-examine the spinlock in the kernel. It is completely different from the spinlock in the user state and is far from simple as it seems. We now start to consider whether there is interrupt, and the different situations on single-core/multi-core CPUs.

No interrupt

First consider the case of no interrupt: here means that the lock will not be used by the interrupt handler, that is, there is only competition between normal threads. From now on we need to discuss single/multiple CPUs separately.

If it is a CPU (note that "a CPU" here can refer to a single-core processor or a certain CPU on a multi-core processor, and two competing threads are running on this core, it is actually It is equivalent to a single-core processor). In the previous discussion, we mentioned that pure spin waiting on a CPU is meaningless, because the thread holding the lock cannot be run and release the lock, so the current thread Spin idling during its operating cycle is a waste of time. So the first thing spinlock needs to do is turn off preempt, so that other threads cannot spin idling on this CPU.

You might say that this is a spinlock, it is completely let a thread occupy the CPU. Of course, this is true, but this is only for one CPU, and you will find that for a single CPU, this is actually the most reasonable approach.

If you further consider the case of multiple CPUs, the semantics of spin is actually meaningful. Threads on multiple CPUs may compete for this lock at the same time, so just turning off preempt on this CPU is not enough. Other CPUs may still join the competition, so a real atomic competition mechanism is also needed here, which is previous article CAS atomic operation:

void spinlock_lock(spinlock_t *splock) {
  // Disable preempt on local cpu.
  disable_preempt();

  // CAS competition for multi-processor.
  #ifndef SINGLE_PROCESSOR
  while (atomic_exchange(&splock->hold , LOCKED_YES) != LOCKED_NO) {}
  #endif
}

Let me emphasize that the above lock cannot be used by interrupt. Our discussion has not taken interrupt into consideration for the time being, and only solves the problem of competition between threads.

In addition article , spinlock is usually used to protect the smaller critical section , which can prevent spin idling from wasting too much CPU time on multi-core CPUs; for single-core cases, the small critical section guarantees the current The thread holding the lock should not occupy the CPU (that is, turn off preempt) for too long, which will affect the fairness of scheduling.

You may ask disable_preempt() is implemented. Following the approach of Linux, define an integer preempt_count in the thread structure, and add 1 to it every time disable_preempt() called, and subtract 1 to enable_preempt You can preempt only when preempt_count is 0, otherwise the scheduler thinks that the current preempt is closed on the CPU where the thread is located:

struct task_struct {
  // ...
  uint32 preempt_count;
};

void disable_preempt() {
  get_crt_thread()->preempt_count += 1;
}
void enable_preempt() {
  get_crt_thread()->preempt_count -= 1;
}

Therefore, it can be found that the so-called closing preempt can only close the preempt on the CPU where the current thread is located, so that the current thread monopolizes the CPU; but you cannot affect other CPUs on the multi-core processor, which is why in the case of multi-core, The above CAS competition mechanism is required to ensure safety.

Introduce interrupt

Next consider the competition between interrupt and thread. As previously analyzed, interrupt does not rely on any thread, so it cannot sleep or yield, so it can only spin and wait in place. If a lock may be used by interrupt, then other users must turn off the interrupt first when acquiring the lock, so that the interference of interrupt can be completely eliminated.

Therefore, in the implementation of the spinlock of the kernel, in addition to the normal lock interface, it also needs to provide an interface that closes the interrupt, such as spinlock_lock_irqsave :

void spinlock_lock_irqsave(spinlock_t *splock) {
  // First disable preempt.
  disable_preempt();

  // Now disable local interrupt and save interrupt flag bit.
  uint32 eflags = get_eflags();
  disable_interrupt();
  splock->interrupt_mask = (eflags & (1 << 9));

  // For multi-processor, competing for CAS is still needed.
  #ifndef SINGLE_PROCESSOR
  while (atomic_exchange(&splock->hold , LOCKED_YES) != LOCKED_NO) {}
  #endif
}

This interface not only closes preempt, but also closes interrupt to ensure that the lock can be used by interrupt; there are similar API usages in the Linux kernel.

It’s called spinlock_lock_irqsave because it saves the previous interrupt state and restores it when unlocked, instead of simply turning on interrupt, because it is possible that the interrupt was originally closed before the lock, so you can’t arbitrarily unlock it. Open it, you can only restore it to the original state.

Similarly, even if preempt and interrupt are turned off at the same time, it can only guarantee the safety of the CPU; and for multi-core situations, it still needs to continue to compete with other CPUs for CAS lock to ensure the safety between multiple cores.

spin or yield

In the scroll project, we have also implemented a yieldlock , that is, actively give up the CPU when the lock is not obtained, but do not sleep, just yield, and try again later.

Obviously yieldlock cannot be used by interrupt. The reason is the same as mentioned before. Interrupt does not belong to any thread, so it cannot yield, otherwise it may lead to deadlock. The example is the same as above. A thread-A holds a lock. If it is interrupted by interrupt, interrupt also wants to acquire the lock, but it is already held by thread-A. There is no way to interrupt at this time. It cannot yield. Because it is not a thread. Just imagine, even if you really want to yield at this time, who does yield, thread-A? But what we want at the moment is thread-A to continue running and release the lock, which is obviously contradictory.

For scenarios where there is no interrupt intervention (that is, there is only competition between threads), spinlock (here refers to the non-interrupt version of the interface function) and yieldlock . Here is a comparison of their principles:

  • spinlock will directly close preempt ensure the exclusive use of this CPU; if it is a multi-core CPU, CAS competition is also required, and spin waiting in place when it fails;
  • yieldlock directly adopts the CAS competition method, whether it is the CPU or the multi-core situation; if the competition fails, the CPU will be temporarily surrendered, and the next time there is a chance to try again;

As mentioned earlier, in the case of a single core, spinlock is a little bit of a misnomer. It does not even need CAS competition or spin waiting. Instead, it directly turns off preempt to eliminate threads competition. But it does not matter, as long as the purpose of lock is achieved, and this may also be the most reasonable approach.

Let's take a look at their respective shortcomings:

  • spinlock temporarily occupy the CPU because preempt is turned off, which may slightly affect fairness;
  • yieldlock is essentially a delayed retry. It may fail after several retry attempts, which will waste some CPU time;

But it must be emphasized that these two locks are used to protect very small critical sections, so the problems mentioned above will be controlled within the smallest possible range to reduce the loss caused by competition.

Summarize

This article reorganizes and discusses the lock problem in the kernel, especially spinlock, as a very commonly used lock in the kernel, it fully reflects the complexity and diversity of the kernel lock. The most important concept here is preempt and interrupt , and the difference between them. The principles and usage scenarios of locks in the kernel are almost directly related to these two concepts. This is also the difference between locks in kernel and user states.

Reference

  1. The relationship between Linux interruption, preemption, and lock
  2. Linux kernel synchronization mechanism
  3. Why linux disables kernel preemption after the kernel code holds a spinlock?
  4. Why disabling interrupts disables kernel preemption and how spin lock disables preemption
  5. Why are spin locks good choices in Linux Kernel Design instead of something more common in userland code, such as semaphore or mutex?
  6. Understanding link between CONFIG_SMP, Spinlocks and CONFIG_PREEMPT in Linux kernel
  7. Linux Kernel: Spinlock SMP: Why there is a preempt_disable() in spin_lock_irq SMP version?

navi
612 声望191 粉丝

naive