Linux——线程的同步和互斥

一、线程互斥

1.1、数据不一致问题

因为线程是共享地址空间的 所以线程会共享大部分资源 没有对资源进程保护 就会出现数据不一致的问题

看一个代码 见见数据不一致问题

建立四个线程 模拟卖票的过程 当票数大于零 某一个线程运行时 就让票数减减 直到票数小于零 每次打印出线程卖的第几张票

cpp 复制代码
#include <pthread.h>
#include <iostream>
#include <unistd.h>

int ticket = 1000;
void *Routine(void *arg)
{
    char* id = static_cast<char*>(arg);
    while(1)
    {
        if(ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket : %d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, Routine, (void *)"thread-1");
    pthread_create(&t2, nullptr, Routine, (void *)"thread-2");
    pthread_create(&t3, nullptr, Routine, (void *)"thread-3");
    pthread_create(&t4, nullptr, Routine, (void *)"thread-4");


    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

看到了结果出现了票数为负数的情况 这就是数据不一致引起的

1.2、加锁(pthread)解决这个问题

访问ticket的代码叫做临界区 ticket叫做共享资源 当ticket被保护起来就叫做临界资源 没有访问共享资源的代码叫做非临界区

要让共享资源被保护起来 本质上就是让访问共享资源的代码 也就是临界区被保护起来

给临界区加锁可以让各个线程走到自己临界区时变为互斥的 所谓互斥就是我在访问一个资源时 其他人不能访问 即在任何时刻 互斥保证有且只有一个执行流进入临界区 访问临界资源 通常对临界资源起到保护作用
看代码


这样就不会出错

1.3、理解数据不一致

为什么多线程抢票会抢到负数

ticket--不是主要原因 但是先理解ticket--这句代码

这句代码在编译之后会转换为汇编代码 有三行 第一个是将ticket的值载入CPU的ebx寄存器 第二个是CPU运算器进行计算 第三个是将计算之后的结果写回内存

结合前面进程切换调度的知识 我们知道 线程的代码在执行时 也可能会因为一些原因被挂起 而执行其他线程

在这里暂时理解原子性就是一条汇编代码 因为一条汇编代码要么不执行要么执行一定会走完

ticket不具有原子性因为它的汇编代码有三句 走完其中任何一句都可能因为一些原因调度其他线程

#############################################################################

现在看一个例子

假设现在有两个线程 A、B来进行卖票

当CPU调度A时 载入ticket变量 进行计算之后 也就是计算的那条汇编代码走完之后 突然调度 B 此时进行调度的相关操作 先保存线程A的上下文 之后将ebx的中的值导入为B看到的ticket 并且将pc指针指向执行流B的代码 此时调度B

B每次都正常载入计算写回 写了很多次 假设最后写回1的时候 线程因为合理原因切换了 调度A 恢复线程A的上下文 PC指针指向之前的那句计算完之后写回的汇编代码 此时写回A计算一次之后的ticket值 共享资源的值变为99 那么B就白写了 这就是数据不一致

插入一个很冷的知识点 PC指针是怎么知道下一次指向哪里的 因为汇编指令也有长度 那么CPU会记录这个长度 走完一条汇编语句之后 会根据这条指令的长度找到下一句

#############################################################################

看到现在的代码 现在解释为什么ticket的值会到负数

ticket--这个操作有影响但是不是主要原因 而判断才是主要原因

ticket > 0也是一种计算 转换为汇编代码先载入再判断

假设现在ticket的值为1 四个线程继续运行 线程1判断ticket>0 进入条件语句板块 此时线程切换到线程2 判断进入条件语句 又切换到线程3 也是同样操作 线程4也是如此 那么此时四个线程拿着值为1的ticket都进入了临界区 现在切回线程1 开始继续执行自己的代码 ticket被减为0 之后线程2因为之前已近判断>0了 PC指针指向后面代码 也会进程--操作 那么ticket就成为负数了 这就是这个代码出现票数为负数的原因

多线程中制造更多的并发 切换就可能出现数据不一致问题

线程切换的原因:1、线程的时间片到了切换其他线程 2、这个线程出现阻塞式IO也会被挂起 比如这个线程要从键盘文件获取输入数据 会阻塞等待 此时会切换为其他线程 3、sleep切走 此时该线程休眠指定时间 其他线程正常跑

选择新的线程的时间 从内核态回到用户态的时候会尽心线程的选择 这也是为什么上述代码出现usleep printf的原因 因为这些代码可以陷入内核

ticket是全局资源 没有被保护起来 那么就会出现上述的线程并发问题 这也是线程安全问题

1.4、引入互斥锁的接口

全局互斥锁 就是之前的代码 在全局区定义一个锁 之后在临界区加锁 临界区走完解锁即可

相关接口

全局锁不需要我们释放 它自己会释放

局部互斥锁

首先要在局部定义一个锁pthread_mutex_t lock;之后还要使用init初始化这个锁 第二个参数是属性不用管 加锁和解锁和全局的一样都在在紧挨着临界区前后 最后还要手动销毁锁 destroy

#############################################################################

申请锁时 各个线程必须先看到锁 那么锁也是共享资源 为了安全问题 申请锁的过程必须是原子的 也就是只会出现一个线程占有锁的情况

锁申请成功 代码向后运行 执行临界区代码 此时临界区就是线程互斥的 ; 申请失败 这个线程会被阻塞挂起申请执行流

1.5、两个问题

1、若是有很多线程 只有一个没有加锁会怎么样 ? 这是一个bug代码 也就是说 一旦加锁所有的线程访问临界区时都要先申请锁

2、在加锁之后 在临界区允许线程切换吗? 答案是允许 因为锁本质上也是代码 但是这不影响 因为当前持有锁的线程没有释放锁 即便这个线程被切换走了 其他的线程也不能被CPU调度 必须等待这个线程回来执行完代码 释放锁之后才能进行锁的竞争之后 某一个线程进入临界区

也就是说 持有锁的线程 在执行期间不会被打扰 这个线程在其他线程看来是原子性的 因为对于其他线程来说 当前线程要么不拿锁 拿到了一定要跑完自己代码 之后释放锁 其他线程才能竞争

互斥锁也是一种变相原子性的表现

1.6、理解互斥锁

所谓理解锁 也就是理解锁是怎么实现的 理解之后使用互斥锁的时候才更加得心应手

1.6.1、硬件实现

关闭时钟中断即可

因为CPU是基于时钟中断来进行调度的 当某一个线程被调度的时候 关闭时钟中断 此时其他线程就不会被调度了 但是这种做法有危险

1.6.2、软件实现

首先理解一个前提

CPU内的寄存器硬件只有一套 但是CPU内的数据可以有多份 各自一份执行当前的执行流的上下文 换句话说若是把一个变量的内容交换到CPU寄存器内部 本质上就是将当前变量的内容获取到执行流的硬件上下文中

这就是互斥锁的实现

为了实现互斥锁的操作 大多数体系结构都提供了swap或者exchange指令 该指令的作用是把寄存器和内存单元的数据交换

这里的mutex可以看作是一个整形变量

当某一个线程被调度时 先将%al寄存器中的值置为0 之后交换当前线程%al中和mutex的值 若是交换之后%al中的值为1 就可以执行临界区代码 反之线程被阻塞挂起

比如现在有多个线程 进行锁的竞争 虽然lock转为汇编代码不是原子性的 因为存在多条汇编指令 但是没影响 当mutex的值为1时并且一个线程交换%al中的值和mutex中的值之后 其他线程就算此时被调度 也不能访问临界区代码 因为mutex值是0了交换了也没用 只会走到else被挂起 那么申请到锁的线程就可以访问临界区了 这样就实现了互斥锁的实现

goto lock就是当被阻塞挂起的线程又一次被调度的时候 重新申请锁

解锁很简单 临界区代码跑完直接将mutex值置为1 让其他线程可以竞争到锁即可

1.7、互斥锁的封装

cpp 复制代码
#pragma once
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex(pthread_mutex_t& lock)
        :_lock(lock)
        {
            int n = pthread_mutex_init(&_lock, nullptr);
            (void)n;
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_lock);
            (void)n;
        }
        void UnLock()
        {
            int n = pthread_mutex_unlock(&_lock);
            (void)n;
        }
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_lock);
            (void)n;
        }
    private:
        pthread_mutex_t _lock;
    };
} // namespace name
cpp 复制代码
#include <pthread.h>
#include <iostream>
#include <unistd.h>

#include "mutexEncapsulation"

using namespace MutexModule;

class ThreadData
{
public:
    ThreadData(std::string name, Mutex &mutex)
        : _name(name), _pmutex(&mutex)
    {
    }
    ~ThreadData()
    {
    }

    std::string _name;
    Mutex *_pmutex;
};

int ticket = 1000;
void *Routine(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        td->_pmutex->Lock();
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket : %d\n", td->_name.c_str(), ticket);
            ticket--;
            td->_pmutex->UnLock();
        }
        else
        {
            td->_pmutex->UnLock();
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_mutex_t lock;
    Mutex mutex(lock);

    pthread_t t1, t2, t3, t4;

    ThreadData *td1 = new ThreadData("thread-1", mutex);
    pthread_create(&t1, nullptr, Routine, td1);

    ThreadData *td2 = new ThreadData("thread-2", mutex);
    pthread_create(&t2, nullptr, Routine, td2);

    ThreadData *td3 = new ThreadData("thread-3", mutex);
    pthread_create(&t3, nullptr, Routine, td3);

    ThreadData *td4 = new ThreadData("thread-4", mutex);
    pthread_create(&t4, nullptr, Routine, td4);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

RAII的互斥锁风格的实现

就是将封装的互斥锁再次封装 再次封装的类的构造函数就是加锁 析构函数就是解锁 在使用时 只需要实例化一个再次封装的类的对象 那么就不需要要调用加锁和解锁的接口 因为在实例化的时候自动调用构造析构 可以直接自动加锁解锁 这样代码就十分简洁

cpp 复制代码
#pragma once
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex(pthread_mutex_t &lock)
            : _lock(lock)
        {
            int n = pthread_mutex_init(&_lock, nullptr);
            (void)n;
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_lock);
            (void)n;
        }
        void UnLock()
        {
            int n = pthread_mutex_unlock(&_lock);
            (void)n;
        }
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_lock);
            (void)n;
        }

    private:
        pthread_mutex_t _lock;
    };

    // RAII风格的互斥锁的实现
    class LockGuard
    {
    public:
        LockGuard(Mutex *mutex)
            : _mutex(mutex)
        {
            _mutex->Lock();
        }
        ~LockGuard()
        {
            _mutex->UnLock();
        }

    private:
        Mutex *_mutex;
    };
}
cpp 复制代码
#include <pthread.h>
#include <iostream>
#include <unistd.h>

#include "mutexEncapsulation"

using namespace MutexModule;

class ThreadData
{
public:
    ThreadData(std::string name, Mutex &mutex)
        : _name(name), _pmutex(&mutex)
    {
    }
    ~ThreadData()
    {
    }

    std::string _name;
    Mutex *_pmutex;
};

int ticket = 1000;
void *Routine(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        LockGuard lg(td->_pmutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket : %d\n", td->_name.c_str(), ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_mutex_t lock;
    Mutex mutex(lock);
    pthread_t t1, t2, t3, t4;

    ThreadData *td1 = new ThreadData("thread-1", mutex);
    pthread_create(&t1, nullptr, Routine, td1);

    ThreadData *td2 = new ThreadData("thread-2", mutex);
    pthread_create(&t2, nullptr, Routine, td2);

    ThreadData *td3 = new ThreadData("thread-3", mutex);
    pthread_create(&t3, nullptr, Routine, td3);

    ThreadData *td4 = new ThreadData("thread-4", mutex);
    pthread_create(&t4, nullptr, Routine, td4);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

二、线程同步

2.1、概念引入

线程同步是为了解决之前出现的一个问题而引入的新技术

之前互斥锁的使用成功让多线程安全访问临界资源 但是打印结果看出 在相当一部分时间内 一直是一个线程占有临界资源 这是因为当正在持有锁的线程释放锁时 它也会立即竞争锁 由于其他的线程还在等待队列中 因此常常有这样一种情况 同一个线程一直占有锁 这里还有几个线程的切换但是在一些环境下可能一直是同一个线程占有锁 这就造成了线程饥饿 也就是其他线程无法占有锁 也就是无法享有CPU资源 不能被调度

虽然这样没有错 但是不公平 不高效 为了解决这个问题引入了线程同步 也就是释放锁的线程释放之后不能立即申请第二次 释放之后进入等待队列队尾进行等待 由其他正在等待的对头的线程占有锁 让所有线程按照一定顺序访问临界资源这就是线程同步

2.2、条件变量

线程同步需要借助于条件变量完成

用一个例子理解条件变量

假设现在有一个盘子 若干个瞎子 还有苹果 有的瞎子负责放苹果到盘子 有的瞎子负责拿出盘子中的苹果并且处理 并且还有一把锁 这把锁限制了一次只能有一个瞎子触摸盘子

当这个放苹果的瞎子 检查盘子发现没有苹果 那么他解开锁放一个进去 但是瞎子不知道这个盘子中的苹果有没有被拿走 他归还锁之后立即又会申请锁 由于他一直在盘子边上 那么其他瞎子一直无法申请到锁 这就是线程饥饿 反之同理当锁是空闲的一个取苹果的瞎子申请到锁 触摸盘子检查有没有苹果之后退出归还锁 但是他不知道放苹果的人有没有放 就是不知道这个盘子在他检查完之后有没有又出现苹果 他会一直申请锁并且检查盘子 那么其他人无法对盘子进行任何操作

那么条件变量如何解决这个问题呢 条件变量可以理解为一个铃铛和一个队列 瞎子不是聋子可以听见 当放苹果的瞎子申请到锁 成功放苹果并且归还锁之后 会敲铃铛并且自己去等待 不会立即申请锁 此时等待着取苹果的瞎子都在一个队列中排着队 听到铃铛被敲响 对头的瞎子会去申请锁并且取出苹果处理归还锁 之后这个瞎子由于不知道有没有放入新苹果到盘子里面 还是会申请锁 并且离得最近会申请到 检查盘子没有苹果之后 这个瞎子会被放到等待队列的队尾进行等待 下一次就是对头的另外一个瞎子对铃铛进行响应

条件变量解决了多线程访问临界资源不高效公平的问题 这就是线程同步

2.3、条件变量相关接口

pthread_cond_wait指明在哪个体哦阿健变量下进行等待 至于mutex后面解释 有等待就有唤醒 pthread_cond_signal或者pthread_cond_broadcast唤醒指定条件变量下的一个线程或者所有线程

2.4、代码演示线程可以同步

cpp 复制代码
// 简单验证线程可以同步

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>

#define NUM 5
int cnt = 1000;

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* threadRun(void* args)
{
    std::string name = static_cast<const char*>(args);
    while(true)
    {
        pthread_mutex_lock(&lock);

        // 等待是由于临界资源不满足 例如卖票票卖完了等待 等待发票之后临界资源满足了唤醒之后继续运行
        // 等待大多数情况下要对临界资源进行判断 也就是访问临界区资源 这里只是为了演示同步就没有判断 
        // 那么等待一定是在临界区内部等待 等待成功后休眠也是在临界区内部
        pthread_cond_wait(&cond, &lock); 
        // 为什么要传入一把锁 这里简单提及后面还会解释 
        // 此时线程等待 要在临界区休眠 那么因为之前是加了锁的 你休眠了 其他线程还要竞争锁 不能占着不用
        // 在休眠前释放锁 所以要传入锁这个参数 
        std::cout << name << ':' << "计算" << cnt << std::endl;
        cnt++;
        pthread_mutex_unlock(&lock);
    }
    return nullptr;
}
int main()
{
    std::vector<pthread_t> tids;
    for(int i = 0; i < NUM; i++)
    {
        char name[64];
        snprintf(name, 64, "thread-%d", i);
        pthread_t tid = 0;
        int n = pthread_create(&tid, nullptr, threadRun, name);
        if(n != 0) 
            continue;
        // 到这里说明线程创建成功
        tids.push_back(tid);
        sleep(1);
    }
    
    sleep(3);
    // 唤醒等待线程
    while(true)
    {
        // // 一次唤醒一个线程
        // std::cout << "唤醒一个线程..." << std::endl;
        // pthread_cond_signal(&cond);
        // sleep(1);
        // 一次唤醒所有线程
        std::cout << "唤醒所有线程" << std::endl;
        pthread_cond_broadcast(&cond);
        sleep(1);
    }

    for(auto& tid : tids)
    {
        pthread_join(tid, nullptr);
    }
    return 0;
}

运行结果看出 线程同步了 不是一个线程一直访问临界资源

pthread_cond_wait之前为什么需要加锁 这个后面解释

等待是由于临界资源不满足 例如卖票票卖完了等待 等待发票之后临界资源满足了唤醒之后继续运行 等待大多数情况下要对临界资源进行判断 也就是访问临界区资源 这里只是为了演示同步就没有判断 那么等待一定是在临界区内部等待 等待成功后休眠也是在临界区内部

为什么要传入一把锁 这里简单提及后面还会解释 此时线程等待 要在临界区休眠 那么因为之前是加了锁的 你休眠了 其他线程还要竞争锁 不能占着不用 在休眠前释放锁 所以要传入锁这个参数

2.5、生产、消费者模型

生产者为各个工厂 消费者就是客户 他们之间通过超市进行交易 否则成本太高了 工厂离城市很远并且工厂不可能启动装置给你一个人生产很少的物品 可能一次交易电费都没赚回来

工厂就是线程 客户也是线程 超时的某个货架的位置就是临界资源 一般是一块内存空间

针对于这个货架 各个工厂之间是互斥的 因为在同一时间内 只能有一个工厂生产物品放到这个货架 也就是访问拿锁临界资源 同样 客户之间也是互斥的 同一时间只有有一个客户来对这个货架上的物品挑选

工厂和客户之间是互斥且同步的 当货架上没了物品 超市管理人员会通知客户此时不能来这个货架买东西 叫他们等待 只有当工厂生产了对应物品 等待的客户才能被唤醒 同理 当货架满了之后 工厂被通知不在生产该物品 只有当客户买了物品之后货架出现空位了 等待的工厂才会被唤醒继续生产访问临界资源

快速记忆生产消费者模型

'321'记忆 :三种关系 生产者和生产者之间互斥 消费者和消费者之间互斥 生产者消费者之间互斥并且同步 ; 两种角色 生产者消费者本质上都是线程); 一个交易场所 本质上就是特定结构构成的一块内存

为什么要有这个模型

1、实现了生产者消费者之间解耦 它们只需要通过中间超市来交易 这样更好管理

2、支持忙闲不均 也就是某一方等待时另一方可能正在处理

3、更加高效 ? 后面详细解释

2.6、实现基于blockQueue的生产消费者模型

BlockQueue.hpp源代码

cpp 复制代码
//BlockQueue.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>

// 基于阻塞队列的单生产者单消费者模型实现

const int dfaultcap = 5;

template <typename T>
class blockQueue
{
private:
    bool isFull()
    {
        return _q.size() >= _cap;
    }
    bool isEmpty()
    {
        return _q.empty();
    }

public:
    blockQueue(int cap = dfaultcap, int cSleepCnt = 0, int pSleepCnt = 0)
        : _cap(cap), _cSleepCnt(cSleepCnt), _pSleepCnt(pSleepCnt)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_fullCond, nullptr);
        pthread_cond_init(&_emptyCond, nullptr);
    }
    void Equeue(T &data)
    {
        pthread_mutex_lock(&_mutex);
        // if(isFull())
        while (isFull())
        {
            _pSleepCnt++;
            std::cout << "生产者等待..." << std::endl;
            // 等待成功需要释放锁
            // 那么被唤醒之后要重新在该地方申请锁
            // 要是申请失败那么就要在锁上面等待
            pthread_cond_wait(&_fullCond, &_mutex);
            // pthread_cond_wait是一个函数调用 可能失败 也就是此时不会成功等待 在队列为满的情况下会插入数据
            // 可能在这个线程被唤醒之前 已经有生产者被唤醒了 此时队列又满了 这称为伪唤醒 那么就不能插入数据
            // 解决这个问题只需要让这个等待循环执行 这样满了就会一直等待 只到不满出去循环合法插入数据
            _pSleepCnt--;
        }
        _q.push(data);
        // 唤醒方式一
        if (_cSleepCnt > 0)
        {
            std::cout << "消费者被唤醒" << std::endl;
            pthread_cond_signal(&_emptyCond);
        }
        // 唤醒方式二
        // 就算被唤醒的对象没有等待 接收到唤醒信号也不影响 所以可以直接signal
        // pthread_cond_signal(&_emptyCond);
        // 放到unlock前面因为这边的锁还没有释放 所以另一边一定会申请锁失败 但是不影响 这边代码会继续向后走
        // 会释放锁 另一边就可以申请到锁了
        pthread_mutex_unlock(&_mutex);
        // pthread_cond_signal(&_emptyCond);
        //  放到unlock后面没有问题 这两种都行
    }
    T Pop()
    {
        pthread_mutex_lock(&_mutex);
        // if(isEmpty())
        while (isEmpty())
        {
            _cSleepCnt++;
            std::cout << "消费者等待..." << std::endl;

            pthread_cond_wait(&_emptyCond, &_mutex);
            _cSleepCnt--;
        }
        T data = _q.front();
        _q.pop();
        if (_pSleepCnt > 0)
        {
            std::cout << "生产者被唤醒" << std::endl;
            pthread_cond_signal(&_fullCond);
        }
        // pthread_cond_signal(&_fullCond);
        pthread_mutex_unlock(&_mutex);
        // pthread_cond_signal(&_fullCond);

        return data;
    }
    ~blockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_fullCond);
        pthread_cond_destroy(&_emptyCond);
    }

private:
    std::queue<T> _q;
    int _cap;
    pthread_mutex_t _mutex;
    pthread_cond_t _fullCond;
    pthread_cond_t _emptyCond;

    int _cSleepCnt;
    int _pSleepCnt;
};

这样一来生产者就和消费之线程同步了 但凡生产了数据 消费者就会被唤醒 之后消费数据 消费者一消费 那么队列出现了空缺 此时生产者被唤醒 开始生产数据

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "blockQueue.hpp"

void *consumer(void *args)
{
    sleep(8);

    blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);
    while (true)
    {
        // 消费数据
        int data = 0;
        data = bq->Pop();
        std::cout << "消费一个数据..." << data << std::endl;

        sleep(1);
    }
    return nullptr;
}
void *productor(void *args)
{
    blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);
    int data = 0;
    // sleep(5);
    while (true)
    {
        // 生产数据
        bq->Equeue(data);
        std::cout << "生产一个数据..." << std::endl;
        data++;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    blockQueue<int> *bq = new blockQueue<int>();
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, (void *)bq);
    pthread_create(&p, nullptr, productor, (void *)bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}

让生产者先走 那么队列满了之后生产者开始等待 直到消费数据了 又开始生产

让消费者先走 那么消费者等待 直到生产者生产数据 线程开始同步

队列里面还可以存其他类型的数据 也就是是任务类型可以多变

比如任务为一个类

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "blockQueue.hpp"
#include "Task.hpp"


void *consumer(void *args)
{
    //sleep(8);

    blockQueue<Task> *bq = static_cast<blockQueue<Task> *>(args);
    while (true)
    {
        //1、消费任务
        Task t = bq->Pop();
        //2、处理任务
        std::cout << "消费一个数据..." 
        << " getX: " << t.getX() << " getY: " << t.getY() 
        << " 任务处理计算结果 :" << t.Execute() << std::endl; 

        sleep(1);
    }
    return nullptr;
}
void *productor(void *args)
{
    blockQueue<Task> *bq = static_cast<blockQueue<Task> *>(args);
    int x = 0, y = 0;
    sleep(3);
    while (true)
    {
        // 1、获得任务
        Task t(x, y);
        //2、生产任务
        bq->Equeue(t);
        std::cout << "生产一个数据..." << " x: " << x << " y: " << y << std::endl;
        x++, y++;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    blockQueue<Task> *bq = new blockQueue<Task>();
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, (void *)bq);
    pthread_create(&p, nullptr, productor, (void *)bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}
cpp 复制代码
// Task.hpp

#pragma once
#include <iostream>

class Task
{
public:
    Task(int x, int y)
        : _x(x), _y(y)
    {
    }
    Task()
    {
    }
    int getX()
    {
        return _x;
    }
    int getY()
    {
        return _y;
    }
    int Execute()
    {
        return _x + _y;
    }
    ~Task()
    {
    }

private:
    int _x;
    int _y;
};

运行结果

实际问题中任务并不是这样一个简单的伪造的任务 而是需要进行专门的获取 这里虽然简单但是可以初步体现 只有再获取了任务之后才能生产任务 之后消费者消费任务之后好需要自己专门处理任务

回答之前的那个问题 生产消费者模型高效体现在哪里?

在临界区一直都是加锁的 也就是说同一时间只能有一个线程访问临界资源 这并不高效 对于临界区来说各个线程都是串行的 高效其实体现在各个线程获得任务和处理任务上面 而不是生产和消费上面 就算生产和消费是串行的 但是在获得任务和处理任务时任务已经被拿到线程的上下午中了 每个线程获得任务和处理任务是并行的 因为各自线程都走自己的函数入口 这才是高效体现所在

当然任务还可以是一个函数

看代码

将函数定义在BlockQueue.hpp里面

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "blockQueue.hpp"
#include "Task.hpp"

// 当任务是一个函数
void *consumer(void *args)
{
    //sleep(8);

    blockQueue<func_t> *bq = static_cast<blockQueue<func_t> *>(args);
    while (true)
    {
        //1、消费任务
        func_t f = bq->Pop();
        //2、处理任务
        f();
        std::cout << "消费一个任务..." << std::endl;

        sleep(1);
    }
    return nullptr;
}
void *productor(void *args)
{
    blockQueue<func_t> *bq = static_cast<blockQueue<func_t> *>(args);
    sleep(3);
    while (true)
    {
        // 1、获得任务
        //2、生产任务
        func_t f = downLoad;
        bq->Equeue(f);
        std::cout << "生产一个数据..." << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    blockQueue<func_t> *bq = new blockQueue<func_t>();
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, (void *)bq);
    pthread_create(&p, nullptr, productor, (void *)bq);

    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    return 0;
}

拓展到多生产多消费

因为访问临界区同一时间只能是一个线程 所以就算有很多生产者消费者 这个规则也不能打破 否则就不满足互斥 会出现数据不一致的问题

那么BlockQueue.hpp不需要改变 同样适用于多线程

只需要在main中多创建几个生产者消费者 验证是否同步即可

cpp 复制代码
// 多生产者多消费者
void *consumer(void *args)
{
    // sleep(8);

    blockQueue<func_t> *bq = static_cast<blockQueue<func_t> *>(args);
    while (true)
    {
        // 1、消费任务
        func_t f = bq->Pop();
        // 2、处理任务
        f();
        std::cout << "消费一个任务..." << std::endl;

        sleep(1);
    }
    return nullptr;
}
void *productor(void *args)
{
    blockQueue<func_t> *bq = static_cast<blockQueue<func_t> *>(args);
    sleep(10);
    while (true)
    {
        // 1、获得任务
        // 2、生产任务
        func_t f = downLoad;
        bq->Equeue(f);
        std::cout << "生产一个数据..." << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    blockQueue<func_t> *bq = new blockQueue<func_t>();
    pthread_t c[2], p[3];
    pthread_create(&c[0], nullptr, consumer, (void *)bq);
    pthread_create(&c[1], nullptr, consumer, (void *)bq);

    pthread_create(&p[0], nullptr, productor, (void *)bq);
    pthread_create(&p[1], nullptr, productor, (void *)bq);
    pthread_create(&p[2], nullptr, productor, (void *)bq);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;
}

可以看到生产者没有生产之前两个消费者都在等待 结合后面打印消息多线程是同步的

2.7、条件变量封装

cpp 复制代码
#pragma once
#include <pthread.h>
#include "mutexEncapsulation.hpp"

using namespace MutexModule;

namespace CondModule
{
    class Cond
    {
    public:
        Cond()
        {
            pthread_cond_init(&_cond, nullptr);
        }
        void Wait(Mutex& mutex)
        {
            pthread_cond_wait(&_cond, mutex.getLock());
        }
        void Signal()
        {
            pthread_cond_signal(&_cond);  
        }
        void broadCast()
        {
            pthread_cond_broadcast(&_cond);
        }
        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }
    private:
        pthread_cond_t _cond;
    };
}

此时阻塞队列的写法 改变

cpp 复制代码
// 使用自己封装的锁和条件变量

#include <iostream>
#include <pthread.h>
#include <queue>
#include <functional>

#include "mutexEncapsulation.hpp"
using namespace MutexModule;

#include "condEncapsulation.hpp"
using namespace CondModule;


// 基于阻塞队列的单生产者单消费者模型实现

const int dfaultcap = 5;

// 当任务是一个函数
using func_t = std::function<void()>;

void downLoad()
{
    std::cout << "这是一个下载任务" << std::endl;
}
template <typename T>
class blockQueue
{
private:
    bool isFull()
    {
        return _q.size() >= _cap;
    }
    bool isEmpty()
    {
        return _q.empty();
    }

public:
    blockQueue(int cap = dfaultcap, int cSleepCnt = 0, int pSleepCnt = 0)
        : _cap(cap), _cSleepCnt(cSleepCnt), _pSleepCnt(pSleepCnt)
    {
    }
    void Equeue(T &data)
    {
        LockGuard lockguard(&_mutex);
        while (isFull())
        {
            _pSleepCnt++;
            std::cout << "生产者等待..." << std::endl;
            _fullCond.Wait(_mutex);
            _pSleepCnt--;
        }
        _q.push(data);
        if (_cSleepCnt > 0)
        {
            std::cout << "消费者被唤醒" << std::endl;
            _emptyCond.Signal();
        }
    }
    T Pop()
    {
        LockGuard lockguard(&_mutex);
        while (isEmpty())
        {
            _cSleepCnt++;
            std::cout << "消费者等待..." << "_cSleepCnt: "<< _cSleepCnt << std::endl;
            _emptyCond.Wait(_mutex);
            _cSleepCnt--;
        }
        T data = _q.front();
        _q.pop();
        if (_pSleepCnt > 0)
        {
            std::cout << "生产者被唤醒" << std::endl;
            _fullCond.Signal();
        }
        return data;
    }
    ~blockQueue()
    {
    }

private:
    std::queue<T> _q;
    int _cap;
    Mutex _mutex;
    Cond _fullCond;
    Cond _emptyCond;

    int _cSleepCnt;
    int _pSleepCnt;
};

三、信号量

3.1、回顾之前信号量的相关知识

信号量本质上是对资源的一种预定机制 就像看电影买电影票一样 先买票也就是预定 只有预定了才能看票 也就是只有申请了信号量才能对一块资源占有

多线程使用资源有两种场景

1、将资源当作整体看待 此时需要的是二元信号量和mutex来维护资源安全

2、将资源分会很多小块来使用 此时需要使用到信号量 具体地 所有地线程在访问资源之前都要先申请信号量 信号量大于0 那么这个线程申请成功 预定成功 可以访问一个资源 信号量-- 信号量小于等于0 那么申请地线程会被阻塞挂起 因此此时没有资源了 资源没有就绪 当资源就绪一个 信号量++

信号量的++、-- 操作就是V、P操作 这个操作必须是原子的 因为信号量也相当于是临界资源 会被多个线程看到 需要安全性

解释之前为什么使用条件变量时要加锁 这是因为那个时候阻塞队列被当作整体使用 是一块资源 要保证线程访问这个整块资源是安全的 也就是要互斥 所以要加锁

3.2、基于环形队列的生产者消费者模型

为了更加深刻的理解信号量 这里写一个基于环形队列的生产者消费者模型 看信号量在生产者消费者模型中是担任的何种角色 作用是什么

3.2.1、环形队列数据结构回顾

这里用数组来模拟环形队列 需要一个头指针 一个尾指针 开始时这两个指针指向同一个位置 尾指针指向队尾 头指针指向对头 当入数据那么尾指针改变 向后面移动 当删除数据出队列1头指针改变 向后面移动

当数据满的时候 不能入数据了 也就是尾指针不能移动 当数据为空的时候 不能出数据 头指针不能移动

有两种实现思路

1、用一个计数器来记录队列里面的数据个数 当队列为空的时候 头尾指针指向同一个位置 放一个数据之前 尾指针先检查计数器看能不呢放 能则放 尾指针后移 最终为满的时候 头尾指针指向同一个位置 当删除数据的时候检查计数器 看为不为0 为0则无法删除 否则删除头指针指向的数据 头指针后移 当头尾指针指向同一个位置时 说每次此时计数器为0 队列里面没有数据 当然头尾指针一直在++ 那么为了保证指针不越界 每次++指针之后都要对队列容量进行模操作

2、空一格位置不放数据 尾指针指向这个位置 尾指针每次放数据之前都要看下一个位置是不是头指针 不是则放数据 向后面移动 若下一个位置是头指针 那么说明已经满了此时尾指针不能移动 当头尾指针指向同一个位置时说明还没有放入数据 队列为空

3.2.2、在环形队列基础上引入生产者消费者

先看单生产者单消费者情况

尾指针生产者p 头指针就是消费者c 这里采用实现思路1 也就是使用计数器的方式

于是有了4个约定

1、当为空时 生产者先运行

2、为满时 消费者先运行

3、生产者不能把消费者套一个圈以上 最多和消费者齐头并进 否则不合逻辑 表示生产满了还在生产

4、消费者不能超过生产者 否则表示队列为空了还在消费

那么得出结论

只要生产者消费者不指向同一个位置就能同时运行 因为在同一个位置的时候要么是空要么是满 此时要么生产者不能运行要么消费者不能运行 也就是不为空或者不为满的时候 两种线程可以同时运行

只有为空或者为满的时候需要保证同步互斥 在这里环形队列就是临界资源 互斥是生产者消费者之间的互斥只能有一方线程运行 同步 体现在为满时消费者先运行 为空时生产者先运行 当不为空或者不为满时两方没有限制要求

如何保证这四个约定和这个结论呢 信号量就可以保证

3.2.3、伪代码

对于生产者来说资源就是队列里面的空位置 那么描述该资源的信号量初始值就是队列大小N 对于消费者来说 资源就是带有数据的位置 那么描述该资源的信号量初始值为0 当然因为这个环形队列使用数组模拟的 资源是分为小块的 需要用一个指针来标记此时访问的是哪一块资源因此对于消费者个生产者来说都需要一个指针

对于该伪代码 现在只用队列为满或者为空思考

当为空的时候 只有生产者P操作能进行 而消费者P的时候检查到自己的资源没有就绪 对应线程会被阻塞挂起 此时生产者生产 对指针描述的位置入数据 指针后移 进行模操作防止越界 此时生产了一个数据 就对消费者资源进行V操作 因为数据多了一个那么消费者的资源就+1 为满时同理

那么现在看来这个信号量的机制就相当于融合了同步互斥 当一方资源不就绪的时候只有一方能访问临界资源 另一方被阻塞挂起 此时就是互斥 体现在P操作这里 当这方对临界资源进行自己的操作之后 自然的对对方资源会V操作 此时就是同步 相当于signal 体现在V操作

3.2.4、信号量接口认识

3.2.5、信号量封装

cpp 复制代码
#pragma once

#include <semaphore.h>


namespace SemModule
{
    int defaultvalue = 1;
    class Sem
    {
    public:
        Sem(int value = defaultvalue)
        {
            sem_init(&_sem, 0, value);
        }
        void P()
        {
            sem_wait(&_sem);
        }
        void V()
        {
            sem_post(&_sem);
        }
        ~Sem()
        {
            sem_destroy(&_sem);
        }
    private:
        sem_t _sem;
    };
}

3.2.6、基于环形队列的生产者消费者模型实现

cpp 复制代码
#pragma once
#include <vector>
#include "mutexEncapsulation.hpp"
using namespace MutexModule;
#include "semEncapsulation.hpp"
using namespace SemModule;

const int gcap = 5; // for debug

template <typename T>
class RingQueue
{
public:
    // 对于类调用构造函数 要写在初始化列表里面 因为类的构造在构造函数体之前就完成 而函数体内部只是进行赋值
    RingQueue(int cap = gcap)
        : _rq(cap), _cap(cap), _cStep(0), _pStep(0), _dataSem(0), _blankSem(5)
    {
    }
    // 入队列操作
    void Push(T& in)
    {
        // 先申请信号量 
        _blankSem.P();
        // 申请成功说明有资源开始访问
        _rq[_pStep++] = in;
        _pStep %= _cap;
        // 通知另一方 V操作
        _dataSem.V();
    }
    // 出队列操作
    void Pop(T* out)
    {
        _dataSem.P();
        // 直接移动指针即可 后面数据会覆盖
        *out = _rq[_cStep++];
        _cStep %= _cap;
        _blankSem.V();
    }
    ~RingQueue()
    {
    }

private:
    std::vector<T> _rq;
    int _cap;

    int _cStep;
    int _pStep;

    Sem _dataSem;
    Sem _blankSem;
};
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "RingQueue.hpp"

void *Productor(void *args)
{
    sleep(5);
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while (true)
    {
        std::cout << "生产数据... data : " << data << std::endl;
        rq->Push(data);
        data += 1;
        sleep(1);
    }
    return nullptr;
}

void *Consumer(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while (true)
    {
        rq->Pop(&data);
        std::cout << "消费数据... data : " << data << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    RingQueue<int> *rq = new RingQueue<int>();
    // 创建线程
    pthread_t p, c;

    pthread_create(&p, nullptr, Productor, rq);
    pthread_create(&c, nullptr, Consumer, rq);

    // 等待线程
    pthread_join(p, nullptr);
    pthread_join(c, nullptr);
}

3.2.7、多生产者多消费者情况

由于生产同一时间只能有一个生产者 消费者也是 那么就要保证生产者之间互斥 消费者之间互斥 需要两把锁 其他代码不变

出现一个问题 加锁是在申请信号量之前还是之后呢

加在前面肯定没问题 但是这样效率会低一点 为什么?

加锁就是排队进电影院 申请信号量就是买票 若是先加锁相当于排队进入电影院后再买票 而先申请信号量就相当于买完票之后排队 带入线程 多个线程先申请信号量就是提前预定了可用资源 因为申请信号量本身就是原子性的 多线程没有安全性问题 当信号量描述资源不就绪 那么阻塞等待 当多线程把资源预定完之后再申请锁 然后互斥访问临界资源 这样效率更高

cpp 复制代码
#pragma once
#include <vector>
#include "mutexEncapsulation.hpp"
using namespace MutexModule;
#include "semEncapsulation.hpp"
using namespace SemModule;

const int gcap = 5; // for debug

template <typename T>
class RingQueue
{
public:
    // 对于类调用构造函数 要写在初始化列表里面 因为类的构造在构造函数体之前就完成 而函数体内部只是进行赋值
    RingQueue(int cap = gcap)
        : _rq(cap), _cap(cap), _cStep(0), _pStep(0), _dataSem(0), _blankSem(5)
    {
    }
    // 入队列操作
    void Push(T &in)
    {
        // 先申请信号量
        _blankSem.P();
        LockGuard lockguard(&_pMutex);
        {
            // 申请成功说明有资源开始访问
            _rq[_pStep++] = in;
            _pStep %= _cap;
        }
        // 通知另一方 V操作
        _dataSem.V();
    }
    // 出队列操作
    void Pop(T *out)
    {
        _dataSem.P();
        LockGuard lockguard(&_cMutex);
        {
            // 直接移动指针即可 后面数据会覆盖
            *out = _rq[_cStep++];
            _cStep %= _cap;
        }
        _blankSem.V();
    }
    ~RingQueue()
    {
    }

private:
    std::vector<T> _rq;
    int _cap;

    int _cStep;
    int _pStep;

    Sem _dataSem;
    Sem _blankSem;

    Mutex _cMutex;
    Mutex _pMutex;
};
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "RingQueue.hpp"

void *Productor(void *args)
{
    sleep(5);
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while (true)
    {
        std::cout << "生产数据... data : " << data << std::endl;
        rq->Push(data);
        data += 1;
        sleep(1);
    }
    return nullptr;
}

void *Consumer(void *args)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);
    int data = 0;
    while (true)
    {
        rq->Pop(&data);
        std::cout << "消费数据... data : " << data << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    RingQueue<int> *rq = new RingQueue<int>();
    // 创建线程
    pthread_t p[3], c[2];

    pthread_create(p, nullptr, Productor, rq);
    pthread_create(p+1, nullptr, Productor, rq);
    pthread_create(p+2, nullptr, Productor, rq);
    pthread_create(c, nullptr, Consumer, rq);
    pthread_create(c+1, nullptr, Consumer, rq);

    // 等待线程
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
}

可以看到 这次生产消费数据都是一批一批的 因为多个线程提前申请了信号量

进一步理解信号量

于阻塞队列判断 发现阻塞队列的线程在访问临界资源前要判断 而信号量把临界资源是否就绪 以原子性的方式提前判断了 并且根据判断结果做出了对应操作 比如阻塞等待 或者线程预定资源成功

为什么环形队列就可以将资源分为小块供多线程访问呢 因为这里环形队列本质是数组 而对于数组我们可以用下标访问每个位置数据 当时阻塞队列封装的是队列这个容器适配器 它的资源是整体的 不能进行分批类似下标访问

其实当环形队列的数组大小为1时 就是将这个资源当作整体看待 本质上没有区别 都是要看临界资源就绪与否

若是将资源整体使用 就用mutex 反之可以用sem


四、线程池

线程池就是一次性提前开出多个线程 在处理任务的时候就不需要创建线程 直接拿着创建好的线程使用 给它们分配任务 这是一种池化技术 就是让cpu不那么繁忙 免得处理任务的时候还要创建线程

4.1、日志

日志是记录系统、设备、软件或用户行为等关键信息的结构化数据集合,核心作用是追溯、监控和排查问题。

日志就像相当于是程序的日记 打印出某一时刻的运行状态

这里封装一个日志类

4.1.1、策略模式

日志的打印可以打印给不同的文件 这是一种策略模式 根据我们的需要打印给不同的文件 这里打印给显示器文件或者指定文件两种文件 下面先封装策略模式

封装思路:先写一个基类LogStrategy 这是一个抽象类 它的纯虚方法是SyncLog 这是打印的策略 需要写出不同子类来重写这个SyncLog 这里有两个子类 一个是像显示器打印 类名称就是ConsoleLogStrategy 像显示器打印很简单 获取到日志之后直接输出 但是要加锁 因为多线程的原因 显示器称为共享资源 另一个子类是FileLogStrategy 这是向指定文件打印 这个文件需要指明文件路径和文件名称这样才能找到文件 这里使用c++17的一些接口来进行文件操作 首先这个类需要属性文件路径和文件名称 在构造函数中将这两个属性初始化为默认的路径和名称 但是路径可能不存在 此时需要使用std::filesystem::exists()判断路径是否存在 不存在就新建路径 使用接口std::filesystem::create_directories() 文件名称不需要判断因为文件不存在的时候打开文件可以直接创建 文件就存在了 只有路径需要判断 做好上述工作之后 重写纯虚方法 SyncLog 此时拼接合法的路径和名称 之后使用接口std::ofstream out()以追加的方式打开文件 之后就可以向文件写入日志了 但是要保证是原子性的 因此也要加锁 写完close()关闭文件即可

代码

cpp 复制代码
#ifndef _LOG_HPP
#define _LOG_HPP

#include <string>
#include <iostream>
#include <cstdio>
#include <filesystem> //C++17
#include <fstream> // 使用c++封装的文件流
#include <sstream>
#include "mutexEncapsulation.hpp"

namespace LogModule
{
    using namespace MutexModule;
    // 策略模式
    const std::string gsep = "\r\n";
    class LogStrategy
    {
    public:
        ~LogStrategy() = default;
        virtual void SyncLog(const std::string &message) = 0;
    };
    // 像显示器打印
    class ConsoleLogStrategy : public LogStrategy
    {
    public:
        ConsoleLogStrategy() {}
        // 进行重写
        void SyncLog(const std::string &message) override
        {
            LockGuard lockguard(_mutex);
            std::cout << message << gsep;
        }
        ~ConsoleLogStrategy() {}

    private:
        Mutex _mutex;
    };
    // 向指定文件打印
    const std::string defaultpath = "./log";
    const std::string defaultfile = "my.log";
    class FileLogStrategy : public LogStrategy
    {
    public:
        FileLogStrategy(const std::string &path = defaultfile, const std::string &file = defaultfile)
            : _path(path), _file(file)
        {
            LockGuard lockguard(_mutex);
            // 路径不存在需要创建
            if (std::filesystem::exists(_path))
            {
                return;
            }
            try
            {
                std::filesystem::create_directories(_path);
            }
            catch (const std::filesystem::filesystem_error &e)
            {
                std::cerr << e.what() << '\n';
            }
        }
        // 重写
        void SyncLog(const std::string &message) override
        {
            LockGuard lockguard(_mutex);
            std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;
            std::ofstream out(filename, std::ios::app); //通过追加写的方式打开
            if(!out.is_open())
            {
                return;
            }
            out << message << gsep;
            out.close();
        }
        ~FileLogStrategy() {}

    private:
        std::string _path;
        std::string _file;
        Mutex _mutex;
    };
}

#endif

头文件filesystem 中包含了路径创建 判断路径存在与否 以及路径创建出错的声明 这是在c++17引入的

头文件fstream中封住了c++自己的一套文件流的操作

代码测试

直接使用c++14中的智能指针来构成多态 这样指针更方便管理 不需要手动释放

my.log文件中的

4.1.2、形成一条完整日志

我们要达成的效果

可读性很好的时间\] \[⽇志等级\] \[进程pid\] \[打印对应⽇志的⽂件名\]\[⾏号\] - 消息内容,⽀持 可变参数 \[2024-08-04 12:27:03\] \[DEBUG\] \[202938\] \[main.cc\] \[16\] - hello world \[2024-08-04 12:27:03\] \[DEBUG\] \[202938\] \[main.cc\] \[17\] - hello world \[2024-08-04 12:27:03\] \[DEBUG\] \[202938\] \[main.cc\] \[18\] - hello world \[2024-08-04 12:27:03\] \[DEBUG\] \[202938\] \[main.cc\] \[20\] - hello world \[2024-08-04 12:27:03\] \[DEBUG\] \[202938\] \[main.cc\] \[21\] - hello world \[2024-08-04 12:27:03\] \[WARNING\] \[202938\] \[main.cc\] \[23\] - hello world

首先要定义一个枚举类 其中就是各个日志等级 然后封装一个Logger类 这就是形成日志的类 首先要选择哪种刷新策略 默认选择向显示器刷新 那么就在构造函数里面选择这种策略 可以在这个类里面定义两个成员函数 EnableConsoleLogStrategy EnableFileLogStrategy 分别代表选择不同测策略 那么构成多态必然要有一个基类指针 这个指针设置为成员变量 这样就可以在选择策略函数里面进行指针赋值 构成多态了

接着在这个类里面封装一个内部类 LogMessage 表示未来的一条日志 也就是这个类是为了形成一条日志而存在的 那么观察上面我们需要的日志形式 这个内部类肯定需要成员变量 当前时间_curr_time、 日志等级 _level、 进程id _pid、源文件名称_src_name、当前行号 _line_number 以及记录形成日志之后一整条的字符串_loginfo ;在这个内部类的构造函数中合并这些信息形成一个字符串 包含头文件<sstream> 这个里面有一个类的声明 std::stringstream 这个类重载了 << 运算符 可以将任何变量的值都转为字符串并且拼接到这个类实例化出来的对象后面 这样很方便将日志的各种不同类型的数据转为字符串并且拼接在一起了 但是有一个就是枚举类 即使替换成了各自的值 也是数字 不是日志等级字符串 因此需要自己写一个转换函数 将枚举类中的各个变量转为对应的日志等级字符串并且返回给streamstring对象进行拼接

-hello world这样的消息该怎么办呢 这个消息是我们自己输入的 支持可变参数 所以我们要在这个内部类里面写一个函数模板 这个函数模板是 << 操作符的重载 可以接受任何参数(可变参数)之后使用streamstring拼接 在_loginfo后面 返回值是LogMessage因为可能不仅仅只有一次 << 操作 所以返回值设为类类型 若是有多个 << 操作那么就会继续调用

这个内部类的析构函数 就是直接进行刷新 按照选择的刷新策略 当然必须要基类指针不为空 为什么在这里刷新呢 很好理解 只有当日志形成完成之后 才能刷新 这个内部类是形成一条日志的类 那么走到析构就说明一条日志一定形成完毕了 此时刷新很灵性 由于内部类不能直接拿到外部类的方法属性 为了更加方便需要定义一个全局Logger对象 那么在内部类里面也需要有一个属性Logger& _logger用这个属性来来调用刷新策略

在我们形成日志的时候 使用的肯定是调用的Logger类 而不是里面的内部类 里面的内部类只是辅助我们更好调用Logger类形成日志的 那么可以在Logger里面写一个仿函数 这个仿函数返回值类型就是内部类的类型 也就是在这个仿函数里面构造一个内部类 这个函数的返回类型就是内部类类型 返回值设置为临时变量 因为外面调用的时候可能后面还存在多个 << 操作 这样返回之后就直接调用LogMessage中的 << 重载函数了 很方便

获取当前时间定义一个函数GetTimeStamp

代码

cpp 复制代码
#ifndef _LOG_HPP
#define _LOG_HPP

#include <string>
#include <iostream>
#include <cstdio>
#include <filesystem> //C++17
#include <fstream>    // 使用c++封装的文件流 并不是c++17有的
#include <sstream>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
#include "mutexEncapsulation.hpp"

namespace LogModule
{
    using namespace MutexModule;
    // 策略模式
    const std::string gsep = "\r\n";
    class LogStrategy
    {
    public:
        ~LogStrategy() = default;
        virtual void SyncLog(const std::string &message) = 0;
    };
    // 像显示器打印
    class ConsoleLogStrategy : public LogStrategy
    {
    public:
        ConsoleLogStrategy() {}
        // 进行重写
        void SyncLog(const std::string &message) override
        {
            LockGuard lockguard(_mutex);
            std::cout << message << gsep;
        }
        ~ConsoleLogStrategy() {}

    private:
        Mutex _mutex;
    };
    // 向指定文件打印
    const std::string defaultpath = "./log";
    const std::string defaultfile = "my.log";
    class FileLogStrategy : public LogStrategy
    {
    public:
        FileLogStrategy(const std::string &path = defaultfile, const std::string &file = defaultfile)
            : _path(path), _file(file)
        {
            LockGuard lockguard(_mutex);
            // 路径不存在需要创建
            if (std::filesystem::exists(_path))
            {
                return;
            }
            try
            {
                std::filesystem::create_directories(_path);
            }
            catch (const std::filesystem::filesystem_error &e)
            {
                std::cerr << e.what() << '\n';
            }
        }
        // 重写
        void SyncLog(const std::string &message) override
        {
            LockGuard lockguard(_mutex);
            std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;
            std::ofstream out(filename, std::ios::app); // 通过追加写的方式打开
            if (!out.is_open())
            {
                return;
            }
            out << message << gsep;
            out.close();
        }
        ~FileLogStrategy() {}

    private:
        std::string _path;
        std::string _file;
        Mutex _mutex;
    };

    // 日志等级 枚举类
    // 枚举类相比于一般枚举优点: 不会出现名称冲突 因为这个枚举类里面枚举变量的访问需要指明类域
    enum class LogLevel
    {
        DEBUG,
        INFO,
        WARNING,
        ERROR,
        FATAL
    };
    std::string Level2Str(LogLevel& level)
    {
        switch(level)
        {
            case LogLevel::DEBUG:
                return "DEBUG";
            case LogLevel::ERROR:
                return "ERROR";
            case LogLevel::FATAL:
                return "FATAL";
            case LogLevel::INFO:
                return "INFO";
            case LogLevel::WARNING:
                return "WARNING";
            default:
                return "UNKNOWN";
        }
    }
    std::string GetTimeStamp()
    {
        time_t curr = time(nullptr);
        // 获取当前时间写到内置结构体中 再从中拿出写到数组中
        struct tm curr_tm;
        localtime_r(&curr, &curr_tm);
        char timebuffer[128];
        snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d", 
            curr_tm.tm_year+1900,
            curr_tm.tm_mon+1,
            curr_tm.tm_mday,
            curr_tm.tm_hour,
            curr_tm.tm_min,
            curr_tm.tm_sec);
        
        return timebuffer;
    }
    class Logger
    {
    public:
        Logger()
        {
            EnableConsoleLogStrategy();
        }
        void EnableConsoleLogStrategy()
        {
            _fflush_strategy = std::make_unique<ConsoleLogStrategy>();
        }
        void EnableFileLogStrategy()
        {
            _fflush_strategy = std::make_unique<FileLogStrategy>();
        }
        // 内部类
        class LogMessage
        {
        public:
            LogMessage(LogLevel &level, std::string &name, int line, Logger& logger)
                : _curr_time(GetTimeStamp())
                , _level(Level2Str(level))
                , _pid(getpid())
                , _src_name(name)
                , _line_number(line)
                ,_logger(logger)
            {
                // 一条日志的左半部分
                std::stringstream ss;
                ss << "[" << _curr_time << "] "
                   << "[" << _level << "] "
                   << "[" << _pid << "] "
                   << "[" << _src_name << "] "
                   << "[" << _line_number << "] - ";
                
                _loginfo += ss.str();
            }
            // 为了支持日志输入的可变参数 重载运算符 <<
            // 返回值写成& 保证在<<过程中只是针对于一个LogMessage对象
            template<typename T>
            LogMessage& operator << (const T& input)
            {
                std::stringstream ss;
                ss << input ;
                _loginfo += ss.str();
                return *this; // 支持多次 << 因为临时变量返回之后又会进行<<操作
            }
            // 在这个LogMessage变量被销毁的时候一条日志已经生成完毕此时按照刷新策略刷新即可

            // 因为要使用外部类来拿到刷新策略 因此需要设置一个外部类引用的成员变量
            // 为什么是引用 实际上外部类类看作一个完整的机器 内部类就是一个零件
            // 若是不是引用可能出现循环依赖无法计算类的大小 内部类里面的外部类里面又有内部类
            // 因此只能写成引用 可以理解为内部类只能标记外部类的存在而不能创建一个外部类
            ~LogMessage()
            {
                if(_logger._fflush_strategy)
                {
                    _logger._fflush_strategy->SyncLog(_loginfo);
                }
            }
        private:
            std::string _curr_time;
            std::string _level;
            pid_t _pid;
            std::string _src_name;
            int _line_number;
            std::string _loginfo;
            Logger& _logger;
        };
        
        // 外部使用的时候应该用括号传入参数再刷新 这里重载()运算符
        LogMessage operator()(LogLevel level, std::string name, int line)
        {
            // 这里直接调用LogMessage的构造函数 完成刷新 第一次返回一个临时对象
            // 这个临时对象会根据右半部分的输入调用自己类的<<重载 完成可变参数输出
            return LogMessage(level, name, line, *this);
        }
        ~Logger()
        {
        }

    private:
        std::unique_ptr<LogStrategy> _fflush_strategy;
    };
    // 定义一个全局的Logger进行日志打印
    Logger logger;
    
    // 定义宏 让调用更见简洁
    #define Enable_FileLog_Strategy() logger.EnableFileLogStrategy() 
    #define Enable_ConsoleLog_Strategy() logger.EnableConsoleLogStrategy() 
    #define logger(level) logger(level, __FILE__, __LINE__) 
}

#endif

4.2、线程池

重要的是理解线程退出的条件:当线程池状态为退出并且此时没有任务的时候

还有一点就是理解线程阻塞等待的条件:当线程池状态为运行并且此时没有任务的时候

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include "thread.hpp"
#include "Log.hpp"
#include "condEncapsulation.hpp"
#include "mutexEncapsulation.hpp"

namespace ThreadPoolModule
{
    using namespace ThreadModule;
    using namespace LogModule;
    using namespace CondModule;
    using namespace MutexModule;

    static const int gnum = 5;
    template <typename T>
    class ThreadPool
    {
    private:
        void WakeupAll()
        {
            LockGuard lockguard(_mutex);
            if (_sleepnum)
                _cond.broadCast();
            logger(LogLevel::INFO) << "唤醒所有的休眠线程";
        }
        void WakeupOne()
        {
            _cond.Signal();
            logger(LogLevel::INFO) << "唤醒一个休眠线程";
        }

    public:
        ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepnum(0)
        {
            for (int i = 0; i < _num; i++)
            {
                _threads.emplace_back(
                    [this](){
                         HandlerTask(); 
                        });
            }
        }
        void Start()
        {
            if (_isrunning)
                return;
            _isrunning = true;
            for (auto &thread : _threads)
            {
                thread.Start();
                logger(LogLevel::INFO) << "start new thread success: " << thread.Name();
            }
        }
        void Stop()
        {
            if (!_isrunning)
                return;
            _isrunning = false;

            WakeupAll();
        }
        void Join()
        {
            for (auto & thread : _threads)
            {
                thread.Join();
            }
        }
        void HandlerTask()
        {
            char name[128];
            pthread_getname_np(pthread_self(), name, sizeof(name));
            while (true)
            {
                T t;
                {
                    LockGuard lockguard(_mutex);
                    // 当任务队列为空并且线程池状态为运行的时候 线程需要等待
                    while (_taskq.empty() && _isrunning)
                    {
                        _sleepnum++;
                        _cond.Wait(_mutex);
                        _sleepnum--;
                    }
                    // 线程退出条件:队列为空并且线程池状态为退出
                    if (_taskq.empty() && !_isrunning)
                    {
                        logger(LogLevel::INFO) << name << " 退出了, 线程池退出&&任务队列为空";
                        break;
                    }
                    // 取任务
                    t = _taskq.front();
                    _taskq.pop();
                }
                //t(); // 处理任务
            }
        }
        ~ThreadPool() {}

    private:
        std::vector<Thread> _threads;
        int _num;
        std::queue<T> _taskq;
        Cond _cond;
        Mutex _mutex;

        bool _isrunning;
        int _sleepnum;
    };
}

单例模式

某些类 应该只有一个对象 这就是单例模式

主要有懒汉和饿汉方式
吃完饭, ⽴刻洗碗, 这种就是饿汉⽅式. 因为下⼀顿吃的时候可以⽴刻拿着碗就能吃饭.
吃完饭, 先把碗放下, 然后下⼀顿饭⽤到这个碗了再洗碗, 就是懒汉⽅式.

template <typename T>
class Singleton {
static T data;
public:
static T* GetInstance() {
return &data;
}
};

(1)static T data; --- 静态成员变量

static 修饰成员变量时,核心特点是:

  • 存储位置 :不存储在类的实例对象中,而是存储在全局 / 静态存储区(程序运行期间只有一份内存);
  • 生命周期:从程序启动到程序结束,全程存在(全局唯一);
  • 访问规则
    • 属于类本身,而非类的某个实例;
    • 即使创建多个 Singleton<T> 对象,data 也只有一份;
    • 这里 data 是私有的(类内默认 private),外部无法直接访问,只能通过 GetInstance() 获取。
(2)static T* GetInstance() --- 静态成员函数

static 修饰成员函数时,核心特点是:

  • 无 this 指针 :静态成员函数不依赖类的实例对象,不需要创建 Singleton<T> 对象就能调用;
  • 访问权限:只能访问类的静态成员(变量 / 函数),不能访问非静态成员(因为非静态成员属于具体实例,而静态函数没有 this 指针);
  • 调用方式 :直接通过 类名::函数名 调用,比如 Singleton<int>::GetInstance(),无需创建对象。
    只要通过 Singleton 这个包装类来使⽤ T 对象, 则⼀个进程中只有⼀个 T 对象的实例

template <typename T>
class Singleton {
static T* inst;
1
2
3public:
static T* GetInstance() {
if (inst == NULL) {
inst = new T();
}
return inst;
}
};

(1)static T* inst; --- 静态成员指针

static修饰类的成员变量时,是实现单例 "唯一性" 的核心基石,具体特点:

  • 存储与生命周期
    • inst 不存储在任何 Singleton<T> 的实例对象中,而是存放在静态存储区(全局内存区域);
    • 生命周期贯穿整个程序(从程序启动到退出),且全局只有一份内存 ------ 这是单例 "唯一" 的根本原因(无论创建多少个Singleton<T>对象,inst都只有一个)。
  • 初始化规则
    • 静态成员变量必须在类外初始化 (如上面补的T* Singleton<T>::inst = nullptr;),否则编译器会报 "未定义的引用" 错误;
    • 这里初始化为nullptr,是懒汉式的关键(饿汉式会直接初始化指向实例)。
  • 访问规则
    • inst是类内私有(默认 private),外部无法直接修改,只能通过GetInstance()函数操作,保证实例创建的可控性。
(2)static T* GetInstance() --- 静态成员函数

static修饰成员函数,是单例 "全局可访问" 的核心入口,具体特点:

  • 无 this 指针
    • 静态成员函数不依赖类的实例对象,不需要先创建Singleton<T>对象,就能直接通过类名::函数名调用(如Singleton<int>::GetInstance());
    • 正因为没有 this 指针,静态函数只能访问类的静态成员 (如inst),无法访问非静态成员(非静态成员属于具体实例,静态函数找不到对应的实例)。
  • 全局访问性
  • 单例的核心需求是 "全局唯一且全局可访问",静态函数正好满足这一点 ------ 无论在程序哪个位置,只要包含头文件,就能调用GetInstance()获取同一个实例。
    存在⼀个严重的问题, 线程不安全.
    第⼀次调⽤ GetInstance 的时候, 如果两个线程同时调⽤, 可能会创建出两份 T 对象的实例.
    但是后续再次调⽤, 就没有问题了.
    需要加锁
    线程池的基于饿汉方式的单例模式实现
    不能拷贝构造 也不能赋值重载
    需要添加两个成员变量 单例指针 和给单例模式加锁的锁
    单例模式的锁也是静态的
cpp 复制代码
// 单例模式
#include <iostream>
#include <vector>
#include <queue>
#include "thread.hpp"
#include "Log.hpp"
#include "condEncapsulation.hpp"
#include "mutexEncapsulation.hpp"

namespace ThreadPoolModule
{
    using namespace ThreadModule;
    using namespace LogModule;
    using namespace CondModule;
    using namespace MutexModule;

    static const int gnum = 5;
    template <typename T>
    class ThreadPool
    {
    private:
        void WakeupAll()
        {
            LockGuard lockguard(_mutex);
            if (_sleepnum)
                _cond.broadCast();
            logger(LogLevel::INFO) << "唤醒所有的休眠线程";
        }
        void WakeupOne()
        {
            _cond.Signal();
            logger(LogLevel::INFO) << "唤醒一个休眠线程";
        }

        ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepnum(0)
        {
            for (int i = 0; i < _num; i++)
            {
                _threads.emplace_back(
                    [this]()
                    {
                        HandlerTask();
                    });
            }
        }
        void Start()
        {
            if (_isrunning)
                return;
            _isrunning = true;
            for (auto &thread : _threads)
            {
                thread.Start();
                logger(LogLevel::INFO) << "start new thread success: " << thread.Name();
            }
        }

        ThreadPool(const ThreadPool<T>& ) = delete;
        ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;

    public:
        static ThreadPool<T> *GetInstance()
        {
            if (inc == nullptr)
            {
                LockGuard lockguard(_lock);
                LOG(LogLevel::DEBUG) << "获取单例....";
                if (inc == nullptr)
                {
                    LOG(LogLevel::DEBUG) << "首次使用单例, 创建之....";
                    inc = new ThreadPool<T> *();
                    inc->Start();
                }
            }
            return inc;
        }
        void Stop()
        {
            if (!_isrunning)
                return;
            _isrunning = false;

            WakeupAll();
        }
        void Join()
        {
            for (auto &thread : _threads)
            {
                thread.Join();
            }
        }
        void HandlerTask()
        {
            char name[128];
            pthread_getname_np(pthread_self(), name, sizeof(name));
            while (true)
            {
                T t;
                {
                    LockGuard lockguard(_mutex);
                    // 当任务队列为空并且线程池状态为运行的时候 线程需要等待
                    while (_taskq.empty() && _isrunning)
                    {
                        _sleepnum++;
                        _cond.Wait(_mutex);
                        _sleepnum--;
                    }
                    // 线程退出条件:队列为空并且线程池状态为退出
                    if (_taskq.empty() && !_isrunning)
                    {
                        logger(LogLevel::INFO) << name << " 退出了, 线程池退出&&任务队列为空";
                        break;
                    }
                    // 取任务
                    t = _taskq.front();
                    _taskq.pop();
                }
                // t(); // 处理任务
            }
        }
        bool Enqueue(const T &in)
        {
            if (_isrunning)
            {
                LockGuard lockguard(_mutex);
                _taskq.push(in);
                if (_threads.size() == _sleepernum)
                    WakeUpOne();
                return true;
            }
            return false;
        }
        ~ThreadPool() {}

    private:
        std::vector<Thread> _threads;
        int _num;
        std::queue<T> _taskq;
        Cond _cond;
        Mutex _mutex;

        bool _isrunning;
        int _sleepnum;

        static ThreadPool<T> *inc;
        static Mutex _lock;
    };

    template <typename T>
    ThreadPool<T> *ThreadPool<T>::inc = nullptr;
    template <typename T>
    Mutex ThreadPool<T>::_lock;
}

线程安全和重入问题

线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结
果。⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
重⼊:同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。
重入分为两种情况
多线程重⼊函数
信号导致⼀个执⾏流重复进⼊函数
函数是可重入的就一定是线程安全的 反之不一定 例子 线程安全的一个函数若是当前执行流已经占有锁了 但是这个执行流接到了自己发给自己的信号 处理 处理的时候发生重入 但是重入的时候还要申请锁 此时锁被之前的执行流占有了 就不能再申请 不可重入

相关推荐
sunfove2 小时前
Python 面向对象编程:从过程式思维到对象模型
linux·开发语言·python
云和数据.ChenGuang2 小时前
达梦数据库安装服务故障四
linux·服务器·数据库·达梦数据库·达梦数据
努力学习的小廉2 小时前
【QT(七)】—— 常用控件(四)
开发语言·qt
PPPPPaPeR.2 小时前
使用vim实现进度条(初级)
linux·编辑器·vim
CoderCodingNo2 小时前
【GESP】C++六级考试大纲知识点梳理, (3) 哈夫曼编码与格雷码
开发语言·数据结构·c++
froginwe112 小时前
C 标准库 - `<errno.h>`
开发语言
鹿角片ljp2 小时前
Java IO流案例:使用缓冲流恢复《出师表》文章顺序
java·开发语言·windows
纵有疾風起3 小时前
【Linux 系统开发】基础开发工具详解:自动化构建、版本控制与调试器开发实战
linux·服务器·开发语言·c++·经验分享·开源·bash
wtsolutions3 小时前
Advanced Features - Unlocking the Power of JSON to Excel Pro
linux·json·excel