互斥与同步(基于阻塞队列的生产消费模型)

文章目录

  • 互斥
    • 互斥量(锁)的接口
    • 为什么加锁是原子的?
    • 为什么pthread_cond_wait调用时要传入互斥锁?
    • 可重入与线程安全
    • 死锁
  • 同步
    • 条件变量
  • 生产者消费者模型
  • 基于阻塞队列的生产消费模型


互斥

所有的线程数据是共享的。
被多个执行流访问的公共资源叫做临界资源,访问临界资源是以线程/执行流的方式去访问的,把每个线程内访问临界资源的那部分代码叫做临界区。

不一定所有的共享资源都会被所有线程访问,比如main只被一个执行流访问。

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

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

多个线程并发的操作共享变量,会带来一些问题:
比如我们假设有ticket = 100,有很多个执行流同时都要去让ticket--,而--操作并不是原子操作,而是对应三条汇编指令:

1. load : 将共享变量ticket从内存加载到寄存器中
2. update : 更新寄存器里面的值,执行-1操作
3. store : 将新值,从寄存器写回共享变量ticket的内存地址

因此必须要满足:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

这时就需要一把锁,Linux上提供的这把锁叫互斥量,互斥锁。
互斥与同步(基于阻塞队列的生产消费模型)_第1张图片

互斥量(锁)的接口

初始化

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);

mutex:要初始化的互斥量
attr:NULL

销毁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

要注意:加锁粒度应是越小越好,加锁多了,会弱化多线程的效率,因为加锁后,它们是串行走的,违背了多线程分工执行提高效率的本来目的。

对上述进行一个总结:

1、我们要对临界区进行保护,前提是所有的执行线程都必须遵守这个规则(这个是要通过编码保证的),否则就无法保护。

2、进入临界区的过程一定是要先进行加锁,访问临界区,再进行解锁。

3、因为所有的线程都必须先加锁,所以所有的线程都必须看到同一把锁因此,锁本身也是个临界资源,要保护别人,就要先保证自己的安全性。
锁本身不安全的情况:一把锁对多个线程同时使用

因此我们必须保证这个锁在任何一个时间只允许被一个线程占用。

申请锁的过程是不能有中间状态的,也就是两态的(原子性),解锁也是原子的。

4、访问临界区是要花时间的,因此在特定线程(互斥锁)/ 进程(通信用的是二元信号量)拥有锁的时候,期间有新线程过来申请锁,它一定是申请不到的。那么新线程要进行阻塞(线程被阻塞,对应的pcb中的状态由R改成非R,并把pcb从运行队列中投入到等待队列)。占有锁的线程解锁后之后,再把等待队列的线程进行一一唤醒。

5、该如何理解pthread库中提供的关于锁的四个接口呢?
锁有很多,因此也需要被管理,可以理解成它是一个结构体
struct mutex{
int lock; // 0代表占有;1代表当前可被申请
wait_queue *head; // 等待队列
} // 伪代码

init:初始化,表示将这把锁设置为可被申请,并把等待队列置为空
destroy:销毁
lock:锁的内部标识变量由1变0
unlock:变量由0变1

以上是一种简单理解。

6、一次保证只有一个线程进入临界区访问临界资源,这种特征叫做互斥。

有可能出现这种情况:在临界区中的多行代码中,这个线程的时间片到了(或者优先级更高的线程来了要抢占),因此当前线程被切换了,但有没有影响?完全没有,因为它是带着锁走的,在被切换时并没有解锁。

7、加锁时为什么一般效率比较低,或者会影响效率?

因为执行流本来是并行或并发执行的,加了锁后变成了串行。且它占着锁时,它也有可能被切换走,这时,别的线程并不能访问临界资源,只能阻塞等待。

因此锁的影响有两点:

  1. 所有的线程在访问临界资源时都会串行
  2. 当占有锁的线程被切换时,会影响到其他所有线程的执行情况。
  • 锁的应用——模拟抢票
#include
#include
#include

int ticket = 10000;
pthread_mutex_t lock; //锁

void* get_ticket(void* arg)
{
     
    usleep(1000);
    int num = (int)arg;
    
    while(1)
    {
     
        pthread_mutex_lock(&lock);

        if(ticket > 0){
     
            usleep(1000);
            printf("thread %d ,get a ticket, no %d\n",num, ticket);
            ticket--;

            pthread_mutex_unlock(&lock);
        }
        else{
     
            pthread_mutex_unlock(&lock);

            break;
        }
    }
}
int main()
{
     
    int i = 0;
    pthread_t tid[4];
    
    pthread_mutex_init(&lock, NULL);

    for(i = 0; i < 4; i++)
    {
     
        pthread_create(tid+i, NULL, get_ticket, (void*)i);
    }

    for(i = 0; i < 4; i++)
    {
     
        pthread_join(tid[i], NULL);
    }

    pthread_mutex_destroy(&lock);// 释放掉锁
    return 0;
}

为什么加锁是原子的?

互斥与同步(基于阻塞队列的生产消费模型)_第2张图片
重点关注exchange指令,它的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。注意,交换并非拷贝式转移!
mutex中存放的1,与%al中存放的0交换,这个过程是一条命令完成的。因为只有一个1,所以只有拿到1的那个线程能够执行if,也就是只有它能够申请成功。

1、整个过程中,为1的mutex只有一份,没有因为拷贝而增多。
2、一条exchange汇编就完成了寄存器数据(al)和内存数据(mutex)的交换。

因此,加锁是原子的。
解锁也是原子的!能够解锁那么曾经一定是加过锁,所以能运行到解锁这里只有它这一个执行流。

为什么pthread_cond_wait调用时要传入互斥锁?

我们先来想,为什么线程要等待?因为当条件不满足时,它只能等。那怎么知道条件满不满足呢?要经过判断。要判断的话,就必须进入临界区才能判断。所以此时一定是持有锁进入临界区的。

当我们判断了条件不满足时,就要执行wait。
在wait执行时必须将锁释放,不然就会导致一个线程持有锁等待,其他线程无法进入临界区。

总结:
在调用wait这个函数时,会自动释放锁。
在调完该函数时,线程醒了,发现自己在临界区里,所以这时该函数会让该线程重新持有锁。

以上都是wait函数自动完成的,因此要传入锁。
调用时方便释放锁,返回时要重新获得锁。

可重入与线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

可重函数强调的主体是函数,线程安全强调的主体是线程。
线程有可能会调一个函数导致出错,这个函数叫做不可重入函数,出现的问题叫做线程安全问题。

死锁

是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

死锁四个必要条件

  1. 互斥条件:一个资源每次只能被一个执行流使用。
  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。

产生死锁一定是因为这几个原因同时发生导致的,那么如果至少破坏一个,就不会产生了。

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

同步

  • 什么是同步?(这里的同步指的是多线程执行时)

在保证数据安全的情况下(一般是使用加锁方式),让多个执行流按照特定的顺序进行临界资源的访问,称之为同步的过程。

  • 为什么要存在同步?

互斥保证我们不出错,同步保证我们多线程协同高效,完成某些事物。

条件变量

是对互相通知时特定条件状态的抽象。

条件变量函数:

  1. 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);

cond:要初始化的条件变量
attr:NULL

  1. 销毁
int pthread_cond_destroy(pthread_cond_t *cond)
  1. 等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释

  1. 唤醒等待:在该条件变量中等的进程都会被唤醒
int pthread_cond_signal(pthread_cond_t *cond);
  • 如何编码实现?

1、如果条件不满足时,pthread_cond_wait等待。
2、通知机制pthread_cond_signal。

  • 创建两个线程:线程2控制线程1,线程2发送通知,线程1活动。
#include
#include
#include

pthread_mutex_t lock;
pthread_cond_t cond;

// 想让线程2控制线程1
void* routine_r1(void * arg)
{
     
    const char* name = (char*)arg;

    while(1)
    {
     
        pthread_cond_wait(&cond, &lock);
        printf("get cond, %s: 我活了\n", name);
    }
}
void* routine_r2(void* arg)
{
     
    const char* name = (char*)arg;

    while(1)
    {
     
        sleep(rand()%3 + 1);
        pthread_cond_signal(&cond);
        printf("%s signal done...\n", name);
    }
}
int main()
{
     
    pthread_mutex_init(&lock, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_t t1, t2;
    pthread_create(&t1, NULL, routine_r1, "thread 1");
    pthread_create(&t2, NULL, routine_r2, "thread 2");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
    return 0;
}

如何理解条件变量?一个线程要在cond上等,一个要把通知信息发到cond。

可以理解为封装成了一个结构体
struct cond{
int val; // 当前条件是否成立,0成立,1不成立
wait_queue *head; // 等待队列,都在等cond条件成立
}

生产者消费者模型

当我们作为消费者在超市买东西时,总有相应的厂商作为生产者会不停的补充货物。

该模型可以理解为上述例子的抽象。

生产者消费者模型本质是:有一段“内存空间”,有多个线程负责生产,有多个线程负责消费。
目的:集中化管理,使效率变高

  • 模型包括:

生产者(n个),消费者(n个):通常是进程 / 线程
空间、交易场所:一块“内存块“
产品:数据

遵守"321”原则:

  • 3:维护3种关系——生产者和生产者(互斥,任何一个时刻只允许有一个生产者往里面写)、消费者和消费者(互斥)、生产者和消费者(同步:生产完消费,消费完生产)
  • 2:两种角色。生产者和消费者
  • 1:一个交易场所。

之前写的单进程代码中,所有的函数调用都是串行的,因为在执行到对应的函数时,要等。比如:
互斥与同步(基于阻塞队列的生产消费模型)_第3张图片
在主函数中,它作为生产者时,要给add函数提供a和b;而它作为消费者时,要获取add执行后的数存放到c。
相应地,在add函数中,它作为生产者,为主函数提供加合,而作为消费者,从主函数获取a和b。

这样的代码,耦合性很强。
因为在执行到主函数的add时,主函数要等待add的执行,以获得c,效率并不高。且二者在互相的这种供给和需求,维护性很差。

如果改成这样:
互斥与同步(基于阻塞队列的生产消费模型)_第4张图片

线程1只生产数据,线程2去获取数据,然后做对应的操作。
那么,就实现了在代码层面解耦,维护性很强。

进程间通信的本质也是生产者消费者模型。

我们先来探讨一下该模型的价值意义。
如图,服务器从网络中读取数据存入数据库。
互斥与同步(基于阻塞队列的生产消费模型)_第5张图片
服务器从网络中读数据时,它并不一定随时都能读到,因此大部分时间都是在等待数据。且写入数据库是要访问硬盘(涉及IO,耗时)。
所以如果不经过任务队列直接将数据存入数据库,一个线程要做的工作都是串行的,用户所等的时间过长。

而任务队列是在内存开辟的空间,有了任务队列,把数据一个线程负责把数据先放在队列里,另一个线程负责从队列中读数据放到数据库,这样耗时短。

基于阻塞队列的生产消费模型

  • BlockQueue.hpp
#ifndef __QUEUE_BLOCK_H__
#define __QUEUE_BLOCK_H__

#include
#include
#include
#include

class Task{
     
public:
    Task(){
     }
    Task(int x, int y)
        :_x(x), _y(y)
    {
     }
    int Run()
    {
     
        return _x + _y;
    }
    ~Task(){
     }
public:
    int _x;
    int _y;
};
//作为交易场所,要保证它的安全,互斥
class BlockQueue{
     
private:
    std::queue<Task> q;
    size_t cap;
    pthread_mutex_t lock;
    //当一方特别慢时,需要另一方等待,可以了之后,也就要被唤醒,需要条件变量,互相通知。
    pthread_cond_t c_cond;//消费者在该条件下等
    pthread_cond_t p_cond;//生产者在该条件下等
public:
    bool IsFull()
    {
     
        return q.size() >= cap;
    }
    bool IsEmpty()
    {
     
        return q.empty();
    }
    void LockQueue()
    {
     
        pthread_mutex_lock(&lock);
    }
    void UnlockQueue()
    {
     
        pthread_mutex_unlock(&lock);
    }
    void WakeUpProducer()
    {
     
        std::cout<< "wake up Producer" << std::endl;
        pthread_cond_signal(&p_cond);
    }
    void WakeUpConsumer()
    {
     
        std::cout<< "wake up Consumer" << std::endl;
        pthread_cond_signal(&c_cond);
    }
    void ProducerWait()
    {
     
        std::cout<< "Producer wait" << std::endl;
        pthread_cond_wait(&p_cond, &lock);
    }
    void ConsumerWait()
    {
     
        std::cout<< "Consumer wait" << std::endl;
        pthread_cond_wait(&c_cond, &lock);
    }
public:
    BlockQueue(int _cap):cap(_cap){
     
        pthread_mutex_init(&lock, nullptr);
        pthread_cond_init(&c_cond,nullptr);
        pthread_cond_init(&p_cond,nullptr);
    }
    void PutTask(Task t){
     
        LockQueue();
        while(IsFull())
        {
     
            WakeUpConsumer();
            ProducerWait();
        }
        
        q.push(t);
        UnlockQueue();
    }
    void getTask(Task& t){
     
        LockQueue();
        while(IsEmpty())
        {
     
            WakeUpProducer();
            ConsumerWait();
        }

        t = q.front();
        q.pop();
        UnlockQueue();
    }
    ~BlockQueue()
    {
     
        pthread_mutex_lock(&lock);
        pthread_cond_destroy(&c_cond);
        pthread_cond_destroy(&p_cond);
    }
};
#endif
  • main.cc
#include"BlockQueue.hpp"

using namespace std;

void* producer_running(void* arg)
{
     
    sleep(1);
    BlockQueue *bq = (BlockQueue*)arg;
    while(true)
    {
     
        //lock 
        int x = rand()%10 + 1;
        int y = rand()%100 + 1;

        Task t(x, y);
        bq->PutTask(t);
        //unlock
        cout<< "producer Task is: " << x << "+" << y << "=?" << endl;
        sleep(1);
    }
}
void* consumer_running(void* arg)
{
     
    BlockQueue *bq = (BlockQueue*)arg;
    while(true)
    {
     
        //lock 如果是多消费者的情况,要实现内部互斥就要上锁
        Task t;
        bq->getTask(t);
        cout<<"consumer: "<< t._x << "+" << t._y << "=" << t.Run() <<endl;
        //unlock
    }
}
int main()
{
     
    BlockQueue* bq = new BlockQueue(5);
    
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer_running, (void*)bq);
    pthread_create(&p, nullptr, producer_running, (void*)bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    delete bq;
    return 0;
}

  • Makefile
main:main.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f main

你可能感兴趣的:(多线程,linux)