Linux——多线程互斥

多线程互斥

  • 抢票问题
  • 互斥锁
    • 锁的接口
  • 理解锁
    • 锁的背景概念
    • 如何看待锁:
    • 加锁和解锁的原理
    • 锁的封装
  • 可重入与线程安全
  • 死锁
    • 死锁的概念与条件

抢票问题

这里用上一篇:
https://blog.csdn.net/qq_63580639/article/details/131054847?spm=1001.2014.3001.5501
的封装函数。
这里还需要用一个函数:
Linux——多线程互斥_第1张图片
这里是以微妙做单位进行休眠的。
假设有1000张火车票,一共四个接口在抢,最后我们要看到什么现象呢?
因为多个线程进行交叉执行。
多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换。
线程一般在什么时候发生切换?当时间片到了,来了更高优先级的线程,线程等待的时候。
那么线程是什么时候检测上面的问题?是从内核态切换到用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换。

#include "Thread.hpp"

int tickets = 1000;//票数

void* thread_run(void* args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1234);//1秒=1000毫秒=1000000微秒
            cout << name << "用户正在抢票:"<< tickets-- <<endl;
        }
        else
            break;
    }
}
int main()
{
    unique_ptr<Thread> thread1(new Thread(thread_run, (void*)"user1",1));
    unique_ptr<Thread> thread2(new Thread(thread_run, (void*)"user2",2));
    unique_ptr<Thread> thread3(new Thread(thread_run, (void*)"user3",3));
    unique_ptr<Thread> thread4(new Thread(thread_run, (void*)"user4",4));

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();

    return 0;
}

Linux——多线程互斥_第2张图片
但是结果竟然出现了0,-1,-2,为什么会发生这种现象呢?
首先:判断的逻辑有两步
1.读取内存数据到CPU的寄存器中。
2.进行判断

因为CPU只有一个,每次线程只能有一个进行判断。

线程1运行的时候,CPU将tickets的数据放进内存其中与线程1的数据进行对比,但是对比结束之后,突然时间片到了,线程切换,线程2也进行了如上步骤,刚对比完又切换了。
如果极端场景,四个进程都在这个时候对比,tickets的数据一直都是1,那么这个时候线程1被唤醒,线程1带着他的上下文回到CPU,CPU处理这段代码,tickets的数据进行- - ,处理完又去处理线程2,线程3,线程4。
这也就导致了出现0,-1,-2的结果。

还有另一种情况。
对一个全局变量进行多线程更改,这个操作也不是安全的。
对于++,- -这两种操作,在C,C++上看起来只有一条语句,其实汇编用了三条语句。
1.从内存中读取数据到CPU寄存器中。
2.在寄存器中让CPU进行对应的逻辑运算。
3.写回新的结果到内存中变量的位置。
Linux——多线程互斥_第3张图片
假设线程1先将票数减少了333张。
然后CPU本来要将666传给内存中的票数时突然进行了线程切换,到了线程2一看票数还是999。
于是线程2开始继续抢票:
Linux——多线程互斥_第4张图片
线程2将票数减少到了222,这个时候,又换回了线程1.
Linux——多线程互斥_第5张图片
这个时候首先恢复的是上下文,然后更新内存中的数据,一下子变成了666,之前变成222等于白做事情了。

总结:我们定义的全局数据在没有保护的时候往往是不安全的,像上面多个线程在交替执行造成的数据安全问题,发生数据不一致。
那么如何解决呢?

互斥锁

锁的接口

之前说过原子性是要么做,要么不做,这里再结合上面抢票问题说一下。
像上面进行++操作,需要三条汇编语言,CPU在执行的时候是一定会将一条汇编语言执行完毕(失败与否不关心),也就是说这会导致有中间状态,是可以被打断的,这就叫做非原子性。
那么原子性其实就是一个对资源进行的操作,如果只用一条汇编能完成,这个就是原子性,反之就不是原子性。

这个时候,对于以上提出问题的解决方案叫做加锁。
锁也有对应的函数:
Linux——多线程互斥_第6张图片
这把锁的类型是:
Linux——多线程互斥_第7张图片
第一个函数是释放锁,第二个函数是初始化锁
Linux——多线程互斥_第8张图片
这里是对全局定义的锁初始化方式。
Linux——多线程互斥_第9张图片
这里第一个函数是对对应的锁进行加锁。
第三个函数是解锁。

#include "Thread.hpp"

int tickets = 1000;//票数

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//全局锁

void* thread_run(void* args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        pthread_mutex_lock(&lock);//这里进行加锁
        if(tickets > 0)
        {
            usleep(1234);//1秒=1000毫秒=1000000微秒
            cout << name << "用户正在抢票:"<< tickets-- <<endl;
            pthread_mutex_unlock(&lock);//解锁
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
}
int main()
{

    unique_ptr<Thread> thread1(new Thread(thread_run, (void*)"user1",1));
    unique_ptr<Thread> thread2(new Thread(thread_run, (void*)"user2",2));
    unique_ptr<Thread> thread3(new Thread(thread_run, (void*)"user3",3));
    unique_ptr<Thread> thread4(new Thread(thread_run, (void*)"user4",4));

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();

    return 0;
}

Linux——多线程互斥_第10张图片
在运行的过程中,这个速度也变慢了,因为现在的线程是串行执行,所以也没有发生之前奇怪的打印结果。
那么为什么一直都是一个线程抢到票了呢?这是因为锁虽然规定了串行执行,但是并没有去管理线程的竞争,这里第四个线程竞争力最强,所以每次都是线程4抢到票。

这里我们在用一下局部锁,并且解决一下刚才的问题。

#include "Thread.hpp"

int tickets = 1000;//票数

class ThreadData
{
public:
    ThreadData(const string& threadname, pthread_mutex_t *mutex_p):_threadname(threadname),_mutex_p(mutex_p)
    {}
    ~ThreadData()
    {}
public:
    string _threadname;
    pthread_mutex_t *_mutex_p;
};
void* thread_run(void* args)
{
    ThreadData* p = static_cast<ThreadData*>(args);
    while(true)
    {
        pthread_mutex_lock(p->_mutex_p);//加锁
        if(tickets > 0)
        {
            usleep(1234);//1秒=1000毫秒=1000000微秒
            cout << p->_threadname << "用户正在抢票:"<< tickets-- <<endl;
            pthread_mutex_unlock(p->_mutex_p);
        }
        else
        {
            pthread_mutex_unlock(p->_mutex_p);
            break;
        }
        usleep(1234);//模拟抢完票形成一个订单,这里也就等于阻止了竞争力强的线程,让竞争力强的到后面排队去
    }
}

int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);//初始化锁,第二个参数设为nullptr就可以
    vector<pthread_t> arr(4);
    for(int i = 0;i < 4; i++)
    {
        char buffer[64];
        snprintf(buffer,sizeof(buffer),"thread %d",i+1);
        ThreadData* p = new ThreadData(buffer, &lock);
        pthread_create(&arr[i], nullptr, thread_run, p);
    }
    for(const auto& e:arr)
    {
        pthread_join(e,nullptr);
    }
    pthread_mutex_destroy(&lock);//解锁
    return 0;
}

Linux——多线程互斥_第11张图片

理解锁

锁的背景概念

临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

如何看待锁:

1.我们在使用锁的时候,锁能被每个线程都看到,所以锁本身就是共享资源。锁是保护资源的,那么锁的安全谁来保护呢?
2.pthread_mutex_lock枷锁的过程中必须是安全的。(其实就是原子的)
3.如果申请成功,就继续向后执行,如果失败执行流就阻塞。
Linux——多线程互斥_第12张图片
注意,我这里申请了两次加锁。
Linux——多线程互斥_第13张图片
这里就阻塞了。
4.谁持有锁,谁就进入临界区。
假如线程1持有锁,进入临界资源,其他线程在阻塞,那么在这个过程中线程1是可以被切换的。
这也说明,线程1是和锁一起被切走了。
所以对于其他线程而言,线程1有意义的状态只有两个。

申请锁前
释放锁后

站在其他线程角度,看待当前线程持有锁的过程就是原子的。
未来我们在使用锁的时候,一定要尽量保证临界区的粒度(锁中间保护的代码)非常小。
并且,加锁是程序员的行为,针对某一处公共资源,对于一个线程加锁,其他线程也要想办法加锁。

加锁和解锁的原理

经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下:
Linux——多线程互斥_第14张图片
假设现在是线程A运行,线程A进行了申请加锁,内存中的int当中的1就是锁。
首先让0放进CPU中的寄存器%al当中,然后将内存中的1%al中的0交换。
Linux——多线程互斥_第15张图片
这里交换是一条汇编完成的。
这个时候,如果突然时间片到了,线程B换了上来,线程A就要带着自己的上下文走。
Linux——多线程互斥_第16张图片
然后线程B从头开始,先将0放入%al,然后交换:
Linux——多线程互斥_第17张图片
这里继续向下执行语句,发现寄存器%al中的内容并不大于0,走第二条语句,线程B就被挂起等待了。
然后线程A又切换回来继续向下执行:
Linux——多线程互斥_第18张图片
这就是为什么当前线程申请锁之后其他线程无法申请锁!
解锁的过程就是将%al的1移动到内存中:
Linux——多线程互斥_第19张图片

锁的封装

因为C语言很多接口是不兼容C++的,所以我们要想办法设计让锁的接口兼容C++。

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

class Mutex
{
public:
    Mutex(pthread_mutex_t *lock_p = nullptr):_lock_p(lock_p)
    {}
    void lock()
    {
        if(_lock_p) pthread_mutex_lock(_lock_p);
    }
    void unlock()
    {
        if(_lock_p) pthread_mutex_unlock(_lock_p);
    }
    ~Mutex()
    {}
private:
    pthread_mutex_t *_lock_p;
};

class LockGuard//这里像智能指针一样,自动解锁
{
public:
    LockGuard(pthread_mutex_t *mutex):_mutex(mutex)
    {
        _mutex.lock();
    }
    ~LockGuard()
    {
        _mutex.unlock();
    }
private:
    Mutex _mutex;
};
#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

class Thread;//声明
class Context//上下文,相当于一个大号的结构体
{
public:
    Thread *this_;
    void* args_;
public:
    Context():this_(nullptr),args_(nullptr)
    {}
    ~Context()
    {}
};
class Thread
{
    typedef function<void* (void*)> func_t;
public:
    //这里需要加一个静态,因为不加静态就是类成员函数,还有一个隐藏的this指针,也就说明这等于前面有一个缺省参数
    //所以在类内创建线程,想让对应的线程执行方法需要在方法前面加一个static
    static void* start_routine(void* args)
    {
        //但是静态方法不能调用成员方法或者成员变量,这里可以设置一个上下文
        Context* ctx = static_cast<Context*>(args);
        void* ret = ctx->this_->run(ctx->args_);//这里让自身去调用这个方法
        delete ctx;
        return ret;
    }
    void* run(void* args)
    {
        return _func(args);//调用该函数
    }
    Thread(func_t func,void* args,int num):_func(func),_args(args)
    {
        char buffer[1024];
        snprintf(buffer, sizeof(buffer), "thread_%d", num);
        _name = buffer;

        Context* ctx = new Context();
        ctx->this_ = this;
        ctx->args_ = _args;//这里是将自身的部分数据传给ctx
        int n = pthread_create(&_tid, nullptr, start_routine, ctx);//这里要通过调用函数来转化,直接传func是不行的,因为类型是C++的类,不是C语言的类
        assert(n==0);
        (void)n;
    }
    void join()
    {
        int n = pthread_join(_tid,nullptr);
        assert(n==0);
        (void)n;
    }
    ~Thread()
    {}
private:
    string _name;//线程名字
    pthread_t _tid;//线程id
    func_t _func;//线程调用的函数
    void* _args;//传给函数的参数
};

#include "Thread.hpp"
#include "MUtex.hpp"
int tickets = 1000;//票数

class ThreadData
{
public:
    ThreadData(const string& threadname, pthread_mutex_t *mutex_p):_threadname(threadname),_mutex_p(mutex_p)
    {}
    ~ThreadData()
    {}
public:
    string _threadname;
    pthread_mutex_t *_mutex_p;
};
void* thread_run(void* args)
{
    ThreadData* p = static_cast<ThreadData*>(args);
    LockGuard lockGuard(p->_mutex_p);//这里会自动加锁解锁
    while(true)
    {
    	{//这里的域是为了避免对下面的usleep进行加锁
	        if(tickets > 0)
	        {
	            usleep(1234);//1秒=1000毫秒=1000000微秒
	            cout << p->_threadname << "用户正在抢票:"<< tickets-- <<endl;
	        }
	        else
	        {
	            break;
	        }
        }
        usleep(1234);//模拟抢完票形成一个订单,这里也就等于阻止了竞争力强的线程,让竞争力强的到后面排队去
    }
}

int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);//初始化锁,第二个参数设为nullptr就可以
    vector<pthread_t> arr(4);
    for(int i = 0;i < 4; i++)
    {
        char buffer[64];
        snprintf(buffer,sizeof(buffer),"thread %d",i+1);
        ThreadData* p = new ThreadData(buffer, &lock);
        pthread_create(&arr[i], nullptr, thread_run, p);
    }
    for(const auto& e:arr)
    {
        pthread_join(e,nullptr);
    }
    pthread_mutex_destroy(&lock);//解锁
    return 0;
}

Linux——多线程互斥_第20张图片
这种风格叫做RAII加锁。

可重入与线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况

不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数

常见的线程安全的情况

每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。

常见不可重入的情况

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。

可重入与线程安全联系

函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生。
死锁,因此是不可重入的。

死锁

死锁的概念与条件

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

死锁四个必要条件

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

上面代码有个例子,申请了两次锁,就等于自己本来有锁,又申请一次之后等于将自己挂起,这样谁也申请不到锁了。

避免死锁

破坏死锁的四个必要条件。
加锁顺序一致。
避免锁未释放的场景。(也就是用完锁一定要释放)
资源一次性分配。(不要到处给锁分配资源,不然看起来很乱,就容易造成死锁)

这里要注意一下,当前线程的锁可以被别的线程释放,上面的汇编语言释放锁的逻辑就说明了这一点。

避免死锁算法

死锁检测算法
银行家算法

注意:平时尽量不要用锁。

你可能感兴趣的:(Linux,笔记,开发语言,c++)