操作系统进程线程(三)—进程状态、同步互斥、锁、死锁

这篇具有很好参考价值的文章主要介绍了操作系统进程线程(三)—进程状态、同步互斥、锁、死锁。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

原子操作

原子操作的概念

原子操作就是不可中断的一个或者一系列操作。

原子操作如何实现

总线锁定

使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号的时候,其他处理器的请求将被阻塞住,那么该处理器可以独占内存。

缓存锁

总线锁开销比较大,因为把CPU和内存之间的通信锁住了,这使得锁住期间,其他处理器不能操作其他内存地址的数据

频繁使用的内存会缓存在处理器的L1,L2,L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。

所谓的缓存锁定指的是**缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取。(缓存一致性机制就整体来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取)。

  • 有两种情况不能用缓存锁:第一种情况是操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定;第二种情况是处理器不支持缓存锁定,对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

Linux下同步机制

  • POSIX信号量:可用于进程同步,也可用于线程同步
  • POSIX互斥锁+条件变量:只能用于线程同步。

进程同步的四种方法

临界区

对临界资源进行访问。

同步和互斥

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

信号量

信号量表示资源的数量,对应的变量是一个整型(sem)变量。另外,还有两个原子操作的系统调用函数来控制信号量,分别是:

  • P操作:将sem-1,如果sem<0,则线程/进程阻塞,否则继续。
  • V操作:将sem加1,如果sem<=0,唤醒一个等待中的进程/线程,表明V操作不会阻塞。
    图片来源小林coding
    操作系统进程线程(三)—进程状态、同步互斥、锁、死锁

使用信号量解决生产者—消费者问题

生产者在生成数据之后,放在一个缓冲区中,消费者从缓冲区中读数据进行数据处理,任何时刻,只能有一个生产者或者消费者可以访问缓冲区。

所以需要三个信号量:

  • 互斥信号量mutex:互斥访问缓冲区
  • 资源信号量full:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为0,表示缓冲区一开始为空
  • 资源信号量empty:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为缓冲区大小
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;

void producer() {
   while(TRUE) {
       int item = produce_item();
       down(&empty);
       down(&mutex);
       insert_item(item);
       up(&mutex);
       up(&full);
   }
}

void consumer() {
   while(TRUE) {
       down(&full);
       down(&mutex);
       int item = remove_item();
       consume_item(item);
       up(&mutex);
       up(&empty);
   }
}

不能先down(mutex)再down(empty),不然生产者发现empty=0,就会等待,但是这时候消费者又无法对empty操作,就一直等下去了。

管程

管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。

经典同步问题

哲学家进餐

方案一

#define N 5

void philosopher(int i) {
    while(TRUE) {
        think();
        take(i);       // 拿起左边的筷子
        take((i+1)%N); // 拿起右边的筷子
        eat();
        put(i);
        put((i+1)%N);
    }
}

问题:如果所有哲学家同时拿起左手筷子,那么就会一起等待右边的筷子,从而导致死锁

方案二

#define N 5
semaphore mutex;
void philosopher(int i) {
    while(TRUE) {
        think();
        P(mutex);
        take(i);       // 拿起左边的筷子
        take((i+1)%N); // 拿起右边的筷子
        eat();
        put(i);
        put((i+1)%N);
        V(mutex);
    }
}

问题:可以解决死锁问题,但是每次进餐只有一个哲学家,效率上来说不是最好的解决方案

方案三

偶数编号的哲学家先拿左边的叉子后拿右边的叉子,奇数编号哲学家先拿右边叉子后拿左边叉子。

#define N 5
semaphore mutex;
void philosopher(int i) {
    while(TRUE) {
        think();
		if(i%2==0){
		  take(i);       // 拿起左边的筷子
          take((i+1)%N); // 拿起右边的筷子
		}
		else if(i%2!=0){
		  take((i+1)%N);       // 拿起左边的筷子
          take(i); // 拿起右边的筷子
		}
      
        eat();
        put(i);
        put((i+1)%N);
      
    }
}

方案四

使用一个数组state来记录每一位哲学家的状态,分别是进餐状态、思考状态、饥饿状态(正在试图拿叉子)
那么,一个哲学家只有在两个邻居没有进餐的时候,才可以进入进餐状态。
代码来源阿秀的学习笔记

#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;         // 临界区的互斥,临界区是 state 数组,对其修改需要互斥
semaphore s[N];              // 每个哲学家一个信号量

void philosopher(int i) {
    while(TRUE) {
        think(i);
        take_two(i);
        eat(i);
        put_two(i);
    }
}

void take_two(int i) {
    down(&mutex);
    state[i] = HUNGRY;
    check(i);
    up(&mutex);
    down(&s[i]); // 只有收到通知之后才可以开始吃,否则会一直等下去
}

void put_two(i) {
    down(&mutex);
    state[i] = THINKING;
    check(LEFT); // 尝试通知左右邻居,自己吃完了,你们可以开始吃了
    check(RIGHT);
    up(&mutex);
}

void eat(int i) {
    down(&mutex);
    state[i] = EATING;
    up(&mutex);
}

// 检查两个邻居是否都没有用餐,如果是的话,就 up(&s[i]),使得 down(&s[i]) 能够得到通知并继续执行
void check(i) {         
    if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
        state[i] = EATING;
        up(&s[i]);
    }
}

读者写者问题

代码来源阿秀的学习笔记

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);
    }
}

读写锁

读写锁允许多个线程同时读取共享资源,但是只允许一个线程写入共享资源。读写锁分为共享锁(读锁)和独占锁(写锁)两种类型。写锁优先于读锁。

共享锁

允许多个线程同时获取锁并读取共享资源,但阻止其他线程获取独占锁。在读写锁中,如果某个线程已经获得了共享锁,则其他线程也可以获得共享锁,但不允许其他线程获得独占锁。

独占锁

只允许一个线程获取锁并修改共享资源,其他线程在获取写锁之前必须等待当前线程释放锁。在读写锁中,如果某个线程已经获得了独占锁,则其他线程无法获得任何锁,必须等待当前线程释放锁。

使用场景

一般适用于读取操作频繁,写入操作较少的情况,但是在写入操作频繁的情况下读写锁可能会出现饥饿现象。

示例
#include <iostream>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <chrono>

using namespace std;

shared_mutex rw_lock;   // 读写锁
int shared_data = 0;    // 共享数据

// 读取共享数据的线程函数
void read_thread(int id)
{
    while (true) {
        // 获取共享锁
        shared_lock<shared_mutex> lock(rw_lock);

        // 读取共享数据
        cout << "thread " << id << " read shared data: " << shared_data << endl;

        // 释放共享锁
        lock.unlock();

        // 等待一段时间
        this_thread::sleep_for(chrono::milliseconds(100));
    }
}

// 修改共享数据的线程函数
void write_thread()
{
    while (true) {
        // 获取独占锁
        unique_lock<shared_mutex> lock(rw_lock);

        // 修改共享数据
        shared_data++;

        // 输出修改后的共享数据
        cout << "write thread write shared data: " << shared_data << endl;

        // 释放独占锁
        lock.unlock();

        // 等待一段时间
        this_thread::sleep_for(chrono::milliseconds(500));
    }
}

int main()
{
    // 创建多个读取共享数据的线程
    thread t1(read_thread, 1);
    thread t2(read_thread, 2);
    thread t3(read_thread, 3);

    // 创建一个修改共享数据的线程
    thread t4(write_thread);

    // 等待所有线程结束
    t1.join();
    t2.join();
    t3.join();
    t4.join();

    return 0;
}

互斥锁

一次只能一个线程拥有互斥锁。互斥锁是在抢锁失败的情况下主动放弃CPU进入水面状态直到锁的状态改变时再换醒。
需要直接把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文切换。
互斥锁加锁时间大概在100ns左右,实际上一种可能的实现是先自旋一段时间,自旋时间超过阈值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁得效果可能不亚于使用自旋锁。

条件变量

互斥锁只有两个状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁不足,通常和互斥锁一起使用,以免出现竞态条件。
当条件不满足的时候,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化,一旦其它的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正彼此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制

自旋锁

如果进程无法取得锁,不会立即放弃CPU,而是一直循环尝试获取锁,直到获取为止。自旋锁一般用于枷锁时间很短的场景,效率比较高。

  • 自旋锁一直占用CPU,如果短时间没有获得锁就会一直自旋,降低CPU效率
  • 递归调用有可能死锁
  • 如果是单CPU并且不可抢占,自旋锁就是空操作
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

// 互斥锁实现
std::mutex mtx;

void increment_mutex(int &x) {
    for (int i = 0; i < 1000000; ++i) {
        mtx.lock();
        ++x;
        mtx.unlock();
    }
}

// 自旋锁实现
std::atomic_flag spin_lock = ATOMIC_FLAG_INIT;

void increment_spin(int &x) {
    for (int i = 0; i < 1000000; ++i) {
        while (spin_lock.test_and_set(std::memory_order_acquire)); // 自旋等待
        ++x;
        spin_lock.clear(std::memory_order_release); // 释放自旋锁
    }
}

int main() {
    int x = 0;

    // 使用互斥锁的线程
    std::thread t1(increment_mutex, std::ref(x));
    std::thread t2(increment_mutex, std::ref(x));

    t1.join();
    t2.join();

    std::cout << "互斥锁实现的x的值: " << x << std::endl;//2000000

    x = 0;

    // 使用自旋锁的线程
    std::thread t3(increment_spin, std::ref(x));
    std::thread t4(increment_spin, std::ref(x));

    t3.join();
    t4.join();

    std::cout << "自旋锁实现的x的值: " << x << std::endl;//2000000

    return 0;
}

死锁

什么是死锁

两个或者多个线程相互等待对方数据,死锁会导致程序卡死,如果不解锁将会永远无法进行下去。

产生原因

  • 互斥
  • 不剥夺:进程在所获得的资源未释放之前,不能被其他进程抢走,只能自己释放
  • 请求和保持:进程当前所拥有的资源在请求其它资源的时候,由该进程继续占有
  • 循环等待:存在一种进程资源循环等待链,链中每个进程已获得的资源同时被链中下一个进程所请求。

举例

#include <iostream>
#include <thread>
#include <mutex>

using namespace std;

mutex mtx1, mtx2;

void ThreadA()
{
    mtx1.lock(); // 线程A获取mtx1
    cout << "Thread A obtained mutex 1" << endl;

    // 在获取mtx2之前,先暂停一会儿,让线程B有机会获取mtx2
    this_thread::sleep_for(chrono::milliseconds(100));

    mtx2.lock(); // 尝试获取mtx2,但是已经被线程B获取了,因此线程A将被阻塞

    cout << "Thread A obtained mutex 2" << endl;

    // 完成任务后,释放互斥锁
    mtx2.unlock();
    mtx1.unlock();
}

void ThreadB()
{
    mtx2.lock(); // 线程B获取mtx2
    cout << "Thread B obtained mutex 2" << endl;

    // 在获取mtx1之前,先暂停一会儿,让线程A有机会获取mtx1
    this_thread::sleep_for(chrono::milliseconds(100));

    mtx1.lock(); // 尝试获取mtx1,但是已经被线程A获取了,因此线程B将被阻塞

    cout << "Thread B obtained mutex 1" << endl;

    // 完成任务后,释放互斥锁
    mtx1.unlock();
    mtx2.unlock();
}

int main()
{
    // 创建两个线程,分别运行ThreadA和ThreadB函数
    thread t1(ThreadA);
    thread t2(ThreadB);

    // 等待两个线程执行完毕
    t1.join();
    t2.join();

    return 0;
}

在这个例子中,线程A和线程B都试图获取互斥锁mtx1和mtx2。但是,当线程A获取了mtx1时,它会休眠一段时间,让线程B有机会获取mtx2。当线程B获取了mtx2时,它也会休眠一段时间,让线程A有机会获取mtx1。由于线程A和线程B都需要互斥锁,它们都被阻塞了,导致死锁。

死锁处理方法

鸵鸟策略

假装没有发生问题

死锁检测与死锁恢复

检索到死锁发生的时候就采取措施恢复。使用资源分配图法,包括每种类型一个资源和每种类型多个资源的死锁检测。

死锁恢复

  • 抢占
  • 回滚
  • 杀死进程

死锁预防

  • 破坏互斥条件
  • 破坏请求和保持条件
  • 破坏不剥夺条件
  • 破坏循环等待

死锁避免

在程序运行的时候避免发生死锁

安全状态

如果没有死锁发生,并且及时所有进程突然对请求对资源的最大需求,也仍然存在某种次序能够使得每一个进程运行完毕,则就是安全的。

银行家算法

阿秀的学习笔记文章来源地址https://www.toymoban.com/news/detail-442111.html

到了这里,关于操作系统进程线程(三)—进程状态、同步互斥、锁、死锁的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 操作系统——进程互斥的软件实现算法(王道视频p27、课本ch6)

    1.总结概览: 2.单标志[turn]法——算法代码: 可能违反“空闲让进” 3.双标志[flag[2]]先检查法——算法代码: 如果不能利用硬件的原语的话,就可能出现违反“忙则等待”的问题: 4.双标志[flag[2]]后检查法——算法代码: 可能会出现 资源死锁(违反“空闲让进”) 5.PeterSon算

    2024年02月07日
    浏览(38)
  • 【Linux操作系统】多线程抢票逻辑——学习互斥量(锁)函数接口

    临界资源 : 多线程执行流共享的资源就叫做临界资源 。 临界区 :每个线程内部, 访问临界资源的代码,就叫做临界区 。 互斥 :任何时刻, 互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用 。 原子性 :不会被任何调度机制打断的操作

    2024年02月16日
    浏览(40)
  • 操作系统:4、进程管理之进程同步

    上述过程,若并发执行就会出现缓冲区数据出错 “哲学家进餐问题中会发生极端情况,所有哲学家都饿死,也就是所有进程都陷入等待状态” “生产者消费者问题”以及“哲学家进程问题”的根源问题是:彼此相互之间没有通信。 若生产者通知消费者我已经完成一件产品生

    2023年04月26日
    浏览(49)
  • 操作系统(一):进程状态与进程调度

            操作系统作为计算机基础的四大件,系统学习无疑是十分重要的。在这个系列的文章中,荔枝会结合操作系统的知识进行归纳梳理,总结输出博文!下面这篇文章主要介绍的是进程状态和调度,重点是几种调度算法的理解和掌握,希望对正在学习的小伙伴有帮助

    2024年02月05日
    浏览(50)
  • 操作系统实验:进程同步控制

    前言 一、开发语言及实验平台或实验环境 二、实验目的 三、实验要求 四、实验原理 五、实验过程 六、代码详解 七、diy一下 总结 计算机操作系统是一门研究计算机系统的基本原理和设计方法的课程,它涉及到计算机系统的结构、功能、性能和管理等方面。操作系统实验是

    2024年02月05日
    浏览(41)
  • Linux--操作系统进程的状态

    【Linux】进程概念 —— 进程状态_linux d状态进程_Hello_World_213的博客-CSDN博客 新建: 字面意思,将你的task_struct创建出来并且还未入队列 运行: task_struct结构体在运行队列中排队,就叫做运行态 阻塞: 等待非CPU资源就绪,阻塞状态   挂起: 当内存不足的时候,OS通过适当的

    2024年02月15日
    浏览(48)
  • 操作系统层面下——进程状态讲解

           目录         一.进程的状态:运行态         1.什么是运行状态?         2.进程进入内存的详细图解:         总结:         二.进程的状态:阻塞态          1.什么是阻塞状态?         三.进程的状态:挂起态         1.什么是挂起

    2024年02月06日
    浏览(30)
  • 【操作系统】第2章进程同步、PV操作、死锁

    (1) 临界资源 :把 一个时间段内只允许一个进程使用的资源 称为临界资源。许多物理设备(摄像头、打印机)和许多变量、数据、内存缓冲区都属于临界资源。 对临界资源的访问必须互斥地进行 。 ① 进入区 :为了进入临界区使用临界资源,在进入区检查可否进入临界区

    2024年02月03日
    浏览(222)
  • 操作系统进程线程(一)—进程线程协程区别、多进程多线程、进程调度算法、进程线程通信

    定义上 进程: 资源分配和拥有 的基本单位,是调度的基本单位。 运行一个可执行程序会创建一个或者多个进程;进程就是运行起来的程序 线程:程序 执行 基本单位,轻量级进程。 每个进程中都有唯一的主线程 ,主线程和进程是相互依赖的关系。 协程: 用户态 的轻量级

    2024年02月01日
    浏览(54)
  • 【操作系统——进程与线程(一)】

    2.1.1 进程的概念和特征 进程是指正在执行中的程序的实例。它是计算机系统进行资源分配和调度的基本单位。每个进程都有自己的地址空间、堆栈和数据区域,以及与其他进程通信和同步所需要的操作系统资源。 进程具有以下特点: 独立性:进程是独立的执行实体,拥有自

    2024年02月11日
    浏览(51)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包