MIT 6.828 操作系统课程系列6 Multithreading#

进程调度流程#

看手册第7章。经过之前的lab,其实已经了解了不少。

每个cpu都会跑最初的entry.S。
cpu0会执行userinit,分配proc,设置好第一个进程,状态设为RUNNABLE。见lab0。
然后每个cpu都会执行scheduler(),死循环不断取出能跑的进程,跑它的代码。
一个进程可以跑在任何cpu上。

一个进程可以说是某一套程序/指令的运行环境,整合了cpu/内存等资源,记录各种状态,让cpu方便地切换进这个环境来执行代码。




// 进程的状态
enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

// 进程数据
struct proc {
    struct spinlock lock; // 每个进程有一个锁

    // p->lock must be held when using these:
    enum procstate state;        // 进程状态
    void *chan;                  // If non-zero, sleeping on chan
    int killed;                  // 标志为killed
    int xstate;                  // 退出状态。返回给parent
    int pid;                     // Process ID

    // wait_lock must be held when using this:
    struct proc *parent;         // Parent process

    // these are private to the process, so p->lock need not be held.
    uint64 kstack;               // kernel stack的虚拟地址
    uint64 sz;                   // 该进程使用的内存大小。单位字节
    pagetable_t pagetable;       // User页表
    struct trapframe *trapframe; // 存进程各种寄存器等数据。常用在syscall,kernel/user切换等流程。
    struct context context;      // 存进程的上下文,各种寄存器值。swtch()用到。一键切换进程。
    struct file *ofile[NOFILE];  // 打开的文件列表
    struct inode *cwd;           // 当前目录
    char name[16];               // 进程名字
};

#define NPROC 64  // maximum number of processes
struct proc proc[NPROC]; // 系统全局进程列表。最多64个进程。


// cpu数据
struct cpu {
    struct proc *proc;          // 记录此cpu正在跑的进程
    struct context context;     // 存此cpu的context。swtch()用到。一键切换进程。
    int noff;                   // Depth of push_off() nesting.
    int intena;                 // Were interrupts enabled before push_off()?
};

#define NCPU 8
struct cpu cpus[NCPU]; // 系统全局cpu列表。最多8个cpu。


scheduler(void)
{
    struct proc *p;
    struct cpu *c = mycpu();
        int id = cpuid();
            return r_tp()
                // tp = thread pointer寄存器
                // 初始化时已经存入了hartid也就是cpu的id

        c = &cpus[id];
  
    c->proc = 0; // 初始状态进程为0
    for(;;){
        // Avoid deadlock by ensuring that devices can interrupt.
        intr_on();

        for(p = proc; p < &proc[NPROC]; p++) {
            acquire(&p->lock);
            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.
                p->state = RUNNING;
                c->proc = p;
                swtch(&c->context, &p->context);
                    // 切换到userinit创建的进程

                    // swtch.S

                    // context包含ra/sp/s0-s11一共14个寄存器

                    // sd ra, 0(a0)
                    // sd sp, 8(a0)
                    // ...

                    // ld ra, 0(a1)
                    // ld sp, 8(a1)
                    // ...

                    // ret

                    // 根据riscv的calling convention。函数调用的前两个参数会存在a0和a1。
                    // sd把寄存器值存到内存地址
                    // ld把内存地址上的值存到寄存器
                    // 最后的效果就是把cpu当前的context寄存器保存到c->context,
                    // 把进程p->context的寄存器值加载到cpu当前寄存器。


                // 跑到这里说明cpu从进程中切换出来了。那么当前proc=0。继续找RUNNABLE的进程。
                c->proc = 0;
            }
            release(&p->lock);
        }
    }
}


哪些情况导致进程切换回kernel的scheduler?
代码里最终是sched()切换回scheduler。
三个入口

  1. exit
    进程设为ZOMBIE。再sched()。
    ZOMBIE进程必须用wait来回收。

  2. yield
    进程设为RUNNABLE。再sched()。
    cpu每次时钟中断都会导致进程yield。

  3. sleep
    进程设为SLEEPING。再sched()。
    wakeup和kill会把SLEEPING的改为RUNNABLE。

实现线程切换#

每个进程是一套独立的资源环境,直接使用系统资源。而线程是由进程生成,使用所在进程的内存。

需要完成uthread.cuthread_switch.S实现线程的切换。
整体可仿照进程的切换流程。

  • thread结构体
    包含线程的stack,状态,context,要执行的函数等。
    三个状态FREE/RUNNING/RUNNABLE

  • all_thread为线程数组

  • current_thread为当前线程指针,指向all_thread其中一个。

  • 初始状态下自己就是线程0。状态RUNNING

  • thread_create创建新线程
    在线程数组中找一个FREE状态的,改为RUNNABLE
    仿照进程的启动流程进行配置。

  • thread_schedule调度线程
    all_thread里找一个RUNNABLE状态的。如果找不到说明所有线程都跑完了,它直接exit()。
    如果找到的与current_thread不同,切换到找到的新线程。
    如果找到自己,那么继续执行本线程。

  • thread_yield把当前线程设为RUNNABLE,再走thread_schedule
    即尝试交出cpu让别的线程执行。

  • 线程函数中可在一些节点使用thread_yield交出cpu。
    线程函数结束时状态设为FREE,并thread_schedule

  • stack的布局要搞清楚 每个进程有一个kernel stack,排在kernel的va末尾处,4k字节。procinit()中确定。
    每个进程有一个user stack排在elf数据之后,4k字节。在exec()中确定。
    每个线程有一个thread stack,在all_thread中,all_thread是user程序的全局变量,在进程的elf数据区域中。每个thread stack有8k字节。
    三种stack,完全不同的区域。

  • 汇编中stack的使用方式是重点
    如果没处理好可能数据会被篡改,查到天荒地老。

  • 有用的gdb命令
    启动qemu make qemu-gdb
    启动gdb gdb-multiarch --command=.gdbinit
    导入symbol-file
    add-symbol-file user/_uthread
    add-symbol-file kernel/kernel
    设置断点
    b uthread.c:123
    b thread_init
    跳过某函数
    skip function printf
    watch系列可监听数据的读写
    watch all_thread[0].state
    watch all_thread[0].context.sp

使用pthread#

Barrier#

Barrier#

可继续学习pthread-Tutorial.pdf

是一种同步机制,让所有线程必须到达某个节点,才能继续执行。
先到的线程必须等待。等所有线程都到达时,一起释放往下走。
一个使用场景是,一个大任务分给多个线程,规定必须完成整个任务才能继续。
那么就要做一个barrier放在子任务完成之后,等待所有子任务完成。

Conditional Variables#

如果想在线程之间发信号,可以用cv。
一个线程用pthread_cond_wait等待一个cv,另一个线程用pthread_cond_signal唤醒等待者。
然后配合一个等待条件和mutex完成功能。

pthread_cond_signal只唤醒一个等待线程,pthread_cond_broadcast唤醒所有等待线程。


最后的代码比较简洁。可看上述链接的教程获取灵感。