小编个人主页详情<—请点击
小编个人gitee代码仓库<—请点击
linux系列专栏<—请点击
倘若命中无此运,孤身亦可登昆仑,送给屏幕面前的读者朋友们和小编自己!
在这里插入图片描述



前言

【linux】多线程(八)线程池小程序,使用单例模式进行优化——书接上文 详情请点击<——,本文会在上文的基础上进行讲解,所以对上文不了解的读者友友请点击前方的蓝字链接进行学习
本文由小编为大家介绍——【linux】多线程(九)STL和智能指针有关的线程安全,自旋锁,读者写者问题


一、c++中如何对原生线程进行的封装

Thread.hpp

基础框架
  1. 我们typedef定义一个返回值为void,参数为空的函数指针类型callback_t用于接收用户传入的线程函数,既然是线程,那么就要有线程名字,我们按照线程创建的先后顺序,依次进行标号,从1开始标号,那么我们就要首先有一个变量作为标号,那么我们定义一个int类型的静态全局变量num,这里的场景是只有主线程创建线程,所以静态全局变量num在此场景下并没有多个线程,即只有一个线程访问并不会出现多线程并发访问的问题
  2. 所以我们期望这个Thread类的功能有,创建线程,等待线程,获得线程的名字,线程是否开始运行,线程开始运行的时间戳。所以Thread类的私有成员变量中就应该有pthread_t类型的线程tid_,string类型的线程名name_,uint64_t(uint64_t类型实际上是long long int类型,8个字节)类型的时间戳start_timestamp_,bool类型的是否运行isrunning_,以及一个callback_t类型的回调函数cb_用于接收用户传入的线程函数
  3. 那么在Thread类的构造函数的参数中,我们就应该有一个callback_t类型的形参cb用于接收用户传入的线程函数,然后我们在初始化列表对各个私有成员变量进行初始化即可,在Thread类实例化对象的时候,我们并不创建线程,而是仅仅对各个私有成员变量进行初始化,所以线程tid初始化为0,线程名字初始化为"",线程由于没有创建出来,自然也就没有运行,所以isrunning_初始化为false,线程由于没有创建出来,线程自然也就没有与运行 ,所以线程开始运行的时间戳start_timestamp_自然也就是0
  4. 那么获取线程名字的Name返回name_即可,获取线程开始运行的时间戳StartTimestamp返回时间戳start_timestamp_即可,判断线程是否开始运行IsRunning则返回isrunning_即可
#include <iostream>
#include <string>
#include <ctime>
#include <pthread.h>

typedef void (*callback_t)();
static int num = 1;

class Thread
{
public:
    Thread(callback_t cb):tid_(0), name_(""), start_timestamp_(0), isrunning_(false), cb_(cb)
    {}

    std::string Name()
    {
        return name_;
    }

    uint64_t StartTimestamp()
    {
        return start_timestamp_;
    }

    bool IsRunning()
    {
        return isrunning_;
    }

    ~Thread()
    {}
private:
    pthread_t tid_;
    std::string name_;
    uint64_t start_timestamp_;
    bool isrunning_;

    callback_t cb_;
};
Run
  1. Run是我们要对外提供的接口,Run方法是封装pthread_create去创建线程,所以对应的一些参数我们也应该进行设置,那么设置线程名,获取当前创建线程的时间戳,将线程是否运行设置为是即可
  2. 然后调用pthread_create创建线程即可,传入的线程函数是Routine,传入Routine的参数为this指针,因为Routine我们要定义在class类内,class的成员函数的第一个参数是一个隐藏的this指针,所以我们在类内定义的普通的成员函数Routine表面上的返回值是void*,参数为void*,但是实际上参数有两个第一个为this指针,第二个为void*
  3. 所以这样的话,普通的成员函数Routine的参数就和pthread_create的要求的参数类型是void*(*)(void*)类型不匹配了,即形参要求是只有一个形参,这个形参是void*类型,而普通的成员函数Routine的参数却又两个第一个为this指针,第二个为void*,所以如何解决呢?
  4. 定义static的静态成员函数即可,因为静态的成员函数没有this指针,所i有静态的成员函数Routine的返回值是void*,参数为void*,这样类型就匹配了,但是静态成员函数还有一个很局限的地方,那么就是静态成员函数不能访问类内的普通的静态成员函数以及普通的静态成员变量,而Routine方法要执行用户传入的线程函数,又必须要访问类内的普通的静态成员函数以及普通的静态成员变量,所以说很难受,那么怎么处理呢?
  5. 参数传入this指针,虽然静态的成员函数Routine无法访问类内的普通的静态成员函数以及普通的静态成员变量,但是this指针却可以访问类内的普通的静态成员函数以及普通的静态成员变量,所以我们给Routine的参数传入this指针即可,所以我们也就理解了为什么pthread_create(&tid_, nullptr, Routine, this);的第四个参数要传入this指针了
  6. 接下来我们定义一个私有的成员函数Entry,在这个函数中仅仅调用用户传入的线程函数cb_即可
  7. 然后Routine中就可以对接收this指针的void*类型的args进行类型转换为Thread*类型的变量thread,然后thread就可以调用Entry了,最后Routine函数返回nullptr即可,此时一旦调用了Entry,那么用户传入的线程函数cb_就会被调用
  8. 其实这里设置Entry主要是为了考虑以后的拓展场景中,用户可能会对线程函数进行传参,如果用户想要传参的话,使用模板,定义私有成员变量,在构造函数中接收,然后修改一下Entry的参数,然后就可以直接在Routine函数调用Entry的进行传参调用,这里小编就不再实现了,感兴趣的读者友友可以自行尝试
private:
    static void* Routine(void* args)
    {
        Thread* thread = static_cast<Thread*>(args);
        thread->Entry();

        return nullptr;
    }

    void Entry()
    {
        cb_();
    }

public:
    void Run()
    {
        name_ = "thread-" + std::to_string(num++);
        start_timestamp_ = time(nullptr);
        isrunning_ = true;
        pthread_create(&tid_, nullptr, Routine, this);
    }
Join
  1. 那么我们还应该提供一个Join接口,用户可以调用Join接口然后实现对创建线程的等待,Join实际上就是pthread_join的封装,那么我们传入tid_,以及nullptr对线程进行等待即可
  2. 线程此时已经被等待销毁释放了,所以线程自然也就无法运行了,那么我们将isrunning_设置为false即可
public:
    void Join()
    {
        pthread_join(tid_, nullptr);
        isrunning_ = false;
    }

main.cc 创建一个线程

  1. 我们编写一个Print打印函数,死循环式的间隔一秒打印信息即可
  2. 然后我们在main函数中使用Thread类创建一个线程对象,然后进行调用对应的接口,先Run将线程创建运行起来调用对应的接口获取线程的相关信息,最后Join等待线程即可
  3. 那么在这个测试的过程中,我们在另一个窗口使用shell脚本检测线程的数目即可,我们期望检测到两个线程运行,一个是主线程,另一个是主线程调用Thread创建出来的新线程
while :; do ps -aL | head -1 && ps -aL | grep Thread | grep -v grep; sleep 1; done
#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"

using namespace std;

void Print()
{
    while(true)
    {
        sleep(1);
        cout << "我是一个正在运行的线程..." << endl;
    }
}


int main()
{
    Thread thread(Print);

    thread.Run();

    cout << "线程名字: " << thread.Name() << endl;
    cout << "线程是否在运行: " << thread.IsRunning() << endl;
    cout << "线程开始运行的起始时间戳: " << thread.StartTimestamp() << endl;

    thread.Join();


    return 0;
}

运行结果如下,无误
在这里插入图片描述

main.cc 创建多个线程

  1. 既然上面我们已经可以创建出一个线程,那么同样的也可以使用Thread创建出多个对象,然后使用vector将这些对象管理起来,进行统一的调度运行与等待
  2. 那么既然我们想要创建出多个线程,那么究竟要创建出多少个呢?所以我们定义一个静态的const修饰的全局变量threadnum,并且初始化为5,这样的话就可以使用for循环进行创建了多个线程,然后push_back到vector中即可
  3. 然后我们遍历vector,逐个调用Run将多个线程运行起来
  4. 最后同样是遍历vector,逐个调用Join等待线程即可
  5. 那么在这个测试的过程中,我们在另一个窗口使用shell脚本检测线程的数目即可,我们期望检测到六个线程运行,一个是主线程,另五个是主线程调用Thread创建出来的新线程
while :; do ps -aL | head -1 && ps -aL | grep Thread | grep -v grep; sleep 1; done
#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"

using namespace std;

static const int threadnum = 5;

void Print()
{
    while(true)
    {
        sleep(1);
        cout << "我是一个正在运行的线程..." << endl;
    }
}


int main()
{
    vector<Thread> threads;

    for(int i = 0; i < threadnum; i++)
    {
        threads.push_back(Thread(Print));
    }

    for(auto& t : threads)
    {
        t.Run();
    }

    for(auto& t : threads)
    {
        t.Join();
    }
    

    return 0;
}

运行结果如下,无误
在这里插入图片描述

二、源代码

Thread.hpp

#include <iostream>
#include <string>
#include <ctime>
#include <pthread.h>


typedef void (*callback_t)();
static int num = 1;


class Thread
{
private:
    static void* Routine(void* args)
    {
        Thread* thread = static_cast<Thread*>(args);
        thread->Entry();

        return nullptr;
    }

    void Entry()
    {
        cb_();
    }

public:
    Thread(callback_t cb):tid_(0), name_(""), start_timestamp_(0), isrunning_(false), cb_(cb)
    {}

    void Run()
    {
        name_ = "thread-" + std::to_string(num++);
        start_timestamp_ = time(nullptr);
        isrunning_ = true;
        pthread_create(&tid_, nullptr, Routine, this);
    }

    void Join()
    {
        pthread_join(tid_, nullptr);
        isrunning_ = false;
    }

    std::string Name()
    {
        return name_;
    }

    uint64_t StartTimestamp()
    {
        return start_timestamp_;
    }

    bool IsRunning()
    {
        return isrunning_;
    }

    ~Thread()
    {}
private:
    pthread_t tid_;
    std::string name_;
    uint64_t start_timestamp_;
    bool isrunning_;

    callback_t cb_;
};

main.cc

#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"

using namespace std;

static const int threadnum = 5;

void Print()
{
    while(true)
    {
        sleep(1);
        cout << "我是一个正在运行的线程..." << endl;
    }
}


int main()
{
    vector<Thread> threads;

    for(int i = 0; i < threadnum; i++)
    {
        threads.push_back(Thread(Print));
    }

    for(auto& t : threads)
    {
        t.Run();
    }

    for(auto& t : threads)
    {
        t.Join();
    }

    // Thread t(Print);

    // t.Run();

    // cout << "线程名字: " << t.Name() << endl;
    // cout << "线程是否在运行: " << t.IsRunning() << endl;
    // cout << "线程开始运行的起始时间戳: " << t.StartTimestamp() << endl;

    // t.Join();


    return 0;
}

makefile

Thread:main.cc
	g++ -o $@ $^ -lpthread -std=c++11

.PHONY:clean
clean:
	rm -f Thread

三、STL中的线程安全

  1. STL中的容器是否是线程安全的?不是线程安全的,为什么?
  2. STL的设计初衷就是将性能挖掘到极致,一旦涉及到加锁要保证性能安全,那么多个线程就要串行的执行临界区的代码,会对性能造成极大的损失
  3. 而且对于不同的容器,加锁方式也可能不同,性能影响也可能不同
  4. 因此STL默认不是线程安全的,如果要在多线程环境下使用,那么需要调用者自行加锁保证线程安全

四、智能指针的线程安全

  1. 小编之前讲解过四个智能指针,详情请点击<——
  2. 对于unique_ptr,由于只在自己当前的代码块范围内生效,即线程函数中使用unique_ptr,那么这个unique_ptr只会在自己线程的独立栈中开辟私有的一份,其它线程无法访问操作,所以不涉及多线程线程安全的问题
  3. 对于shared_ptr,多个对象由于会使用同一个引用计数变量,所以会存在线程安全的问题,但是标准库在设计的时候考虑了这个问题,因此基于原子性操作(CAS)保证shared_ptr能够高效,原子的操作引用计数

五、其它常见的各种锁

悲观锁

  1. 悲观锁:在每次取数据之前,总担心数据会被其它线程修改,所以会在取数据前先加锁,其它线程想要访问数据时,申请锁失败会被阻塞挂起。例如:我们之前学习的互斥锁其实就是悲观锁的一种类型

乐观锁

  1. 乐观锁:在每次取数据之前,总是乐观的认为数据不会被其它线程修改,因此不上锁。但是在更新数据之前,会判断其它线程在更新数据之前有没有对数据进行修改,如果进行修改了,那么可能会放弃当前的更新操作,转为去做一些:重试,抛异常,返回失败结果。
  2. 其中判断其它线程在更新数据之前有没有对数据进行修改,主要通过两种方法进行判断,版本号机制和CAS操作

CAS操作

  1. CAS操作:当需要更新数据时,将当前内存之和之前取得的数据值进行对比,如果相等,则新值更新,如果不相等,则失败,失败则重试,一般是一个不断自旋的过程,需要不断重试

六、自旋锁

在这里插入图片描述

  1. 自旋锁不同于互斥锁,互斥锁是当申请锁时候,如果申请锁失败,那么就会将线程直接阻塞挂起,而对于自旋锁来讲,当调用上面的接口pthread_spin_lock申请锁的时候,如果申请锁失败,那么不会将线程阻塞挂起,而是重复继续去申请锁,直到申请锁成功,重复的过程这也就是如同一个自旋的过程,所以才叫做自旋锁
  2. 同样的当使用自旋锁申请锁失败的时候,由于自旋锁不会将线程阻塞挂起,而是继续重复自旋式的申请锁,所以使用自旋锁申请锁的线程也就不会将线程的状态进行切换,即不会将线程阻塞挂起,那么也就必然决定了自旋锁的使用场景只能是其它线程执行临界区的时间较短的情况下使用
  3. 如果其它线程执行临界区的时间较长,那么自旋锁就会不断的重复自旋去申请锁,即也就是一个while死循环的过程,直到申请锁成功,所以一旦其它线程执行临界区的时间较长,那么当前线程使用自旋锁的线程就会长时间执行while循环,那么长时间调用while死循环消耗的资源是十分恐怖的,所以如果其它线程执行临界区的时间较长适合使用互斥锁,不适合使用自旋锁
  4. 可是如果其它线程执行临界区的时间较短,那么自旋锁的效率就会相对互斥锁较高,因为互斥锁当其它线程持有锁执行临界区的时候,互斥锁就会申请锁失败,那么就会将当前线程阻塞挂起,那么当前线程就会被从CPU的运行队列拿下来,然后将线程链入锁的阻塞队列中进行等待,并且将当前线程从运行状态切换成阻塞挂起等待,还是很耗费时间的,当其它线程执行临界区完毕,归还锁了之后,那么锁资源就绪,就会将当前线程唤醒,从阻塞挂起状态切换到运行状态,并且链入CPU的运行队列中运行,同样的较为花费时间。可是对于自旋锁来讲,由于其它线程执行临界区的时间较短,那么自旋锁仅仅重复自选申请几次就申请到了锁资源,这其中也不用涉及到线程的状态切换等,所以其它线程执行临界区的时间较短适合使用自旋锁,不适合使用互斥锁

七、读者写者问题(读写锁)

  1. 读者写者问题,那么屏幕面前的读者友友可以思考一下在生活中的有哪些读者写者的场景?
  2. 读者写者问题的场景:CSDN文章,黑板报,圣旨,通缉令等等都是一些读者写者问题的场景,那么下面小编就使用黑板报为例引入读者写者问题
    在这里插入图片描述
  3. 相信大家在上学的时候,经历过班级里老师挑选两个人画黑板报,然后画出来精美的黑板报可以供班级里的其他人进行观赏。那么在这个过程中画黑板报的两个人就是写者,他们写的场所就是黑板,而班级里的其他人扮演的是读者的角色
  4. 对于画黑板报的两个人来讲,这两个人,一个人负责文字的写入,一个人负责画画。而一个人在向黑板写字的时候,另一个人不能上来就将文字擦掉,然后画画,所以对于画黑板报的这两个人来讲是互斥关系,即在读者写者问题中,写者和写者是互斥关系
  5. 那么对于画黑板报的两个人和其他人来讲,不能这两个人画了一半,其他人就过来看,一看:“你咋画了一个蛇”,但是当这两个人画到最后,实际上是画的龙,所以这两个人画画的时候,其他人不能过来看,否则有可能导致对信息解读错误。相反,当这两个人已经画完了,其他人正在观看黑板报的时候,这两个人不能过来将黑板报全部擦掉,因为其他人还没有看完。而这两个人画画以及其他人看黑板报应该遵循一定的顺序,画完再看,看完再画。所以这两个画画的人代表的写者和其他人代表的读者应该是互斥,同步的关系
  6. 那么对于其它人来讲,当其他人中的一个人在看黑板报的时候,其他人中的另一个人可不可以也过来看黑板报,当然可以,一起欣赏多好,所以对于其他人来讲,即对于读者和读者之间是共享关系
  7. 所以说读者写者问题也有自己的321原则
    3种关系:(1)写者和写者之间是互斥关系(2)读者和写者之间是同步、互斥关系(3)读者和读者之间是共享关系
    2种角色:(1)写者(2)读者
    1种场合:(1)特定结构的内存空间用于交换数据
  8. 而生产者与消费者模型中的消费者和消费者的关系是互斥关系,读者写者问题的读者和读者之间的关系是共享关系,为什么?
  9. 因为生产者与消费者模型中的消费者是要将数据拿走的,所以消费者和消费者要对数据进行竞争,所以是消费者和消费者是互斥关系
  10. 读者写者问题的读者不会讲数据拿走,所以读者和读者也就不会对数据进行竞争,读者和读者之间的也就不可能是互斥关系,所以读者和读者之间不会将数据拿走,即由于数据共享,所以读者和读者之间是共享关系
  11. 那么黑板报通常是几个人画,然后其他很多很多人看黑板报,所以也就是说常规情况下,写者少,读者多喽,那么在linux中读者和写者通常是采用的线程进行的模拟,所以大多数的场景下,读者线程多,写者线程少,即读者线程在所有的线程中的占比数目大,所以读者线程对于锁的竞争能力强,进而写者线程对于锁的竞争能力弱,所以就会导致写者长时间得不到锁资源而造成的饥饿问题。这有问题吗?
  12. 这当然没问题了,我读者在所有的线程中占比数目大,我就应该竞争锁能力强,持有锁的时间长,并且访问临界区的时间长,你写者在所有的线程中占比数目少,你写者就应该竞争锁能力弱,持有锁的时间少,并且访问临界区的时间少
  13. 在实际过程中,读者写者问题的场景,如果你不设置选项,那么读者写者问题中默认就是读者的竞争锁能力强,读者访问临界区资源时间长,写者的竞争锁能力弱,写者访问临界区资源的时间短,即读者优先,如果设置选项,可以对这种情况进行调整,即写者优先,让写者优先访问临界资源
  14. 那么什么才是访问临界区的时间长呢?那么在linux中一般涉及到计算,IO等一般都是访问临界区的时间较长,我们采用互斥锁(挂起等待锁)
    在这里插入图片描述
  15. 那么写者线程在申请锁的时候要使用接口pthread_rwlock_wrlock,读者线程在申请锁的时候要使用接口pthread_rwlock_rdlock申请读锁
  16. 读者线程和写者线程释放锁的时候共同使用同一个接口pthread_rwlock_unlock释放锁
  17. 那么我们该如何理解这个读写锁中的读者优先和写者优先呢?其实,例如读者优先的场景中,假设此时写者多,读者少,所以读者的竞争能力锁,所以读者持有锁的占比小,但是读者总归是可以持有锁的,当第一个读者申请了读锁之后,我就去把写锁申请了,此时写者就无法申请写锁成功了,那么读者优先状态下,会将所有读者访问完成临界资源之后,最后一个读者才会去归还写锁,此时写者才能申请写锁成功,写者才能够继续进行写操作,下面小编编写一份伪代码帮助大家理解
    在这里插入图片描述
  18. 首先是读者优先,那么底层实际上是有一个读者计数reader_count = 0;然后定义读锁rlock和写锁wlock,完成初始化工作
  19. 然后我们先理解读者的申请锁和释放锁,读者申请锁要先lock申请读者锁lock,那么当申请成功之后,要对读者计数reader_count进行++,然后如果此时if判断读者计数reader_count等于1, 那么代表这个读者线程是所有读者中第一个读取数据的,而读者和写者之间要维护互斥关系,所以读者读数据的时候,写者不能过来写数据,所以在if判断中读者就去申请写者的锁,读者一旦申请写锁成功,那么此时读者在读数据的时候,写者就无法访问数据了,所以此时写者就无法申请写锁了,所以写者自然也就就无法写入数据了,所以就维护了读者和写者之间的互斥,同步关系
  20. 由于访问读者并不拿走数据,读者仅仅是读数据,所以多个读者可以同时读数据,即不需要对数据进行保护,所以读者就将读者锁释放即可,然后读者就可以开始读数据了,当读完数据之后,读者申请读锁,将读者计数reader_count进行- -,if判断如果读者计数reader_count此时等于0,那么代表此时读者是最后一个读数据的线程,即代表着此时所有的读者已经完成了对数据的读了,接下来轮到了写者了,但是别忘了此时读者还拿着写者的锁的,那么此时最后一个读完数据的读者就必须要将写者的锁释放,然后读者释放自己的锁即可
  21. 那么此时读者释放了写者的锁,读者读完成数据之后写者区申请写锁然后了写数据,读者之后写者,恰恰是读者和写者之间按照一定的顺序访问数据,即维护的读者和写者之间的同步关系,那么对于写者而言,写者只需要申请自己的锁,此时写者申请成功自己的锁,那么读者此时如果想要读数据,是无法读的,因为第一个想要读数据的读者要申请写者读锁,此时写者的锁已经被写者申请走了,是断然无法被写者申请到的,所以写者此时无法读数据,维护了读者和写者之间的互斥关系,然后此时写者就可以放心的写数据了,当写完数据的时候,释放写者锁即可,此时一旦写者锁释放,那么读者就可以申请写锁成功,就可以访问数据了,所以写者之后读者,读者之后写者,这又恰恰是读者和写者之间按照一定的顺序访问数据,即维护的读者和写者之间的同步关系
  22. 并且当写者已经成功申请到写锁的时候,这个写者正在写数据,那么其它写者也想要写数据,对不起,那么你其它写者就要先申请写锁,但是此时写锁已经被申请走了,所以其它写者只能阻塞等待,即当一个写者申请到写锁了,正在写数据的时候,其它的写者无法写入数据干扰当前写者,那么这也就维护了写者和写者之间的互斥关系
    在这里插入图片描述
  23. 那么如何理解写者优先呢?站在读者角度,同样可以在底层维护一个计数,只不过这个计数是写者计数writer_count = 0;当写者申请到写锁,或者写者申请写锁陷入阻塞的时候就对写者计数进行++,当读者申请到读锁的时候,通过if判断写者计数writer_count是否大于0,如果大于0,代表此时有写者正在对数据进行写入,那么非阻塞方式下,读者就直接释放锁然后返回,这样就维护了写者优先

总结

以上就是今天的博客内容啦,希望对读者朋友们有帮助
水滴石穿,坚持就是胜利,读者朋友们可以点个关注
点赞收藏加关注,找到小编不迷路!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐