操作系统 (6) 线程与进程的创建

511 阅读8分钟

本文引用图片如无注明均来自 李治军: 操作系统32讲

Linux早期版本并没有线程的概念,后面引入线程后也没有为线程设计特殊的数据结构而是和进程一样用task_struct来表示,一般通过pidtgid来判断是进程还是线程,PCBTCB则是基于task_struct衍生出来的数据结构

创建进程一般用fork,线程用pthread_create,但最终都是调用了copy_process

图片来源: Gityuan

讲线程创建之前先讲线程切换,主要是中断出入口和切换两块

中断入口和出口

线程切换从中断开始,假设某个线程执行流程如下:

image.png

fork是系统调用会引起中断使得线程切换到内核态执行,在进入内核态时系统会自动将线程的用户栈和内核栈关联起来。SSSP分别执行用户栈的栈底和栈顶,ret(表示CS和PC)中存放mov res,%eax的地址(表示系统调用完成后应该跳到该处执行)

我们知道system_call是所有中断的入口,它会根据寄存器中的中断号查找sys_call_table并执行相应的函数,这里为了方便表述图中直接使用call sys_fork表示整个过程

image.png

在系统调用过程中是有可能发生阻塞的,sys_fork可能没那么直观,我们以sys_read为例,我们读文件的代码最终会导致sys_read被调用,sys_read需要等待磁盘响应所以会被阻塞住

sys_read的例子就是用来说明系统调用过程是可能导致阻塞的,我们这里的sys_fork也不例外。但是这里说的阻塞并不是指一直卡在系统调用的函数里不返回,而是当前线程会卡在内核态里不返回用户态

如上图,sys_fork里需要申请资源的时候就把资源请求放到相应的队列(例如sys_read就把请求放到磁盘调度队列)然后设置当前线程的状态为阻塞态sys_fork就执行完了

sys_fork执行完成之后返回到system_call的执行流,system_call在后面会比较当前线程的状态和时间片以确定线程是否阻塞,如果阻塞就调用reschedule让内核调度其他线程执行,reschedule一路往下最终会调用switch_to在其中完成线程切换,切换完成后cpu已经指向新的线程,当前线程卡在switch_to中(卡在内核态)

此时当前线程的和新线程的栈中内容和下图类似,新的线程之前也是卡在switch_to中,被重新选中后就沿着栈一路回退直到返回用户态继续执行

图片来源: rlk8888 image.png

当前线程阻塞后会被内核放入阻塞队列当所需资源就位后(例如磁盘阻塞中,磁盘驱动器将数据读入内存后会发出中断指令)才会被移出

reschedule中真正进入调度流程前会将ret_from_sys_call的函数地址放入栈中,当线程再次被调度的时候(此时所需资源已就位)将执行该函数。ret_from_sys_call的作用就是让线程从内核态返回到用户态,这样用户代码就可以继续执行

中断进入时根据硬件设定一堆寄存器的值会自动被push进栈,中断返回时(ret_from_sys_call)就是相应的一堆寄存器的值被pop出来(iret指令)

回到用户态前我们回顾内核栈中保存的retret保存的是mov res,%eax,也就是int 0x80的后一句,所以回到用户态后或者说中断完成后紧接着执行流程中的代码,这样进入中断和从中断返回的故事就通顺了

image.png

切换

Linux基于内核栈切换来实现线程切换,也就是保存当前上下文到TCB,然后将新线程TCB中的数据覆盖取出来当前CPU寄存器完成切换,这些切换是由代码实现的。这一部分可以参考 操作系统(5) 内核级线程

在Linux 0.11中进程(此版本还没有线程)切换是使用TSS(Task State Segment)完成的

image.png

switch_to函数里使用内嵌汇编调用ljmp指令,该指令由硬件设计保证完成TSS之间的替换,具体过程为根据当前TR寄存器的值在GDT表中找到TSS描述符进而找到内存中的TSS,然后将当前CPU寄存器的值复制一份放到TSS中完成上下文(如上一节描述的内核栈栈顶和栈底等内容)保存

后面再将调度选出来的新的TSS的内容复制到CPU寄存器就完成了线程的切换

调度过程怎么选出新的TSS是一个复杂的问题本文暂不讨论,另外ljmp一条指令就要完成这么多内容的切换所以实现起来是很复杂的,而且因为是单条指令不能进行指令流水无法利用CPU的硬件加速,所以该方法效果一般

创建

如前文所述无论创建进程还是线程最终都会调用copy_process,图中使用call _copy_process表示整个流程

image.png

copy_process的参数是直接从父进程的内核栈里取的,它其实就是把父进程的资源都复制一份然后形成自己的内核栈,用户栈用的是父进程的

image.png

更详细一点说,此时子进程和父进程是共享用户态内存的,父子进程拥有各自的虚拟地址空间,但虚拟地址空间页表对应的物理内存是一样的,且物理内存被设置为只读

图片来源:bob62856

1.png

copy_process产生的效果就如同fork这个单词的含义一样,制作了一个叉子,虽然用户栈相同,但内核栈不同,操作系统看到的就是两个线程(进程)

image.png

copy_process复制父进程资源的时候eipeax这两个寄存器的值比较关键,回顾本文第一节 "中断入口与出口" 可以知道eip指向int 0x80的后一句指令也就是父进程调用创建进程(线程)的系统调用返回后的第一句指令,eax置为0则中断返回后会将res置为0,最后就是将状态置为running

image.png

因为copy_process会将子进程(线程)的eax置为0(也就是返回值为0),所以我们常常可以看到这样的代码:

image.png

fork返回0表明现在是子进程,执行花括号里面的内容,否则是父进程(返回子进程的id)执行其他内容,这里要注意子进程被创建出来后父子进程谁会被调度是不确定的,有可能子进程创建完成后从中断返回前父进程阻塞或者时间片耗尽导致子进程被调度执行(可参考中断入口和出口一节)

当父或子进程在运行时,如果进行了内存写操作(如变量赋值、函数调用)则会触发写时复制(Copy On Write),此时对应物理内存的内容会被复制到新的物理内存,同时进程虚拟地址空间对物理内存的映射也会相应变化,父子进程相应的内存便独立开来

图片来源:bob62856

2.png

子进程创建完成就用exec执行命令(比如打印hello),关键在于让子进程知道执行入口在哪,也就是exec中断返回后要回到用户态执行的时候应该找到哪个地址(线程部分大同小异,此处只以子进程为例)

exec操作就是将静态的程序读进内存,替换了进程的代码段和初始化了数据区域,当进程从exec中断返回后执行的就是新程序的代码

image.png

exec最终会调用_do_execve,调用前会把内核栈存储eip的位置的地址放到eax,然后eax(eip的地址)入栈作为参数

image.png

然后将可执行文件(目标进程)的入口地址赋值给eip[0],也就是内核栈中存储CS和IP的对应位置,那么中断返回后就能找到要执行的指令了

ex.a_entry是将我们的代码链接编译成程序的时候写入的,操作系统将程序从磁盘读入内存的时候可以从文件头里读取入口地址,在 操作系统(3) 多进程图像 介绍过每个进程都会有自己的虚拟地址空间,操作系统读取文件头后会建立可执行文件和虚拟地址空间的映射,这样子进程就可以顺利的找到入口地址并执行指令了

参考文献