进程线程
进程:资源分配的基本单位,分配包括地址空间(代码段、数据段)、打开的文件等各种资源。
线程:独立调度的基本单位,同一进程的线程共享地址空间的所有资源。线程只拥有指令流执行的必要资源 线程控制块(TCB)、程序计数器、堆栈,线程控制块(TCB) 保存线程信息,程序计数器记录取值位置,堆栈保存线程参数。
用户级线程:用户空间实现的线程机制,每个进程有私有的线程控制块(TCB)列表,用户级线程库函数完成线程的管理,包括线程的创建 pthread_create()
、阻塞 pthread_join()
和终止 pthread_cancel()
等。
优点:不依赖操作系统内核,用户级线程切换速度快。
缺点:线程发起系统调用而阻塞时,则整个进程进入等待;线程不可抢占执行,以进程为单位进行CPU时间分配。
内核级线程:内核空间实现的线程机制,内核维护PCB和TCB,内核调用通过系统调用完成线程的管理。
同步与互斥
竞争条件(race condition):两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序
原子操作(atomic operations):一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
互斥原语:使4条语句合并原子操作,从而避免竞争条件的出现,实现指令的一次性执行
临界区
访问规则
- 空闲让进:没有进程在临界区时,任何进程可进入
- 忙则等待:有进程在临界区时,其他进程均不能进入临界区
- 有限等待:等待进入临界区的进程不能无限期等待
- 让权等待:不能进入临界区的进程,应释放CPU(如转换到阻塞状态)
实现方法:
禁用中断
禁用中断:硬件禁止使用中断,避免产生上下文切换,阻止进程并发执行。适用于发生一次中断时,禁止其他进程发生中断,避免CPU无法确定执行哪一个进程中断
缺点:进程无法被停止,从而整个系统都会为此停下来或可能导致其他进程处于饥饿状态;临界区可能很长,无法确定响应中断所需的时间(可能存在硬件影响)
软件同步
1. 共享变量
共享变量:线程之间共享一些共有变量来同步它们的行为。
// 共享变量
int turn = 0;
turn = i;
// 线程Ti的代码
do {
while (turn != i);
critical section
turn = j;
reminder section;
} while (1);
特征:满足忙则等待,不满足空闲让进,Ti
不在临界区,Tj
想要继续运行,但是必须等待 Ti
进入过临界区后
// 共享变量
bool flag[2];
flag[0] = flag[1] = 0;
flag[i] == 1; // 表示线程Ti是否在临界区
// 线程Ti的代码
do {
while (flag[j] == 1);
flag[i] = 1;
critical section;
flag[i] = 0;
reminder section;
} while (1);
特征:不满足忙则等待,一轮 Ti
和 Tj
执行后, Ti
和 Tj
均可进入临界区
// 共享变量
int flag[2];
flag[0] = flag[1] = 0;
flag[i] == 1; // 表示线程Ti是否在临界区
// 线程Ti的代码
do {
flag[i] = 1;
while (flag[j] == 1);
critical section;
flag[i] = 0;
reminder section;
} while (1);
特征:满足忙则等待,不满足空闲让进
// Peterson算法
// 共享变量
int turn; //表示该谁进入临界区
bool flag[]; //表示进程是否准备好进入临界区
// 线程Ti的代码
do {
flag[i] = true;
turn = j;
while (flag[j] && turn == j);
critical section
flag[i] = false;
reminder section
} while (true);
特征:满足临界区的访问规则,但实现复杂
2. 自旋锁
锁:抽象的数据结构,二进制变量(锁定/解锁)
测试和置位(Test-and-Set )指令:从内存单元中读取值,测试该值是否为1(然后返回真或假),内存单元值设置为1
自旋锁的实现:
class Lock{
int value = 0;
}
// 如果锁被释放,那么TS指令读取0并将值设置为1, spin()
// 如果锁处于忙状态,那么TS指令读取1并将值设置为1, spin()
Lock::Acquire() {
while (TestAndSet(value)); // spin
}
Lock::Release() {
value = 0;
}
优点:适用于多处理器的多进程同步、多临界区;简单容易证明
缺点:忙则等待浪费CPU时间,饥饿(进程离开临界区,多个等待进程),死锁(拥有临界区的低优先级进程,请求高优先级进程访问临界区等待)
信号量
信号量是一种抽象数据类型,由一个整形变量 sem
和两个原子操作 P()
和 V()
组成。
- 信号量
sem
:被保护的整数变量,只能由操作系统通过原子操作P()
和V()
修改。 P()
:sem
减1,如sem<0
,进入等待, 否则继续V()
:sem
加1,如 如sem≤0
,唤醒一个等待进程
class Semaphore { // 信号量数据结构
int sem;
WaitQueue q;
}
Semaphore::P() { // 减少
sem--;
if (sem < 0) {
Add this thread t to q;
block(p);
}
}
Semaphore::V() { // 增加
sem++;
if (sem<=0) {
Remove a thread t from q;
wakeup(t);
}
}
信号量分为分为二元信号量和资源信号量,二元信号量解决互斥访问,资源信号量解决条件同步。
- 互斥访问:临界区的互斥访问控制,每个临界区设置一个信号量,其初值为1
- 条件同步:线程间的事件等待,每个条件同步设置一个信号量,其初值为0
1. 生产者-消费者问题:
问题描述:
一个或多个生产者在生成数据后放在一个缓冲区里,单个消费者从缓冲区取出数据处理,任何时刻只能有一个生产者或消费者可访问缓冲区
问题分析:
- 任何时刻只能有一个线程操作缓冲区(互斥访问)
- 缓冲区空时,消费者必须等待生产者(条件同步)
- 缓冲区满时,生产者必须等待消费者(条件同步)
信号量描述:
二进制信号 mutex
、资源信号量 fullBuffers
和资源信号量 emptyBuffers
Class BoundedBuffer {
mutex = new Semaphore(1); // 互斥锁
fullBuffers = new Semaphore(0); // 满缓冲区个数
emptyBuffers = new Semaphore(n); // 空缓冲区个数
}
代码:
缓冲区是互斥访问,所以临界区需要紧贴一对 mutex->P()
和 mutex->V()
操作
存放数据操作 Deposit(c)
需要先检查是否有空缓冲区 emptyBuffers->P()
,存放完毕后需要增加满缓冲区资源 fullBuffers->V()
取出数据操作 Remove()
需要先检查满缓冲区是否有资源 fullBuffers->P()
,取出完毕后需要增加空缓冲区空位 emptyBuffers->V()
2. 读者写者问题(读者优先)
问题描述:
多个进程共享一个数据区,这些进程分为读者进程和写者进程:读者进程只读数据区中的数据,写者进程只往数据区写数据。
“读”-”读“ 允许:同一时间允许多个读者同时读
“读”-“写”互斥:没有写者时,读者才能读;没有读者时,写者才能写
“写”-“写”互斥:没有其他写者时,写者才能写
问题分析:
- 写者与任何其他进程互斥(互斥访问)
- 允许多个读者同时读,需要设置计数器统计读者数量,任何时刻只允许一个读者修改计数器(互斥访问)
- 读者优先,所以第一个读者进来时需要阻塞写者,最后一个读者离开时需要唤醒写者(条件同步)
信号量描述:
二进制信号量 WriteMutex
、资源信号量 Rcount
、二进制信号量 CountMutex
代码:
3. 哲学家就餐问题
问题描述:
一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆着一根叉子和一碗面条。哲学家们倾注毕生精力用于思考和进餐,哲学家在思考时并不影响他人。只有当哲学家饥饿时,才试图拿起左右两根筷子进餐。
问题分析:
- 相邻的两个哲学家只能有一个拿起他们之间的叉子(互斥访问)
- 哲学家需要同时拿起左右两根叉子(互斥访问)
信号量:
二进制信号量数组 fork[5]
、二进制信号量 mutex
代码: