历史归档 · 操作系统

操作系统(三):进程与线程

进程、线程、上下文切换与调度关系的系统性入门笔记。

10 分钟阅读

进 程

1.进程描述

定义:一个具有一定独立功能的程序在一个数据集合上的一次动态执行的过程

进程包括:

  • 一个程序的代码
  • 程序处理的数据
  • 程序计数器的值(指示着下一条将运行的指令)
  • 一组通用寄存器的当前值,堆栈
  • 一组系统资源

总之,进程包含了正在运行的一个程序的所有状态信息

与程序的联系

  • 程序是进程产生的基础
  • 程序的每次执行构成不同的程序
  • 进程是程序功能的体现
  • 通过多次执行,一个程序可以对应多个进程
  • 通过调用关系,一个进程可以包括多个程序

与程序的区别:

  • 进程是动态的,程序是静态的;进程有核心态和用户态
  • 进程是暂时的,程序是永久的
  • 组成不同:进程的组成包括程序,数据和进程控制块(PCB)

2.进程控制块/PCB/Process Control Block

描述进程的数据结构

OS为每一个进程都维护了一个PCB,用来保存与改进程有关的各种状态信息。

PCB是进程存在的唯一标志 进程的创建:生产一个PCB; 进程的终止:回收它的PCB

PCB包含的三大类信息:

1.进程标识信息

本进程的标识;父进程标识;用户标识

2.处理机状态信息保存区

  • 用户可见寄存器:用户程序可使用的数据,地址寄存器
  • 控制和状态寄存器:程序计数器/PC;程序状态字/PSW……
  • 栈指针:过程调用/系统调用/中断处理 和返回时需要用到它

3.进程控制信息

  • 调度状态信息:OS调度进程并占用处理机使用
  • 进程间通信信息:各种标识,信号,信件等(存在接收方的PCB中)
  • 存储管理信息:包含有指向本进程映像存储空间的数据结构
  • 进程所用资源:有进程打开使用的系统资源
  • 有关数据结构连接信息:进程可以连接到一个进程队列中,或连接到相关的其他进程的PCB

3.进程状态

进程的生命期:创建、(就绪)、运行、等待、唤醒、结束

引起进程的创建:

  • 系统初始化时——init进程
  • 用户请求创建一个新的进程
  • 正在运行的进程执行了创建进程的系统调用

进程运行:内核选择一个就绪的进程,让它占用处理及并执行

进程 等待/阻塞 的三种情况:

  • 请求并等待OS服务
  • 执行某种操作无法马上完成
  • 需要的数据没有到达,进程只能自己阻塞自己

进程唤醒的原因:

  • 被阻塞的进程需要的资源得到满足
  • 被阻塞进程所等待的时间到达
  • 将改进程的PCB插入到就绪队列

进程只能被别的进程或者OS唤醒

进程结束

  • 自愿:正常退出;错误退出
  • 强制性:致命错误;被其他进程所杀

进程状态变化模型


  1. 进程挂起

为了合理且充分利用资源

进程在挂起状态时,没有占用内存空间,进程映像在磁盘上

挂起状态:

  • 阻塞挂起/Block-suspend:进程在外存并等待某事件的出现
  • 就绪挂起/Ready-suspend:进程在外存,只要进入内存就可执行

进程内存到外存的情况:

  1. 阻塞——>阻塞挂起
  2. 就绪——>就绪挂起
  3. 运行——>就绪挂起

在外存的状态变化:阻塞挂起——>就绪挂起

解挂/激活/Activate——从外存到内存:

  • 就绪挂起——>就绪:没有就绪进程,或挂起的就绪进程的优先级高于就绪进程
  • 阻塞挂起——>阻塞:当一个进程释放足够内存时,OS会把高优先级的阻塞挂起进程——>阻塞进程

线 程

为什么需要线程/需求的产生: 需要一种新的实体:

  • 实体之间可以并发的执行
  • 实体之间共享相同的地址空间

Thread的定义:进程当中的一条执行过程

从资源管理的角度:进程把一组相关的资源组合起来,构成了一个资源平台(环境),包括地址空间(代码段,数据段),打开文件等各种资源。

从运行的角度:代码在这个资源平台上的一条执行流程(线程)。

TCB:Thread Control Block

线程 = 进程 - 共享资源

进程的优点:

  • 一个进程可存在多个线程
  • 各个线程可以并发地执行
  • 各个线程可以共享地址空间和文件等资源

缺点:

一个线程崩溃,会导致其所属进程的所有线程的崩溃

各个线程之间,寄存器和堆栈(register,stack)是独占的,data,code,files之类的是共享的


  1. 与进程的比较

  2. 进程是资源分配的单位,线程是CPU调度单位

  3. 进程拥有一个完整的资源平台,而线程只独享必不可少的资源(寄存器,栈)

  4. 线程同样拥有就绪,阻塞,执行三种状态,同样具有状态之间的转换关系

  5. 线程能够减少并发执行的时间和空间开销 4.1 线程的创建、终止用时比进程短(内存,文件管理……) 4.2 同一进程内,线程切换比进程快(各线程拥有同一个页表;进程切换:页表(cache,TLB)重新加载 4.3 同一进程的各个线程共享内存和文件资源,可不通过内核直接通信。


  1. 线程实现

三种实现方式:

  • 内核线程——由OS管理的线程
  • 用户线程——OS看不到的线程(应用态的用户程序进行管理)
  • 轻量级进程

用户线程和内核线程对应关系:多对一;一对一;多对多

用户线程 在用户控件实现线程机制,不依赖与内核,由一组用户级的线程库函数来完成线程的管理——创建,终止,同步、调度

缺点:

  1. 若一个线程发起系统调用而阻塞,则整个进程等待
  2. 除非一个线程主动交出CPU的使用权,否在其所在进程的其他线程无法运行
  3. 由于时间片分配给进程,故与其他进程相比,多线程执行时,每个线程得到的时间片较少,执行会较慢

内核线程(Windows) 在OS内核实现的一种线程机制,由内核来完成线程的创建,终止和管理

  • 由内核来维护线程和进程的上下文信息(PCB和TCB)
  • 由内核管理(系统调用/内核函数),系统开销较大
  • 在一个进程中,有一个线程发出系统调用而被阻塞,并不会影响其他内核线程的运行
  • 时间片分配给线程,多线程进程获得更多的CPU时间

轻量级进程(Linux)

由内核支持的用户线程。一个进程可以有一个或多个轻量级进程,每个轻量级进程由一个单独的内核线程来支持。


  1. 上下文切换/Context Switch

停止运行当前的进程(从running态变成其他态),并调度其他进程为运行态

储存上下文:寄存器(PC,SP,…),CPU状态…

OS将PCB放到一个合适的队列中:就绪队列,等待I/O队列(每个设备的队列);僵尸队列。


  1. 进程的创建

Windows:进程创建API——CreateProcess(filename)

Unix进程创建系统调用:

  • fork():把一个进程复制成两个进程 parent(old PID), child(new PID)
  • exec():用新程序来重写当前程序(PID没变)

fork()

创建一个继承的子进程

  • 复制父进程的所有的变量和内存呢
  • 复制父进程所有CPU寄存器(有一个register除外)

返回值:子进程fork()返回0;父进程的fork()返回子进程的标识符。返回值可方便后续使用,子进程可以使用getpid()来获取PID。

实现开销:若像上面一样开销大。若在fork()直接调用exec(),fork()中内存复制是没有作用的。现在用copy on write技术。


  1. 加载和执行进程

exec()调用允许一个进程“加载”一个不用的程序,并在main()中运行(_start)

  • 允许进程指定参数的数量argc和它的字符串参数数组(argv)
  • 重写stack 和 heap

  1. 等待和终止进程

wait():系统调用——父进程用来等待子进程结束

  • 使父进程睡眠来等待子进程的结束
  • 当一个子程序调用exit(),OS解锁父进程,并将exit()的返回值作为wait()调用的一个结果(连同子进程的PID一起)
  • 若有为父进程的僵尸等待,wait()会立即返回其中的一个值,并解除僵尸等待。

exit()系统调用

  • 将此进程的“结果”作为一个参数
  • 关闭所有打开的文件连接等
  • 释放内存
  • 释放大部分支持进程的操作系统结构
  • 检查父进程是否存活:是——保留结果直到父进程需要它。此情况下,该子进程进入僵尸等待。否——释放所有结果,这个进程死亡
  • 清理所有等待的僵尸进程

进程的终止是最终的资源回收


  1. 线程的堆栈

在Linux中,线程栈的大小可以用 ulimit -a,回车之后会显示 stack size 为 8192 kB,也就是8M。那么线程默认栈大小就是8M。这节参考了这篇博客

如果需要修改的话

  • 可使用命令ulimit -s去修改默认栈大小
  • 使用 pthread_attr_setstack() 函数

一些小知识:

  • 两个线程时,两个线程栈的总和不是固定值,也不是线程栈的2倍
  • 进程的栈大小不是固定的,而是比线程栈大一些
  • 若手动给线程分配栈空间,可以从堆中或则进程栈中分配

零 散 知 识

  1. 回调函数

例如用户程序 A 让 另一个程序 B 监听某个事件 并同时传入一个处理该事件的函数,当 B 监听到这个事件发生后,便会调用这个函数处理。这种情况下有两个调用:1. A 调用 B;2. B 调用 A 传给 B 的函数。

来看一个简化版的回调函数的代码。在这段代码中,有函数(APIMethod())需要在发生某个事件后对一个数组排序,但是不知道用什么排序方法。这时候就要向这个函数传入一个排序方法。代码中有三种排序方法:1. algorithm 库中的 sort 函数; 2. 冒泡排序; 3. 快速排序。

typedef void(*callback)(vector<int>&);

void APIMethod(vector<int>& nums, callback p)
{
cout << "do something" << endl;
cout << "a event happened || a condition met" << endl;
(*p)(nums);
}

void librarySort(vector<int>& nums)
{
sort(nums.begin(), nums.end());
}

void bubbleSort(vector<int>& nums)
{
for (int i = 1; i < nums.size(); i++)
{
for (int j = 0; j < i; j++)
{
if (nums[i] < nums[j])
swap(nums[i], nums[j]);
}
}
}

void quikeSortHelper(int start, int end, vector<int>& nums)
{
if (start > end)
return;

int i = start, j = end;
int base = nums[i];

while (i < j)
{
while (i < j && nums[j] >= base)
j--;
nums[i] = nums[j];

while (i < j && nums[i] <= base)
i++;
nums[j] = nums[i];
}

nums[i] = base;
quikeSortHelper(start, i - 1, nums);
quikeSortHelper(j + 1, end, nums);
}

void quikeSort(vector<int>& nums)
{
int start = 0;
int end = nums.size() - 1;
quikeSortHelper(start, end, nums);
}

int main()
{
vector<int> nums = { 0,-1,5,9,-8,10 };
//APIMethod(nums, librarySort);
//APIMethod(nums, bubbleSort);
APIMethod(nums, quikeSort);
for(int n : nums)
cout << n << endl;
return(0);
}

  1. 同步和异步

同步和异步关注的是 消息通信机制。这里借鉴了知乎上的回答,请点这里

1. 同步

在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。这是由调用者主动等待这个调用的结果。

2. 异步

调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

3. 处理IO时

  • 只有使用了特殊的API,才是异步IO
  • 除此之外,阻塞和非阻塞都是同步IO