Linux多线程系列2: 模拟封装简易语言级线程库,线程互斥和锁,线程同步和条件变量,线程其他知识点

Linux多线程系列2: 模拟封装简易语言级线程库,线程互斥和互斥锁,线程同步和条件变量,线程其他知识点

1.前言

我们之前简单了解了C++11线程库的一部分,今天我们试着写一下C++11的线程库,并且模拟实现一下多线程(今天就是代码环节),写完一堆代码之后,我们进入线程互斥,锁,线程安全和可重入部分的学习,依旧是代码+理论

当然绝对没有写库的大佬们写的那么完整且周到,我们实现的版本能让我们很好的使用即可,我们模拟实现主要是为了

  1. 为了后续实现线程池做准备(我们以后的线程池就用这个我们自己写的线程库了,因为我们自己写的用起来更随心所欲,库里的只能按大佬设计的走
  2. 让我们更加熟悉线程与线程接口,更加适应多线程环境代码的编写
  3. 增强代码能力
  4. 体会编程的乐趣(写了一年多单执行流的代码[除了进程池和进程间通信让我们体会到了多执行流的乐趣]了,该换换口味了)

废话不多说,直接走起

一.模拟C++11线程库自己封装简易语言级线程库

1.实现框架

什么代码都是这样,有了框架,下面就是实现函数,并按需求更新框架的工作了

2.迅速把构造等等函数写完

代码:

验证

没任何问题

3.start和work

1.尝试一

2.尝试二

我们把work加上static修饰

3.最终版本

4.给出代码

cpp 复制代码
#pragma once
#include <iostream>
using namespace std;
#include <string>
#include <functional>
#include <pthread.h>
//T是函数对象的参数类型
template<class T>
class Thread
{
public:
    //创建线程对象,初始化成员变量
    Thread(const string& name,const function<void(T)>& func,const T& data)
        :_threadName(name),_func(func),_data(data)
    {
        #ifdef DEBUG//用一下条件编译
        cout<<"构造函数执行完毕: name: "<<_threadName<<endl;
        #endif
    }

    //封装_func为void*(*pf)(void*)的函数对象,因为pthread_create只能传入这个类型的函数对象
    static void* work(void* arg)//arg给我this!!!!,别给错了
    {
        Thread<T>* mythis=static_cast<Thread<T>*>(arg);
        //虽然我是静态成员函数,但是我有this,我想怎么玩就怎么玩
        mythis->_func(mythis->_data);
        return nullptr;
    }
    
    bool start()//启动线程
    {
        if(_isRunning==false)
        {
            pthread_create(&_tid,nullptr,work,this);//没问题!!
            _isRunning=true;
            return true;
        }
        //如果线程已经被启动,就不能再被启动了
        return false;
    }

    bool joinable() const
    {
        return _isJoin;
    }

    bool running() const
    {
        return _isRunning;
    }

    bool join()//回收线程
    {
        if(!_isRunning) cout<<"该线程 "<<_threadName<<" 还未开始"<<endl;
        else if(!_isJoin) cout<<"该线程 "<<_threadName<<" 不可join"<<endl;
        else
            return pthread_join(_tid,nullptr)==0;//返回join是否成功
        return false;//还未开始或者不可join,返回false
    }

    bool detach()//分离线程
    {
        if(!_isRunning) cout<<"该线程 "<<_threadName<<" 还未开始"<<endl;
        else if(pthread_detach(_tid)==0)//如果分离成功
        {
            _isJoin=false;
            return true;
        }
        return false;//分离失败
    }

private:
    string _threadName;//线程名字
    function<void(T)> _func;//具体的函数对象
    T _data;//函数参数
    pthread_t _tid;//线程ID
    bool _isRunning=false;//该线程是否正在运行
    bool _isJoin=true;//该线程可分离
};

二.模拟实现多线程(为编写线程池做准备)

写完我们的线程库之后,我们玩一下特别小的线程池雏形,跟我们进程池的需求一样

此时,我们拿出进程池的User代码过来,改一下

1.进程池部分代码修改一下

cpp 复制代码
User.h
#pragma once
const int task_num=5;
void printLog(int i);
void ConnectDatabase(int i);
void UserLogin(int i);
void GenerateReports(int i);
void TestSoftwarePerformance(int i);
cpp 复制代码
User.cpp
#include <unistd.h>
#include <iostream>
#include <vector>
#include <string>
#include <chrono>  
#include <iomanip>
#include <sstream>
#include "User.h"
using namespace std;
// 打印日志的函数  
void printLog(int i) 
{  
    #ifdef DEBUG
    cout<<"void printLog(int i) i: "<<i<<endl;
    #endif
    // 获取当前时间  
    auto now = std::chrono::system_clock::now();  
    auto now_c = std::chrono::system_clock::to_time_t(now);  
    // 格式化时间戳  
    ostringstream oss;  
    oss << put_time(std::localtime(&now_c), "%Y-%m-%d %H:%M:%S");  
    string timestamp = oss.str();  
    const vector<string>& message={"This is a log message.","Another log message with some information."};
    // 打印日志信息
    for(auto& e:message)
    {
        cout << "[" << timestamp << "] " << e << endl;
    }
    usleep(1000);//休息1000微秒,也就是1ms
}
//下面的我就随便遍了,MySQL还没学....
//连接数据库
void ConnectDatabase(int i)
{
    #ifdef DEBUG
    cout<<"void ConnectDatabase(int i) i: "<<i<<endl;
    #endif
    cout<<"Connect to the database succeed"<<endl;
    usleep(1000);//休息1000微秒,也就是1ms
}

//用户登录
void UserLogin(int i)
{
    #ifdef DEBUG
    cout<<"void UserLogin(int i) i: "<<i<<endl;
    #endif
    cout<<"User login succeed"<<endl;
    usleep(1000);//休息1000微秒,也就是1ms
}

//报告生成
void GenerateReports(int i)
{
    #ifdef DEBUG
    cout<<"void GenerateReports(int i) i: "<<i<<endl;
    #endif
    cout<<"Generate reports succeed"<<endl;
    usleep(1000);//休息1000微秒,也就是1ms
}

//测试软件性能
void TestSoftwarePerformance(int i)
{
    #ifdef DEBUG
    cout<<"void TestSoftwarePerformance(int i) i: "<<i<<endl;
    #endif
    cout<<"Test software performance succeed"<<endl;
    usleep(1000);//休息1000微秒,也就是1ms
}

2.代码走起

thread.hpp不用改


关于其他的对于多线程的玩法,有时间了我们再来玩吧,其实锁也挺好玩的,下面进入线程互斥与锁

三.线程互斥与互斥锁

记得我们之前介绍过的信号量吗?

我们当时说二元信号量就是一把互斥锁,并且我们用二元信号量实现了共享内存的互斥,下面我们就来学一下互斥锁和原子性啦

最后的时候我们会引出互斥的局限,此时就需要有不同来解决这一问题

1.模拟多线程抢票的场景

1.代码

这里直接给出代码,然后说明一下,这代码写起来没啥难的

理想状态: 最后一次ticket打印时是1,然后所有线程都退出

我们可以看到,整个运行逻辑是非常快的,这就是多线程的一大好处

记住: 此时我们没有加锁,整个代码运行很快,而且多个线程之间切换的频率也很快(并发性高)

但是:

我们先介绍一下原子性

2.原子性

1.介绍


我们目前的理解是: 只要一条代码不是一条汇编指令,那么这条代码就不是原子的

我们可以类比一下:

cpp 复制代码
if(ticket > 0)这个逻辑判断也不是原子的
为何呢?
1. 将ticket在内存当中的值读取到xxx寄存器当中
2. 将xxx寄存器当中的值与0进行比较,设置条件标志
3. 根据比较结果进行跳转执行if语句或者else语句
至少是有着3步的

2.分析一下问题原因

下面我们利用原子性来分析一下ticket出现0,-1,-2的原因

cpp 复制代码
首先我们要达成共识的是: 
线程在被CPU调度时,只要时间片到了,那么这个线程一定会被切换走,无论即将执行哪条指令
(不考虑线程/进程陷入内核态,因为那个时候OS会进行介入)

下面我们分析一下

只要出现数据不一致,那么就一定会有问题发生(尽管问题发生的概率较小,但是在如此高并发的调度下,次数变多,概率在小的事件发生的次数也会变大啊)

而我们刚才的多线程调度频率算是很快很平均的了

一直都是0 1 2 3 0 1 2 3这样调度

但是当线程A刚if判断成功进入if语句准备ticket--时,此时时间片到了,需要切换

而且此时ticket就是1,本来这个票应该是我的,因为我这个线程正准备减它,但是我被切换了

然后线程B被调度了,将ticket减为0,然后大部分线程判断的时候看到了ticket都变成0了,我们走else就退出吧

但是当线程A再次被调度时,它是直接执行上次被切换时刚进入的if语句,然后将ticket减为了-1 !!,

此时就导致卖出了比10000张更多的票,导致了很严重的问题

那怎么办呢?

需要利用互斥锁来解决这一问题

3.介绍并使用互斥锁解决问题

注意:ubuntu下man手册没有按照POSIX标准的文档,因此man手册查不到pthread_mutex_destroy

解决方法:sudo apt-get install manpages-posix-dev

mutex:就是互斥锁的意思

1.介绍

man 2 pthread_mutex_destroy

2.申请锁和释放锁

2.法一:全局锁

下面我们演示一下

ticket没有出现0,-1,-2,到1就结束了,可见锁成功保护了临界资源

而且: 我们明显发现: 1.运行速度变慢 2.多线程并发效率降低

互斥锁导致的线程饥饿问题

锁还有其他缺点:

如果某个线程申请锁的能力太强,一直频繁申请锁,释放锁,申请锁,释放锁,就会导致其他线程出现"饥饿"问题!!

我们模拟一下: 某个线程申请锁之后再也不释放与申请这个锁了,直到把票抢光之后在释放锁

只有0号线程一直在运行,其他线程都阻塞了

请注意:即使只有一个线程能够抢票,但是多线程的速度依然跟刚才加锁的情况一样

为何??

因为所有线程瓜分进程的时间片,某个线程时间片到了,就会轮到其他线程运行,而其它线程一直阻塞,永远无法抢票(饥饿)

刚才这种情况下倒也不会造成太大的问题: 至少你这个线程把任务都完成了嘛(而且你们这些线程完成的都是同一个任务)

但是如果极端情况下,你这个拥有锁的线程一直不干正事呢?? 比如陷入了某种意外的死循环等等...

或者你们这些线程完成不同任务,但是依然需要申请同一把锁(我总觉得这种情况应该不多吧...)

那就是一个大事故了,因为你这个线程不仅出了错误不干正事,你还导致其他线程饥饿,让其他线程也干不了正事

整个多线程环境就都被这个线程坑了,怎么解决??

利用线程同步(我们以后会重点介绍的,这里先埋一个伏笔,到时候就有场景可以玩了)

注意: 如果某个线程申请锁之后,没有释放锁,并且又申请锁了,那么这个线程就卡死了,这就是一个锁造成的最经典的死锁问题

死锁的时候我们会演示的

3.法二


验证成功

4.RAII登场

1.引入

总感觉刚才不是很优雅,而且万一写错了.....................

经过观察,我们发现了一个惊天动地的特点,从而引发了下面...

2.代码

演示:

5.小小总结


4.互斥锁的原理

下面我们来谈一下互斥锁的原理,互斥锁是如何保证原子性的(特别是申请锁的原子性),当然,互斥锁有很多实现方式

我们介绍的只是其中一种而已,但是大家理解一下这种保证原子性的方式即可

1.说明

首先要先说明的是:

在介绍进程切换的时候,我们曾经提到过,每个进程(现在是线程)都有自己独立的硬件上下文,而硬件上下文就是该线程被调度时需要存放在寄存器当中的内容

因此我们当时说单核CPU下寄存器只有一套,而寄存器当中的数据是有多套的,每个线程都私有一套寄存器当中的数据,因此才实现了线程切换

unlock就是把1写入mutex,表示该锁资源重新拥有了

因为每个线程在访问临界资源之前都要先申请锁,而申请锁时都会先把al寄存器当中的内容清为0,所以无需担心释放锁时没有修改持有锁时al寄存器当中的内容

2.演示



大家可以画图自己感受一下:

  1. xchgb的作用: 本质上就是将锁资源转移到线程自己的硬件上下文当中为该线程所私有
  2. 线程在执行完xchgb之后,谁的al当中是1,谁就有锁资源,否则就没有锁资源

3.小总结

可以看出这个互斥锁的设计是非常巧妙的

通过

  1. 只为锁设置一个1表示该锁的资源
  2. 每个线程申请锁时都先将自己的al寄存器当中的data清0,表示当前该线程没有锁资源
  3. 申请锁时,每个线程都将自己al寄存器当中的data跟mutex交换
  4. 因为整个环境下只有一个1,而且交换的指令是原子的(由计算机的体系结构提供的swap/exchange指令保证)
  5. 因此一定只有一个线程交换之后能拿到1,表示该线程申请锁成功
  6. 而其它线程交换之后只能拿到0,表示该线程申请锁失败

    这一切的一切发生时,互斥锁还都只是一个内存当中用户级/进程级(因为进程是用户的代表嘛)的数据而已
    锁的设计巧妙归巧妙,但是锁终归是会将多线程执行加锁的代码由并发访问改成并行访问,从而降低并发效率
    但是互斥(锁)与同步在生产者消费者模型下就大有裨益了,到那时我们写代码再好好分析一下大佬设计的优雅之处

所以关于锁,希望大家辩证判断,仔细权衡自己的需求之后在妥善适用它们(临界资源的保护? 生产者消费者模型下的应用? ...)

不过单拿出锁来说,它本身的确是无奈之举(因为要保护临界资源嘛),如果不加锁就能保证临界资源的安全,那谁还加锁...

4.申请释放锁一定不会陷入内核吗?

你给我说完了互斥锁的原理之后,你强调了mutex是用户空间的变量,而且pthread库也是用户空间的,

5.频繁申请释放锁导致程序变慢的原因

  1. 加锁代码块的串行化: 多线程执行临界区代码,加锁之后: 由并发执行变为串行执行,限制了多线程的高并发的优点
  2. 互斥锁的锁竞争: 导致多线程频繁陷入内核,回到用户态,陷入内核,回到用户态... 开销较大
  3. CPU缓存的无效化: 如果被锁保护的资源被频繁访问修改,会导致CPU缓存的无效化和重新加载,而CPU缓存的加载才是线程切换优于进程切换的最直接原因,因此这会限制多线程切换代价低的优点

又因为线程是瓜分进程时间片的,因此线程切换的频率更高,而加锁限制了多线程的很多优点,极端情况下会使得多线程还不如单线程不加锁呢...

四.线程同步与条件变量

经过刚才抢票的代码,我们看到了线程互斥完美的保护了临界资源的安全,但是由于多线程竞争锁的能力不同,

抢到锁并执行临界区的线程也就没有任何的顺序性

因此在某些情况下会导致线程饥饿,甚至引发更大的问题

此时就需要有线程同步来保证临界资源访问的顺序性和高效性(高效性大家可能不太好理解,但是我们写代码分析的时候大家就能够很好地理解了)

1.线程同步与场景的引入

在临界资源使用安全的前提下,让多线程访问临界资源具有一定的顺序性就叫做线程同步

其实不仅仅条件变量可以完成这一任务,信号量也可以,我们以后会使用的,我们以后会写一个信号量+环形队列实现生产者消费者模型的代码

下面我们改造一下抢票的需求:

票初始时只有1000张,每隔一段时间就会多发1000张

但是时间间隔有点长,导致新线程抢光票都退出了,票才补上,因此我们的else 当中就不能break了

所以我们让它打印"没票了..."

为了方便演示,我们用一下全局的互斥锁

2.代码与演示


下面我们介绍一下条件变量

3.条件变量的理解

记住锁🔒,条件变量🔔

4.接口


5.signal使用

我们这里就只用一下signal吧,因为broadcast在我们这个场景当中太容易导致伪唤醒了

而且为了方便演示,我们只给100张票,每1s加100张票

而且每次抢票只打印一次票数

6.伪唤醒结论

伪唤醒是指线程在等待条件满足时被唤醒,但是因为竞争锁失败,导致临界资源被其他线程所修改从而又使得等待条件不满足,

但是因为对应线程被唤醒之后没有继续检查临界资源是否符合条件而直接访问临界资源导致的一种bug

可能有点长且抽象,我们举一个例子

7.伪唤醒演示

1.broadcast导致伪唤醒


broadcast比起signal来说是一种类似于用户级文件缓冲区似的,将本来要多次陷入内核的操作减为了一次...

所以减少了线程陷入内核的次数,从而提高效率(因为每次唤醒线程都要将进入了阻塞状态的线程再次调度运行起来,势必需要陷入内核执行该操作)

因此一次唤醒所有线程比起一次唤醒一个线程,唤醒多次来说是更加高效的

因此signal适用于需要严格控制线程访问临界资源顺序的场景

而broadcast适用于无需严格控制线程访问临界资源顺序的场景

2.signal导致伪唤醒

3.解决伪唤醒的bug

将if改成while,让伪唤醒的线程看到自己不满足条件之后继续乖乖的到等待队列当中等

1.broadcast
2.signal

五.线程安全与可重入

1.线程安全

线程安全是指:多线程并发访问临界资源时,不会出现临界资源的数据不一致问题

常见的线程安全的情况:

  1. 每个线程对临界资源只读取,不修改
  2. 执行具有原子性的代码(比如定义变量)
  3. 多个线程之间的切换不会导致对应代码的执行结果存在二义性
  4. 加锁/使用信号量了

2.可重入

重入 : 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,

一个函数在重入的情况下,运行结果不会出现任何问题,则该函数被称为可重入函数,否则,是不可重入函数

常见不可重入的情况:

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

常见可重入的情况:

  1. 不使用全局变量或静态变量
  2. 不使用用malloc或者new开辟出的空间
  3. 不调用不可重入函数
  4. 不返回静态或全局数据,所有数据都有函数的调用者提供
  5. 使用本地数据,或者通过对全局数据进行本地拷贝来保护全局数据

3.两者的区别与联系

  1. 可重入函数是线程安全函数的一种
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
  3. 可重入描述的是函数的特点,而线程安全描述的是多线程访问临界资源的特点

六.死锁

1.概念

多执行流在不释放自己所占有资源的情况下互相申请对方所占有的资源而处于的一种永久等待的状态

2.四个必要条件

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

3.如何预防/解决死锁问题

解决死锁问题 : 破坏四个必要条件当中的任意一个即可

预防死锁问题:

  1. 申请资源时按序申请(加锁顺序一致)
  2. 及时释放锁
  3. 资源一次性分配

七.读者写者模型(读写锁)

在多线程环境当中,有一种情况非常常见:

公共资源被修改的场景较少,被读取的场景较多,读取公共资源并不会修改公共资源

此时这种场景被称为读者写者模型

它的特点是:

读者多,写者少,读场景较多,读数据不修改公共资源

1.介绍

1个交易场所(用特定的容器/数据结构申请的内存空间)

两种角色(读者和写者)

三种关系

读读之间: 不互斥(并发)

读写之间: 互斥+同步

写写之间: 互斥

  1. 为何读读之间不互斥呢?
    因为读者只会读数据,不会把数据给取走
  2. 如果读者一直存在,那么写者就会一直阻塞,也就是读者优先级高(默认情况)
    当然自己使用接口时,也可以自己调整为写者优先级高

写独占,读共享,读锁优先级高

2.接口

这个不是重点,大家感兴趣的话自己用一下测试测试

3.原理

读者之间不是并发吗? 读者之间又不互斥,为什么有读锁? 只搞一个写锁不就行了吗?

因为读写之间是互斥的,而且读者优先,写者要等到读者都退出之后才能进行写操作

必须为读者要维护一个计数,表示读者的个数,每个读者要进行读操作之前要对计数++,读操作结束之后要对计数--

而这个计数对于读者来说是共享资源,而且读者都要修改该共享资源,而且++和--操作不是原子的,因此读者修改共享资源就要加锁,因此读者也要有锁

用伪代码来解释:

八.自旋锁

1.什么是自旋锁

当申请锁失败之后,互斥锁会阻塞等待,当锁释放之后会重新唤醒竞争锁

而自旋锁在申请锁失败之后会继续申请锁(被称为自旋/忙等待),直到申请成功为止

2.为何要有自旋锁/自旋锁的应用场景

当执行流访问修改临界资源的速度较快时(比如我们上面的多线程抢票,就只有一个判断和一个--),适合使用自旋锁

速度较慢时,适合使用互斥锁,避免频繁无谓的申请锁操作

因为互斥锁的锁竞争会导致多线程频繁陷入内核,回到用户态,陷入内核,回到用户态(因为要不断切换状态,一会被OS放到运行队列,一会又被OS放到阻塞队列)... 开销较大

而自旋锁无需进行陷入内核,回到用户态的操作,因为锁是进程地址空间当中用户空间的一个内存级变量(我们介绍互斥锁原理的时候说的)

以上就是Linux多线程系列2: 模拟封装简易语言级线程库,线程互斥和互斥锁,线程同步和条件变量,线程其他知识点的全部内容,希望能对大家有所帮助!!

相关推荐
朱小弟cs67 小时前
Orange的运维学习日记--41.Ansible基础入门
linux·运维·学习·ci/cd·自动化·ansible·devops
CIb0la7 小时前
kali linux 2025.2安装WPS并设置无报错的详细步骤
linux·运维·wps
醉方休8 小时前
Node.js 精选:50 款文件处理与开发环境工具库
linux·运维·node.js
代码老y10 小时前
从裸机到云原生:Linux 操作系统实战进阶的“四维跃迁”
linux·运维·云原生
CMCST10 小时前
CentOS 7.9 升级 GLibc 2.34
linux·运维·centos
xiep143833351010 小时前
Rocky Linux 10 部署 Kafka 集群
linux·运维·kafka
笨鸟要努力14 小时前
Ubuntu 全盘备份
linux·运维·ubuntu
ChironW14 小时前
Ubuntu 22.04 离线环境下完整安装 Anaconda、CUDA 12.1、NVIDIA 驱动及 cuDNN 8.9.3 教程
linux·运维·人工智能·深度学习·yolo·ubuntu
轻松Ai享生活15 小时前
linux 日志详解
linux
小白的代码日记15 小时前
Linux常用指令
linux·运维·服务器