“silhouette of mountain at daytime” by Willian Justen de Vasconcellos on Unsplash

线程和进程

gao ge
9 min readSep 14, 2018

--

在操作系统中,线程(thread)与进程(process)是非常重要的概念,它们是操作系统对硬件资源的抽象,赋予应用可执行能力。

但线程和进程的定义不是不变的,以linux的线程实现为例(linux中线程和进程的实现没有本质区别):

  • 对于内核而言,线程是可调度的(schedular)、可执行的(executable)的最小单元。
  • 对于CPU而言,线程是相关寄存器的集合。

在谈到线程和进程时,我们还经常会提到另外两个名词,堆和栈。

通过命名,我们就能了解到,栈应该是一个线性结构,保证先进后出原则。但数据结构仅在操作系统及其上层级才有意义,对于CPU而言,是否存在“栈”呢?答案是当然存在。如上文所说,CPU是通过寄存器知晓栈的。

栈结构通过两个寄存器,bp和sp,分别指向栈底和栈顶实现的。可是仅仅两个寄存器,CPU又如何知晓每个栈帧的大小呢?

在回答这个问题前,我们先思考下栈结构的特性——除了先进后出原则外,做为线性结构,删除和添加元素也必然是串行的——这就意味着每次只能push或pop一个栈帧。因此,CPU规定:

  • push:1)将当前的bp的值存在sp指向地址。2)推入新的栈帧。3)更新bp的值为sp指向的地址的下一位(当前位是保存的bp值)。4)“增加”sp的值,为新栈帧的大小。

在操作系统中,栈是从高地址向低地增长的,所以这里的值实际上是在“减少”。

  • pop:1)更新sp为bp的值的上一位。2)更新bp的值为sp指向的地址的下一位的值(之前保存的bp值)。

这里,我们可以看到,整个push和pop过程中,没有任何向系统申请内存,也没有任何释放内存的操作。因此,栈操作是非常高效的。

上面描述的内存操作细节中,bp、sp指向地址的下一位等,要看标准的具体定义,不保证一定符合最终的实现细节。

具体到编程语言里,这里的栈帧就是一个函数调用所需的(userspace的)资源,如本地变量,函数参数,返回值(可能会被RVO/NRVO优化掉),因此栈又被称为调用栈。

在数据结构中,堆是一种树结构,但想想日常编程,申请和释放堆内存时,我们没有进行任何树相关的操作。这里我们需要深入动态内存管理的实现细节。

在深入了解前,我们先将动态内存区称为池(Pool),我们向池申请某个大小的内存,在合适的时间点再返回给池。但内存是物理资源,不是编程语言领域的资源,因此不能通过复制、引用等方式说明该块内存是否被使用。在glibc中,malloc通过一个命名为chunk的结构体来表示,它指示内存区域是否被使用。这里,chunk间可以是链表或者。因为历史原因,不知何时,大家都用堆来称呼动态内存区。从这点上来看,可能称为池更合适,但实际上现代操作系统非常复杂,以进程为单元,在操作系统层面,一个进程内可存在多个“堆”——比如当前首次分配的堆用完后,需要分配新的堆,或者通过mmap映射到进程外的一块内存区域——这些“堆”如果统称为池就有些不合适了。

关于函数调用的具体细节和内存管理,我将在新的文章里做详细描述。

线程

在讨论完栈和堆后,我们来看下线程。在linux中,线程是最小的可调度单元,持有自己的栈和线程特有数据(TSD),在内核中还对应维护一个线程控制块(TCB)。线程和线程间是隔离的,但同一个进程下的线程们共享同一个堆。

进程

进程则更像是应用层的概念——每个进程对应一个可运行的应用。进程初始化时默认包含一个线程,称为主线程。进程可以有多个进程,堆对于进程而言是唯一的。内核在内核空间会对应地维护一个进程控制块(PCB)。进程与进程间是隔离的,用户空间内没有任何共享资源。

看上去进程和线程的概念十分简单明了,但具体到某个特定问题上时,情况就变得复杂起来——当我们在调用pthread_create时,具体发生了什么?

实现细节

在创建新线程时,实际上会创建两个栈,分别为用户栈和内核栈。从名字可以看出,两个栈分别存在与用户空间和内核空间。所有的系统调用都在内核栈上进行,普通的函数调用则在用户栈上进行。

为什么创建线程时要初始化两个栈?

  1. 为了隔离操作,如果一个函数调用出现问题了,throw exception后unwinding时不会传递到系统调用上。
  2. 系统调用通过INT 80触发,该终端发生时,CPU由用户态trap到内核态,此时CPU的权限由ring3提高到ring0,被允许执行所有的指令。两个栈分离了权限。

这里我们详细说明下系统调用的工作原理。

首先,从代码的角度来看,系统调用是我们通过相关函数触发的,但请注意,系统调用不是函数。以malloc为例,它可能触发brk系统调用,而brk的实现地址存在于内核中,当我们编译如libc库时,我们无法得知brk的起始位置,因此就无法“静态”地调用它。

同时,系统调用需要CPU的运行模式为ring0,而不是通常的ring3,这里涉及了权限保护问题,需要环境隔离。

因此,通常的实现是通过INT80(当然,现代CPU还会支持更快的SYSENTER),trap到内核,从用户态转为内核态,权限提升,将系统调用需要的参数从用户态copy到内核态(实际上无论是用户栈copy到内核栈还是将系统调用的返回值传给调用方,都是通过寄存器的),根据syscall_num,硬件通过查询TSS(Task State Segment),找到内核地址中相应的系统调用的实现地址,调用它。

这里还有个小细节,在trap前(注意不是指调用lic里的包装函数前),用户栈不会保存当前寄存器值到栈上,而是将寄存器中的ESP(栈顶寄存器),EIP(指令寄存器)push到内核栈上,同时还会push用户栈的SS(stack segment),CS(code segment)和EFLAGS。在恢复到用户态时,就会根据这些保存的寄存器值来恢复之前的执行环境。

而当存在多个线程时,又涉及到内存对线程的调度问题。在看操作系统相关书籍时,我们经常会看到一段对线程模型的描述:

  1. 一对一模型:一个内核线程对应一个用户线程。
  2. 一对多模型:一个内核线程对应多个用户线程。
  3. M对N模型:M个内核线程对应N个。

同时,还会分别描述三个模型间的优缺点。比如一对一模型最简单,一对多模型需要用户态线程调度器,M对N模型在一对多模型的基础上还需要让用户态线程调度器和内核态线程调度器通信。基本所有的操作系统实现都选择一对一模型。

但我们似乎忽略了几个问题——什么是内核线程?什么是用户线程?pthread_create时创建了两个线程?内核怎么调度用户线程的?有没有用户态线程调度器?

实际上,linux中没有内核线程,或者说,内核线程指的是内核需要串行化做某件事时创建的线程,和多线程模型没有关系。

我们可以反向思考下,如果pthread_create时会创建一个内核线程一个用户线程,那么对内核而言就有两个可调度对象?可内核应该是与用户空间是隔离的,它如何感知到用户态线程的状态的?如果用户线程由用户态线程调度器调度,那么在平常编程时,我们有涉及到调度器吗?pthread又是怎么实现用户态线程调度器的?这样一思考,我们就发现,对于一对一模型,创建一个线程就足够了,因此根本就没有用户线程,内存线程之分,可调度对象只有一个,也不存在任何用户态线程调度器。

实际上,多线程模型中提到的两种线程,更多的谈的是执行上下文,而不是可执行、可调度对象。

一对一模型的优势是非常简单,M对N则是过于复杂,那么一对多模型就没有优势了吗?

其实不是这样的。近几年大火的协程(coroutine)概念,就是一种典型的一对多模型。我将在新的文章中详细的介绍协程。

阻塞、非阻塞、同步、异步

这里提到这四个名词,是因为很多人都会将它们和进程、线程联系到一起。

实际上,同步和异步指的是消息的通知方式,具体到代码上,指的是caller如何获得callee的结果,如果caller主要询问结果,如正常的函数调用,为同步;如果callee来通知caller结果,如回调,为异步。比如JavaScript是单线程语言,ES7中的async函数,其实就是返回promise对象,消息通知则是绑定到promise上的匿名函数,这种通知风格本质上还是回调,因此确实为异步,它和进程、线程并无联系。

而阻塞、非阻塞,指在处理一个事件时是否会阻塞后续事件的处理,具体到代码上,调用一个方法时,在方法未返回前,是否可以调用下一个方法。看上去这里和栈、线程等概念是有联系的,但实际上阻塞、非阻塞描述的是可执行对象(executable object)。在绝大多数语言中,可执行对象即原生的thread(pthread),而在某些语言中并非如此,如erlang、golang等。

因为我们通常接触的可执行对象即为原生线程,同时异步也通过新开线程的方式实现,但阻塞、非阻塞与同步、异步间是不一样的,同样,它们和线程也并无联系。我们要分清相关概念,在之后的文章中遇到时才不会混淆,理解错语义。

线程和进程是非常重要的概念,了解了它们,我们才能更好地深入底层,去学习底层知识。

--

--