< Linux > 多线程(生产者消费者模型)

目录

1、生产者消费者模型

        生产者消费者模型的例子

        生产者消费者模型的特点

        生产者消费者模型的优点

2、基于BlockingQueue的生产者消费者模型

        概念

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

        基于计算任务的生产者消费者模型(并发)


1、生产者消费者模型

生产者消费者模型的例子

来看下生活中的生产者消费者模型:

  • 在日常生活中,我们作为消费者是会进场去超市里购物的,超市这个角色本身没有生产能力,我们所买的东西都是由工厂供应商提供的。而超市的角色就是把供应商生产的东西放在超市内的架子上来供消费者买。

上述超市的作用很明显:

  • 提高效率(站在供应商角度,它一次性可以批发大量商品给超市;站在消费者角度,购买方便)。
  • 解耦(供应商可以随时随地供应,消费者可以随时随地购买,消费者在消费期间不影响工厂生产,工厂生产期间不影响消费者消费,二者之间不再是强耦合关系)
  • 综合超市的两个作用,这就是像缓冲区一样

总结:

  • 上述例子中:消费者其实就是消费线程,工厂供应商就是生产线程,把这一整个体系称之为生产者消费者模型。消费者要的是超市的商品,生产者要的是超市里的展架位,也就意味着消费者和生产者都要访问超市内的资源。所以超市扮演的角色就是临界资源。

问1:消费者有多个,那么消费者之间是什么关系呢?

  • 竞争关系 —— 互斥。一旦超市里的资源很稀缺,就比如疫情初发期间,人们疯狂购物口罩,生怕口罩被别人抢走了,导致超市一罩难求。

问2:供应商有多个,供应商之间是什么关系呢?

  • 竞争关系 —— 互斥。供应商之间争的是超市里的展架位,就比如这个展架一旦被白象方便面所占用,那么你康师傅方便面它就不能放上来售卖。

问3:消费者和供应商之间又是什么关系呢?

  • 互斥关系:就比如我白象供应商正把方便面放在货架上的时候,此时消费者不能直接拿走,生产者生产数据的时候不是原子的,消费者消费也不是原子的,生产者要生成就生成完,这样消费者读取数据的时候才能有确定的结果
  • 同步关系:假设你迫切需要康师傅方便面,但是超市里只有白象的,所以你每天都以轮询检测的方式跑去超市里询问康师傅方便面到货了吗,而生产者一直询问超市有没有位置来拜访货物此时就发现了一个问题,如果只有互斥的关系,生产者一直要来检测超市里有没有位置来摆放货物,消费者不断互斥式的询问工作人员有没有货物到了,他们俩各自关心各自需要的资源,通过互斥的方式不断轮询检测,这种做法是对的,但是不合理,因为不断询问效率太低,太麻烦,所以工作人员加了消费者的微信,等货物到了再发消息给消费者,当货物快售完了,此时工作人员再和厂商打电话催促货物。上述我们能感受到生产和消费要有一定的顺序,消费完了再生产,生产完了再消费。所以生产者和消费者除了要保证临界资源的安全性外,还要保证消费过程中的合理性,所以消费者和供应商之间也应具备同步关系。

生产者消费者模型的特点

生产者消费者模型的概念:

  • 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
  • 生产者和消费者彼此之间不直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中,消费者也不用找生产者要数据,而是直接从这个容器里取数据,这个容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器实际上就是用来给生产者和消费者解耦的。

生产者消费者模型的特点如下:

  • 三种关系:生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥、同步)
  • 两种角色:生产者和消费者(通常是由线程承担的)
  • 一个交易场所:通常是指内存中特定的一种内存结构(数据结构)

未来我们用代码编写生产者消费者模型的时候,本质就是对这三个特点进行维护。

问:

  • 如何让多个消费者线程等待呢?又如何让线程被唤醒呢?
  • 如何让多个生产者线程等待呢?又如何让线程被唤醒呢?
  • 如何衡量消费者和生产者所关心的条件是否就绪呢?

解决上述三个问题,我们是需要在代码中体现的。前两个问题我们之前其实已经遇到过,我们使用条件变量解决的,具体过程见后续的代码实现。


生产者消费者模型的优点

  • 解耦。
  • 支持并发。
  • 支持忙闲不均。

紧耦合:如果我们在主函数中调用某一函数,那么我们必须等该函数体执行完后才继续执行主函数的后续代码,因此函数调用本质上是一种紧耦合。

  • 如下的两块代码中,代码块1main函数调用add函数,处理完后返回结果,这就是一个很普通的函数调用。

  • 代码块1main函数是个单进程执行流,add函数被调用时,实际上是主执行流把a,b数据交给add函数,这就是生产数据,我们假设add函数要调用很久,main执行流必须得等add函数执行完才能继续执行自己的逻辑。上述main函数和add函数就是强耦合关系。

松耦合:对应到生产者消费者模型中,函数传参实际上就是生产者生产的过程,而执行函数体实际上就是消费者消费的过程,但生产者只负责生产数据,消费者只负责消费数据,在消费者消费期间生产者可以同时进行生产,因此生产者消费者模型本质是一种松耦合。

  •  现在我把main函数要执行的工作和add函数分别交给两个线程,并提供了一块缓冲区。现在我线程1中main函数不再直接调用add函数了,改为调用Put函数,把a,b俩数据生成到此缓冲区里,随后main函数直接向后执行自己的代码逻辑,此时线程2add函数不再通过传参的方式获得数据了,而是通过内部的Get函数获得a,b数据,随后执行自己的逻辑。此时main函数就不用再等add函数了,现在就是把main函数和add函数实现了解耦,以前是串行化的。


2、基于BlockingQueue的生产者消费者模型

概念

在多线程编程中,阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。

其与普通的队列的区别在于:

  • 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素。
  • 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

根据上述对阻塞队列特点的描述,很容易联想到管道,而阻塞队列最典型的应用场景实际上就是管道的实现。


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

为了方便理解,下面我们先以单生产者、单消费者为例进行模拟实现:

BlockQueue.hpp文件的代码逻辑如下:

如下我们把阻塞队列设计成BlockQueue模板类型,便于后续需要时的复用,该模板中的私有成员变量如下:

  • bq_:用queue定义bq_来承担阻塞队列的角色,
  • cap_:阻塞队列有容量大小,还需定义cap_容量
  • mutex_:此bq_阻塞队列将来是要被其它线程所访问的,这里的队列就充当一种需要被保护的全局变量,所以后续我们需要加锁,所以还需要在模板中定义一把锁mutex_,来保护阻塞队列
  • conCond_(生产者条件变量)&  proCond_(消费者条件变量):单纯的互斥锁会导致在生产过程中,生产者会出现条件不满足而导致的轮询检查频繁竞争锁从而导致另一方饥饿的问题,消费者也是如此。所以我们需要用到条件变量在双方条件不满足时让生产者进入不满足就休眠的状态,同样让消费者不满足条件也休眠的状态。等待条件满足了再唤醒对方。如上就是同步式的阻塞队列。

其内部公有成员函数如下:

  • BlockQueue构造函数:初始化cao_容量为5(全局变量gDefaultCap),使用pthread_mutex_init动态初始化锁,使用pthread_cond_init动态初始化conCond_(生产者条件变量)&  proCond_(消费者条件变量)
  • ~BlockQueue析构函数:释放锁和两个条件变量
  • push函数:供生产者线程向阻塞队列放数据,在生产数据前需要进行加锁,其次进行判断是否适合生产,bq_阻塞队列里得有空间,满了就不生产,此时要进入休眠,等待你有空间时再将你唤醒,从而避免后续频繁的加锁解锁问题,不满就继续生产,生产完后唤醒消费者,因为可能上一次队列里没有数据,导致消费者进入休眠状态,生产后唤醒消费者来消费
  • pop函数:供消费者线程向阻塞队列拿数据,在消费前需要加锁,其次进行判断是否适合消费,当bq_阻塞队列为空时,不消费,进入休眠状态;当bq_有数据时,唤醒,然后消费,每消费一个,就意味着当前阻塞队列空出一个位置,所以唤醒生产者来生产数据

私有成员函数:(为了更好的体现封装,我们对加锁、解锁、判断函数、等待函数、唤醒函数都进行了封装)

  • lockQueue加锁:复用pthread_mutex_lock函数
  • unlockQueue解锁:复用pthread_mutex_unlock函数
  • isEmpty判空:return bq_.empty()即可,返回值bool类型。注意在主逻辑进行判断的时候一定要while(isEmpty())循环判断,而不能用if,因为wait函数被唤醒不一定代表条件是满足的,即使这种概率很小。
  • isFull判满:return bq_.size() == cap_即可,返回值bool类型。注意在主逻辑进行判断的时候一定要while(isFull())循环判断,而不能用if,因为wait函数被唤醒不一定代表条件是满足的,即使这种概率很小。
  • proBlockWait生产者等待:复用pthread_cond_wait函数即可,注意此函数要传入mutex_锁,为的就是在阻塞线程的时候,帮我们自动释放mutex_锁,防止占着锁不放,因为要维持生产者和消费者的互斥关系,传入的条件变量就是维护同步关系。注意:当我醒来的时候会重新获得mutex_锁,然后才返回。从而避免了未持有锁而访问后续临界资源造成的安全问题。
  • conBlockWait消费者等待:复用pthread_cond_wait函数即可,和上面一样,此函数的第一个参数条件变量维持的是生产者和消费者的同步关系,第二个参数锁维持的就是生产者和消费者的互斥关系。注意:当我醒来的时候会重新获得mutex_锁,然后才返回。从而避免了未持有锁而访问后续临界资源造成的安全问题。
  • wakeupPro唤醒生产者:复用pthread_cond_signal函数即可,此函数的目的是为了防止生产者因阻塞队列满了而即使后续有空位还依然处于等待状态的情况。要唤醒生产者生产数据供消费者消费
  • wakeupCon唤醒消费者:复用pthread_cond_signal函数即可,此函数的目的是为了防止消费者因阻塞队列为空而即使后续有数据还依然处于等待状态的情况。要唤醒消费者消费,从而让生产者继续生产数据
  • pushCore生产数据:复用push函数即可
  • popCore消费数据:定义tmp临时变量保存队头数据,随后bq_.pop()消费数据,返回此临时变量

总代码如下:(BlockQueue.hpp文件)

#pragma once
#include <iostream>
#include <queue>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;

// 定义默认容量大小为5
const uint32_t gDefaultCap = 5;

template <class T>
class BlockQueue
{
public:
    // 构造函数
    BlockQueue(uint32_t cap = gDefaultCap)
        : cap_(cap)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&conCond_, nullptr);
        pthread_cond_init(&proCond_, nullptr);
    }
    // 析构函数
    ~BlockQueue()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&conCond_);
        pthread_cond_destroy(&proCond_);
    }

public:
    // 生产函数
    void push(const T &in) // const &: 纯输入
    {
        // 加锁
        // 判断 -> 是否适合生产 -> bq_是否满 -> 程序员视角的条件 -> 1、满(不生产) 2、不满(生产)
        // if(满) 不生产,休眠
        // else if(不满) 生产
        // 解锁
        lockQueue();
        while (isFull())
        {
            //before: 当我等待的时候,会自动释放mutex_
            proBlockWait(); // 阻塞等待,等待被唤醒
            //after: 当我醒来的时候,我是在临界区里醒来的
        }
        // 条件满足,可以生产
        pushCore(in); // 生产完成
        // 解锁
        unlockQueue();
        // 生产数据后唤醒消费者消费数据
        wakeupCon();
    }
    // 消费接口
    T pop()
    {
        // 加锁
        // 判断 -> 是否适合消费 -> bq_是否为空 -> 程序员视角的条件 -> 1、空(不消费) 2、有(消费)
        // if(空) 不消费,休眠
        // else if(有) 消费
        // 解锁
        lockQueue();
        if (isEmpty())
        {
            conBlockWait(); // 阻塞等待,等待被唤醒
        }
        // 条件满足,可以消费
        T tmp = popCore();
        // 解锁
        unlockQueue();
        // 消费数据后唤醒生产者生产数据
        wakeupPro();
        return tmp;
    }

private:
    // 加锁
    void lockQueue()
    {
        pthread_mutex_lock(&mutex_);
    }
    // 解锁
    void unlockQueue()
    {
        pthread_mutex_unlock(&mutex_);
    }
    // 判空
    bool isEmpty()
    {
        return bq_.empty();
    }
    // 判满
    bool isFull()
    {
        return bq_.size() == cap_;
    }
    // 生产者等待
    void proBlockWait() // 生产者一定是在临界区的
    {
        // 1、在阻塞线程的时候,会自动释放mutex_锁,维持生产者和消费者的互斥关系
        pthread_cond_wait(&proCond_, &mutex_);
        // 2、当阻塞结束,返回的时候,pthread_cond_wait,会自动帮我们重新获得mutex_锁,然后才返回
    }
    // 消费者等待
    void conBlockWait() // 阻塞等待,等待被唤醒
    {
        // 1、在阻塞线程的时候,会自动释放mutex_锁,维持生产者和消费者的互斥关系
        pthread_cond_wait(&conCond_, &mutex_);
        // 2、当阻塞结束,返回的时候,pthread_cond_wait,会自动帮我们重新获得mutex_锁,然后才返回
    }
    // 唤醒生产者
    void wakeupPro()
    {
        pthread_cond_signal(&proCond_);
    }
    // 唤醒消费者
    void wakeupCon()
    {
        pthread_cond_signal(&conCond_);
    }
    // 生产数据
    void pushCore(const T &in)
    {
        bq_.push(in);
    }
    // 消费数据
    T popCore()
    {
        T tmp = bq_.front();
        bq_.pop();
        return tmp;
    }

private:
    uint32_t cap_;           // 容量
    queue<T> bq_;            // blockqueue阻塞队列
    pthread_mutex_t mutex_;  // 保护阻塞队列的互斥锁
    pthread_cond_t conCond_; // 让消费者等待的条件变量
    pthread_cond_t proCond_; // 让生产者等待的条件变量
};

BlockQueueTest.cc文件的逻辑如下:

  • 在主函数中我们创建一个生产者线程和一个消费者线程,让生产者不断生产数据,让消费者不断消费数据,总代码如下:
#include "BlockQueue.hpp"
#include <ctime>
void *consumer(void *args)
{
    //创建阻塞队列
    BlockQueue<int> *bqp = static_cast<BlockQueue<int>* >(args);
    while (true)
    {
        sleep(2);
        int data = bqp->pop();
        cout << "consumer 消费数据完成: " << data << endl;
    }
}
void *productor(void *args)
{
    //创建阻塞队列
    BlockQueue<int> *bqp = static_cast<BlockQueue<int>* >(args);
    while (true)
    {
        //1、制作数据
        int data = rand() % 10;
        //2、生产数据
        bqp->push(data);
        cout << "productor 生产数据完成: " << data << endl;
        //生产慢一些
        // sleep(2);
    }
}

int main()
{
    // 定义一个阻塞队列
    // 创建两个线程, productor, consumer
    // 建立联系 productor ———— consumer
    srand((unsigned long)time(nullptr) ^ getpid());
    BlockQueue<int> bq;
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, &bq);
    pthread_create(&p, nullptr, productor, &bq);

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

补充:

  • 阻塞队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个阻塞队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将该阻塞队列作为线程执行例程的参数进行传入。
  • 代码中生产者生产数据就是将获取到的随机数Push到阻塞队列,而消费者消费数据就是从阻塞队列Pop数据,为了便于观察,我们可以将生产者生产的数据和消费者消费的数据进行打印输出。

Makefile文件代码如下:

blockQueue:BlockQueueTest.cc
	g++ -o $@ $^ -lpthread -std=c++11
.PHONY:clean
clean:
	rm -f blockQueue

Makefile文件内部也是可以定义变量的,定义变量后的代码如下:

CC=g++
FLAGS=-std=c++11
LD=-lpthread
bin=blockQueue
src=BlockQueueTest.cc

$(bin):$(src)
	$(CC) -o $@ $^ $(LD) $(FLAGS)
.PHONY:clean
clean:
	rm -f $(bin)

测试结果:

生产者消费者步调一致:

  • 主函数代码中,生产者是每隔1s生产一个数据,消费者是每隔1s消费一个数据,因此运行代码后我们可以看到生产者和消费者的步调是一致的。如下我们看到的结果是生产者先生产数据,然后消费者再消费数据,二者步调一致:

生产者生产的快,消费者消费的慢:

  • 让生产者不停的生产,消费者每隔1s进行消费。我们看到的结果是生产者很快连续生产5个数据然后阻塞队列满了,继而挂起,2s后消费者消费一个数据,随后生产者再生产一个数据。往后就是消费者消费一个,生产者生产一个,此时二者步调又一致了:

生产者生产的慢,消费者消费的快:

  • 我们让生产者每隔2s生产一个数据,而让消费者不断消费数据。我们看到的结果应该是运行程序,2s内什么都没有,2s后生产者生产一个数据,消费者消费数据。往后每隔2s,生产者生产一个,消费者消费一个:


基于计算任务的生产者消费者模型(并发)

前面说到生产者消费者模型的其中一个优点是支持并发,可是我们上述的代码逻辑并没有体现到,我们上述代码的交易场所就是此queue队列,生产者生产数据,消费者消费数据都是在此queue队列里头依旧是互斥的啊,即使有同步,这里并不能深刻的感受到支持并发的特性,现在我们来更改上述代码,使其具有并发的特性。

  • 上述BlockQueue模板中,我们使用的是int类型的数据,那么这里我们也可以用自己封装的类型,包括任务。假设这里的生产消费模型要能够支持完成计算的任务(生产是生产计算任务,消费者计算任务)。所以这里我们只需要定义一个Task类,内部需要包含一个run成员函数,并把此成员函数设计成仿函数。该函数代表着我们想让消费者如何处理拿到的数据。内部还需要提供一个get函数,从而辅助我们后续需要获得三个参与计算的操作数,这里巧用c++的引用实现。

Task.hpp文件的代码如下:

#pragma once
#include <iostream>
#include <string>

class Task
{
public:
    Task()
        : elemOne_(0), elemTwo_(0), operator_('0')
    {}
    Task(int one, int two, char op)
        : elemOne_(one), elemTwo_(two), operator_(op)
    {}

    // 仿函数
    int operator()()
    {
        return run();
    }

    // 执行任务
    int run()
    {
        int result = 0;
        switch (operator_)
        {
        case '+':
            result = elemOne_ + elemTwo_;
            break;
        case '-':
            result = elemOne_ - elemTwo_;
            break;
        case '*':
            result = elemOne_ * elemTwo_;
            break;
        case '/':
        {
            if (elemTwo_ == 0)
            {
                std::cout << "div zero, abort" << std::endl;
                result = -1;
            }
            else
            {
                result = elemOne_ / elemTwo_;
            }
        }
        break;
        case '%':
        {
            if (elemTwo_ == 0)
            {
                std::cout << "mod zero, abort" << std::endl;
                result = -1;
            }
            else
            {
                result = elemOne_ % elemTwo_;
            }
        }
        break;
        default:
            std::cout << "非法操作: " << operator_ << std::endl;
            break;
        }
        return result;
    }
    // 获取参与计算的三个操作数
    int get(int &e1, int &e2, char &op)
    {
        e1 = elemOne_;
        e2 = elemTwo_;
        op = operator_;
    }

private:
    int elemOne_;
    int elemTwo_;
    char operator_; // 具体的运算符号
};

BlockQueueTest.cc文件的代码逻辑:

  • 此时生产者放入阻塞队列的数据就是一个Task对象,我们利用rand函数随机生成两个运算变量one、two,再利用rand函数随机生成一个运算符号op。随后将这三者传入Task 的任务对象里。随后在内部打印一些提示信息。
  • 此时消费者从阻塞队列里头拿任务,拿到任务后直接利用先前的仿函数去执行任务,并定义result变量获得执行完任务后的结果,随后调用get函数获得参与计算的三个操作数。最后在内部打印一些提示信息。

BlockQueueTest.cc文件的代码:

#include "Task.hpp"
#include "BlockQueue.hpp"
#include <ctime>

const std::string ops = "+-*/%"; // 定义符号变量

void *consumer(void *args)
{
    // sleep(2);
    // 创建阻塞队列
    BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
    while (true)
    {
        sleep(1);
        //1、消费任务
        Task t = bqp->pop();
        //2、处理任务
        int result = t();
        //获取参与计算的三个操作数
        int one, two;
        char op;
        t.get(one, two, op);
        cout << "consumer[" << pthread_self() << "]" << (unsigned long)time(nullptr) << 
        " 消费了一个任务: " << one << op << two << "=" << result << endl;
    }
}
void *productor(void *args)
{
    // 创建阻塞队列
    BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
    while (true)
    {
        sleep(2);
        // 1、制作任务
        int one = rand() % 50;
        int two = rand() % 20;
        // 利用rand函数让op计算符号随机设定
        char op = ops[rand() % ops.size()];
        Task t(one, two, op);
        // 2、生产任务
        bqp->push(t);
        cout << "productor[" << pthread_self() << "]" << (unsigned long)time(nullptr) << 
        " 生产了一个任务: " << one << op << two << "=?" << endl;
    }
}

int main()
{
    srand((unsigned long)time(nullptr) ^ getpid());
    BlockQueue<Task> bq;
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, &bq);
    pthread_create(&p, nullptr, productor, &bq);

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

测试结果:

  • 我们使用如下的监控脚本辅助我们观察现象:
[xzy@ecs-333953 blockqueue]$ while :; do ps -aL | grep blockQueue; sleep 1; echo "——————————————————————————————————————"; done
  • 我们控制让生产者每隔1s生产一个任务,消费者不断消费任务。看到的现象应该是前1s内什么现象都没,1s后生产者每生产一个任务,消费者就消费一个任务。

总体效果如下:

  • 也就是说,此后我们想让生产者消费者模型处理某一种任务时,就只需要提供对应的Task类,然后让该Task类提供一个对应的Run成员函数告诉我们应该如何处理这个任务即可。

总结:

  • 我们不能简单的把生产者消费者模型理解为把数据或任务放在队列里,然后你来拿,制作任务和处理任务都要花费时间。当你放任务的时候,消费者可能正在处理任务,生产者生产任务的时候和消费者消费任务的是并发执行的。
  • 并发并不是在临界区中并发(一般而言),而是生产前(before blockqueue)和消费后(after blockqueue)对应的并发。我在处理任务时生产者可以不断的生产任务。这才是并发的。
  • 生产者消费者模型的解耦就体现在生产者和消费者之间不直接交互,而是通过一个中介(对应上述的阻塞队列)来帮忙交互。
  • 生产者消费者模型支持的忙闲不均指的是制作任务和处理任务,当制作任务要花1s,消费任务要话10s,那么生产者就可以多搞几个一同制作任务,然后放到仓库供消费者消费。
  • 2
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三分苦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值