上一节我们已经实现了线程的基本结构并且能够切换到新的线程,但是这个切换过程是我们手动指定的。这一节我们来实现内核线程调度,使得我们只需要创建线程,处理器就会按照某个调度算法自动调入调出线程,实现并发。
6.1 线程管理
1、线程辅助状态:我们目前的线程 Thread 结构体只存储了线程上下文相关的信息,我们需要更多的信息来用于线程的调度。首先就是线程的状态,这里划分四个状态:
- Ready,线程就绪
- Running,线程正在占有 CPU 执行
- Sleeping,线程等待资源而休眠
- Exited,线程退出。其实 Exited 状态可有可无,因为一个线程调用
Exit()
退出时就会被直接回收资源,而不会继续存储在线程池中。
// kernel/thread.h
/* 线程状态 */
typedef enum {
Ready, // 就绪
Running, // 运行
Sleeping, // 休眠
Exited // 退出
} Status;
- 接着我们就可以定义存储在线程池中的线程信息了。其实定义的是线程池中的一个线程信息空位。
// kernel/thread.h
/* 线程池中的线程信息槽 */
typedef struct {
Status status; // 线程状态
int tid; // 线程ID
int occupied; // 该槽位是否被占用
Thread thread; // 线程
} ThreadInfo;
- 我们同时定义一个结构 RunningThread,用来表示一个正在运行的线程,其实就是将 tid 和 Thread 封装一下。
// kernel/thread.h
// 正在运行的线程
typedef struct {
int tid;
Thread thread;
} RunningThread;
2、线程池
- 我们定义一个结构体,用于存储调度算法的一些函数。这相当于一个算法框架,要实现一个调度算法只需要实现其中的函数即可。
// krenel/thread.h
// 调度器算法实现(函数指针)
typedef struct {
void (* init)(void); // 初始化调度器
void (* push)(int); // 将一个线程加入线程调度
int (* pop) (void); // 从就绪线程中选择一个运行,如果没有可运行的线程则返回 -1
int (* tick)(void); // 提醒调度算法当前线程又运行了一个 tick,返回的 int 表示调度算法认为当前线程是否需要被切换出去
void (* exit)(int); // 告诉调度算法某个线程已经结束
} Scheduler;
- 接着就可以定义线程池了
// kernel/consts.h
// 线程池最大线程数
#define MAX_THREAD 0x40
// kernel/thread.h
// 线程池
typedef struct {
ThreadInfo threads[MAX_THREAD];
Scheduler scheduler;
} ThreadPool;
3、线程池相关函数
allocTid()
函数用于遍历线程池,寻找一个未被使用的 tid。若所有 tid 都被使用则会进入 panic。addToPool()
函数用于将一个线程添加到线程池中,线程池会为其分配一个 tid,并分配一个空位保存这个线程相关的信息,并通知调度算法让这个线程参与调度(调度算法只会操作 tid)。acquireFromPool()
函数用于向线程池获取一个可以运行的线程,由于调用该函数的下一步就要直接切换到这个线程,所以在线程池中直接标记为 Running 状态。如果线程池中没有可以运行的线程,那么返回的 RunningThread 中的 tid 为 -1。retrieveToPool()
函数会在一个线程停止运行,切换回调度线程后调用,用于修改线程池内的线程信息。线程停止运行有两种情况,一种是线程运行结束,另一种是还没有运行完,但是时间片用尽,这种情况就需要重新将线程加入调度器。tickPool()
函数基本就是对调度器的tick()
函数的包装,用于查看当前正在运行的线程是否需要切换。exitFromPool()
函数的参数是 tid,用于释放该 tid 线程信息的空位,并且通知调度器,让这个 tid 不再参与调度。
// kernel/thread.c
// 遍历线程池,寻找未被使用的tid
int
allocTid(ThreadPool *pool)
{
int i;
for(i = 0; i < MAX_THREAD; i++) {
if(!pool->threads[i].occupied)
return i;
}
panic("Alloc tid failed!\n");
return -1;
}
// 将线程添加到线程池中
void
addToPool(ThreadPool *pool, Thread thread)
{
int tid = allocTid(pool); // 遍历线程池,寻找未使用tid
// 配置线程信息
pool->threads[tid].status = Ready; // 就绪
pool->threads[tid].occupied = 1; // 占用
pool->threads[tid].thread = thread; // 线程上下文地址和栈底地址
pool->scheduler.push(tid); // 将线程加入参与调度
}
// 向线程池获取一个可以运行的线程,若没有返回-1
RunningThread
acquireFromPool(ThreadPool *pool)
{
int tid = pool->scheduler.pop(); // 从就绪线程中获取一个可运行线程
RunningThread rt;
rt.tid = tid;
if(tid != -1) {
ThreadInfo *ti = &pool->threads[tid]; // 从线程池取出线程
// 修改取出线程在线程池的状态(上行代码用&引用传入的)
ti->status = Running; // 由于调用该函数的下一步就要直接切换到这个线程,所以在线程池中直接标记为 Running 状态
ti->tid = tid; // 线程ID(因为将线程添加到线程池中时没用设置ThreadInfo.tid,所以这里初始化)
rt.thread = ti->thread;
}
return rt;
}
// 修改线程池内的线程信息:在一个线程停止运行,切换回调度线程后调用
// 线程停止运行有两种情况
// 一种是线程运行结束
// 一种是还没有运行完,但是时间片用尽,这种情况就需要重新将线程加入调度器
void
retrieveToPool(ThreadPool *pool, RunningThread rt)
{
int tid = rt.tid;
// 若线程不被占用了,即线程运行结束
if(!pool->threads[tid].occupied) {
// 表明刚刚这个线程退出了,回收栈空间(传入栈底地址,根据HEAP维护的二叉树,即可知道回收多大空间)
kfree((void *)pool->threads[tid].thread.kstack);
return;
}
// 线程时间片用完,重新加入调度器
ThreadInfo *ti = &pool->threads[tid];
ti->thread = rt.thread; // 更新线程上下文、栈地址
if(ti->status == Running) {
ti->status = Ready; // 更新线程状态
pool->scheduler.push(tid); // 加入线程调度
}
}
// 对调度器的 tick() 函数包装,用于查看当前正在运行的线程是否需要切换
int
tickPool(ThreadPool *pool)
{
// 提醒调度算法当前线程又运行了一个 tick,返回的 int 表示调度算法认为当前线程是否需要被切换出去
return pool->scheduler.tick();
}
// 释放该 tid 线程信息的占用位,并且通知调度器让这个 tid 不再参与调度
void
exitFromPool(ThreadPool *pool, int tid)
{
pool->threads[tid].occupied = 0; // 清除占用标志
pool->scheduler.exit(tid); // 告诉调度算法某个线程已经结束
}
6.2 调度线程
1、我们所有的运行流程都是运行在线程中的,如果我们要对所有的线程进行调度,我们还需要另外创建一个线程专门用于调度。调度线程的作用是:
- 当没有线程在运行时,调度线程根据一定的策略来选择一个线程来执行;
- 当一个线程被调度器判断需要让出 CPU 控制权时,例如运行时间过长或者运行结束,并不是直接切换到另一个线程,而是先切换到这个调度线程,让调度线程根据一定的策略来选择另一个线程执行。
- 我们定义一个结构,用来保存调度线程参与调度所需要的所有信息
// kernel/thread.h
// 调度线程参与调度所需要的所有信息
typedef struct {
ThreadPool pool; // 线程池
Thread idle; // 调度线程
RunningThread current; // 当前运行线程信息
int occupied; // 当前是否有线程(除了调度线程)正在运行
} Processor;
- 我们需要定义一个全局唯一的 Processor,来进行调度。
// kernel/processor.c
// 全局唯一的 Processor 实例
static Processor CPU;
2、我们需要在进入 idle 线程时关闭调度,防止调度过程被时钟打断,并在某个适当的时机恢复。涉及的就是关闭全局中断,通过设置sstatus
寄存器实现操作。
// kernel/riscv.h
/* 打开异步中断,并等待中断 */
static inline void
enable_and_wfi()
{
// csrsi - 控制状态寄存器某个位, 1<<1 - 置位第二位SIE
// wfi - Wait for Interrupt特殊指令,用于暂停 CPU 直到某个中断发生,CPU进入低功耗状态
asm volatile("csrsi sstatus, 1 << 1; wfi");
}
/* 关闭异步中断并保存原先的 sstatus */
static inline usize
disable_and_store()
{
usize x; // 保存操作后的 sstatus 返回
// csrrci - CSR read and clear with Immediate,清除SIE位并存储到%0(即x)
asm volatile("csrrci %0, sstatus, 1 << 1" : "=r" (x) );
return x;
}
/* 用 flags 的值恢复 sstatus */
static inline void
restore_sstatus(usize flags)
{
// cars - CSR set with Immediate,用输入变量flags的值设置sstatus寄存器
asm volatile("csrs sstatus, %0" :: "r"(flags) );
}
3、线程调度操作相关的函数
initCPU()
函数使用idle
线程和pool
线程池来对 CPU 进行初始化,参数pool
主要就是为了指定这个Processor
所使用的调度算法。addToCPU()
函数主要就是对addToPool()
函数的包装,不用做其他处理。exitFromCPU()
这个函数由线程主动执行,效果类似于exit()
,用于主动通知 CPU 这个线程运行结束,CPU 会通知线程池释放资源,并切换到 idle 线程进行下一步调度。runCPU()
函数,用于切换到 idle 线程,表示正式由 CPU 进行线程管理和调度,这个函数通常在启动线程中调用,由于启动线程被构造为一个局部变量,我们再也无法切换回启动线程,相当于操作系统的初始化工作已经结束。
// kernel/processor.c
// 对CPU(调度线程)初始化
// 使用 idle 线程和 pool 线程池来对 CPU 进行初始化
// 参数 pool 主要就是为了指定这个 Processor 所使用的调度算法
void
initCPU(Thread idle, ThreadPool pool)
{
CPU.idle = idle; // 调度线程
CPU.pool = pool; // 线程池
CPU.occupied = 0; // 当前没有线程在运行
}
// 将线程添加到CPU管理的线程池中(对 addToPool() 进行包装)
void
addToCPU(Thread thread)
{
addToPool(&CPU.pool, thread);
}
// 线程主动退出,通知 CPU 这个线程运行结束
// CPU 会通知线程池释放资源,并切换到 idle 线程进行下一步调度
void
exitFromCPU(usize code)
{
disable_and_store(); // 关闭异步中断
int tid = CPU.current.tid; // 当前运行线程tid
exitFromPool(&CPU.pool, tid); // 清除线程池中占用标记,告诉调度算法线程已经结束
printf("Thread %d exited, exit code = %d\n", tid, code);
switchThread(&CPU.current.thread, &CPU.idle); // 切换到调度器线程
}
// 切换到 idle 线程,表示正式由 CPU 进行线程管理和调度,这个函数通常在启动线程中调用
// 由于启动线程被构造为一个局部变量,我们再也无法切换回启动线程,相当于操作系统的初始化工作已经结束
void
runCPU()
{
Thread boot = {0L, 0L}; // 启动线程
switchThread(&boot, &CPU.idle); // 从启动线程切换进 idle,boot 线程信息丢失,不会再回来
}
4、线程调度的入口点函数,idleMain()
,是调度线程最核心的函数。调度线程的所有逻辑都在这个函数中循环。
// kernel/processor.c
// 线程调度的入口点函数,是调度线程最核心的函数
void
idleMain()
{
// 进入 idle 时禁用异步中断
disable_and_store();
while(1) {
// 向线程池获取一个可以运行的线程
RunningThread rt = acquireFromPool(&CPU.pool);
if(rt.tid != -1) {
// 有线程可以运行
CPU.current = rt; // 设置调度器当前线程
CPU.occupied = 1; // 标志线程正在运行
printf("\n>>>> will switch_to thread %d in idle_main!\n", CPU.current.tid);
// 从调度器线程 切换到 当前线程
switchThread(&CPU.idle, &CPU.current.thread);
// 切换回 idle 线程处
printf("<<<< switch_back to idle in idle_main!\n");
CPU.occupied = 0; // 标记当前没有线程正在运行
// 修改线程池内的线程信息:在一个线程停止运行,切换回调度线程后调用
retrieveToPool(&CPU.pool, CPU.current);
} else {
// 无可运行线程,短暂开启异步中断并处理
enable_and_wfi();
disable_and_store();
}
}
}
5、时钟中断引发调度:线程调度,很重要的一个特点就是由时钟中断来触发。
tickCPU()
函数在时钟中断时被调用,每当时钟中断发生时,如果当前有正在运行的线程,都会检查一下当前线程的时间片是否用完,如果用完了就需要切换到调度线程。
// kernel/processor.c
// 在时钟中断时被调用,每当时钟中断发生时,如果当前有正在运行的线程,
// 都会检查一下当前线程的时间片是否用完,如果用完了就需要切换到调度线程
void
tickCPU()
{
// 判断当前是否有正在运行线程(不是 idle)
if(CPU.occupied) {
// 当前线程运行时间片是否耗尽
if(tickPool(&CPU.pool)) {
// 关闭中断
usize flags = disable_and_store();
// 切换到 idle 调度器线程
switchThread(&CPU.current.thread, &CPU.idle);
// 某个时刻再切回此线程时从这里开始
restore_sstatus(flags);
}
}
}
- 不要忘了在时钟中断处理函数中调用这个函数。
// kernel/interrupt.c
// 时钟中断处理:设置下一次时钟中断时间
void
supervisorTimer()
{
extern void tick(); tick(); // 设置下一次时钟中断时间
extern void tickCPU(); tickCPU(); // 检查当前线程的时间片是否用完
}
6.3 Round-Robin 调度算法
1、我们在第一节已经实现了一个调度算法的框架,只要实现其中的五个函数即可,本节将实现一个很基础的 Round-Robin 调度算法 wiki(即时间片轮转调度算法)。大致思想(下图来自小林coding图解操作系统
6.1 进程调度/页面置换/磁盘调度算法 | 小林coding (xiaolincoding.com)):
2、我们使用一个双向环形链表来实现队列,链表的节点按照 tid + 1 都存放在数组中,其中下标 0 处为 Dummy Head,用于快速找到队列头。
- 队列中的元素如下定义:
// kernel/rrscheduler.c
// 双向环形链表来实现队列,队列元素如下
// 链表的节点按照 tid + 1 都存放在数组中,其中下标 0 处为 Dummy Head,用于快速找到队列头
typedef struct
{
int valid; // 标记线程是否有效
usize time; // 线程剩余时间片
int prev; // 前一个线程tid
int next; // 后一个线程tid
} RRInfo;
- 这些元素并不存储 Thread,只存储 tid,这种实现方式侵入性较小,耦合度低,便于替换。
- 定义一个结构体用于存储调度器相关信息,其中 current 表示当前正在运行的线程的 tid。
// kernel/rrscheduler.c
// 调度器信息结构体
struct
{
RRInfo threads[MAX_THREAD + 1]; // 优先级调度队列(由于 0 号位有个 Dummy Head,所以 threads 数组的长度为 MAX_THREAD + 1)
usize maxTime; // 最大时间片
int current; // 当前正在运行的tid
} rrScheduler;
3、具体的五个调度函数实现,代码中附有详细注释:
// kernel/rrscheduler.c
// 初始化调度器
void
schedulerInit()
{
rrScheduler.maxTime = 1; // 设置最大时间片为1
rrScheduler.current = 0; // 当前没有线程运行,设置当前线程为0
/* 第 0 个位置为 Dummy head,用于快速找到链表头和尾 */
RRInfo ri = {0, 0L, 0, 0}; // 初始化一个无效的线程信息结构
rrScheduler.threads[0] = ri;
}
// 将一个线程加入线程调度,即加入调度队列尾部
void
schedulerPush(int tid)
{
tid += 1; // 调整索引
if(tid + 1 > MAX_THREAD + 1) {
panic("Cannot push to scheduler!\n");
}
// 若线程没有时间片,初始化为最大时间片
if(rrScheduler.threads[tid].time == 0) {
rrScheduler.threads[tid].time = rrScheduler.maxTime;
}
// 获取当前队列尾部
int prev = rrScheduler.threads[0].prev;
// 将线程加入队列尾部
rrScheduler.threads[tid].valid = 1; // 标记线程有效
rrScheduler.threads[prev].next = tid; // 尾部next指向当前线程
rrScheduler.threads[tid].prev = prev; // 当前线程prev指向尾部线程
rrScheduler.threads[0].prev = tid; // 头部prev指向当前线程
rrScheduler.threads[tid].next = 0; // 当前线程next指向头部
}
// 从就绪线程中选择一个运行,如果没有可运行的线程则返回 -1
int
schedulerPop()
{
// 获取队列一个有效线程
int ret = rrScheduler.threads[0].next;
if(ret != 0) {
// 若有可用线程,则从队列头部弹出
int next = rrScheduler.threads[ret].next; // 获取该线程的下一个线程
int prev = rrScheduler.threads[ret].prev; // 获取该线程的上一个线程
rrScheduler.threads[next].prev = prev; // 更新下一个线程的prev
rrScheduler.threads[prev].next = next; // 更新上一个线程的next
rrScheduler.threads[ret].prev = 0; // 清空当前线程的prev
rrScheduler.threads[ret].next = 0; // 清空当前线程的next
rrScheduler.threads[ret].valid = 0; // 标记当前线程为无效
rrScheduler.current = ret; // 设置调度器当前线程为弹出线程
}
return ret-1; // 调整索引
}
// 提醒调度算法当前线程又运行了一个 tick
// 输出:1-表示调度算法认为当前线程需要被切换出去,0-不需要切换出去
int
schedulerTick()
{
int tid = rrScheduler.current; // 获取当前线程tid
if(tid != 0) {
// 当前线程有效
rrScheduler.threads[tid].time -= 1; // 当前线程时间片-1
if(rrScheduler.threads[tid].time == 0) {
return 1; // 时间片用尽则切换出去
} else {
return 0; // 否则不切换
}
}
return 1; // 如果当前线程也进行切换
}
// 告诉调度算法某个线程已经结束
void
schedulerExit(int tid)
{
tid += 1; // 调整索引
// 判断结束的线程是否为当前正在运行的线程
if(rrScheduler.current == tid) {
rrScheduler.current = 0; // 将当前线程设置为0,表示没有线程在运行
}
}
6.4 调度测试
1、我们完成了所有的部分,终于可以开始测试了,我们计划创建一些线程,线程的入口点是这个函数:
// kernel/thread.c
// 线程测试函数,作为入口点
void
helloThread(usize arg)
{
printf("Begin of thread %d\n", arg);
int i;
// 将传入的参数输出800遍
for(i = 0; i < 800; i ++) {
printf("%d", arg);
}
printf("\nEnd of thread %d\n", arg);
exitFromCPU(0); // 退出
while(1) {}
}
- 会将传入的参数输出 800 遍,之后调用
exitFromCPU()
退出。 - 初始化线程更新为如下:
// kernel/thread.c
// 初始化线程
void
initThread()
{
// 1.创建调度函数实现
Scheduler s = {
schedulerInit,
schedulerPush,
schedulerPop,
schedulerTick,
schedulerExit
};
s.init(); // 初始化调度器
// 2.创建线程池
ThreadPool pool = newThreadPool(s);
// 3.构建idle调度线程
Thread idle = newKernelThread((usize)idleMain);
// 4.初始化CPU调度器
initCPU(idle, pool);
// 5.构造线程并添加到CPU中
usize i;
for(i = 0; i < 5; i ++) {
Thread t = newKernelThread((usize)helloThread); // 构造新内核线程
usize args[8];
args[0] = i;
appendArguments(&t, args); // 为线程传入初始化参数
// 6.启动
addToCPU(t); // 将线程添加到调度队列中
}
printf("***** init thread *****\n");
}
- 在
main
函数中,加入线程初始化和切换到idle
调度线程
void main()
{
extern void initInterrupt(); initInterrupt(); // 设置中断处理程序入口 和 模式
extern void initTimer(); initTimer(); // 时钟中断初始化
extern void initMemory(); initMemory(); // 初始化 页分配 和 动态内存分配
extern void mapKernel(); mapKernel(); // 内核重映射,三级页表机制
extern void initThread(); initThread(); // 初始化线程管理
extern void runCPU(); runCPU(); // 切换到 idle 调度线程,表示正式由 CPU 进行线程管理和调度
while(1) {}
}
- 运行输出结果如下:
==== Init Interrupt ====
***** Init Memory *****
***** Remap Kernel *****
***** init thread *****
>>>> will switch_to thread 0 in idle_main!
Begin of thread 0
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
End of thread 0
Thread 0 exited, exit code = 0
<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 1 in idle_main!
Begin of thread 1
1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 2 in idle_main!
Begin of thread 2
22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222
End of thread 2
Thread 2 exited, exit code = 0
<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 3 in idle_main!
Begin of thread 3
33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333
End of thread 3
Thread 3 exited, exit code = 0
<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 4 in idle_main!
Begin of thread 4
444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 1 in idle_main!
1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
End of thread 1
Thread 1 exited, exit code = 0
<<<< switch_back to idle in idle_main!
>>>> will switch_to thread 4 in idle_main!
44444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444
End of thread 4
Thread 4 exited, exit code = 0
<<<< switch_back to idle in idle_main!
- 你的输出可能与我不完全一样,但是可以看出,线程 1 在第一次运行时没有来得及运行结束,就被切换到线程 2 了,在线程 3 运行结束后,线程 1 又被调度占用了 CPU 才运行结束。