Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

线程

执行流是什么

宏观上:其实就是具有上下文环境的一段代码,可以被调度器调度上处理器执行。执行流是独立的,它拥有自己的栈、寄存器映像、内存等资源,这就是一个执行流的上下文环境。

微观上:是CPU的执行轨迹。

注意:当有线程后,执行流就是调度器的调度单位,就是指线程。

PCB到底是什么

PCB是操作系统为每个进程维护的一个数据结构,相当于进程的“身份证”,记录了进程的全部状态和控制信息,操作系统通过PCB来管理进程的创建、切换、调度和销毁。

因为PCB是由操作系统管理和调度的,即使是用户进程,其PCB也是从内核物理内存池中申请的,一般系统中一个进程的PCB是整数个物理页。

将系统中所有的PCB按照一定的顺序存储在一个数据结构中(双向链表,大/小堆)中的结构,就是调度表,简单情况下,调度表是PCB的双向链表。

调度器实际上是扫描PCB双向链表选择执行流上处理器,在选择新的执行流上处理器之前,需要保存当前的执行流的上下文环境,可知在调度器进行任务切换时,一定需要保存当前断点然后加载新任务环境,那么这些断点和新任务环境的数据存放在哪呢?其实这个存放的地址只要操作系统在内核中划分出一块空间存放就行,操作系统还要去维护这段内存,找到执行流对应的存放地址,这个过程还挺麻烦的,不如将执行流的执行环境放在它自己的身份标记里,也就是放在自己的PCB结构中,在调度PCB时就能直接获取存放上下文环境的地址,非常方便,为了便于操作系统快速获取存放地址,将上下文环境直接放在它们PCB的顶端就行,将它们的身份信息放在PCB的底端,当执行流在执行的时候,一定还需要栈存放自己的局部变量参数等,所以,在PCB中间还没用上的那部分作为栈空间。

PCB的结构信息如下

线程和进程3.drawio

PCB结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 进程或线程的状态 */
enum task_status {
TASK_RUNNING,
TASK_READY,
TASK_BLOCKED,
TASK_WAITING,
TASK_HANGING,
TASK_DIED
};

/* 进程或线程的pcb,程序控制块 */
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
uint8_t priority; // 线程优先级
char name[16];
uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出
};

PCB中的栈结构

intr_stak:中断栈,用于中断发生时,保护程序的(线程或进程)上下文环境,中断发生时栈的保护见《深入理解操作系统中断》那篇文章

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

/*********** 中断栈intr_stack ***********
* 此结构用于中断发生时保护程序(线程或进程)的上下文环境:
* 进程或线程被外部中断或软中断打断时,会按照此结构压入上下文
* 寄存器, intr_exit中的出栈操作是此结构的逆操作
* 此栈在线程自己的内核栈中位置固定,所在页的最顶端
********************************************/
struct intr_stack {
uint32_t vec_no; // kernel.S 宏VECTOR中push %1压入的中断号
uint32_t edi;
uint32_t esi;
uint32_t ebp;
uint32_t esp_dummy; // 虽然pushad把esp也压入,但esp是不断变化的,所以会被popad忽略
uint32_t ebx;
uint32_t edx;
uint32_t ecx;
uint32_t eax;
uint32_t gs;
uint32_t fs;
uint32_t es;
uint32_t ds;

/* 以下由cpu从低特权级进入高特权级时压入 */
uint32_t err_code; // err_code会被压入在eip之后
void (*eip) (void);
uint32_t cs;
uint32_t eflags;
void* esp;
uint32_t ss;
};

thread_stack:线程自己的栈,用于保存线程中待执行的函数,主要由eip来保存。thread_stack有两个作用:

①首次运行时,存储线程运行的函数和参数。

②线程正常运行时,任务切换时保存寄存器环境。内核调度器也是一个软件,函数调用需要遵守ABI,即intel386硬件体系上所有寄存器都具有全局性,因此在函数调用时需要遵守ABI规定,不论被调函数中是否使用了ebp、ebx、edi、esi这5个寄存器,在被调函数执行完后,这5个寄存器的值不该被改变,因此被调函数必须要为主调函数保护好这几个寄存器的值,将它们保存在自己的栈中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

/*********** 线程栈thread_stack ***********
* 线程自己的栈,用于存储线程中待执行的函数
* 此结构在线程自己的内核栈中位置不固定,
* 用在switch_to时保存线程环境。
* 实际位置取决于实际运行情况。
******************************************/
struct thread_stack {
uint32_t ebp;
uint32_t ebx;
uint32_t edi;
uint32_t esi;

/* 线程第一次执行时,eip指向待调用的函数kernel_thread
其它时候,eip是指向switch_to的返回地址*/
void (*eip) (thread_func* func, void* func_arg);

/***** 以下仅供第一次被调度上cpu时使用 ****/

/* 参数unused_ret只为占位置充数为返回地址 */
void (*unused_retaddr);
thread_func* function; // 由Kernel_thread所调用的函数名
void* func_arg; // 由Kernel_thread所调用的函数所需的参数
};

PCB低端的身份信息

PCB是进程或线程的身份信息,既然是身份信息一定要有标识进程或线程的基本信息,比如该进程或线程当前0特权级的栈指针,优先级,进程或线程的名称、时间片、pid等

创建线程时需要填充PCB信息

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
#define PG_SIZE 4096

/* 由kernel_thread去执行function(func_arg) */
static void kernel_thread(thread_func* function, void* func_arg) {
function(func_arg);
}

/* 初始化线程栈thread_stack,将待执行的函数和参数放到thread_stack中相应的位置 */
void thread_create(struct task_struct* pthread, thread_func function, void* func_arg) {
/* 先预留中断使用栈的空间,可见thread.h中定义的结构 */
pthread->self_kstack -= sizeof(struct intr_stack);

/* 再留出线程栈空间,可见thread.h中定义 */
pthread->self_kstack -= sizeof(struct thread_stack);
struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack;
kthread_stack->eip = kernel_thread;
kthread_stack->function = function;
kthread_stack->func_arg = func_arg;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}

/* 初始化线程基本信息 */
void init_thread(struct task_struct* pthread, char* name, int prio) {
memset(pthread, 0, sizeof(*pthread));
strcpy(pthread->name, name);
pthread->status = TASK_RUNNING;
pthread->priority = prio;
/* self_kstack是线程自己在内核态下使用的栈顶地址 */
pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE);
pthread->stack_magic = 0x19870916; // 自定义的魔数
}

/* 创建一优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg) */
struct task_struct* thread_start(char* name, int prio, thread_func function, void* func_arg) {
/* pcb都位于内核空间,包括用户进程的pcb也是在内核空间 */
struct task_struct* thread = get_kernel_pages(1);

init_thread(thread, name, prio);
thread_create(thread, function, func_arg);

asm volatile ("movl %0, %%esp; pop %%ebp; pop %%ebx; pop %%edi; pop %%esi; ret" : : "g" (thread->self_kstack) : "memory");
return thread;
}

为什么要创建多线程

【主要是提速!】

  1. 假设时间片是n,计算机中有两个进程分别是A进程和B进程,其中A进程创建了3个线程,B进程是单线程进程,那么当A和B都执行完成后,A实际上获得了3n个时间片,B只有n个时间片,A执行的更快。

  2. 如果一个进程创建了多个线程,那么那些需要等待其它进程提供数据的线程就会被阻塞,例如需要等待用户输入的线程,这样的线程就会被阻塞,而进程中的其它线程可以正常被调度上处理器,这样这个进程只需要等待被阻塞进程执行完就行。如果该进程没有创建线程,那么当遇到需要等待输入的情况时就需要将整个进程都阻塞,显然比创建多线程慢。

任务调度器

任务调度器本质上是一个软件,它在内核维护一个调度表,使用一定的调度算法将任务放上处理器去运行。这个调度表就是按照一定顺序维护的PCB表。

img

常见的调度算法有:先来先服务、短作业优先、高响应比优先、时间片轮转、最高优先级调度算法、多级反馈队列调度算法。

任务调度器在内核中按照不同的调度算法维护一个或多个调度表,这个调度表本质上就是按照调度算法排好序的双向链表,当系统需要实现任务切换时,调度器将当前任务的现场数据加载到当前任务的PCB中,将当前PCB放回调度链表中,接着任务调度器根据调度算法从调度表中取出对应的PCB,获取该PCB中执行流执行的环境,然后加载到对应的资源中,开始执行。这就完成了任务的切换。

调度表是全部PCB表按照调度算法排好序的双向链表吗?

调度表宏观上体现的是系统中所有执行流的PCB按照系统使用的调度算法进行排序的双向链表,但是操作系统的内存是有限的,一个执行流的PCB是整数个物理页,一个物理页的大小的4KB,假设系统中一个执行流的PCB数据结构是2个物理页,那么系统中如果有1024个进程,那么调度表就要占8MB了,进程越多,调度表越大。操作系统内存有时候都不够用,需要进行磁盘的换入换出,所以操作系统在实现系统功能的基础上,资源管理所占用的内存当然是越小越好,如果操作系统的调度器维护的是完整的调度表,那么操作系统中每个执行流的PCB数结构至少有2个副本,调度器本质上就是要找到要调用的执行流的PCB,完成现场的保存和加载,所以调度表不需要维护所有执行流的PCB,调度表中的数据元素只要是PCB的虚拟地址就行,在调度器调度执行流的时候,直接根据PCB的地址找到PCB。对于32位的系统,1024个进程,调度链表所占用的实际的物理内存是(4B+4B+4B)×1024 = 12KB,大大缩小了占用内存。

线程和进程.drawio

调度算法

先来先服务

利于长作业,不利于短作业。

微观上:创建pcb时,直接将其加入到调度队列的队尾,调度新执行流时,直接区队列的队首

短作业优先

利于短作业,不利于长作业

微观上:将按照pcb中的执行时间进行排序,每次取队首pcb调度(队头到队尾,执行时间越来越大)

高响应比优先

权衡了短作业和长作业
$$
优先权 = (等待时间+要求服务时间)/要求服务时间
$$

时间片轮转

时间片的设置比较重要,太小容易频繁上下文切换,太大演变成了先来先服务算法

最高优先级调度算法

给每个进程设置一个优先级,调度时按照优先级调度,但是这可能导致低优先级任务饥饿

多级反馈队列调度算法

img

设置多个队列,队列的优先级从高到低,优先级越高的时间片越短;

新的进程放入第一级队列的末尾,按照先来先服务排队等调度,如果在第一级队列规定的时间片没有运行完,就将其转入第二级队列的末尾,按照这个规律直到运行完成;

如果进程运行时有新的进程进入较高的队列中,停止当前进程并将其放入原队列的末尾,执行较高优先级的进程。

任务调度器如何保护和加载执行流上下文环境

进程和线程的上下文环境保存在PCB中,调度线程的时候保存上下文环境在PCB中,然后从要调度的PCB中加载上下文环境到对应的寄存器中。在操作系统中,PCB数据结构占据几个物理页,而一个物理页大小是4KB,PCB中存放了执行流的关键信息,例如pid,父子进程,页表等以及局部变量,所以几个页大小的PCB结构完全可以存放下执行流的上下文环境(上下文环境本质上就是当前CPU的执行状态,而当前CPU的执行的最新状态是存放在寄存器中的)

以时间轮转算法为例,分析调度的过程

step1:注册时钟中断处理函数

时钟中断处理函数是0x20号中断,由8259A的IR0号引脚接收到的中断。每触发一次时钟中断,时钟中断处理程序就会将当前运行的线程的时间片-1,当时间片 = 0时,调用调度器函数,调度器函数将该线程换下处理器,然后重新选择一个线程上处理器。

注意:任务调度一定是由中断触发的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 时钟的中断处理函数 */
static void intr_timer_handler(void) {
struct task_struct* cur_thread = running_thread();

ASSERT(cur_thread->stack_magic == 0x19870916); // 检查栈是否溢出

cur_thread->elapsed_ticks++; // 记录此线程占用的cpu时间嘀
ticks++; //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数

if (cur_thread->ticks == 0) { // 若进程时间片用完就开始调度新的进程上cpu
schedule();
} else { // 将当前进程的时间片-1
cur_thread->ticks--;
}
}

step2:实现任务调度函数

在内核当中维护一个任务调度表,这个任务调度表是PCB的双向链表。如果当前任务的状态是运行态,说明其时间片到期了,需要将其状态转换成就绪态,重新赋值其时间片,然后将其加入就绪队列的末尾。

如果当前的任务状态不是运行态,那么重新选择一个任务上处理器,将就绪队列队首的线程调度上处理器,将其就绪态转换成运行态,然后将它从就绪队列中删除。最后调用step3的任务切换函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 实现任务调度 */
void schedule() {

ASSERT(intr_get_status() == INTR_OFF);

struct task_struct* cur = running_thread();
if (cur->status == TASK_RUNNING) { // 若此线程只是cpu时间片到了,将其加入到就绪队列尾
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->ticks = cur->priority; // 重新将当前线程的ticks再重置为其priority;
cur->status = TASK_READY;
} else {
/* 若此线程需要某事件发生后才能继续上cpu运行,
不需要将其加入队列,因为当前线程不在就绪队列中。*/
}

ASSERT(!list_empty(&thread_ready_list));
thread_tag = NULL; // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
thread_tag = list_pop(&thread_ready_list);
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
switch_to(cur, next);
}

step3:实现任务切换switch_to函数

任务调度是由时钟中断引起的,在中断时需要保存程序的上下文环境,这需要保存任务的全部的寄存器映像,这涉及到中断的压栈过程(如果有特权级转移,需要压入ss_old, esp_old, eflags, cs_old, eip_old,error_code, 4个段寄存器和8个通用寄存器),然后进入中断处理程序;

中断处理程序一定是在内核态下工作,中断处理程序还没执行完成但是时间片用完了,也会触发调度器去调度别的线程,这就需要保护好内核线程执行的环境。线程本质上执行的是程序,程序调用根据ABI规则,需要由被调函数保护好esi、edi、ebx、ebp这四个寄存器,也就是说无论被调函数有没有使用这四个寄存器,在被调函数调用前和调用执行完成后,这四个寄存器需要保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[bits 32]
section .text
global switch_to
switch_to:
;栈中此处是返回地址
push esi
push edi
push ebx
push ebp

mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20]
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ----------------
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24]
mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
pop ebp
pop ebx
pop edi
pop esi
ret ; 返回到上面switch_to下面的那句注释的返回地址,
; 未由中断进入,第一次执行时会返回到kernel_thread

线程和进程4.drawio

总结 :

****【上下文保护的第一部分】:****保存任务进入中断前的全部寄存器,目的是能让任务恢复到中断前

****【上下文保护的第二部分】:****保存esi、edi、ebx、ebp这四个寄存器,目的是让任务恢复执行在任务切换发生时尚未切换的内核代码,保证顺利走到退出中断的出口,利用第一部分保存的寄存器彻底恢复任务。

2种线程实现机制

①内核实现线程

由内核维护调度表,某个线程阻塞不会影响到其它线程和进程

②用户实现线程

用户维护调度表,内核感知不到线程存在,一旦某个线程阻塞会导致整个进程阻塞

那这两种机制有什么区别呢?哪种机制更高效呢?

**用户实现线程:**在用户空间实现线程,操作系统是感知不到线程的存在因为内核感知不到线程的存在,所以线程的调度需要在用户空间实现,也就是线程调度表要由用户实现。

优点是:用户可以根据业务的需要自定义调度算法,更加的灵活,移植性强

缺点是:操作系统调度器的调度单位是进程,只要一个线程阻塞,那么它所在的进程整体都会被阻塞。

**内核实现线程:**操作系统可以感知到线程的存在,调度表是由操作系统维护的,当某一个线程被阻塞后,并不会影响这个线程所在进程的其它线程,它对比于在用户空间实现线程,它更能加快进程的执行。

线程和进程2.drawio

用户进程

任务:程序从文件系统被加载到内存后,位于内存中的程序就是映像,也称为任务。任务和任务的区别在于执行流一整套的上下文资源,包括寄存器映像、地址空间、IO位图等,这些上下文资源恰恰就是TSS结构中的内容,拥有这些资源才算的上是任务。换句话说,完整拥有TSS中的内容的就是一个任务。

TSS(Task State Segment)

TSS的本质实际上是一个内存段,需要在GDT中注册才能使用,通过TR寄存器(存储的是TSS选择子)在GDT中找到它。

img

TSS由程序员提供,CPU维护。

***程序员提供:***程序员为每一个任务单独定义一个TSS结构

***CPU维护:***CPU自动用TSS结构做任务的切换,它是CPU原生支持的数据结构,CPU能够直接正确识别其中的所有字段,当任务被换下CPU时,CPU自动将当前寄存器的值存储到当前任务的TSS中;当新任务上CPU时自动从新任务的TSS中找到相应的寄存器值加载到对应的寄存器中。

TSS结构和选择子

TSS中的字段基本全是寄存器的名称,这些寄存器就是任务运行中的最新状态。TSS中只有0,1,2三个特权级的栈,仅仅是用来让CPU从低特权级到高特权级使用的。

*TR寄存器中存储的是TSS的选择子,GDTR存储的是GDT的起始地址和表界限,IDTR是中断描述符表的起始地址和表界限*

img

img

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
/* 任务状态段tss结构 */
struct tss {
uint32_t backlink;
uint32_t* esp0;
uint32_t ss0;
uint32_t* esp1;
uint32_t ss1;
uint32_t* esp2;
uint32_t ss2;
uint32_t cr3;
uint32_t (*eip) (void);
uint32_t eflags;
uint32_t eax;
uint32_t ecx;
uint32_t edx;
uint32_t ebx;
uint32_t esp;
uint32_t ebp;
uint32_t esi;
uint32_t edi;
uint32_t es;
uint32_t cs;
uint32_t ss;
uint32_t ds;
uint32_t fs;
uint32_t gs;
uint32_t ldt;
uint32_t trace;
uint32_t io_base;
};
static struct tss tss;

CPU如何知道TSS切换了?

使用TR寄存器,TR寄存器始终指向当前正在运行的任务,TR寄存器中存储的是TSS的选择子

CPU原生任务切换方式

TSS 是处理器在硬件上原生支持的多任务的一种实现方式,原始处理器厂商提供的一种多任务的切换方案是让每一个任务都关联一个TSS结构,一个任务的最新状态是处理器当前的寄存器状态,寄存器中的值可以存储到TSS结构中,任务切换只需要切换TSS就行。

在多任务操作系统中,每个任务都会关联一个TSS结构,那么每个TSS结构的位置肯定是不固定的,TSS结构也是一段内存,在任务被换下CPU时,CPU自动将当前任务的资源状态保存到该任务对应的TSS中。CPU通过新任务的TSS选择子加载新任务时,自动将新任务TSS的数据加载到CPU的寄存器中,同时更新TR寄存器为新任务的TSS的选择子。*这些都是CPU硬件原生支持的*,在任务切换的时候只需要更改TR寄存器就可以。这是处理器厂商提供的多任务的切换方式,实际上Linux 系统并没有使用这种任务切换的方式。

现代操作系统的任务切换方式

不为每个任务关联一个TSS结构,因为每个TSS结构使用时都需要在GDT中注册,GDT的描述符的个数有上限,当任务多了,就会在GDT中注册很多的TSS描述符,每当增减一个TSS描述符,GDT就需要被重新加载,消耗CPU资源,显然不高效。所以为了满足硬件机制又要提高效率,Linux的做法是为每个CPU创建一个TSS结构,TR永远指向这一个TSS结构,不再进行切换,GDTR也不会因为TSS频繁加载,TSS结构不做任务的上下文环境保护,它只有一个作用:为0特权级的任务提供栈,在进程进行上下文环境保护时,只需要把TSS中的SS0和esp0更新为新任务的内核栈的段地址和栈指针,上下文环境的保护都压栈了。当CPU从低特权级转向高特权级时(在用户模式下发生中断),CPU自动从TSS中获取0特权级下的栈,然后将上下文环境保存在栈中。

1
2
3
4
5
6
/*在gdt中创建 tss并重新加载gdt*/

void tss_init(){
memset(&tss, 0, tss_size);
tss.ss0 = SELECTOR_K_STACK;
}
1
2
3
4
5
/*在进行任务切换时,更改tss中的0特权级栈*/

void update_tss_esp(struct task_struct* pthread){
tss.esp0 = (uint32_t*)((uint32_t)pthread + PG_SIZE);
}

如何创建用户进程

创建页表

用户进程和线程一样,都有PCB,且线程的PCB和进程的PCB是一样的,唯一的区别是线程的PCB中,页表的虚拟地址为null,进程有自己独立的页表,进程的虚拟地址不为null。每一个进程的地址空间都是4GB的隔离的,所以每个进程都需要维护一个虚拟地址池。

创建3特权级栈

处理器在不同的特权级下需要使用不同特权级的栈,PCB的顶端只是用户程序运行在0特权级下的栈,用户程序运行的3特权级下也是需要使用3特权级的栈的。

进入特权级3

在用户程序之前,整个程序都是在0特权级下的,操作系统的让处理器的特权级由高到低变换只能通过中断和调用门返回,但是本操作系统中是不使用调用门的,但是用户程序还没有创建谈何从中断返回。从中断返回一定会将进入中断前由硬件自动压栈的部分寄存器以及手动压栈的4个段寄存器和8个通用寄存器全部弹出到相应的寄存器中,这才能全部恢复任务的上下文环境。那么我们利用中断返回指令 iretd指令从中断返回的方式恢复用户级环境,假装现在的0特权级环境是由用户级通过中断进入的,那么就需要在中断栈中先写好用户的上下文环境,等到中断退出时就能进入到用户级环境下了。

所以进入特权级3的环境下,需要在中断栈中填好3特权级下的信息

① 在PCB的中断栈中预先准备好3特权级的运行环境,借用一系列pop出栈的操作恢复用户进程的运行环境

② 从中断退出后处理器的特权级为3特权级,处理器根据中断栈中的CS.RPL 来判断退出中断后要进入的特权级,所以预先填入的CS选择子的RPL=3

③ 退出中断时需要进行特权级检查,为了避免低特权级访问高特权级的资源,需要将栈中的段选择子的RPL设置成用户的CPL,CPU只负责检查,设置是由操作系统做的,所以栈中段寄存器的选择子必须要指向DPL=3的内存段

④ 退出中断后还需要保证操作系统能够继续响应中断,需要将eflag寄存器的IF位置为1

⑤ 不允许用户进程直接访问硬件,需要将EFLAGS的IOPL为置0

创建用户进程的具体过程

step1:创建进程PCB结构

①在内核内存池中申请一页内存作为用户的PCB。进程和线程的唯一区别是,进程有自己的空间,线程没有,也就是说进程有页表,线程没有,而且为了实现进程之间的地址隔离,还要为每一个进程维护一个虚拟地址池

②初始化PCB下方的进程信息和上面的内核线程栈,在内核线程栈中的eip 初始化为创建进程上下文的函数③,以便在switch_to 的上下文切换中能够让用户进程运行创建进程上下文环境的函数③,并且退出中断恢复特权级3的环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 进程或线程的pcb,程序控制块 */
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
char name[16];
uint8_t priority;
uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数

/* 此任务自上cpu运行后至今占用了多少cpu嘀嗒数,
* 也就是此任务执行了多久*/
uint32_t elapsed_ticks;

/* general_tag的作用是用于线程在一般的队列中的结点 */
struct list_elem general_tag;

/* all_list_tag的作用是用于线程队列thread_all_list中的结点 */
struct list_elem all_list_tag;

uint32_t* pgdir; // 进程自己页表的虚拟地址

struct virtual_addr userprog_vaddr; // 用户进程的虚拟地址
uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 构建用户进程初始上下文信息 */
void start_process(void* filename_) {
void* function = filename_;
struct task_struct* cur = running_thread();
cur->self_kstack += sizeof(struct thread_stack);
struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack;
proc_stack->edi = proc_stack->esi = proc_stack->ebp = proc_stack->esp_dummy = 0;
proc_stack->ebx = proc_stack->edx = proc_stack->ecx = proc_stack->eax = 0;
proc_stack->gs = 0; // 用户态用不上,直接初始为0
proc_stack->ds = proc_stack->es = proc_stack->fs = SELECTOR_U_DATA;
proc_stack->eip = function; // 待执行的用户程序地址
proc_stack->cs = SELECTOR_U_CODE;
proc_stack->eflags = (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1);
proc_stack->esp = (void*)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE) ;
proc_stack->ss = SELECTOR_U_DATA;
asm volatile ("movl %0, %%esp; jmp intr_exit" : : "g" (proc_stack) : "memory");
}

step2:创建用户进程的上下文环境,并退出中断

①从中断返回进入用户态,将用户进程的上下文环境保存在中断栈中

②在栈中存储的CS选择子的RPL必须为3

③用户进程使用的栈SS必须指向DPL为3的内存段

进程的PCB的顶端是进程在0特权级下使用的栈,进程在3特权级下要使用3特权级的栈,且根据进程的4GB的内存分布,0xc0000000~0xffffffff是内核空间,0xc0000000-1是用户空间的最高地址,该地址就是用户进程在3特权级下栈能访问到的最高地址,所以3特权级下栈的地址是0xc0000000,需要在用户内存池中申请一页物理内存,并将这个物理内存和0xc0000000这个虚拟地址进行一个映射。所以用户空间下的栈顶虚拟地址都是0xc0000000,实际的物理地址是用户空间内存池中任意一个空闲的物理页。

④eflag的IF位为1,保证在用户态能够触发中断

⑤eflag的IOPL为为1,禁止用户访问硬件

⑥将当前的栈设置为中断栈,然后jmp到中断退出口,将提前写入中断中的用户进程的上下文环境的寄存器资源弹出到相应的寄存器中,就实现了进入用户态

step3:创建用户进程页目录表

因为用户进程的页目录表是由操作系统管理的,所以需要在内核的物理池中申请

将内核页表的第768项到第1022项拷贝到用户程序页目录表的第768项~第1022项,第1023项为自己页目录表的物理地址。

step4:创建用户进程虚拟地址位图

算一下用户可用空间有多大,需要多大的内存位图去管理,因为用户4GB内存空间,因为0xc0000000~0xffffffff的空间给内核了,可以使用的空闲虚拟内存空间大小为(0xc0000000-USER_VADDR_START),其中USER_VADDR_START是宏定义,linux的用户进程的入口地址一般是0x804800,计算出这些可分配的虚拟地址空间所需要的页表个数是bitmap_pg_cnt,然后在*内核内存池*中申请bitmap_pg_cnt个物理页,返回的起始虚拟地址就是进程PCB中虚拟地址池的起始地址。

step5:将用户进程虚拟地址位图和页表写入其PCB对应的字段

step6:将进程PCB加入就绪队列,等待调度

进程调度schedule()

Schedule()调度程序在时钟中断处理函数中,当时间片到期了,如果运行状态是运行态,说明时间片到期,将它的状态设置为就绪态,重新插入就绪队列的队尾。然后弹出就绪队列中队首PCB,根据新的PCB是内核线程还是用户进程做不一样的操作:

*【如果新的PCB是用户进程】*,更新页表寄存器CR3为当前运行进程的页目录表,然后更新TSS的esp值为用户进程的0特权级栈,也就是PCB结构最上面的栈指针。

*【如果新的进程是内核线程】*,更新页表寄存器CR3位0x100000(内核的页目录表的物理地址是0x100000),它是内核的页目录表,并且不用更新TSS 的esp值,因为内核态就是0特权级。

如何实现虚拟地址空间隔离

每一个用户进程都有自己的页表,CR3寄存器只有一个,每当切换一个任务的时候,都要更新CR3寄存器为当前任务的页表。

总结:线程和进程的区别以及使用场景

多进程和多线程资源分配区别

  1. 多进程:每个进程都有独立的地址空间,他们在内存中有自己独立的区域来存储代码、数据和堆栈。例如一个进程的全局变量在另一个进程中是不可见的,进程之间的数据交换相对复杂,需要通过进程间通信机制,例如管道、消息队列、共享内存来实现。

  2. 多线程:线程共享进程的地址空间,线程之间共享全局变量和堆内存等资源,线程之间的数据共享相对简单,但是也有资源竞争等问题,需要同步机制(互斥锁、信号量)来解决。

多进程和多线程调度区别

  1. 多进程切换:进程之间的切换开销大,涉及整个地址空间的切换、保存和恢复进程上下文(寄存器值、程序计数器等)

  2. 多线程切换:线程之间切换相对进程更小,因为线程共享进程的地址空间,只需要切换线程的私有数据(如栈指针、程序计数器等)和保存恢复一些寄存器的值(就是遵守ABI规则的那4个寄存器)

线程和进程的稳定性差异

  1. 多进程:一个进程的崩溃通常不会影响其它进程的运行,因为进程之间是地址隔离的。例如邮件服务进程的崩溃不会影响到web服务进程。

  2. 多线程:线程共享进程的资源空间,一个线程出现问题可能会导致整个进程崩溃,例如死锁,例如一个线程因为内存访问错误而导致整个服务进程退出。可以这么理解,线程是共享进程空间的,如果一个线程出现了问题,相当于一个大家庭中出现了反贼,使得家庭里面的每个成员(线程)变得不再可信,最终导致家庭破裂(进程崩溃)。

进程和线程的使用场景

****【CPU密集型】:****需要大量计算资源的任务(数学运算、逻辑处理、图像处理、数据压缩等),性能主要受限于CPU的计算能力

****【I/O密集型】:****需要大量输入输出操作(文件读写、网络请求、数据库查询等),性能主要受限于I/O设备的速度。

【多进程适用场景】:

CPU密集型任务(图像处理、数据计算等)、且需要高稳定性要求(进程相互独立的,一个进程崩溃了不会影响其它进程)、使用于多核CPU的,能充分利用多核CPU。

*【优点】*

①性能高,进程之间不会相互影响

②能充分利用多核CPU

③资源隔离性好

*【缺点】*

①创建进程和切换进程的开销比较大

②进程间的通信复杂且开销大

【多线程适用场景】:

适用于I/O密集型任务(例如网络请求,文件读写等需要等待外部操作的任务)、线程切换开销小,适合需要快速响应的任务、创建线程和切换的开销小,适合需要大量并发但资源有限的场景;

*【优点】*

创建和切换的开销小

线程之间的通信方便

*【缺点】*

稳定性低,一个线程崩溃可能会影响整个进程

需要处理线程同步和竞争的问题

无法充分利用多核CPU

评论