【Operating System】进程 - 进程(process)与线程(thread)

Posted by 西维蜀黍 on 2019-02-14, Last Modified on 2021-09-30

进程(Process)

进程的概念

进程(Process)是操作系统管理资源的基本单元。

一个进程对应有一块**(内存)地址空间(address space)**,进程可以对这块自己的地址空间进行任意的读写,这块地址空间中包括可以执行的程序(executable program)代码区、数据区(program’s data)和栈(stack)。

同时,操作系统会维护一个进程表(process table),进程表是一个数组,用于管理当前系统中所有的进程。

进程状态

在操作系统中,有三种状态:

  • 运行(Running):在这一刻使用CPU。
  • 就绪(Ready):可以被运行(runnable),只是暂时地被停止以让其他进程运行。
  • 阻塞(Blocked):不能被运行直到一些依赖的外部事件发生。

运行(Running)和就绪(Ready)是类似的,只是处于就绪情况时,暂时地没有可用的CPU时间片。当CPU有可用的时间片时,进程调度器(Process Schuduler)会将这个进程从就绪态切换为运行态。

阻塞(Blocked)与上面两种状态有本质上的不同,即使这时CPU空闲没有其他任务,这个进程也由于要等待依赖事件的发生而不能被运行。

进程状态状态机(State machine)

下图是这三种状态变化的状态机(State Machine):

状态迁移1

当一个进程读取管道(pipe)或者特定文件,且此时还没有完成数据的获取时,进程会自动从运行态切换为阻塞态(发生状态迁移1)。

状态迁移2和状态迁移3

状态迁移2和状态迁移3由**进程调度器(Process Scheduler)**触发,进程调度器是操作系统的一部分,而进程调度器对进程来说是透明的(进程不会意思到它的存在)。

当进程调度器认为一个正在运行的进程已经执行了足够长的时间,是时候到其他进程来占用CPU时间了,进程调度器就会将这个进程从运行态切换为就绪态(发生状态迁移2)。

类似的,当进程调度器认为是时候让这个进程来重新获得CPU了,进程调度器就会将这个进程从就绪态切换为运行态(发生状态迁移3)。

状态迁移4

当依赖的外部数据到达,进程就会从阻塞态切换为就绪态(发生状态迁移4)。如果此时CPU空闲,进程就会紧接着从就绪态切换为运行态(发生状态迁移3)。

进程的调度(Process Scheduling)

在不同的场景中,需要的调度算法是不同的。这些场景可以被分类为:

  • 批处理(batch)系统
  • 交互型(interactive)系统
  • 实时(real-time)系统

线程(Thread)

为什么要有线程

如果非要问为什么需要线程,还不如问为什么需要进程中还有其它的进程。这些进程中包含的其它迷你进程(Miniprocess),就是线程。

线程之所以说是迷你进程,是因为线程和进程有很多相似之处,比如线程和进程的状态都有运行,就绪,阻塞状态。


我们希望在一个应用程序中,多个任务能同时运行。他们其中的某些任务可能时不时会被阻塞。通过将这样的应用程序分解成多个串行(sequential)的线程,能够使它们伪并行(quasi-parallel)地运行。

这里讨论的上下文有一个前置条件,我们的CPU是单核单线程的。因此,在某一瞬间只有一个进程或其中的一个线程被运行。然而,通过拆分CPU时间片来做到“并行”。所以我们成为伪并行(quasi-parallel)。


我们希望有一种并行实体(Parallel entity),能够共享内存空间,且它又比进程要轻量(它们被创建/销毁时的成本低),这时线程就诞生了。

总结来说,线程的好处如下:

  1. 在很多程序中,需要多个线程互相同步或互斥地并行完成工作,将这些工作分解到不同的线程中去无疑简化了编程模型。
  2. 线程相比进程来说,更加的轻量,所以线程的创建和销毁的代价远远小于对应进程的操作(10到100倍)。
  3. 线程提高了性能,虽然线程宏观上是并行的,但微观上却是串行。从CPU角度而言,线程并无法提升性能,但如果某些线程涉及到等待资源(比如I/O,等待输入)时,多线程允许进程中的其它线程继续执行而不是整个进程被阻塞,因此提高了CPU的利用率,最终提升了性能。
  4. 在多CPU或多核的情况下,使用线程不仅仅在宏观上并行,在微观上也是并行的。

下面我们来看一个具体的例子:

就拿我写博客的LiveWriter来说,LiveWriter需要监听我打字输入的状态,还需要每隔5分钟对草稿进行自动保存。假设如果这个进程只有一个线程的话,那么当对草稿进行保存时,因为此时需要访问硬盘,而访问硬盘的时间线程是阻塞状态的,这时我的任何输入都会没有响应,这种用户体验是无法接受的,或许我们可以通过键盘或者鼠标的输入去中断保存草稿的过程,但这种方案也并不讨好。而使用多线程,每个线程仅仅需要处理自己那一部分应该完成的任务,而不用去关心和其它线程的冲突。因此简化了编程模型。如下图所示。

这里值得注意的是,上面的两个线程如果改成两个进程,那么达不到所要的效果,因为进程有自己独立的内存地址空间,而线程共享进程的内存地址空间。

经典的线程模型

一个进程模型通常包含两个事务:资源的组织(grouping)和执行(execution)。在过去没有线程的操作系统中,资源的组织和执行都是由进程统一完成的。换句话说,完成一个任务所用到的数据资源和这个任务的执行都由一个进程管理。

当引入线程后,对于资源执行的管理粒度更细了,即进程对完成一个任务所用到的数据资源进行管理(通过存储在同一块内存区域);而由进程中的线程来负责具体资源的执行,线程共享资源的组织(即一个进程中的多个线程均可访问进程的资源)。

线程,是每一个进程中执行的一个条线。线程虽然共享进程中的大多数资源,但线程也需要自己的一些资源,比如:用于标识下一条执行指令的程序计数器(Program Counter),一些容纳局部变量的寄存器(Register),以及用于记录执行历史的栈(Stack)。

总而言之:进程是组织资源的最小单位,而线程是安排CPU执行的最小单位。

我们来看一个例子,在 (a) 中,有三个进程,每个进程拥有自己的内存地址空间(address space)和一个单独的线程;而在 (b) 中,只有一个进程,这个进程中包含三个线程,且这三个线程共享同一块内存地址空间。

每一个进程和线程所独自占有的资源如下所示:

  • 进程占有的资源
    • 内存地址空间(Address space)
    • 全局变量(Global variables)
    • 打开的文件(Open files)
    • 子进程(Child processes)
    • 等待的警告(Pending alarms)
    • 信号量和信号吹程序(Signals and signal handlers)
    • 账户信息(Accounting information)
  • 线程占有的资源
    • 程序计数器(Program counter)
    • 寄存器(Registers)
    • 栈(Stack)
    • 状态 (State)

线程与进程拥有完全相同的状态:运行(running)、阻塞(blocked)、就绪(ready)和停止(terminated)。

另外,每一个线程都拥有独立的栈(stack),如下图所示。在栈中,包含多个frame,每一个frame都对应一个被调用但未被返回的方法。在每一个frame中,包含这个方法的局部变量表(local variables)和这个方法的返回地址。

操作系统实现线程的几种方式

在用户空间中实现线程

若在用户空间(User Space)中实现线程,对内核而言,只存在包含一个单线程的进程。

这样的做法最大的好处在于当操作系统不支持线程时,我们仍然可以通过库函数(Library)来支持多线程。事实上,仍然有部分现代操作系统并不支持线程。

在这种模式下,在每一个进程中,都有一个自己进行管理的线程表(thread table)。

另外,在用户空间中实现线程还有一个好处,就是每一个进程都拥有定制化的线程调度算法。

有好处就有坏处,这种模式最致命的缺点也是由于操作系统不知道线程的存在,因此当一个进程中的某一个线程进行系统调用时,比如缺页中断而导致线程阻塞,此时操作系统会阻塞整个进程,即使这个进程中其它线程还在工作。还有一个问题是假如进程中一个线程长时间不释放CPU,因为用户空间并没有时钟中断机制,会导致此进程中的其它线程得不到CPU而持续等待。

在操作系统内核中实现线程

若在操作系统内核(Kernel)中实现线程,内核会为每个进程维护一张线程表(thread table),以跟踪这个进程中所有线程的状态(比如有线程被销毁或新的线程被创建了)。在线程表中,每一行记录了每一个线程的寄存器、线程状态等信息。

在这种模式下,所有可能阻塞线程的调用都以系统调用(System Call)的方式实现,相比在用户空间下实现线程造成阻塞的运行时调用成本会高出很多。当一个线程阻塞时,操作系统可以选择将CPU交给同一进程中的其它线程,或是其它进程中的线程,而在用户空间下实现线程时,调度只能在本进程中执行,直到操作系统剥夺了当前进程的CPU。

因为在内核模式下实现进程的成本更高,一个比较好的做法是另线程回收利用,当一个线程需要被销毁时,仅仅是修改标记位,而不是直接销毁其内容,当一个新的线程需要被创建时,也同样修改被“销毁”的线程其标记位即可。

这种模式下同样还是有一些弊端,比如接收系统信号的单位是进程,而不是线程,那么由进程中的哪一个线程接收系统信号呢?如果使用了表来记录,那么多个线程注册则通过哪一个线程处理系统信号?

混合模式

还有一种实现方式是将上面两种模式进行混合,用户空间中进程管理自己的线程,操作系统内核中有一部分内核级别的线程,如下图所示。

在这种模式下,操作系统只能看到内核线程。用户空间线程基于操作系统线程运行。因此,程序员可以决定使用多少用户空间线程以及操作系统线程,这无疑具有更大的灵活性。而用户空间线程的调度和前面所说的在用户空间下执行实现线程是一样的,同样可以自定义实现。

进程与线程的区别

谈了这么多,我们来总结一下进程与线程的区别。

进程

进程是运行着的程序,是系统进行资源分配的一个独立单位。进程之间相互独立,多个进程的内部数据和状态都是完全独立的;而同一进程的线程之间共享数据段(全局变量),但是每个线程有自己的程序计数器和栈。

线程

线程是进程的一部分,是 CPU 调度和分派的基本单位,也是比进程更小的能独立运行的基本单位,线程基本不拥有系统资源,只拥有一点在运行中必不可少的资源(程序计数器,一组寄存器和栈),但是它可以和进程的其它线程共享一个进程所拥有的全部资源。每个线程有自己的栈(Stack),而共用主进程的堆(Heap)。

一个进程异常退出不会引起另外的进程运行异常;但是线程若异常退出一般是会引起整个进程奔溃。

而且,创建/撤销/切换进程的开销远大于线程的(创建线程比创建进程快10~100倍)。

Reference