操作系统——进程与线程

操作系统系列1—— 进程与线程

操作系统学习总结系列,主要是对操作系统概念和重点主干知识的总结与归纳。参考:《现代操作系统》第4版

其他系列链接:

操作系统系列1——进程与线程链接

操作系统系列2——内存管理

操作系统系列3——死锁

操作系统系列4——文件系统

一、概念

1、进程是资源分配的基本单位。

2、线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。

二、进程的实现
1、进程表

​ 为了实现进程模型,操作系统维护着一张表格(一个结构数组),即进程表。每个进程占有一个进程表项。(有些著作称这些为进程控制块)
​ 该表项包含了一个进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号的调度信息,以及其他在进程由运行态转换到就绪态或阻塞态时必须保存的信息,从而保证该进程随后能再次启动,就像从未中断过一样。

2、中断发生后操作系统最底层的工作步骤
  • 硬件压入堆栈程序计数器
  • 硬件从中断向量装入新的程序计数器
  • 汇编语言过程保存寄存器值
  • 汇编语言过程设置新的堆栈
  • C中断服务例程运行(典型地读和缓冲输入)
  • 调度程序决定下一个将运行的进程
  • C过程返回至汇编代码
  • 汇编语言过程开始运行新的当前进程
三、进程的状态切换
1、进程的状态
  • 运行状态:进程正在处理机上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态;

  • 就绪状态:进程已处于准备运行的状态,获得了除处理机之外的一切所需资源,一旦得到处理机即可运行。

  • 阻塞状态,又称等待状态:进程正在等待某一事件而暂停运行,如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。

  • 创建状态:进程正在被创建,尚未转到就绪状态。创建步骤包括:申请空白的 PCB,向 PCB 中填写一些控制和管理信息,系统向进程分配运行时所需的资源。

  • 结束状态:进程正常结束、发生错误或者系统中断退出运行。系统必须首先将进程置为结束状态,再进一步处理资源释放及回收工作。

    img

注意区别就绪状态和等待状态:就绪状态是指进程仅缺少处理机,只要获得处理机资源就立即执行;而等待状态是指进程需要其他资源(除了处理机)或等待某一事件。之所以把处理机和其他资源划分开,是因为在分时系统的时间片轮转机制中,每个进程分到的时间片是若干毫秒。也就是说,进程得到处理机的时间很短且非常频繁,进程在运行过程中实际上是频繁地转换到就绪状态的;而其他资源(如外设)的使用和分配或者某一事件的发生(如I/O操作的完成)对应的时间相对来说很长,进程转换到等待状态的次数也相对较少。这样来看,就绪状态和等待状态是进程生命周期中两个完全不同的状态,需要加以区分。

2、状态转换

就绪状态 -> 运行状态:处于就绪状态的进程被调度后,获得处理机资源(分派处理机时间片),进程由就绪状态转换为运行状态。

运行状态 -> 就绪状态:处于运行状态的进程在时间片用完后,不得不让出处理机,进程由运行状态转换为就绪状态。此外,在可剥夺的操作系统中,当有更高优先级的进程就绪时,调度程度将正执行的进程转换为就绪状态,让更高优先级的进程执行。

运行状态 -> 阻塞状态:当进程请求某一资源(如外设)的使用和分配或等待某一事件的发生(如I/O操作的完成)时,它就从运行状态转换为阻塞状态。进程以系统调用的形式请求操作系统提供服务,这是一种特殊的、由运行用户态程序调用操作系统内核过程的形式。

阻塞状态 -> 就绪状态:当进程等待的事件到来时,如I/O操作结束或中断结束时,中断处理程序必须把相应进程的状态由阻塞状态转换为就绪状态。

四、进程的调度算法
1、批处理系统中的调度
  • 先来先服务

    非抢占式的调度算法,按照请求的顺序进行调度。

    有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。

  • 最短作业优先

    非抢占式的调度算法,按估计运行时间最短的顺序进行调度。

    长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

  • 最短剩余时间优先

    最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

2、交互式系统中的调度
  • 时间片轮转

    将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。

  • 优先级调度

    为每个进程分配一个优先级,按优先级进行调度。

    为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

  • 多级队列

    一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。

    多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。

    每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。

    可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。

  • 最短进程优先

    如果我们将每一条命令的执行看作是一个独立的“作业”,则我们可以通过首先运行最短的作业来使响应事件最短

3、实时系统中的调度
五、进程间通信
1、管道Pipe

管道是通过调用 pipe函数创建的,fd[0]用于读,fd[1]用于写

#include <unistd.h>
int pipe(int fd[2]);

只支持半双工通信(单向交替传输)
只能在父进程或者兄弟进程(有亲缘关系)中使用

2、命名管道FIFO

有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);

FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。

3、消息队列MessageQueue

相比于 FIFO,消息队列具有以下优点:

消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;
避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。

4、共享内存ShareMemory

允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。

需要使用信号量用来同步对共享存储的访问。

多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用内存的匿名段。

5、信号量Semaphore

它是一个计数器,用于为多个进程提供对共享数据对象的访问。

6、套接字Socket

与其它通信机制不同的是,它可用于不同机器间的进程通信。

六、进程同步
1、临界区

对临界资源进行访问的那段代码称为临界区。

为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。

2、同步与互斥

同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
互斥:多个进程在同一时刻只有一个进程能进入临界区。

3、信号量

信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。

如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

4、管程

使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。

七、经典IPC问题
1、哲学家进餐问题
/*
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。
当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。

下面是一种错误的解法,如果所有哲学家同时拿起左手边的筷子,
那么所有哲学家都在等待其它哲学家吃完并释放自己手中的筷子,导致死锁。
*/

#define N 5;                     // 哲学家的数目

void philosopher(int i) {        // 哲学家编号,从 0到 4
    while(True) {
        think();                 // 哲学家在思考
        take_fork(i);            // 拿起左边的筷子
        take_fork((i+1)%N);      // 拿起右边的筷子
        eat();                   // 进食
        put_fork(i);             // 将左叉放回桌子上
        put_fork((i+1)%N);       // 将右叉放回桌子上
    }
}


/*
为了防止死锁的发生,可以设置两个条件:
    必须同时拿起左右两根叉子;
    只有在两个邻居都没有进餐的情况下才允许进餐。

    注意: 每个进程将过程 Philosopher作为主代码运行,而其他过程 take_forks、put_forks和 test只是普通的过程,而非单独的进程
*/

#define N 5             // 哲学家的数目
#define LEFT (i+N-1)%N  // i的左邻居编号
#define RIGHT (i+1)%N   // i的右邻居编号
#define THINKING 0      // 哲学家在思考
#define HUNGRY 1
#define EATING 2

typedef int semaphore;  // 信号量是特殊的整型数据
int state[N];           // 数组用来跟踪记录每位哲学家的状态
semaphore mutex = 1;    // 临界区的互斥,0表示解锁,1表示加锁
semaphore s[N];         // 每个哲学家一个信号量,0表示解锁,其它表示加锁

void philosophier(int i) {  // i: 哲学家编号,从0-(N-1)
    while(TRUE) {           // 无限循环
        
    }
}

void take_forks(int i) {    // i: 哲学家编号,从0-(N-1)
    down(&mutex);           // 进入临界区,解锁
    state[i] = HUNGRY;      // 记录哲学家i处于饥饿的状态
    up(&mutex);             // 离开临界区,加锁
    down(&s[i]);            // 如果等不到需要的叉子则阻塞。 s[i]<0阻塞, ==0解锁,>0加锁
}

void put_forks(i) {         // i: 哲学家编号,从0-(N-1)
    down(&mutex);           // 进入临界区,解锁
    state[i] = THINKING;    // 哲学家已经就餐完毕
    test(LEFT);             // 检查左边的邻居现在可以吃吗
    test(RIGHT);            // 检查右边的邻居现在可以吃吗
    up(&mutex);             // 离开临界区,加锁
}

void test(i) {              // i: 哲学家编号,从0-(N-1)
    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 mutex = 1;                // 控制对rc的访问
semaphore db = 1;                   // 控制对数据库的访问
int rc = 0;                         // 正在读或者即将读的进程数目

void reader(void)
{
    while(TRUE) {                   // 无限循环
        down(&mutex);               // 获得对rc的互斥访问权
        rc = rc+1;                  // 现在又多了一个读者
        if(rc == 1) down(&db);      // 如果这是第一个读者
        up(&mutex);                 // 释放对rc的互斥访问
        read_data_base();           // 访问数据
        down(&mutex);               // 获得对rc的互斥访问权
        rc = rc-1;                  // 现在减少了一个读者
        if(rc == 0) up(&db);        // 如果这是最后一个读者...
        up(&mutex);                 // 释放对rc的互斥访问
        use_data_read();            // 非临界区
    }
}

void writer(void)
{
    while(TRUE) {                   // 无限循环
        think_up_data();            // 非临界区
        down(&db);                  // 获取互斥访问
        write_data_base();          // 更新数据
        up(&db);                    // 释放互斥访问
    }
}
3、生产者消费者问题
/*
生产者-消费者问题(有界缓冲区问题)
当缓冲区已满,而此时生产者和一个消费者还想向其中放入一些数据项的情况。
其解决方法是 让生产者先睡眠,待消费者从缓冲区取出一个或多个数据项时再唤醒它。
同样的,当消费者试图从空的缓冲区中读取数据项时,消费者就睡眠,直到生产者向其中放入一些数据再将其唤醒
*/

#define N 100                               // 缓冲区中的槽目数
int count = 0;                              // 缓冲区中的数据项数目

void producer(void)
{
    int item;
    while(TRUE) {                           // 无限循环
        item = produce_item();              // 产生下一个新数据项
        if(count == N)  sleep();            // 如果缓冲区满了,就进入休眠状态       
        insert_item(item);                  // 将(新) 数据项放入缓冲区中
        count = count+1;                    // 将缓冲区的数据项计数器赠1
        if(count == 1)  wakeup(consumer);   // 缓冲区空吗?
    }
}

void consumer(void)
{
    while(TRUE) {                           // 无限循环
        if(count == 0)  sleep();            // 如果缓冲区空,则进入休眠状态
        item = remove_item();               // 从缓冲区中取出一个数据项
        count = count-1;                    // 从缓冲区中的数据项计数器减1
        if(count == N-1)  wakeup(producer); // 缓冲区满吗
        consumer_item(item);                // 打印数据项
    }
}
/*
上述代码存在问题,当消费者读取count还未进入睡眠时,CPU执行生产者的代码,生产者生产一个数据项,
企图唤醒消费者,而消费者没有睡眠忽略这个信号,而后消费者睡眠,生产者不断生产,直到填满缓冲区
生产者也睡眠,最后两个进程都将永远睡眠下去。

用信号量解决:
*/

#define N 100                       // 缓冲区中的槽数目
typedef int semaphore;              // 信号量是一种特殊的整型数据
semaphore mutex = 1;                // 控制对临界区的访问
semaphore empty = N;                // 计数缓冲区的空槽数目
semaphore full = 0;                 // 计数缓冲区的满槽数目

void producer(void)
{
    int item;   
    while(TRUE) {                   // TRUE是常量
        item = produce_item();      // 产生放在缓冲区中的一些数据
        down(&empty);               // 将空槽数目减1
        down(&mutex);               // 进入临界区
        insert_item(item);          // 将新数据项放到缓冲区中
        up(&mutex);                 // 离开缓冲区
        up(&full);                  // 将满槽的数目加1
    }
}

void consumer(void)
{
    int item;
    while(TRUE) {                   // 无限循环
        down(&full);                // 将满槽的数目减1
        down(&mutex);               // 进入临界区
        item = remove_item();       // 从缓冲区中取出数据项
        up(&mutex);                 // 离开临界区
        up(&empty);                 // 将空槽数目加1
        consumer_item(item);        // 处理数据项
    }
}

八、进程与线程的区别
Ⅰ 拥有资源

进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。

Ⅱ 调度

线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。

Ⅲ 系统开销

由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。

Ⅳ 通信方面

线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值