【MOS读书笔记】进程与线程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/imilano/article/details/82797248

一. 进程与线程

01. 进程

前述

原语与原子操作

原语 内核或者微核提供核外调用的过程与或函数称为原语(Primitive)。原语是一段用机器指令编写的完成特定功能的程序,在执行过程中不允许中断。原语是通过屏蔽中断来实现的。
x
原子操作 原子操作设计到同步控制中的原子性(atomic),在多进程(线程)的操作系统中不能被其他进程(线程)打断的操作就叫做原子操作。原子操作不可拆分。对于计算机的一次原子操作,要么不发生,要么一次性做完。所有原子操作是同步的,并且不可以被外部中断,但是可以被内中断。

实际上,定义原语的前提是观察者所处的位置。

进程模型

进程模型
进程是对正在运行的程序的一种抽象。在任何多道程序设计中,CPU由一个进程快速切换至另一个进程。在某一瞬间,CPU只能运行一个进程,而在1秒内,它可以运行多个进程。CPU实在太快,因为让我们有了并行的错觉。区别于真正的多处理器硬件并行,这样的错觉也称为伪并行。

一个进程是某中类型的一个活动,它有程序、输入、输出和状态。单个处理器可以被若干进程共享,它使用某种调度算法决定何时进行进程间的切换。

多进程的模拟

我们把CPU运行的所有指令称为一个顺序控制流,但是一个顺序流又可以分为若干个逻辑流,每个逻辑流对应着自己单独的一个程序。在任何一个瞬间,只有一个进程真正在运行。

进程的生命周期

进程的创建

4种导致进程创建的事件:

扫描二维码关注公众号,回复: 3723476 查看本文章
  • 系统初始化。前台进程与守护进程(后台进程/deamon)
  • 正在运行的程序执行了创建进程的系统调用。
  • 用户请求创建一个新进程。
  • 一个批处理作业的初始化。

父进程通过一个系统调用创建了子进程,父进程所作的工作是:执行一个创建新进程的系统调用,这个调用通知操作系统创建一个新进程,并且直接或间接的指定在该进程中运行的程序。(父进程 ——> 系统调用 ——> 操作系统 ——> 子进程 )

UNIX系统通过fork系统调用创建新的子进程。执行fork后,子进程拥有父进程全部的系统映像、同样的环境字符串和相同的副本。但是二者拥有不同的内存地址空间。

如果某个进程在其地址空间修改了一个字,这个修改对其他进程而言是不可见的。在 UNIX 中,不可写的内存区是共享的,某些 UNIX 的实现使程序正文在两者间共享,因为他不能被修改。后者,子进程想要共享父进程的所有内存,这种情况下内存通过写时复制共享,确保修改发生在私有地址空间。

进程的终止

进程的宗旨由以下条件引起:

  • 正常退出(自愿的)
  • 出错退出(自愿)
  • 严重错误(非自愿)
  • 被其他进程杀死(非自愿)

进程的层次结构

UNIX 中, 父进程和他的子进程以及所有后裔进程共同组成一个进程组。当用户从键盘发送信号时,该信号被送给与键盘相关的进程组的所有成员。每个进程可以分别捕获、忽略该信号或者杀死该信号。

Windows中没有进程层次的概念,所有进程都是地位相同的。唯一类似于进程层次的暗示是在进程创建的时候,父进程得到一个特别的令牌(句柄),该句柄可以用来控制子进程。但是,他有权把这个令牌送给其他进程。

进程的状态

进程状态模型有三态模型,五态模型和七态模型。本处主要介绍三态模型。

三态模型:

  • 运行态:处理器正在运行
  • 就绪态:具备运行条件,等待分配处理器
  • 等待态:不具备运行条件,正在等待某个事件的完成

五态模型:在三态模型的基础上引入新建态和终止态。

七态模型:在五态模型上新增了挂起就绪态和挂起等待态。

三态模型之间的转化过程如下:
在这里插入图片描述

  • 运行 ——> 阻塞 :当前进程的执行需要等待某种操作时, 例如 IO 操作,当前进程就会进入阻塞态。
  • 阻塞 ——> 就绪 :引起当前线程阻塞的时间已经完成了,当成线程就会进入就绪状态,等待调度程序分配给自己CPU。
  • 就绪 ——> 运行 : 当前进程就绪之后,并不能直接运行,它还需要操作系统给它分配 CPU 才能运行。当 CPU 空闲或者当前进程被调度程序分配了时间片时,他就进入运行态。
  • 运行 —— > 就绪 : 当前正在 CPU 上运行的线程时间片用完了,就会进入就绪态,等待下一次被调度程序选中运行。

进程的实现

为了实现进程模型,操作系统维持着一张表格(一个数据结构),即进程表(process table), 又叫做进程控制块(PCB)。每个进程表占用一个进程表项。PCB 是操作系统感知进程存在唯一标识。

该表项保存了进程状态的重要信息,一旦进程被中断,操作系统就会将这些信息入栈,等中断结束,切换回原线程的时候,操作系统就根据这些信息恢复原线程。

这些信息是程序恢复上下文所需要的所有的相关信息, 主要是进程管理信息,存储管理信息, 文件管理信息。包括:

进程管理 存储管理 文件管理
寄存器 正文段指针 根目录
程序计数器 数据段指针 工作目录
程序状态字 堆栈段指针 文件描述符
堆栈指针 用户ID
进程状态 组ID
优先级
调度参数
进程ID
父进程
进程组
信号
进程开始时间
使用的CPU时间
子进程的CPU时间
下次定时器时间

实际上,如前所述,计算机之所以给我们一种并发执行的错觉,实际上就在于进程间的快速调度。与每一个IO操作关联的是一个称为中断向量的位置(靠近内存底部的固定区域),它包含有中断服务程序的入口地址。当一个磁盘中断发生时,中断硬件将当前状态信息入栈,计算机随后跳转到中断向量所指向的地址,然后软件,特别是中断服务例程就接管一切剩余的工作。当例程结束后,调度程序决定随后运行哪个进程。每次中断结束后,被中断的进程都能返回到与中断发生前完全相同的进程。

02. 线程

传统计算机中,每个进程有一个地址空间和一个控制线程。但是现在,常常会发生在同一个地址空间中运行着多个控制线程的情形。

为什么需要线程

使用进程的原因如下:

  • 在应用程序中发生着多种活动,其中某些活动随着时间的推移会被阻塞。将这些应用程序分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单。
  • 并行的线程拥有共享一个地址空间和所有可用数据的能力,而这是进程所不具备的(每个进程都有自己独立的不同的地址空间)。
  • 线程比进程更加轻量,因而比进程更容易创建,也更容易撤销。
  • 在IO处理较多的程序中,使用线程能活获得更快的程序执行速度。

有了线程,我们可以将一个字处理软件分解为三个独立的线程:一个与用户交互,一个进行后台处理,另外一个处理磁盘备份。三个线程互不干扰,大大降低了耦合性。但是,也随之 引入了进程间的协作和通信问题。

经典线程模型

我们先来说说进程。进程基于两种独立的概念:资源分组处理和执行。进程是资源的集合,进程存放有程序正文和数据以及其他资源的地址空间。

我们将进程的两种概念分开,于是引入了线程的概念。每个线程都拥有自己的程序计数器、寄存器、独立的堆栈。栈用于记录本线程的执行历史,其中每一帧保存一个过程。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。在同一个进程中并行运行多个线程,是对在一台计算机上并行运行多个进程的模拟。由于线程具有进程的某些性质,所以有时也被称为轻量级进程

**同一个进程内,所有线程都有着完全一样的地址空间,共享同样的全局变量, 由于每个线程都可以访问地址空间中的每一个内存地址,所以一个线程可以读 、写或者清除另一个线程的堆栈,线程之间是没有保护的。**除了地址空间之外,所有线程还共享一个打开文件集、子进程、定时器和相关信号等。

每个进程中的内容(共享) 每个线程中的内容(不共享)
地址空间 程序计数器
全局变量 寄存器
打开文件 堆栈
子进程 状态
即将发生的定时器
账号与信号处理程序
账户信息

线程的实现方式

在用户空间实现线程

第一种办法就是把整个线程包放在用户空间中,内核对线程包一无所知。这种线程最大的优点就是,用户线程可以在不支持线程的操作系统上实现。在这种结构中,线程在一个运行时系统的上层运行,该运行时系统是一个管理线程的过程的集合。

在用户空间管理线程时,每个进程需要有专门的线程表,用来跟踪进程中的线程。这些表和内核中的进程表类似,只不过记录的是线程的属性。该线程表由运行时系统管理。下图a就是用户级线程包。

在这里插入图片描述

用户级线程比较轻量,有着更好的性能。在用户空间实现线程的优点有:

  • 线程切换成本低。线程切换比陷入内核快一个数量级。线程状态的保存和调度程序都只是本地过程,所以启动线程比进行内核调用更高。不需要陷入内核,不需要切换上下文,这使得线程调度非常快捷。
  • 用户级线程允许每个进程都有自己定制的调度算法。例如,有 垃圾收集线程的应用处理程序不用担心线程会在不合适的时刻停止。
  • 用户级线程具有很好的可扩展性。这是因为在内核空间中内核线程需要一些固定表格空间和堆栈空间,如果内核现成的数量非常大,就会很容易出现问题。

虽然用户级线程优点众多,但是它也存在着一些明显的问题:

  • 第一个问题是如何实现阻塞系统调用。使用线程的一个主要目标就是,允许每个线程使用系统调用,但是避免被阻塞的线程影响其他线程。而进行阻塞系统调用会影响整个进程。这个问题还没弄懂
  • 缺页中断问题。如果某个程序调用或者跳转到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令,这就称为页面故障。在对所需的指令进行定位和读入时,相关的进程就被阻塞,如果有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会把整个进程阻塞直到磁盘IO完成为止,尽管其他的线程是可以运行的。(意思与第一个问题相同,就是说,阻塞式系统调用会影响整个进程,而不单单只是对单个线程起作用。)
  • 如果一个线程开始运行,那么在该线程中的其他线程就不能运行,除非第一个线程自动放弃CPU。在一个单独的进程内部,没有时钟中断,所以不可能使用轮转调度。

在内核空间实现线程

内核级线程如图b所示,它主要具有如下特点:

在这里插入图片描述

  • 不再需要运行时系统。
  • 每个进程中没有线程表了。
  • 在内核中有用来记录系统中所有线程的线程表。当某个线程需要创建或者撤销一个已有线程时,他进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建或撤销工作。

内核不仅维护了一个线程表,还维护着一个进程表。线程表存放的内容与用户级线程一致。内核级线程中,所有能够阻塞线程的调用都以系统调用的形式实现,缺点是代价比较大;用户级线程阻塞时,被调度的线程与原线程是在同一个进程中的,但是在内核级线程中,当前线程中的进程被阻塞,可能会调度另一个进程中的线程。

混合实现

混合实现试图将内核级线程和用户级线程的优点结合起来。一种方法就是,使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。采用这种办法,内核只是被内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。

调度线程激活机制

尽管内核级线程在一些方面优于用户级线程,但是内核级线程最大的特点就是慢。调度程序激活机制的提出就是为了解决这个问题。

调度程序激活机制的目标是,模拟内核线程的功能,但是为线程包提供通常在用户空间才有的更好的性能和更大的灵活性。特别的,如果用户线程进行某种系统调用时是安全的,那就不应该进行专门的非阻塞调用或者进行提前检查。

该机制的基本思路就是,当内核知道一个线程被阻塞之后,内核通知该进程的运行时系统,并在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。内核通过在一个已知的地址启动运行时系统,从而发出通知,这个机制就称为上行调用。

在cpu中用户空间为上层,内核为下层,常规调用应该是上层调用下层,下层不应该调用上层,upcall就是指内核调用用户空间。

弹出式线程

我们一般使用一个线程来出来一个事件。举例来说,在服务请求中,我们通过将进程或者线程阻塞在一个 receive 系统调用上,等待消息到达。每到达一个消息,该调用对打开这消息并对齐进行处理。

现在我们可以这样。每到达一个消息,我们就让该线程创建一个线程去处理该消息,这种线程就叫做弹出式线程。优点是创建快速,可以同时处理很多消息。

单线程代码多线程化

这里主要介绍的是把单线程代码改写为多线程代码时需要注意的问题。把当线程改写为多线程代码需要注意以下几个方面:

  • 变量可见性问题。有些变量,对线程来说是全局变量,但是对整个进程并不是全局的,甚至可能与其他线程逻辑无关。解决办法就是为每个线程赋予私有的全局变量。例如errno,当某个线程调用出错,它就把返回值放到了errno中,如果此时有另外一个线程重写了errono,那么就会出现问题。
    解决方法就是为每个线程设置自己的私有变量。
  • 可重入性问题。某些库一个事件段只能允许一个线程运行,如果一个线程在缓冲区设置好了变量,然后这个线程被突然中断,另一个线程在该区域重写了变量,也会导致问题。
  • 内存分配问题。每次malloc都必须维持着一个指针,但是当发生线程切换时,新线程重写了该指针,则会出现内存分配问题。
  • 信号传递问题。
  • 堆栈管理问题

个人理解,难免有误,还望指教。

猜你喜欢

转载自blog.csdn.net/imilano/article/details/82797248