【操作系统】线程同步、线程互斥、原子操作

线程互斥

引入

来看一段多线程的代码,这是一个经典的卖火车票例子,西安火车站现在剩余 10 张到北京西的票,有 3 个售票窗口在买票:

// 销售火车票
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10;

// 每个窗口都执行的售票操作,假设每个窗口每次只卖出 1 张
void* SellTicket(void*);

int main()
{
    // 有 3 个售票窗口在售票
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, NULL, SellTicket, "窗口1");
    pthread_create(&tid2, NULL, SellTicket, "窗口2");
    pthread_create(&tid3, NULL, SellTicket, "窗口3");
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    pthread_join(tid3, NULL);

    return 0;
}

void*
SellTicket(void* arg)
{
    char* id = (char*) arg;
    while (1)
    {
        if (ticket > 0) 
        {
            sleep(1);  // 售票员小姐姐操作一下
            --ticket;
            printf("%s 售出 1 张, 剩余 %d 张\n", id, ticket);
        }
        else
        {
            printf("票售罄了!\n");
            break;  // 关闭售票窗口
        }
    }
}

代码开起来是没什么毛病,没毛病走两步?
编译 gcc 3-销售火车票.c -lpthread
运行 ./a.out
在这里插入图片描述
看到结果就惊了,为什么票已经没了其他窗口还在卖?
罪魁祸首就是线程的抢占式执行

原因

把大象装冰箱需要几步?
打开冰箱门、把大象塞进去、关上冰箱门。

那 --ticket 需要几步?
在冯诺依曼体系结构下:
把 ticket 读取到寄存器、电路啪啪啪转换将寄存器的值 -1、把值放回 ticket 对应的内存中。

既然线程是抢占式调度的,那么就有可能出现下面的情况:
假设 ticket 现在有 10 张。
线程 1:ticket -> 寄存器【ticket:10,寄存器:10】
【线程 1 的 CPU 时间片到保存现场,切换到线程2执行】
线程 2:ticket -> 寄存器【ticket:10,寄存器:10】
线程 2:寄存器值 -1 【ticket:10,寄存器:9】
线程 2:寄存器 -> ticket 【ticket:9,寄存器:9】
【线程 2 执行完,切换到线程1执行,恢复现场】
线程 1:寄存器值 -1 【ticket:10,寄存器:10】
线程 1:寄存器 -> ticket 【ticket:9,寄存器:9】
最终!!!两个窗口共售出 2 张票,但是 ticket 是 9!!!

解决

先来了解一些概念:
临界资源:多线程执行流共享的资源就叫做临界资源。比如上面的 ticket 就是一个临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。比如上面的 --ticket
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

线程互斥

互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。

当线程 1 在做 “把大象装冰箱” 的三步操作的时候,不让其他的线程抢去线程 1 的执行权。专业点说就是当代码进入临界区执行时,不允许其他线程进入该临界区。
要做到互斥那么我们就需要一个东西来标识当前是否有线程在使用临界资源。这个东西就叫做互斥量 mutex(互斥锁)。

来看看修改后的代码:

// 销售火车票
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10;
pthread_mutex_t mutex;

void* SellTicket(void*);

int main()
{
    pthread_mutex_init(&mutex, NULL);

    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, NULL, SellTicket, "窗口1");
    pthread_create(&tid2, NULL, SellTicket, "窗口2");
    pthread_create(&tid3, NULL, SellTicket, "窗口3");
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    pthread_join(tid3, NULL);

    pthread_mutex_destroy(&mutex);
    return 0;
}

void* SellTicket(void* arg)
{
    char* id = (char*) arg;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0) 
        {
            sleep(1);
            --ticket;
            printf("%s 售出 1 张, 剩余 %d 张\n", id, ticket);
            pthread_mutex_unlock(&mutex);
            sched_yield();  // 测试:放弃 CPU 执行权
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            printf("票售罄了!\n");
            break;
        }
    }
}

当一个线程进入临界区后加锁,出临界区后解锁。
其他线程在执行到这里的时候如果锁被用了,那就等待。

在这里插入图片描述

互斥锁 mutex 是一种 挂起等待锁,一旦有一个进程上了锁,其他进程获取锁失败,就会挂起(进入操作系统的等待队列中)。
当锁被释放后并且被操作系统调度,才能继续执行!
互斥锁能够保证线程安全,但是最终程序的效率受到影响,除此之外还有可能出现更严重的问题 死锁
那么还需要注意一点就是 mutex 的上锁解锁状态也必须是原子操作。

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单 元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后, 一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

其他类型锁:
悲观锁: 在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁, 行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁: 每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据 前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作: 当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若 不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁,公平锁,非公平锁?

线程同步

同步:同步控制着线程之间的执行顺序,不让他们抢占式执行。
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。

举个生活中的栗子:
在篮球比赛中,有传球、扣篮两个动作,假设传球和扣篮是两个人完成,那么就需要有个先后顺序,先传球,再扣篮。
假设传球的耗时 789789ms,扣篮耗时 123123ms。

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>

// 传球动作
void* ThreadEntry1(void* args) {
    (void) args;
    while (1) {
        printf("传球\n");
        usleep(789789);
    }
    return NULL;
}

// 扣篮动作
void* ThreadEntry2(void* args) {
    (void)args;
    while (1) {
        printf("-扣篮\n");
        usleep(123123);
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, ThreadEntry1, NULL);
    pthread_create(&tid2, NULL, ThreadEntry2, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    return 0;
}

跑两步:
可以看到,没拿到球就扣篮了,这样的情况就需要控制一下顺序,首先你得拿到球,然后才能扣篮。
在这里插入图片描述
我们给上面的代码加上控制:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>

// 互斥锁
pthread_mutex_t mutex;
// 条件变量
pthread_cond_t cond;

// 传球动作
void* ThreadEntry1(void* args) {
    (void) args;
    while (1) {
        printf("传球\n");
        // 传球过去了,通知一下
        pthread_cond_signal(&cond);
        usleep(788789);
    }
    return NULL;
}

// 扣篮动作
void* ThreadEntry2(void* args) {
    (void)args;
    while (1) {
        // 首先得等待球传过来
        // 一直等到球传过来
        pthread_cond_wait(&cond, &mutex);
        printf("-扣篮\n");
        usleep(123123);
    }
    return NULL;
}

int main() {
    // 初始化 cond 和 mutex
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, ThreadEntry1, NULL);
    pthread_create(&tid2, NULL, ThreadEntry2, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    return 0;
}

跑一跑:
在这里插入图片描述
注意 ThreadEntry2 执行到 pthread_cond_wait() 时候会做三个动作:
1、先释放锁;
2、等待 cond 条件就绪;
3、重新获取锁,执行后面的操作。
其中的 1、2 操作必须是原子性的,否则可能错过其他线程通知消息,导致还在这里傻等。
大部分情况下,条件变量得和互斥锁一起使用。

为什么 pthread_cond_wait 需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必 须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在 条件变量上的线程。 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没 有互斥锁就无法安全的获取和修改共享数据。

  1. 比如两个线程都要访问一个共享资源,那么这个共享资源是不是就需要加锁。
  2. 如果等待的函数先获取了锁,那么另一个发信号的线程需要获取锁怎么办,那就得需要收信号的线程在wait函数的时候释放锁,等待发信号的线程访问完临界资源之后发信号。
  3. 如果,在等待函数前先释放锁,那么同时发信号的线程发送了信号。那么还没来得及进入等待函数信号已经错过了,那这不就会一直等待嘛。
  4. 所以就需要这个解锁和等待的动作是原子的,所以这个函数就需要这个互斥量。然后再函数内部,程序设计者会用一些原子的指令来完成这两个操作。

EOF

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值