不落辰

知不可乎骤得,托遗响于悲风

0%

操作系统-xv6-thread

  • 线程调度流程 : user thread A -> kernel thread A -> scheduler thread -> kernel thread B -> user thread B

  • xv6和OS的线程调度策略 : pre-emptive scheduling(定时器中断) + voluntary scheduling(kernel thread主动swtch)

  • xv6的3个thread

    • user process的user thread (上下文在trapframe)
    • user process的kernel thread (上下文在trapframe和context)
    • 每个cpu核 的 scheduler thread (上下文在cpu.context)
  • thread切换核心 : swtch

    • 用于实现 kernel thread 和 scheduler thread的切换
    • 切换return addr , kernel stack pointer , callee saved regs

thread

待完成问题

  • 线程是什么?

    • 从几个角度来说吧应该。
    • 先列下来。
    • 从构成的角度来说
      • 。。。与progress对比一下。
      • thread是process中的一条指令流,使用process的地址空间及其他context。每个thread都应当有自己的stack。thread之间应当是共享process的地址空间的。
      • os应当支持一个用户进程的一个用户线程对应一个内核线程。xv6中内核线程和用户线程不是共享地址空间的。一个是user pagetable,一个是kernel pagetable。正常linux来说,也即在我们做完实验3page table之后的页表机制,user thread和kernel thread用的都是同一个pagetable。
      • 感觉说的有待完善。。。。
    • 从编程的角度来说
      • 是os提供的一种执行多任务的抽象机制。
      • 类似于event-driven progarmming中的reactor模型等。都是执行多个任务的逻辑流。
      • 在所有的支持多任务的方法中,线程技术并不是非常有效的方法,但是线程通常是最方便,对程序员最友好的,并且可以用来支持大量不同任务的方法。
      • 感觉可以再多说一点reactor模型相关的。。太累了。。改天复习reactor模型。
  • switch的ret什么时候执行?当再次切换回来的时候执行?

    • 不对,是顺序执行的。只不过由于$ra被改变,ret的时候会ret到另一处对swtch的调用处
  • switch ret之前cpu使用的就变成了另一个线程的栈?可是pc也没有改变呀?如何做到的?

    • pc不含有效信息,指示栈的就是$sp
  • xv6进程和线程的关系?

    • process由2个thread组成
      • user thread
      • kernel thread
    • 且存在限制,一个进程要么是其user thread 在运行,要么是在kernel thread运行(系统调用/响应中断),要么不运行。永远不会2个thread同时运行。
  • 所以说kernel的那个proc结构体 实际上是kenrel thread的结构体是吗?

    • 不只是。proc是整个process的结构体
    • 其中记录了
      • process本身的状态信息,如pid , state , parent process , name等
      • 和process中的user thread的所用的user pagetable , user的heap大小 , user的trapframe等
      • 以及process中的kernel thread 的context等。(用于kernel thread 在swtch保存前一刻kernel thread的状态)
    • 所以,通过mycpu(),我们可以获得当前正在运行的线程所属的process。不过从逻辑上来讲,我们也就不应当在scheduler thread中调用mycpu()。因为scheduler不属于任何process。是单独的一个线程。
  • 说说kernel thread到底是个啥

    • 感觉就是进入kernel的C code之后,调用kernel的C code,所形成的函数栈帧。
  • intena记录在acquire之前的中断状态

  • 一个进程让出之后,会不会又运行这个进程?

  • struct proc proc[NPROC = 64];

    • 至多有64个process,故至多有64个user thread 和64个kernel thread(64个kstack) + 每个cpu核上一个scheduler thread+带的kstack。
  • xv6里一个进程的用户线程和内核线程也不是共享地址空间的啊。页表都不一样。为啥教授还说是一个进程里的用户线程和内核线程?

    • 在xv6中,这两个线程比较特殊,确实不是共享地址空间。
    • 但是对于主流的linux系统,其pagetable的机制就和我们在page table实验所完成的一样。user thread 和 kernel thread使用的是同一个page table。
  • 切换地址空间了是不是就是代表一定切换进程了?那么从user到kernel是否应当叫做切换了进程?用户进程切换到内核进程。

    • 不应当。都是一个进程里的。原因见上。
  • linux进程的线程的pagetable是不是分开的?是么?不是吧?

    • 我觉得一个进程的多个线程共用同一个的pagetable。没验证。。。改天验证。。。太累了。。。仅仅是觉得。。。。

thread 概述

  • 线程的作用 / 为什么计算机需要多线程?

    • 首先,我们可能会要求计算机分时复用的执行任务,而不是在一段时间里只执行一个任务
    • 其次,多线程可以让程序的结构变得简单
    • 最后,使用多线程可以利用多核cpu以获得更快的速度。
      • 常见的做法是将程序进程拆分,分给多个线程运行,运行在不同的cpu核上。
    • 线程可以认为是一种在有多个任务时,用于简化编程的一种抽象机制
      • 感觉也是一种event-driven programming 编程模型。
      • event-driven programming或者state machine,这些是在一台计算机上不使用线程但又能运行多个任务的技术。
      • 在所有的支持多任务的方法中,线程技术并不是非常有效的方法,但是线程通常是最方便,对程序员最友好的,并且可以用来支持大量不同任务的方法。
        • 所以那些事务驱动模型,如单线程的reactor 实际上不也是个并发的程序?也是个执行多任务的编程方法。只不过不是os自己提供的抽象出来的线程,而是用户自己通过代码实现的。
  • 线程的状态

    • 程序计数器PC: 当前线程执行的指令位置
    • 保存变量的寄存器reg。
    • 程序栈stack: 每个线程都有自己的stack,stack记录了函数调用过程,并反应了当前线程的执行点。
    • 变量
    • ….
  • 如何管理多个线程运行?两种策略结合

      1. 利用多核cpu,每个cpu上运行一个thread
      1. 一个cpu在多个thread之间来回切换。
  • xv6的thread是否共享内存

    • 内核线程 kernel thread
      • 所有kernel thread共享内核内存。
      • 每一个用户进程都有个kernel thread执行来自用户线程的syscall
    • 用户线程 user thread
      • 用户线程之间没有共享内存。
      • 每个用户进程都有独立的地址空间,并且包含了一个user thread,user thread控制了user process code的执行。
  • Linux中

    • 实现了一个用户进程中包含多个线程(xv6没有实现,xv6中一个进程只有一个用户线程)
    • 一个进程的多个线程共享地址空间。

thread切换流程

  • 流程图镇楼
    • 当进入kernel的C code之后,运行的就是process 的 kernel thread了。

如何实现thread切换?:scheduler调度器
如何保存线程状态并恢复?:那些是需要保存的、保存在哪里
如何处理计算密集型线程?:对于长时间的计算任务,线程不会自愿的让出cpu给其他线程运行,所以需要一种机制,能夺走计算密集型线程对cpu的控制,之后再运行。

  • 下面是如何处理计算密集型任务

    定时器机制

  • 机制:利用定时器中断

    • 内核利用定时器中断将对cpu的控制从计算密集型线程的手中夺走
    • 每个cpu核上,都存在一个硬件设备,会定时产生中断。xv6与其他os一样,将这个中断传输到了kernel中。中断打断并处理如下


流程概述 pre-emptive / voluntary scheduling

  • pre-emptive scheduling 抢占式调度:

    • 用户代码本身不想让出cpu,但是timer interrupt会导致cpu的控制权被拿走,yield给scheduler thread,
      • 例如中断处理的流程。
    • 相反的是voluntary scheduling 非抢占式调度
  • 在xv6和其他os中,线程调度这样实现

    • pre-emptive 和 voluntary的结合
    • 定时器中断 会强制的将cpu控制权从user process(的user thread)传递到kernel (user process的kernel thread),之后kernel会代表user process 进行 voluntary scheduling。
      • pre-emptive:中断的强制,哪怕这些用户进程一直占用cpu不愿让开,通过中断,kernel也可以从user process夺得cpu控制权。
      • voluntary:kernel如何代表user process使用voluntary scheduling ?
        • kernel thread内核线程 的 timer interrupt handler 会将 cpu 让s(yield)给 scheduler thread调度器线程通过swtch
          • 这就是kernel 代表 user process 进行 voluntary的让出了cpu
          • 如何yield:保存当前cpu上正在运行的内核线程。,将scheduler thread的上下文替换到当前cpu上.
        • 然后scheduler thread 会选择一个runnable的kernel thread 并swtch到cpu上
          • 这也就是 kernel 代表 user process 进行 voluntary的 将 控制 传递给了 另一个 user process
  • 尽管我们这节课主要是基于定时器中断来讨论,但是实际上XV6切换线程的绝大部分场景都不是因为定时器中断,比如说一些系统调用在等待一些事件并决定让出CPU。exit系统调用会做各种操作然后调用yield函数来出让CPU,这里的出让并不依赖定时器中断

  • 相关的进程状态

    • RUNNING:线程正在当前cpu上运行
      • PC和reg在cpu中,正在被使用
    • RUNNABLE:线程还没有在某个cpu上运行,一旦有空闲的cpu就可以运行
      • PC和reg被保存在内存中某处。
    • SLEEPING:线程在等待一些IO事件,只会在IO事件发生了以后运行。
    • 线程调度就是将当前的running process 变成 runnable process

一些需要注意的东西

  • xv6中总共有3种thread

    • user process的user thread
      • 在不运行时,其线程状态会保存在trapframe中
      • 有自己的用户栈
    • user process的kernel thread
      • 在不运行时,其线程状态会保存在proc.context中
      • 有自己的内核栈
    • 每个cpu核 的 scheduler thread
      • 在不运行时,其线程状态会保存在cpu.context中
      • 每个scheduler thread都有自己独立的栈。scheduler thread的所有内容,都和user thread不一样。是在系统启动时设计好的。
  • 进程 process

    • process由2个thread组成
      • user thread
      • kernel thread
    • 且存在限制,一个进程要么是其user thread 在运行,要么是在kernel thread运行(系统调用/响应中断),要么不运行。永远不会2个thread同时运行。
  • xv6并没有实现用户线程直接让出cpu,内核会在如下场景下让出cpu

    • 定时器触发
      • 内核总是会让当前进程yield出cpu,因为我们需要交织执行所有可执行的线程。
    • 调用系统调用并等待I/O
      • 如进程调用syscall read进入kernel,read会等待磁盘IO,此时read syscall code->sleep()->sched()->swtch()
  • 所谓的context switching

    • 一个 thread 切换到 另一个 thread
    • 一个 user process 切换到 另一个 user process的完整过程。
    • 用户空间和内核空间的切换
    • 内核线程kernel thread和调度器线程scheduler thread的切换
  • 小结

    • 一个cpu核在同一时间只会做一件事情,也即只会运行一个线程,线程切换造成了多个线程同时运行在一个cpu核上的假象。
      • user process的user thread
      • user process的kernel thread
      • cpu核 的 scheduler thread
    • 一个thread不会同时运行在多个cpu核上
      • 要么运行在一个cpu核上
      • 要么状态被保存在context(kernel thread)/ trapframe(user thread)中,没有被运行

thread切换核心 : swtch

  • thread切换的核心

  • swtch

    • void swtch(struct context *old, struct context *new);
    • Save current registers in old. Load from new.
    • 将当前cpu上的reg保存在old中,并将new加载到cpu的reg上
    • 这也就是 实现了 所谓的 context switching的函数,用于在kernel thread 和 scheduler thread之间进行上下文切换。
    • 调用情况如下:
      • p->context 是 kernel thread的上下文。mycpu()->context 是该cpu核的scheduler thread 上下文。
      • sched中:swtch(&p->context, &mycpu()->context);
        • cpu reg ----store into---> kernel thread.context (mem)
        • scheduler context (mem) ----load into---> cpu reg
      • scheduler:swtch(&c->context, &p->context);
        • cpu reg ----store into---> scheduler context (mem)
        • kernel thread.context (mem) ----load into---> cpu reg
    • 所谓的保存到mem和加载到reg的context
      • $ra : ret的返回地址
      • $sp : thread对应的stack pointer
      • s[1,11] : 被调用者保存的寄存器
      • 并没有保存PC,因为PC并不是有效信息,随着code执行,PC会一直改变,没意义。我们关心的应当是swtch之后,thread应道跳到哪行code继续执行,以及其函数栈帧。那么,所要关注的寄存器就是ra和sp
  • code如下:kernel thread -> scheduler thread

    • kernel thread 由 usertrap -> yield -> sched -> swtch
    • 在swtch中 保存kernel thread context 并加载 scheduler thread context
    • 可以看到,在将scheduler的ra以及sp加载cpu reg之后,ret的返回地址以及函数调用栈帧bt都变成了scheduler thread的。
    • swtch —ret–> scheduler thread
    • 同理:scheduler thread -> kernel thread
  • 注意:scheduler thread 调用了swtch函数,但是我们从swtch函数返回时,实际上是返回到了对于switch的另一个调用处:kenrel thread 对swtch的调用处,而不是scheduler thread的调用。

    • 通过改变ra来达成此种效果,也就达成了切换线程的效果。
    • 也即,比如kernel thread a调用swtch,由于在swtch对ra以及其他context进行了替换,故这个swtch函数结束后,ret就跳转到了另一处代码。非本次swtch调用处,而是scheduler 对 swtch的调用处。
  • thread切换过程中,cpu上的reg是唯一不稳定的状态,需要保存并恢复,因为我们想在新的线程中也使用相同的一组寄存器;而所有其他在内存中的数据(如heap、stack)会保存在内存中不被改变,所以不用特意保存并恢复。

scheduler thread

  • 下面是scheduler函数
    • 选择一个runnable的进程,将其context替换进cpu,然后运行。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      // Per-CPU process scheduler.
      // Each CPU calls scheduler() after setting itself up.
      // Scheduler never returns. It loops, doing:
      // - choose a process to run.
      // - swtch to start running that process.
      // - eventually that process transfers control
      // via swtch back to the scheduler.
      void
      scheduler(void)
      {
      struct proc *p;
      struct cpu *c = mycpu();
      c->proc = 0;
      for(;;)
      {
      // Avoid deadlock by ensuring that devices can interrupt.
      intr_on();
      int nproc = 0;
      for(p = proc; p < &proc[NPROC]; p++)
      {
      acquire(&p->lock);
      if(p->state != UNUSED)
      {
      nproc++;
      }
      if(p->state == RUNNABLE)
      {
      // Switch to chosen process. It is the process's job
      // to release its lock and then reacquire it
      // before jumping back to us.

      // 令cpu运行kernel thread p
      // 即将运行p,故先设置状态为RUNNING
      p->state = RUNNING;
      // 即将运行p,故先设置cpu上正在运行的proc为p
      c->proc = p;
      // context switching , 切换到kernel thread p。之后cpu run kernel thread
      swtch(&c->context, &p->context);
      // after a while
      // 从swtch返回。
      // kernel thread p 切换回 scheduler thread
      // 此时cpu上没有任何正在运行的kernel thread 和 user thread。只有scheduler thread

      // Process is done running for now.
      // It should have changed its p->state before coming back.
      c->proc = 0;
      }
      release(&p->lock);
      }
      if(nproc <= 2)
      { // only init and sh exist
      intr_on();
      asm volatile("wfi");
      }
      }
      }

关于lock

  • 关于锁p->lock的使用

  • acquire(p->lock) release(p->lock)流程如下

    • 从kernel thread -> scheduler
      • kernel thread yield 上的锁 由 切换到 scheduler的swtch之后 释放
    • 从scheduler -> kernel thread
      • scheduler 上的锁 由 切换到 kernel thread的swtch之后 释放。
  • p->lock作用

    • 在以下两个场景中,都是为了线程切换的原子性
      • 防止在我们这个线程切换还没完成的时候,另一个cpu核上的scheduler thread看到并使用该线程。
    • 线程切换步骤概述:设置p->state,保存当前thread context,替换成要切换的thread的context
    • 1. kernel thread -> schedulers
        1. p->state = RUNNABLE;
        1. swtch(&p->context, &mycpu()->context);
        • 2.1 将当前kernel thread的context保存($ra,$sp,regs)
        • 2.2 cpu上的reg替换成scheduler thread的context。
    • 2. scheduler -> kernel thread
        1. p->state = RUNNING
        1. swtch(&c->context, &p->context);
        • 2.1 将当前scheduler thread的context保存($ra,$sp,regs)
        • 2.2 cpu上的reg替换成kernel thread的context。

对每个proc的第一次swtch

  • allocproc中为什么要设置context?为了scheduler对被分配的proc的第一次swtch。

    • 刚变成runnable的proc还没有被放在cpu上运行。其变成running需要由scheduler thread调度。scheduler通过swtch将runnable thread.context替换到cpu上。
    • 一般情况下,runnable thread.context 是该thread对应的proc的kernel thread在sched的时候,通过swtch将自身context保存起来,以待下次scheduler调度到该thread时替换进来。
    • 可是,对于该proc第一次由runnable变成running,被scheduler调度,其context则不是由上次swtch保存好的,而是由allocproc中,对$ra和$sp进行的初始化。ra初始化成forkret->usertrapret。$sp自然就是本kernel thread的ktsack。
  • allocproc 会被 启动时的userinit和系统调用fork调用

  • fork调用allocproc

  • allocproc

  • 一个process刚被fork出来的时候,一直处于内核态kernel thread ,且没有被运行。直到scheduler之后,才会swtch到该process。由于一开始刚fork出来时设置的ra为forkret,因此回swtch到process的forkret,然后usertrapret 经由trampoline到process的user thread,开始运行。

Linux 如何区分进程和线程

  • process 和 thread在 linux kernel中都是通过task_struct表述,那么linux kernel如何区分一个task_struct 代表的是process还是thread ?
    • 通过比较tgid和pid。相等,则为进程;不等,则为线程。
  • tgid : thread group ID
    • 任何一个process,如果只有一个main thread,那么,tgid = pid(即为process A.pid)。
    • 但是,如果一个process A创建了其他线程a1。那么thread a1有自己的pid,而thread a1.tgid = main thread.pid(即为process A.pid)。
      • 一个thread group中的所有thread使用和该thread group leader相同的PID,并被存放在tgid成员中。
      • 只有leader的pid设置为=tgid。
    • getpid()系统调用返回的是当前thread的tgid值而不是pid值。