最近在看《现代操作系统》这本书的第二章“进程和线程”,同时结合网上查找资料的学习,将进程和线程以及常见的一些问题整理如下:
进程
概念
- 一个进程是一个正在执行程序的实例的抽象,包括程序计数器、寄存器和程序变量的当前值等
- 操作系统的其他所有内容都是围绕着进程展开的
- 进程是操作系统进行资源分配和调度的一个基本单位
多道程序设计
多道程序设计是在计算机内存中同时存放几道相互独立的程序,使它们在管理程序控制之下,相互穿插的运行,宏观上让人感觉程序是并行执行的、微观上其实是串行执行的:
- CPU 从一个进程切换到另外一个进程,使每个进程在一段时间内都可以获得运行的机会
- 单个 CPU 可以被若干进程共享,通过调度算法进行进程间的切换
- 在多核系统中,每一个核只能一次运行一个进程
- 多道程序设计可以使系统中的各种资源尽可能地满负荷工作,从而提高整个计算机系统的使用效率
进程的创建
- 方式:
- 系统初始化时创建
- 守护进程
- 前台进程
- 正在执行的进程通过系统调用创建另外一个进程
- 用户请求创建一个新进程
- 批处理创建
- 操作系统决定它有资源来运行另一个任务时,它将创建一个新进程并从其中的输入队列中运行下一个作业
- 本质:
- 执行了一个用于创建进程的系统调用
- UNIX 系统调用 fork 函数创建
- windows 调用 CreateProcess 函数创建
- 父进程和子进程拥有各自的地址空间,不可以相互修改
- 不可写的内存区是可以共享的
进程的关闭
- 正常退出:
- UNIX 执行 exit
- windows 执行 ExitProcess
- 异常退出:
- 可预先感知的错误退出(自愿的)
- 代码异常等不可以预先感知的严重错误(非自愿的)
- 被其它进程杀死:
- windows 中 TerminateProcess
- UNIX 执行 kill 系统调用
进程间的结构
- UNIX 进程体系:
- 进程有父子关系,成树状结构,所有的进程都属于 init 为根的一棵树
- 进程和它的所有子进程以及子进程的子进程共同组成一个进程组
- Windows 进程体系:
- Windows 中没有进程层次的概念,Windows 中所有进程都是平等的
进程的状态
- 三种状态:
- 运行
- 进程实际占用 CPU
- 就绪
- 可运行,因为其他进程占用 CPU 而停止运行
- 阻塞
- 除非某种外部事件发生,否则进程不能运行
- 状态之间的转换:
- 运行 -> 阻塞:
- 等待输入而阻塞
- 阻塞 -> 就绪:
- 出现有效输入
- 就绪 -> 运行:
- 调度程序选择当前进程
- 运行 -> 就绪:
- 调度程序选择另外一个进程
进程的实现
操作系统为每个进程分配一个进程控制块(PCB),用来保存与该进程有关的各种状态信息,PCB 是进程存在的唯一标志,操作系统用 PCB 来描述进程的基本情况以及运行变化的过程,进程的任何状态变化都会通过 PCB 来体现,PCB 中主要包括下面内容:
- 进程标记信息:
- 本进程标记
- 父进程标记,进程开始时间、使用 CPU 的时间等
- 用户标记
- 进程组标记
- 进程运行现场:
- 用户可见寄存器:用户程序可以使用的数据,地址等寄存器
- 控制和状态寄存器:程序计数器,程序状态字
- 栈指针:过程调用、系统调用、中断处理和返回时需要用到它
- 进程控制信息:
- 调度和状态信息,运行态转换到就绪态或阻塞态时必须保存的信息
- 进程间通信信息:为支持进程间与通信相关的各种标识、信号、信件等,这些信息存在接收方的进程控制块中
- 存储管理信息:包含有指向本进程映像存储空间的数据结构,内存分配状况
- 进程所用资源:说明由进程打开使用的系统资源,如打开的文件等
- 有关数据结构连接信息:进程可以连接到一个进程队列中,或连接到相关的其他进程的 PCB
从前面讲到的“多道程序设计”和“进程状态之间的转换” 概念中,我们知道,一个进程在运行过程中可能被多次中断,而中断向量就是存放中断服务程序的入口地址,一般中断向量的位置会存放一条跳转到中断服务程序入口地址的跳转指令。中断向量主要用于每次中断后,被中断的进程可以返回到与中断发生前完全相同的状态。那么中断发生后操作系统做了什么呢?
- 硬件压入堆栈程序计数器。
- 硬件从中断向量装入新的程序计数器
- 汇编语言过程保存寄存器值
- 汇编语言过程设置新的堆栈
- C 中断服务例程运行
- 调度程序决定下一个将运行的进程
- C 过程返回到汇编代码
- 汇编语言过程开始运行新的当前进程
进程的通信(IPC,Inter Process Communication)
1. 要解决的问题:
- 一个进程如何传递消息给其他进程
- 如何确保两个或多个进程之间不会相互干扰(也适用于线程)
- 多个相互关联的进程间执行顺序的问题,典型的生产者——消费者问题(也适用于线程)
2. 设计概念:
关于解决进程通信问题,我先引入两个概念:竞态条件和临界区。
- 竞态条件:
- 两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件。
- 临界区:
- 用来避免竞态条件的设计
- 定义
- 任何时候两个进程不能同时处于临界区
- 不应对 CPU 的速度和数量做任何假设
- 位于临界区外的进程不得阻塞其他进程
- 不能使任何进程无限等待进入临界区
3. 忙等待互斥的通信方案
- 屏蔽中断:
- 概念
- 每个进程在进入临界区后立即屏蔽所有中断
- 在屏蔽中断后 CPU 不会切换到其他进程,不用担心其他进程介入访问共享数据
- 可行性
- 屏蔽中断仅仅对执行 disable 指令的 CPU 有效,其他 CPU 仍将继续运行,并可以访问共享内存
- 屏蔽中断对于操作系统本身来说是一项很有用的技术,但是对于用户线程来说,屏蔽中断却不是一项通用的互斥机制
- 锁变量:
- 概念
- 软件层面解决方案,设置一个共享的锁变量,初始为值为 0
- 当一个线程想要进入关键区域时,它首先会查看锁的值是否为 0 ,如果锁的值是 0 ,进程会把它设置为 1 并让进程进入关键区域
- 如果锁的状态是 1,进程会等待直到锁变量的值变为 0
- 可行性
- 读写不是原子性操作,多个进程在读和写之间进行,还是避免不了临界区同时存在两个进程
- 严格轮询法:
- 概念
- 加强版的锁变量,通过忙等待的方式检查共享锁变量
- 可行性
- 非常浪费 CPU
- 可能导致位于临界区外的进程也会阻塞其他进程
- Peterson 解法:
#define N 2 //进程数为2
int turn; //现在轮到哪个进程?
int interested[N]; //初始化置为false,即没有在临界区等待读写共享数据的
void enter_region(int process) //进入临界区
{
turn = process;
int other = 1 - turn; //另一个进程
interested[turn] = true;
while(turn == process && interested[other] == true)
; //一直循环,直到other进程退出临界区
}
void leave_region(int process)
{
interested[process] = false;
}
- 概念
- 通过将锁变量与警告变量相结合,最早提出了一个不需要严格轮换的软件互斥算法
- 可行性
- 临界区范围大,不能保证所有指令并发执行
- 开关中断只能作用于一个处理器,所以适用于单处理器
- 用户程序不能使用特权指令
- TSL 指令:
- 概念
- 硬件方案,保证读写指令是原子的,不可分割
- 这个指令结束之前其他处理器均不允许访问内存
- TSL 和禁止中断的区别
- 禁用中断并不能保证一个处理器在读写操作之间另一个处理器对内存的读写
- TSL 通过一根总线就可以确保总线由锁住它的处理器使用,而其他的处理器不能使用
- XCHG 指令:
- 概念
- 还有一个可以替换 TSL 的指令是 XCHG,它原子性的交换了两个位置的内容,例如,一个寄存器与一个内存字
- 总结:
- 资源消耗问题:上面提到了几种实现互斥的方案,本质上说他们都是一种采用忙等待的方式,并且不断地检查当前条件是否允许其运行。
- 优先级反转问题:现在假设某一时刻,阴差阳错也好,还是什么也好,L进入了临界区,但是运行到中途,时间片到期了,这时候 H 处在 ready 状态,但是由于 L 还处在临界区,那其实 H 也无法运行,而 L 又没有时间片。结果就是大家就这么干耗着。
4. 进程的几种通信方式
- 信号量:
- 它使用一个整形变量来累计唤醒次数,以供之后使用,这个变量被称为信号量
- 信号量有两个操作,现在通常使用 down 和 up
- down
- 这个指令的操作会检查值是否大于 0 。如果大于 0 ,则将其值减 1 ;若该值为 0 ,则进程将睡眠,而且此时 down 操作将会继续执行
- 检查数值、修改变量值以及可能发生的睡眠操作均为一个单一的、不可分割的 原子操作(atomic action) 完成
- up
- up 操作会使信号量的值 + 1
- 如果一个或者多个进程在信号量上睡眠,无法完成一个先前的 down 操作,则由系统选择其中一个并允许该程完成 down 操作
- 对一个进程在其上睡眠的信号量执行一次 up 操作之后,该信号量的值仍然是 0 ,但在其上睡眠的进程却少了一个
- 信号量的值增 1 和唤醒一个进程同样也是不可分割的原子操作
- 确保信号量能正确工作,最重要的是要采用一种不可分割的方式来实现它。通常是将 up 和 down 作为系统调用来实现
- 互斥量:
- 是信号量的一个简单版本,称为 mutex(互斥量)
- 互斥量是一个处于两种状态之一的共享变量:解锁(unlocked) 和 加锁(locked)
- 当一个线程(或者进程)需要访问关键区域时,会调用 mutex_lock 进行加锁。如果互斥锁当前处于解锁状态(表示关键区域可用),则调用成功,并且调用线程可以自由进入关键区域
- 如果 mutex 互斥量已经锁定的话,调用线程会阻塞直到关键区域内的线程执行完毕并且调用了 mutex_unlock
- 如果多个线程在 mutex 互斥量上阻塞,将随机选择一个线程并允许它获得锁
- 管程:
- 管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包
- 进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序
- 管程有一个很重要的特性,即在任何时候管程中只能有一个活跃的进程,这一特性使管程能够很方便的实现互斥操作
- 进入管程中的互斥由编译器负责,由于编译器而不是程序员在操作,因此出错的几率会大大降低
- 通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性
- C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则
- 屏障:
- 用于进程组而不是进程间的生产者-消费者情况
- 某些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段,可以通过在每个阶段的结尾安装一个 屏障(barrier) 来实现这种行为
- 当一个进程到达屏障时,它会被屏障所拦截,直到所有的屏障都到达为止
- 匿名管道:
- 匿名管道只能在父子进程间通信,而且数据只能单向流动(半双工通信)
- 匿名管道的特殊文件只存在于内存,没有存在于文件系统中
- shell 命令中的「|」竖线就是匿名管道
- 通信的数据是无格式的流并且大小受限
- 实现方式:
- 父进程创建管道,会得到两个文件描述符,分别指向管道的两端
- 父进程创建子进程,从而子进程也有两个文件描述符指向同一管道
- 父进程可写数据到管道,子进程就可从管道中读出数据,从而实现进程间通信
- 命名管道:
- 命名管道其实就是一种特殊类型的文件,所谓的命名其实就是文件名,文件对各个进程都可见
- 通过命名管道创建好特殊文件后,就可以实现进程间通信
- 匿名管道有个缺点就是通信的进程一定要有亲缘关系,而命名管道就不需要这种限制
- 不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中
- 消息队列:
- 消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法
- 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构
- 消息队列克服了管道通信的数据是无格式的字节流问题,消息队列实际上是保存在内核的消息链表
- 我们可以通过发送消息来避免命名管道的同步和阻塞问题
- 但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制
- 如果频繁的发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间
- 另外消息队列每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程,也较为浪费时间
- 共享内存:
- 可开辟一块内存,用于各个进程间共享,使得各个进程可以直接读写同一块内存空间
- 不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名
- 但由于共享同一块地址空间,数据竞争的问题就会出现,需要自己引入同步机制解决数据竞争问题,比如信号量来保护共享资源
- 共享内存只是一种方式,它的实现方式有很多种,主要的有 mmap 系统调用、Posix 共享内存以及 System V 共享内存等
- 信号:
- 信号可以在任何时候发送给某一个进程,如果进程当前并未处于执行状态,内核将信号保存,直到进程恢复到执行态再发送给进程
- 信号是进程间通信机制中唯一的异步通信机制
- 信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)
- 进程有三种方式响应信号:
- 执行默认操作:Linux 对每种信号都规定了默认操作
- 捕捉信号:我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数
- 忽略信号:当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理
- 有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,这是为了方便我们能在任何时候结束或停止某个进程
- 常见信号:
- SIGHUP:该信号在用户终端结束时发出,通常在中断的控制进程结束时,所有进程组都将收到该信号,该信号的默认操作是终止进程
- SIGINT:程序终止信号,通常的 CTRL+C 产生该信号来通知终止进程
- SIGQUIT:类似于程序错误信号,通常的 CTRL+\ 产生该信号通知进程退出时产生 core 文件
- SIGILL:执行了非法指令,通常数据段或者堆栈溢出可能产生该信号
- SIGTRAP:供调试器使用,由断电指令或其它陷阱指令产生
- SIGABRT:使程序非正常结束,调用 abort 函数会产生该信号
- SIGBUS:非法地址,通常是地址对齐问题导致,比如访问一个4字节长的整数,但其地址不是4的倍数
- SIGSEGV:合理地址的非法访问,访问了未分配的内存或者没有权限的内存区域
- SIGPIPE:管道破裂信号,socket 通信时经常会遇到,进程写入了一个无读者的管道
- SIGALRM:时钟定时信号,由 alarm 函数设置的时间终止时产生
- SIGFPE:出现浮点错误(比如除 0 操作)
- SIGKILL:杀死进程(不能被捕捉和忽略)
- 套接字(socket):
- 套接字通信有两种类型
- IPC 套接字:
- (即 Unix 套接字)给予进程在相同设备(主机)上基于通道的通信能力
- IPC 套接字依赖于本地系统内核的支持来进行通信
- 网络套接字:
- 给予进程运行在不同主机的能力,因此也带来了网络通信的能力
- 网络套接字需要底层协议的支持,例如 TCP(传输控制协议)或 UDP(用户数据报协议)
- IPC 套接字和网络套接字的 API 是一致的
- 共享文件:
- 显而易见,多个进程可以操作同一个文件,所以也可以通过文件来进行进程间通信
- 和共享内存一样需要引入锁等机制来解决竞态条件问题
进程的调度
1. 为什么需要调度算法
- 不同进程在不同时刻重要性不一样
- 提高 CPU 利用率
- 进程切换代价高
- 首先必须从用户态切换成内核态
- 保存当前进程的状态:存储寄存器的值、内存映像(页面内的内存访问位等)
- 运行调度算法选择另外一个进程
- 将新进程的内存映像重新装入 MMU(Memory Management Unit 内存管理单元)
- 新进程开始运行
- 进程切换还要使整个内存高速缓存失效
2. 何时调度
- 创建一个新进程时,需要决定运行父进程还是子进程
- 一个进程退出时
- 一个进程被阻塞:I/O 阻塞、信号量阻塞等
- I/O 中断发生时,某个设备完成了 I/O 工作
- 时钟周期中断:每个时钟中断或者在每 k 个时钟中断
- 抢占式
- 发生时钟中断时,把 CPU 控制返回给调度程序
- 非抢占式
- 进程被挑选调度直至被阻塞或者该进程自动释放 CPU
3. 不同的调度环境目标不一样
- 所有系统:
- 公平:相似的进程应该得到相似的服务
- 策略强制执行:有些进程必须强制保障执行
- 平衡:保持系统所有部分尽可能忙碌
- 批处理系统:
- 吞吐量:每小时最大作业数
- 周转时间:从作业提交到终止的平均时间
- CPU 利用率:充分利用 CPU
- 交互式系统:
- 最小响应时间:发出命令到得到响应的时间
- 均衡性:满足用户的期望,用户对有些请求期望高,有些期望低
- 实时系统:
- 满足截止时间:避免丢失数据
- 可预测性:在多媒体系统中避免品质降低
4. 批处理作业中的调度算法
- 先来先服务(FCFS)
- 优点:易于理解便于在程序中运行
- 缺点:无法考虑到优先级和充分利用资源
- 主要用于批处理系统
- 非抢占式
- 最短作业优先(SJF)
- 非抢占式
- 只有在所有作业同时到达的情形下,最短作业优先算法才是最优的
- 主要为了保障周转时间
- 主要用于批处理系统
- 最短剩余时间优先(SRT)
- 最短作业优先抢占式版本
- 当新的进程比当前运行进程需要更少的时间,当前进程被挂起,运行新的进程
- 主要用于批处理作业
5. 交互系统中的调度
- 轮转调度(Round Robin)
- 最古老、最简单、最公平、使用最广的算法
- 抢占式的
- 主要用于交互式系统
- 每个进程被分配一个时间段,如果在该时间片结束时,进程还在运行则被剥夺 CPU 分配个另一个进程
- 时间片
- 太短导致过多的进程切换,降低 CPU 效率
- 太长有可能引起对短的交互请求响应变长
- 20ms~50ms 比较合理
- 优先级轮转调度
- 抢占式
- 每个进程被赋予一个优先级,运行优先级高的进程优先运行一定时间再交给次优先级进程
- 优先级制定可以动态也可以静态
- 主要用于交互式系统
- 多级队列
- 优先级轮转调度的改进
- 为 CPU 密集型进程设置较长的时间片比频繁地分给他们很短的时间要更有效(减少交换次数)
- 属于最高优先级的进程运行一个时间片,次高优先级进程运行 2 个时间片,再下面一级运行 4 个时间片,以此类推。
- 当一个进程用完分配的时间片后,它被移到下一类
- 最短进程优先
- 类似于最短作业优先
- 难点是如何从当前可运行的进程中找到最短的那一个
- 保证调度
- 对用户做出明确的性能保证,然后按照这个保障去实现
- 例如 n 个用户登录,可以每个用户将获得 CPU 处理能力的 1/n
- 类似地,在一个有 n 个进程运行的单用户系统中,若所有的进程都等价,则每个进程将获得 1/n 的 CPU 时间。
- 彩票调度(Lottery Scheduling)
- 向进程提供各种系统资源的彩票,一旦需要调度时就随机抽出一张彩票
- 不同进程获取彩票机会可以不一样
- 公平分享调度
- 它认为 CPU 应该根据拥有进程的组(对Linux 来说是用户)来分配时间,它实现了从用户角度考虑的公平原则
- 在公平共享调度策略中,一个进程能够分配到的时间与登录的系统用户数以及拥有该进程用户开辟进程数的多少有关
6. 实时系统中的调度
- 实时系统可以分为两类
- 硬实时:意味着必须要满足绝对的截止时间
- 软实时:虽然不希望偶尔错失截止时间,但是可以容忍
- 实时系统的调度算法可以是静态的或动态的
- 静态:系统开始运行之前做出调度决策
- 动态:在运行过程中进行调度决策
- 实时系统中的事件可以按照响应方式进一步分类为周期性(以规则的时间间隔发生)事件或非周期性(发生时间不可预知)事件
- 可预测性:可预测性是指在系统运行的任何时刻,在任何情况下实时系统的资源调配策略都能为争夺资源的任务合理的分配资源,使每个实时任务都能得到满足
进程行为
- 几乎所有进程的 IO(磁盘)请求和计算都是交替突发进行的
- IO 活动:当一个进程等待外部设备完成工作而被阻塞时
- 行为分类:
- I/O 密集型
- 进程绝大部分时间花费在等待 I/O 上
- CPU 密集型
- 进程绝大部分时间花费在计算上
线程
什么是线程
- 线程是进程当中的一条执行流程,这几乎就是进程的定义,一个进程内可以有多个子执行流程,即线程
- 线程是 CPU 执行的基本单位
- 同一个进程中的所有线程都会有完全一样的地址空间
- 线程之间的状态转换和进程之间的状态转换是一样的
- 每个线程都会有自己的堆栈
- 因为线程会包含有一些进程的属性,所以线程被称为轻量的进程(lightweight processes)
为什么引入线程
- 使并行实体共享同一个地址空间和所有可用数据的能力,也就是共享进程拥有的资源
- 线程比进程轻量级,销毁和创建更快更容易,10 - 100 倍
- 并行线程可以加快应用程序的执行速度
- 在多核 CPU 的系统中,多线程真正的并行有了实现的可能
- 如果存在着大量计算和 IO 处理,拥有多个线程可以让这些活动彼此重叠进行
多线程存在的问题
- 父子进程与多线程的复杂性
- 一旦一个线程崩溃,会导致其所属进程的所有线程崩溃
- 内存等数据结构共享引入的线程安全问题
线程的创建和退出
- thread_create: 通过创建线程调用库函数创建新的线程
- thread_yield: 它允许创建线程自动放弃 CPU 而转让给新线程
- thread_exit:退出某个线程
- thread_join:表示一个线程可以等待另一个线程退出,这个过程阻塞调用线程直到等待特定的线程退出
POSIX 线程
- 为了使编写可移植线程程序成为可能,在 IEEE 标准 1003.1c 中定义了线程标准
- 线程包被定义为 Pthreads
- 大部分的 UNIX 系统支持它
- POSIX 标准定义了 60 多种功能调用
- POSIX 线程是一种独立于语言而存在的执行模型,以及并行执行模型
- 它允许程序控制时间上重叠的多个不同的工作流程,每个工作流程都称为一个线程
线程的实现
1. 分类
根据操作系统内核是否对线程有感知
- 用户线程
- 内核线程
- 组合线程
2. 用户线程
- 概念
- 由应用程序编写线程库实现,内核无感知
- 线程所有管理工作都在用户空间
- 用户线程多见于一些历史悠久的操作系统,例如 UNIX 操作系统
- 用户级线程是一种”多对一”的线程映射,将多个用户线程映射到一个内核线程
- 优点
- 让在不支持线程的操作系统中也可以使用多线程模型
- 线程的创建、销毁、切换等线程管理的代价比内核线程小得多, 因为保存线程状态的过程和调用程序都只是本地过程
- 允许每个进程定制自己的调度算法,线程管理比较灵活
- 线程能够利用的表空间和堆栈空间比内核级线程多
- 不需要陷阱,不需要上下文切换,也不需要对内存高速缓存进行刷新,使得线程调用非常快捷
- 线程的调度不需要内核直接参与,控制简单
- 缺点
- 同一进程中只能同时有一个线程在运行,不能使用多核 CPU
- 一个单独的进程内部,没有时钟中断,所以不可能用轮转调度的方式调度线程
- 一个线程阻塞了,那么所有线程都进入阻塞态
3. 内核线程
- 概念
- 内核线程建立和销毁等管理都是由操作系统负责,通过系统调用完成的,应用程序只有一个到内核级线程的编程接口
- 内核线程和用户线程是“一对一”的线程映射,一旦用户线程终止,两个线程都将离开系统
- 缺点
- 线程的创建等管理需要系统调用,开销较大
- 优点
- 这些线程可以在全系统内进行资源的竞争,可以使用多核 CPU,并行执行同一进程内的多个线程
- 如果进程中的一个线程被阻塞,能够切换同一进程内的其他线程继续执行
- 信号是发给进程而不是线程的,当一个信号到达时,应该由哪一个线程处理它,线程可以“注册”它们感兴趣的信号
4. 组合线程
- 线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行
- 一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上
- 在这种模型中,每个内核级线程有一个可以轮流使用的用户级线程集合
- POSIX 线程调度是一个混合模型
- POSIX 引入了一个线程调度竞争范围的概念,这个概念赋予了程序员一些控制权,使它们可以控制怎样将内核实体映射为线程
- POSIX 的标准中定义了两个值:PTHREAD_SCOPE_SYSTEM 和 PTHREAD_SCOPE_PROCESS,前者表示与系统中所有线程一起竞争 CPU 时间,后者表示仅与同进程中的线程竞争 CPU 时间
线程调度
- 用户线程和内核线程调度者不一样
- 内核线程调度很容易产生上下文切换开销
- 用户线程调度比较适合时间片较长的情况,这样可以在一个时间片内切换多个线程
- 用户线程可以专为应用程序定制线程调度算法
- 线程调度算法和进程调度算法实现上本身没有什么区别
一些问题
进程和程序的区别与关系?
- 程序是 CPU 指令的集合,进程是操作系统的概念
- 进程是程序在一个数据集合上的运行过程
- 程序是静态的观念,进程是动态的观念,程序是有序代码的集合,进程是程序的执行
- 进程是暂时的,程序是永久的,进程是一个状态变化的过程,程序可长久保存
- 一个程序可对应多个进程,一个进程可以执行一个或几个程序
- 进程和程序的组成不同:进程的组成包括程序、数据和进程控制块(进程状态信息)等
- 进程是程序执行的过程,包括了动态创建、调度和消亡的整个过程
什么是进程挂起?为什么会出现进程挂起?
- 进程挂起就是为了合理且充分的利用系统资源,把一个进程从内存转到外存
- 进程在挂起状态时,意味着进程没有占用内存空间,处在挂起状态的进程映射在磁盘上
进程和线程的区别和联系?
- 进程是资源分配的基本单位,线程是 CPU 执行的基本单位
- 进程负责把资源集中到一起,比如程序正文、数据以及其他资源的地址空间
- 线程用来记录执行的详情、比如执行到哪条命令、执行堆栈、执行历史、状态等
- 线程必须在某个进程中执行
- 多线程共享信息也就是进程的一些内容,比如:同一地址空间和全局变量、打开文件、子进程、报警、信号和信号处理程序、账户信息等
- 线程和进程都具有就绪、阻塞和执行三种基本状态,具有同样的状态之间的转换关系
- 线程能减少并发执行的时间和空间开销:创建、终止、切换、通信的开销
- 多进程是在内核中的进程表中维护的,多线程是在所在进程的线程表中维护的
- 进程和线程的调度逻辑基本差不多,进程调度是内核实现的,内核线程调度是内核实现的,用户线程调度是应用程序实现的
- 应对到写代码的场景中:
- 每个线程共享进程的代码段内存空间,所以我们编写多线程代码的时候,可以在任何线程调用任何函数
- 每个线程共享进程的数据段内存空间,所以我们编写多线程代码的时候,可以在任何线程访问全局变量
- 每个线程共享进程的堆,所以我们编写多线程代码的时候,可以在一个线程访问另外一个线程 new/malloc 出来的内存对象
- 每个线程都有自己的栈的空间,所以可以独立调用执行函数(参数,局部变量,函数跳转)相互之间不受影响
什么是协程?
- 协程 Coroutines 是一种比线程更加轻量级的微线程
- 类比一个进程可以拥有多个线程,一个线程也可以拥有多个协程,因此协程又称微线程和纤程
- 可以粗略的把协程理解成子程序调用,每个子程序都可以在一个单独的协程内执行
- 协程的调度完全由用户控制,协程拥有自己的寄存器上下文和栈
- 协程的切换直接操作用户空间,完全没有内核切换的开销
- 协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程
- 在有大量 IO 操作业务的情况下,我们采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升
- 在协程中尽量不要调用阻塞 IO 的方法,比如打印,读取文件,Socket 接口等,除非改为异步调用的方式,并且协程只有在 IO 密集型的任务中才会发挥作用
- Python 和 Go 从语言层面提供了对协程更好的支持
Chrome 浏览器中的是怎么使用进程和线程的 ?
1. 四类进程
- 1 个浏览器进程
负责控制浏览器除标签页外的界面,包括地址栏、书签、前进后退按钮等,以及负责与其他进程的协调工作,同时提供存储功能。
- 1 个 GPU 进程
负责整个浏览器界面的渲染。Chrome 刚开始发布的时候是没有 GPU 进程的,而使用 GPU 的初衷是为了实现 3D CSS 效果,只是后面网页、Chrome 的 UI 界面都用 GPU 来绘制,这使 GPU 成为浏览器普遍的需求,最后 Chrome 在多进程架构上也引入了 GPU 进程。
- 1 个网络进程
负责发起和接受网络请求,以前是作为模块运行在浏览器进程里面的,后面才独立出来,成为一个单独的进程
- 多个插件进程
主要是负责插件的运行,因为插件可能崩溃,所以需要通过插件进程来隔离,以保证插件崩溃也不会对浏览器和页面造成影响
- 多个渲染进程
- 负责控制显示 tab 标签页内的所有内容
- 核心任务是将 HTML、CSS、JS 转为用户可以与之交互的网页,排版引擎 Blink 和 JS 引擎 V8 都是运行在该进程中
- 默认情况下 Chrome 会为每个 Tab 标签页创建一个渲染进程,特殊情况:A 页面里面打开一个新的页面 B 页面,而 A 页面和 B 页面又属于同一站点的话,A 和 B 就共用一个渲染进程。
2. 渲染进程中的线程
- GUI 渲染线程
负责渲染页面,解析 html 和 CSS、构建 DOM 树、CSSOM 树、渲染树、和绘制页面,重绘重排也是在该线程执行
- JS 引擎线程
- 一个 tab 页中只有一个 JS 引擎线程(单线程),负责解析和执行 JS
- 它和 GUI 渲染进程不能同时执行,只能一个一个来,如果 JS 执行过长就会导致阻塞掉帧
- 计时器线程
- 指 setInterval 和 setTimeout,因为 JS 引擎是单线程的,所以如果处于阻塞状态,那么计时器就会不准了,所以需要单独的线程来负责计时器工作
- 异步 http 请求线程
- XMLHttpRequest 连接后浏览器开的一个线程,比如请求有回调函数,异步线程就会将回调函数加入事件队列,等待 JS 引擎空闲执行
- 事件触发线程
- 主要用来控制事件循环,比如 JS 执行遇到计时器,AJAX 异步请求等,就会将对应任务添加到事件触发线程中
- 在对应事件符合触发条件触发时,就把事件添加到待处理队列的队尾,等 JS 引擎处理
3. 多标签之间怎么通信
- 没有办法直接通信,需要有一个类似中介者进行消息的转发和接收
- 几种通信方式:
- localStorage:在一个标签页监听 localStorage 的变化,然后当另一个标签页修改的时候,可以通过监听获取新数据
- WebSocket:因为 websocket 可以实现实时服务器推送,所以服务器就可以来当这个中介者。标签页通过向服务器发送数据,然后服务器再向其他标签推送转发
- ShareWorker:会在页面的生命周期内创建一个唯一的线程,并开启多个页面也只会使用同一个线程,标签页共享一个线程
- postMessage:通过发送消息的方式通信
什么是僵尸进程和孤儿进程、守护进程?
- 孤儿进程
- 父进程退出了,而它的一个或多个子进程还在运行,那么这些子进程都会成为孤儿进程
- 这些孤儿都将被 init 进程收养,并负责这些孤儿的以后
- 僵尸进程
- 就是子进程比父进程先结束,而父进程又没有释放子进程占用的资源
- 那么子进程的描述还留在系统中,这种进程就是僵尸进程
- 守护进程
- 普通的进程, 在用户退出终端之后就会直接关闭. 通过 & 启动到后台的进程, 之后会由于会话( session 组)被回收而终止进程.
- 守护进程是不依赖终端(tty)的进程, 不会因为用户退出终端而停止运行的进程