程序被编译为ELF格式存储在磁盘中,OS将它加载到内存中从第一条指令开始执行。每个运行中的程序实例被称为一个进程。
加载过程中CPU会运行别的进程,加载完毕后通过中断通知CPU。CPU允许多个进程交替执行,或者说并发执行。每个进程运行一段时间然后切换到另一个进程。
进程的状态可以分为:运行、就绪、阻塞,以及创建和结束
当物理内存紧缺,会有部分进程的物理内存被换出到外存,这种状态被称为挂起。根据其进入挂起前的状态又可分为就绪挂起和阻塞挂起
process control block 进程控制块,标识并控制一个进程。它包含:
OS通常通过链表组织相同状态的进程,例如阻塞队列、就绪队列:
允许一个进程创建另一个进程,子进程可拷贝父进程所拥有的资源,其基本流程为:
进程有三种终止方式:正常/异常结束、kill 信号。进程终止时,父进程负责回收子进程的资源;若父进程终止子进程还在运行则子进程变为孤儿进程,被托管给1号进程回收。回收过程为:
进程可能需要等待事件发生,此时会阻塞自己,且只能被另一进程唤醒。阻塞的过程为:
唤醒的过程为:找到pcb,转换状态为就绪状态,插入到就绪队列
一个进程运行在CPU时需要自己的运行信息,即进程上下文。这包含此刻CPU中通用寄存器和PC的值、以及在内存资源等等,前者被称为CPU上下文。
每次切换进程时只需要重新加载CPU上下文即可,这被存储在pcb中。另外还需要刷新TLB,切换页表
进程调度只能发生在内核态。
更小的独立运行基本单位,线程共享进程的资源。
进程和线程被 linux 统一抽象为任务,作为调度的基本单位,这意味着线程也有运行状态。
同进程的线程切换只需要切换CPU上下文,不需要切换页表;而不同进程的线程切换还需要另外切换页表
内核管理的线程,可被OS看到,可被调度。使用 tcb Thread Control Block 控制
优点在于一个内核线程被阻塞并不会影响其他内核线程,且相较于用户线程有更多的CPU时间
缺点在于,创建一个内核线程需要系统调用,开销较大
非内核管理,由用户态线程库管理。OS看不到tcb,只能看到整个进程的 pcb
多个用户线程分时共用同一个内核线程
优点在于:
缺点在于:
light weight process
内核支持的用户线程,一个进程可以有多个LWP。每个LWP都被一个内核线程支持,被内核像普通进程一样调度。
进程的状态变化时都会切换到调度程序,OS 通过 调度程序 scheduler 选择下一个执行的进程/线程。
一个运行中的进程可能因为:
切换到调度程序,更新原进程状态,选择下一个进程,切换到那个进程,继续运行
抢占式调度算法:时间片机制,时间片用完被中断切换到调度程序
非抢占式调度算法:还是会被中断,但是执行完 handler 后会返回原进程
调度原则:综合考虑CPU利用率、长任务短任务运行、等待时间、响应时间、周转时间
First Come First Serve
不利于短作业的运行,等待时间长
Shortest Job First
不利于长作业
Highest Response Ratio Next,等待时间相同,优先短任务;时间相同,优先等待长的;长任务等待太长了由于高响应比也有更大的出场机会
但服务时间不可预知
Round Robin,每个进程一个时间片,用完就换下一个,所有进程优先级相同
需要权衡时间片长短:
Highest Priority First,从就绪队列中选择优先级最高的执行。优先级可以分为:
根据是否抢占又可分为两种方法。
可能导致低优先级的进程永远不会执行
Mutilevel Feedback Queue = 时间片 + 最高优先级
存在多个队列:
短作业会被优先调度,长作业拥有更长的运行时间,兼顾二者
单向通道,数据为连续的字节流,先入先出,一方读一方写。分为两种:
其背后是系统调用:0读1写。一般搭配 fork 使用
写端的数据被缓存在内核中,再从读端读取。通信中一段数据需要拷贝两次,在管道满时写方进程会被阻塞在内核中,等待读方读取后,进行写入再返回继续进程运行
一般只用于读的一方会关闭fd[1],写方会关闭fd[0]。
匿名进程被杀掉后,其中的管道数据也会消失
在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。
它的不足在于:
把一片物理内存映射到两个进程中,避免了用户态内核态的切换以及数据拷贝
一般需要锁保护
一个整型计数器,用于同步进程对共享资源的访问,保护共享数据。有两种原子操作:
信号量被初始化为不同值表示不同的含义:
可以通过 kill -信号id pid
发送信号给进程:
跨网络的进程间通信——也可以与同主机进程通信。需要通过 socket syscall:
不同的通信方式有不同的参数:
服务端:
客户端:
UDP 面向无连接,不需要三次握手,服务端不需要监听再连接
接口与上述的字节流/数据报模型接口一致,但 bind 时需要绑定本地的一个文件
防止多线程访问共享资源产生冲突,即 race condition——即便是单核也可能产生冲突,它们很可能导致结果与预期不符,且每次的结果都可能不同,难以复现,这被称为 不确定性
例如两个线程对一个变量各增加1000,每次加1;一个进程加到51时被中断,此时可能有一个值在寄存器中,然后进程2对变量进行累加,恢复到进程1时,他会把51存到变量中——这就使得进程2的工作完全作废
我们希望在操作共享资源的这段 代码临界区 中,我们对资源的操作是原子的,对其他进程的访问是互斥的
此外,进程在可能需要合作完成某件事情,为此可能需要等待,进行同步,保证一定的事件发生顺序
基本的互斥与同步策略有:锁、信号量
锁的实现需要硬件提供的 test and set 原子指令,它可以原子地设置一个值并返回旧值
一般 自旋锁 的实现大概是这样:
flag 表示锁是否被持有,释放锁时会把 flag 设为 0,获取锁时会设为 1。
睡眠锁 不需要自旋,而是在某个 channel 上睡眠,直到被唤醒
可以用于同步/互斥,取决于它们初始化的值
其他细节。如果有三个进程共享资源,互斥信号量的值可为1、0、-1、-2
一个进程在进入临界区前会进行P操作,并在离开临界区后进行V操作,且PV操作都是原子的
具体代码实现中,信号量的结构体需要维护 信号量 & 等待队列 & 信号量的锁,锁确保访问信号量的操作是原子的
数据是资源,空位也可以是资源,再加上一把读写共享资源的锁
实现时,我们需要变量:
生产者的操作为:
消费者的操作为:
如果使用锁/互斥信号量,即每个叉子一把锁,获取叉子的锁即是用叉子。这有一种极端情形,即所有人都拿到了左边/所有人都拿到右边的叉子,试图获取另一边的叉子,但是陷入了死锁
方案1:同一时间只有一个哲学家吃饭,即吃饭前获取锁,不吃了释放锁,但这样效率会很低
方案2:避免极端情形,改变获取锁的顺序,例如奇数号哲学家先获取左边的叉子,偶数获取右边的叉子,从而避免出现上述的极端情形
方案3:确保两根叉子都可用才开始用餐
以下的锁可用互斥信号量
描述:
方案1:读者优先,读时阻塞写
方案2:写者优先,写时阻塞读
方案3:公平策略
两种情况:
pstack
+ gdb
两个线程同时调用了 lock,可能发生死锁。使用gdb进一步查看情况:
产生死锁有四大条件:
经典的解决方法是有序获取锁,例如在获取锁B前必然持有锁A
获取锁都是系统调用,都需要切换到内核态
基本上是所有锁的基础,睡眠锁获取锁失败会使进程进入睡眠,自旋锁则会不断尝试获取
如果临界区的代码较短,则完全可以用自旋锁;如果时间很长,则使用睡眠锁
CAS指令:比较&交换,若指定地址处的值等于预期值,则设置该地址处的值为新值,然后返回当前指定地址处的值
读写锁基于睡眠锁和自旋锁实现,根据具体的常见选择具体的实现方式
读锁+写锁,共享锁+独占锁。适用于读多写少的场景
根据实现,读写锁可被分为:读优先锁、写优先锁、公平锁
前面提到的睡眠锁、自旋锁、读写锁都是悲观锁,他们假定会出现很多竞态情况,访问资源前需要上锁
乐观锁假定竞态出现的概率不高,它先修改资源,然后检查这段时间有没有冲突
乐观锁是一种无锁编程,即是使用了乐观锁
CAS 不是乐观锁吗,为什么基于 CAS 实现的自旋锁是悲观锁?
加了while,形成了自旋的效果
取决于两点:地址空间大小 & 系统参数
每次创建一个线程,内核都会为其分配一个栈空间,例如8M,可以用 ulimit -a 查看,使用 ulimit -s 调整栈空间大小
不考虑系统参数,我们可以对一个进程的线程数进行估算
系统有三个参数可能限制线程的上限:
进程崩溃的原因一般是收到了某个信号,在默认情况下 handler 都会杀死整个进程。
进程的所有线程共用一套异常处理机制,如果某个线程触发了异常,而内核发送的信号可能会导致整个进程被杀死
我们一般通过自定义信号 handler来处理每个异常,而非杀死整个进程,例如JVM的针对栈溢出和空指针异常的处理方式: