3

Series catalog

Prepare

last article , we started the first thread , which seems to be just letting an ordinary function run on a brand new stack. As a real operating system, it needs to be able to run and schedule multiple threads. The process of creating multiple threads is very simple, the question is how to make them switch to run alternately. Back to the two core components of thread mentioned in the previous article:

  • Code
  • stack

This article will enter the world of multithreading. Here will show how the two threads complete stack , and while the stack is converted, the execution flow of the code is automatically switched. And based on the multi-threads switching capability, we will initially construct a scheduler scheduler .

Thread switching

First review the last article, the stack status after thread is started, the program runs in the stack area in the light blue part of the figure:

If there is no external trigger, the program will always run here, and thread switching is impossible, even if there is another thread elsewhere, which has its own independent stack; therefore, a trigger is needed here, which is the clock interrupt, at At the end of the interrupt handling article, we tried to turn on the clock interrupt, where the handler is just a simple printing function. Now we need to implement threads switch inside.

Imagine that the program is running on the stack in the above figure. At this time, a clock interruption occurs, and the CPU will automatically enter the interrupt processing. Review interrupt processing . At this time, the stack will become like this:

After a series of stack push operations by the CPU and the kernel, the program execution flow is roughly as follows:

isr32 (32 是时钟 interrupt 中断号)
  --> isr_common_stub
    --> isr_handler
      --> timer_callback

Finally came to the function timer_callback

struct task_struct* crt_thread;
struct task_struct* next_thread;

static void timer_callback(isr_params_t regs) {
  // For only 2 threads switch.
  struct task_struct* old_thread = crt_thread;
  struct task_struct* new_thread = next_thread;
  
  // swap, for next time switch.
  crt_thread = new_thread;
  next_thread = old_thread;

  context_switch(old_thread, new_thread);
}

If you don't remember the task_struct , it is the description structure of thread. Here is another post:

struct task_struct {
  uint32 kernel_esp;
  uint32 kernel_stack;
  
  uint32 id;
  // ...
};

The above sample code should not be difficult to understand. Assuming we only have 2 threads, then in timer_callback , we can switch between them.

The key part is the last line context_switch function, which completes the switch of two threads, it is actually divided into upper and lower halves:

The first half is to save the context of the current thread:

context_switch:
  push eax
  push ecx
  push edx
  push ebx
  push ebp
  push esi
  push edi

  mov eax, [esp + 32]
  mov [eax], esp

In the second half, complete the recovery of the next thread to be run:

  ; jump to next thread's kernel stack
  ; and recover its execution
  mov eax, [esp + 36]
  mov esp, [eax]

resume_thread:
  pop edi
  pop esi
  pop ebp
  pop ebx
  pop edx
  pop ecx
  pop eax

  sti
  ret

Note that esp + 32 and esp + 36 are context_switch function, that is, the new and old threads to be switched:

void context_switch(old_thread, new_thread);

Parameter is a pointer task_struct pointer, and task_struct first field is kernel_esp , that is where the threads esp when switching occurs, is stored in the kernel_esp this field; To be the next thread to resume operation, read out kernel_esp found The esp at the last moment before it was cut away, and it continues to run.

And after stack conversion, the execution flow of the code is automatically restored to the original execution flow of the next thread. In fact, they are originally the same flow. Because the thread to be switched was cut away before, it also went through the context_switch function, pushed all general registers, saved the esp, and then was cut away. Now it is restored in place at the same instruction location and continues to execute.

So the most critical point to understand here is that the context_switch of the upper and lower halves of the 061cec40212bb2 function belong to the new and old threads. The old thread is cut off and suspended after the first half of execution is completed; it will resume in place when it is scheduled next time, and continue to run the second half to form a complete context_switch ; then return to the upper layer, and finally Go back to the place where the clock interrupted before it was interrupted and continue to run.

Switch to the newly created thread

In the situation discussed above, the next thread to be run is a thread that has been run before and has been cut and suspended, so its stack layout is the same as the currently running thread, and both come to the run stack of the context_switch But what if the thread to be run is a newly created thread? If you look back at the content of the previous chapter, you will find that the design here is seamless.

The above figure is the stack of the newly created thread. It just initializes a switch stack and kernel_esp field to the stack. Therefore, starting from context_switch function, it can immediately initialize esp correctly and pop all common Register, which is consistent with the way the first thread is started.

In other words, the first thread is resume_thread ; and all subsequent threads are context_swith in the same way through 061cec40212c7a.

Thread scheduler

With the ability to switch between 2 threads, we can actually create more threads and start building a scheduler scheduler . The simplest scheduling mode is that all threads run in sequence, cyclically, so this first needs a linked list to save all threads.

I src/utils/linked_list.c . It is a general linked list. Each node holds a pointer to the actual object. In our case, it is task_struct* . The specific implementation can refer to the code, which is relatively simple .

struct linked_list_node {
  type_t ptr;
  struct linked_list_node* prev;
  struct linked_list_node* next;
};
typedef struct linked_list_node linked_list_node_t;

struct linked_list {
  linked_list_node_t* head;
  linked_list_node_t* tail;
  uint32 size;
};
typedef struct linked_list linked_list_t;

Therefore, we can define a linked list of all threads to be run, that is, ready_tasks . All the thread states are TASK_READY , which is the ready state. They are tasks that can be scheduled to run next time:

static linked_list_t ready_tasks;

Therefore scheduler is very simple, here is shown in pseudo code:

next_thread = Fetch head from ready_tasks queue;
set next_thread.status = TASK_RUNNING;
set crt_thread.status = TASK_READY;
Enqueue crt_thread to tail of ready_tasks queue;

context_switch(crt_thread, next_thread);

task time slice

There is a detail not mentioned above, that is, threads switching does not happen in every clock interrupt, but is cut away when the CPU time slice of this task is exhausted, so the current structure of each task will be recorded Elapsed time slice:

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

We organize the schedule logic into a separate function maybe_context_switch . Each time the clock is interrupted, the field ticks + 1 will be set. When the number of ticks reaches the set upper limit, that is, the thread time slice is exhausted, and do_context_switch triggered, where the thread switching operation will be executed. .

static void timer_callback(isr_params_t regs) {
  maybe_context_switch();
}
void maybe_context_switch() {
  disable_interrupt();
  merge_ready_tasks();

  bool need_context_switch = false;
  if (ready_tasks.size > 0) {
    tcb_t* crt_thread = get_crt_thread();
    crt_thread->ticks++;
    // Current thread has run out of time slice.
    if (crt_thread->ticks >= crt_thread->priority) {
      crt_thread->ticks = 0;
      need_context_switch = true;
    }
  }

  if (need_context_switch) {
    do_context_switch();
  }
}

thread yield

In some cases, thread will also actively give up the CPU, end its run ahead of time, and hand over the CPU to the next thread. This is called yield . For example, when the lock competition fails, the thread ends, and so on. Note that yield here is not block . The current thread does not block sleep, but gives up the CPU and put itself back into the ready_tasks queue. After a while, it will be rescheduled to run.

So yield is very simple, just call do_context_switch directly:

void schedule_thread_yield() {
  disable_interrupt();
  merge_ready_tasks();
  // ...
  if (ready_tasks.size > 0) {
    do_context_switch();
  }
}

Summarize

This article implements the basic framework of multi-threaded operation and scheduler. In fact, so far, this kernel can be called a real OS, and it already has the basic skeleton of the simplest operating system:

  • Memory management
  • Interrupt handling
  • Task management

In the future work, we will continue to enrich this kernel to make it more functions and better user interaction capabilities.


navi
612 声望191 粉丝

naive