0%

进程与线程

进程是什么?

一切的一切,要从进程说起,我们编写出来的代码只是在硬盘上存储的静态文件,经过编译器编译成二进制可执行文件后,才可以被装载到内存中运行,而CPU也会执行其中的每一条指令,这个运行中的程序就是进程(Process

若是运行的程序中需要从磁盘或者网络IO中读取数据,这个时候不会阻塞等待读取完毕,而是会去执行别的进程,等到数据返回接收到中断后,这个进程才恢复到就绪状态。多个进程(程序)交替执行的思想就是CPU管理进程的初步思想。在一个较短的时间内,多个进程交替执行,但是任意时刻,都只有一个进程在执行,这就是并发,若是同一时刻有多个进程同时执行,这就是并行。那么在CPU上多个进程不断交替执行,由此可以引出进程的几个基础状态:创建、就绪、运行、阻塞和结束。从进程的角度来看,若是进程状态不断切换,CPU中应该保存好进程的状态信息,而CPU管理进程需要的就是进程控制块(PCB,PCB是进程存在的唯一标识,生命周期伴随整个进程。PCB在通过链表的形式组织,逻辑上是列表

大多数操作系统都是多任务系统,支持多个任务差不多在“同时”运行,而这只是宏观上的同时,其本质仍然是并发,再加上大多数的调度策略都是基于时间片(OS给每个任务分配定额时间片,到期会让出运行状态),这才有了任务调度的概念。而任务的调度本身是基于CPU上下文切换的,首先要明确的是CPU上下文是什么,其实就是CPU寄存器和程序计数器这些CPU运行任何任务前必须依赖的环境。而进程上下文切换的范围其实远大于CPU上下文切换,其切换的内容不仅包括虚拟内存、栈、全局变量等用户空间资源,还包括内存堆栈、寄存器等内核空间资源。

以上几段将进程相关的内容进行了一个概括,但是其实这个问题的答案在第一段就已经给出,进程就是运行的程序。这些内容中还有许多有关进程的重要内容,以下进行解释。

进程的状态

进程的状态包含:创建、就绪、运行、阻塞、结束。其中针对就绪和阻塞还有挂起状态。
image.png|500
其中只有就绪状态和阻塞状态的进程的PCB有链表组成的队列。对于单核CPU而言,运行态的进程只有一个运行指针。
引入挂起状态的原因是现在的虚拟内存分配策略,该策略的引入核心是为了解决大量阻塞状态的进程PCB存储在内存中浪费本就珍贵的内存资源,所以会将阻塞状态的进程的物理内存空间换到硬盘,等到需要再次运行的时候,再从硬盘换入到内存上,因此才引入一个新的状态描述进程没有占用实际的物理内存空间的情况,这就是挂起状态。挂起状态又分为就绪挂起和阻塞挂起,其中阻塞挂起需要等待事件的出现,而就绪挂起只待换入内存就会立刻运行。

进程的控制

正如前文所言,进程存在的唯一标识是PCB,其生命周期与进程相随,所以进程控制的核心就是PCB的控制,以下从进程的生命周期,以PCB的信息进行分析,首先介绍PCB中的要素。
进程描述信息:

  • 进程标识符:每个进程唯一。
  • 用户标识符:进程归属的用户,主要用于共享和保护等权限控制。
    进程控制和管理信息:
  • 进程当前状态:如new、ready、running、waiting和blocked等。
  • 进程优先级:进程抢占CPU时的优先级。
    资源分配清单: 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用IO设备的信息。
    CPU相关信息: 寄存器中的值。

而PCB在内存中以链表形式组织的原因是为了更加方便的插入和删除。

  1. 创建进程
    操作系统运行进程创建进程,以及父进程以fork的形式创建一个继承其所有资源的子进程。
    创建过程是:会创建新的PCB,分配运行必须得资源,将PCB插入到就绪队列,等待被调度运行。
  2. 终止进程
    进程可能由正常结束、异常结束以及外部kill三种方式终止。
    子进程终止,其继承的资源会被还给父进程,父进程终止,其子进程将变成孤儿进程,并且被1号进程收养,完成状态收集工作。
    终止的过程是:先找到对应的PCB,如果处于执行状态,会立即终止执行,CPU资源分配给别的进程,如果该进程有子进程,子进程会被1号进程收集,接着,该进程拥有的所有资源会被归还给OS,然后将其PCB从所在队列删除。
  3. 阻塞进程
    当一个进程需要等待某事件完成,会自己调用阻塞语句将自己阻塞等待,而一个阻塞进程只能由另一个进程唤醒
    阻塞进程的过程是:找到要被阻塞的进程的PCB,如果是运行状态,需要保护现场,调整为阻塞状态,停止运行,然后将该PCB插入到阻塞队列。
  4. 唤醒进程
    举个例子,若是进程需要等待IO事件完成,则需要发现进程在发现事件完成时,调用唤醒语句将该阻塞进程唤醒。
    唤醒的过程是:从阻塞队列中找到对应PCB,将其从阻塞队列中移除,并修改状态为就绪,插入到就绪队列中等待调度执行。

进程的上下文切换

大致的过程已经在开头说明,这里需要补充一下进程上下文切换发生的场景

  • 时间片耗尽
  • 系统资源不足时也会被挂起
  • sleep主动挂起
  • 有优先级更高的进程
  • 发生硬件中断时,会被中断挂起转而执行内核中的中断服务程序

线程就是更小的进程?

在远古世纪(进程年代),计算机的整体应用都是存在问题的,首先是进程间的隔离性,数据很难在进程间共享;其次是软件核心功能多样,可能既存在IO任务,也存在CPU任务,那么在一个进程中就可能因为IO任务导致阻塞等待,CPU任务无法执行。这一切的根本原因核心就是一句话:CPU调度的单位是进程。因此需要引入新的实体解决以上两个问题:共享内存地址、可以并发运行。由此引入线程(Thread

线程是进程中的一个执行流程。所以一个进程中有多个线程,多个线程共享进程的内存地址(堆空间),而线程才是CPU调度的单位,所以线程会有自己独有的寄存器和栈,由此来保证独立的控制,各线程的运行控制间互不干扰。

可是线程解决了进程年代的问题,其本身有没有什么问题呢?当然是有的,那就是进程中的一个线程崩溃,其他线程也会崩溃,这里指C++,而Java中是不会的。

要回答标题的问题,首先要对进程和线程进行比较如下:

  • 进程是资源分配的单位,线程是CPU调度的单位。
  • 进程拥有完整的资源平台,而线程只独占寄存器和栈等必不可少的资源。
  • 线程和进程一样,都有就绪、运行、阻塞三种基本状态,线程调度同样是在状态间进行转换。
  • 线程可以减少创建和上下文切换(并发运行)的开销(这都是因为其独占的资源更少)。

接着从线程的上下文切换来看,若是进程中只有一个线程,可以认为进程就等于线程,此时线程上下文切换就是进程上下文切换,若是切换的两个线程不属于一个进程,也是如此;若是属于同一个进程,则线程上下文切换时只需要进行寄存器和栈等私有数据的切换即可,也正是因为如此,线程上下文切换的开销更小

好了,言归正传,现在的问题是线程和进程到底是什么关系?线程就是更小的进程吗?还是说其就是两个完全不同的实体?这一点Linus在1996年的一封邮件里就已经给了我们答案:

  • 把线程和进程区分为不同实体是背负着历史包袱的传统做法,没有必要做这样的区分,甚至这样的思考方式就是重大的错误。
  • 线程和进程在实体层面来说是一回事,即执行上下文(context of execution COE,其状态包括
    • CPU状态(寄存器等)
    • MMU状态(页映射)
    • 权限状态(uid、gid等)
    • 各种通信状态(打开的文件、信号处理器等)
  • 传统观念认为:进程和线程的主要区别是线程有CPU状态以及可能的其他最小必要状态,而其他上下文来自进程,然而,这种区分并不准确,这是一种愚蠢地自我设限
  • Linux内核认为没有进程和线程的概念,只有COE(Linux中称为任务),不同COE可以相互共享一些状态,通过此类共享向上构建出进程和线程的概念
  • 从实现来看,Linux下的线程目前是LWP实现,线程就是轻量级进程,所有的线程都当做进程来实现,因此进程和线程都是用task_struct来描述的,这一点通过/proc文件也可以看出端倪,线程和进程拥有比较平等的地位。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组
  • 简言之,内核不要基于进程/线程的概念做设计,而应该围绕COE的思考方式做设计。然后,通过暴露优先的接口给用户去满足pthreads库的要求