进程与线程
1. 进程
一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。在多道程序设计系统中,CPU在各个进程之间快速切换,宏观上看一段时间内许多进程共同运行,实际上在一个给定瞬间一个CPU对应只有一个进程在运行。
-
四种主要事件导致进程的创建:
- 系统初始化
- 执行了正在运行的进程所调用的进程创建系统调用
- 用户请求创建一个新进程
- 一个批处理作业的初始化
-
在UNIX系统中,只有一个系统调用可以创建新进程:fork。这个进程调用会创建一个与调用进程相同的副本。在调用fork后,两个进程(父进程和子进程)拥有相同的存储映像、同样的环境字符串和同样的打开文件。
-
进程创建之后,父进程和子进程有各自不同的地址空间。如果其中某个进程在其地址空间中修改了一个字,这个修改对其他进程而言是不可见的。
-
进程终止通常有下列条件:
- 正常退出(自愿)
- 出错退出(自愿)
- 严重错误(非自愿)
- 被其他进程杀死(非自愿)
-
进程有三个状态:
- 运行态
- 就绪态 (等待被调度)
- 阻塞态(等待资源)
-
为了实现进程模型,操作系统维护着一张表格(一个结构数组),即进程表
-
2. 线程
-
线程也称轻量级进程,是独立调度的基本单位。一个进程中可以有多个线程,所有的线程都有完全一样的地址空间,意味着它们共享同样的全局变量,一个线程可以读、写甚至清除另一个线程的堆栈。
-
线程也可处于运行态、就绪态、阻塞态中的一个,每个线程有其自己的堆栈。
-
线程可以调用
thread_exit
退出,thread_join
等待特定线程退出,thread_yield
转化为就绪态让其他线程运行 -
线程可分为用户级线程和内核级线程:
- 用户级线程:性能更好,可以实现自己定制的调度算法,但存在阻塞系统调用问题,且一旦一个用户级线程开始运行,该进程中的其他线程就不能运行,除非第一个线程自动放弃CPU
- 内核级线程:内核中有用来记录系统中所有线程的线程表。内核线程不需要新的、非阻塞系统调用。当一个线程阻塞时,内核可以选择运行同一个进程中的另一线程或运行另一进程中的线程。在内核中创建或撤销线程代价比较大,故效率比用户级线程低。
进程间通信
-
竞争条件:两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件。
-
临界区:共享内存进行访问的程序片段。有关临界区的优秀程序需要满足条件:
- 任何两个进程不能同时处于其临界区
- 不应对CPU的速度和数量做任何假设
- 临界区外运行的进程不得阻塞其他进程
- 不得使进程无限期等待进入临界区
-
忙等待互斥:
- 屏蔽中断:在进程刚进入临界区后立即屏蔽所有中断,就要离开之前再打开中断。屏蔽中断权力交给用户进程是不明智的,且在多核系统中,屏蔽一个CPU中断无法阻止其他CPU干预该CPU所做的操作。
- 锁变量:设想一把初始值为0的锁,当一个进程想要进入临界区时,首先测试这把锁,若该锁的值为0,则设为1并进入临界区;若为1,则等待直到其变为0。若一个进程读取该锁并设为1前,另一进程读取未更改的值也设为1,则将同时又两个进程处于临界区。
- 严格轮换法:设置变量
turn
,忙等待测试该变量直到turn
的值为某进程对应值,则退出循环进入临界区。若某进程一直处于临界区外,则turn
的值得不到及时的更改,另一线程就无法继续执行,这破坏了上述条件3:进程被临界区外的进程阻塞。 - Peterson解法:当某进程想要进入临界区时设置
interested[process] = TRUE
表示想要进入临界区,若turn == process
且interested[other] == FALSE
时表示可以安全进入临界区,否则循环等待
1. 管道
用于父子进程或兄弟进程之间通信,只能进行半双工通信,即单向传输
2. 信号量(Semaphore)
是一个整形变量,可以对其执行down
和up
操作,即常说的PV操作。检查数值、修改变量值即可能的睡眠操作均为原子操作。
- down:若信号量大于0,则对其-1并继续下一操作;若为0,则进程睡眠,等待其值大于0
- up:对信号量+1,唤醒正在睡眠的进程让其继续进行down操作
- 信号量可用于实现同步,控制某些事件顺序发生或不发生
//信号量解决生产者-消费者问题
#define N 100
typedef int semaphore; //信号量
semaphore mutex = 1; //互斥量,控制对临界区的访问
semaphore empty = N; //计数缓冲区的空槽数目
semaphore full = 0; //计数缓冲区的满槽数目
void producer(void)
{
int item;
while (TRUE) {
item = produce_item; //产生数据
down(&empty); //若空槽不为0则放入数据,空槽数目-1
down(&mutex); //进入临界区
insert_item(item); //将数据放入缓冲区
up(&mutex); //离开临界区
up(&full); //满槽数目+1
}
}
void consumer(void)
{
int item;
while (TRUE) {
down(&full); //若满槽数目不为0则取出数据,满槽数目-1
down(&mutex); //进入临界区
item = remove_item(); //从缓冲区取走数据
up(&mutex); //离开临界区
up(&empty); //空槽数目+1
consume_item(item); //处理数据项
}
}
- 互斥量(Mutex):信号量的简化版本,值只能为0或1,0表示解锁,1表示加锁。若互斥量为解锁,则调用成功,调用线程可以自由进入临界区;若互斥量被加锁,则调用线程阻塞,直到临界区中的线程完成并调用
unlock
3. 管程
- 一个管程是由过程、变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。任一时刻管程只能有一个活跃进程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。
- 管程引入了 条件变量 以及相关的操作:
wait()
和signal()
来实现同步操作。对条件变量执行wait()
操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal()
操作用于唤醒被阻塞的进程。
//Java实现管程解决生产者-消费者问题
public class ProducerConsumer {
static final int N = 100;
static producer p = new producer();
static consumer c = new consumer();
static out_monitor = new our_monitor();
public static void main(String[] args) {
p.start(); //开始生产者线程
c.start(); //开始消费者线程
}
static class producer extends Thread {
public void run() {
int item;
while (true) {
item = produce_item();
mon.insert(item);
}
}
private int produce_item() {...} //实际生产
}
static class consumer extend Thread {
public void run() {
int item;
while (true) {
item = mon.remove();
consume_item(item);
}
}
private void consume_item() {...} //实际消费
}
static class our_monitor {
private int buffer[] = new int[N];
private int count = 0, lo = 0, hi = 0; //计数器和索引
public synchronized void insert(int val) {
if (count == N) go_to_sleep(); //若缓冲区满,则线程休眠
buffer[hi] = val; //向缓冲区中插入数据项
hi = (hi + 1) % N; //设置下一个数据项的槽
count = count + 1;
if (count == 1) notify(); //若消费者在休眠,则将其
}
public synchronized int remove() {
int val;
if (count == 0) go_to_sleep() //若缓冲区空,进入休眠
val = buffer[lo]; //从缓冲区中取出数据项
lo = (lo + 1) % N; //设置待取数据项的槽
count = count - 1;
if (count == N - 1) notify(); //若生产者在休眠,则将其唤醒
return val;
}
private void go_to_sleep( ) {try{wait();} catch(InterrupedException e) {};}
}
}
4. 消息传递
消息传递使用两条原语send
和receive
,他们像信号量而不像管程,是系统调用而不是语言成分。
send(destination, &message) //向指定的目标发送消息
receive(source, &message) //从给定的源接收消息
- 信箱:用来对一定数量的消息进行缓冲的地方。使用信箱时,
send
和receive
调用中的地址参数就是信箱中的地址。可使用信箱解决生产者-消费者问题:生产者向信箱发送包含实际数据的消息,消费者则向生产者信箱发送空的消息。目标信箱容纳那些已被发送但尚未被目标进程接受的消息。
#define N 100
void produce(void)
{
int item;
message m; //消息缓冲区
while (TRUE) {
item = produce_item(); //产生放入缓冲区的数据
receive(consumer, &m); //等待消费者发送空缓冲区
build_message(&m, item); //建立一个待发送的消息
send(consumer, &m); //发送数据项给消费者
}
}
void consumer(void)
{
int item, i;
message m;
for (i = 0; i < N; i++) send(producer, &m); //发送N个空缓冲区
while (TRUE) {
receive(producer, &m); //接受包含数据项的消息
item = extract_item(&m); //将数据项从消息中提取出来
send(producer, &m); //将空缓冲区发送回生产者
consume_item(item) //处理数据项
}
}
5. 屏障
当一个进程到达屏障时,它就被屏障阻拦,直到所有进程都到达该屏障为止。
调度
-
何时调度:
- 在创建一个新进程之后,需要决定运行父进程还是运行子进程
- 在一个进程退出时必须做出调度决策
- 当一个进程阻塞在I/O和信号量上或由于其他原因阻塞时,必须选择另一个进程运行。
- 在一个I/O中断发生时,必须做出调度决策
-
调度算法评价原则:
-
批处理系统:批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。
- 先来先服务 first-come first-serverd(FCFS)
非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。 - 短作业优先 shortest job first(SJF)
非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。 - 最短剩余时间优先 shortest remaining time next(SRTN)
最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
- 先来先服务 first-come first-serverd(FCFS)
-
交互式系统:交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
- 时间片轮转
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
算法效率依赖于时间片长度:时间片设得太短会导致过多的进程切换,降低了CPU效率;设得太长又可能引起对短的交互请求的响应时间变长。时间片设为20ms~30ms通常是一个比较合理的折中。
- 优先级调度
每个进程被赋予一个优先级,允许优先级最高的的可运行进程先运行。
为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级,或者赋予进程一个允许运行的最大时间片,当这个时间片用完时,下一个次高优先级的进程获得机会运行。
- 多级队列
假设一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次,而多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
- 时间片轮转
经典IPC问题
1. 哲学家就餐问题
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
为了防止死锁的发生,可以设置两个条件:
- 必须同时拿起左右两根筷子;
- 只有在两个邻居都没有进餐的情况下才允许进餐。
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N // 右邻居
#define THINKING 0
#define HUNGRY 1
#define EATING 2
typedef int semaphore;
int state[N]; // 跟踪每个哲学家的状态
semaphore mutex = 1; // 临界区的互斥
semaphore s[N]; // 每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think();
take_two(i);
eat();
put_two(i);
}
}
void take_two(int i) {
down(&mutex);
state[i] = HUNGRY;
test(i);
up(&mutex);
down(&s[i]);
}
void put_two(i) {
down(&mutex);
state[i] = THINKING;
test(LEFT);
test(RIGHT);
up(&mutex);
}
void test(i) { // 尝试拿起两把筷子
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]);
}
}
2. 读者-写者问题
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。
一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
void reader() {
while(TRUE) {
down(&count_mutex);
count++;
if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
up(&count_mutex);
read();
down(&count_mutex);
count--;
if(count == 0) up(&data_mutex);
up(&count_mutex);
}
}
void writer() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
参考资料:
- 《现代操作系统》
- cyc大大的博客