多线程(线程互斥)

抢票代码编写

学习了前面有关线程库的操作后,我们就可以模拟抢票的过程
假设我们创建四个线程,分别代表我们的用户
然后设定总票数为1000张,四个线程分别将进行循环抢票操作,其实就是循环对票数进行打印,并进行对应的减减操作
一旦票数为0,也就是票没有了,我们就让线程从循环中退出
当然,我们知道抢票,和抢到票后付费等等操作,都是需要时间的
所以我们每次抢票的时候,加上相应的延时函数usleep,它的功能和sleep函数一样,不过是micro微秒级别的,而sleep里的参数是秒.
在这里插入图片描述
具体的代码实现如下:

1 #include <iostream>
  2 #include <pthread.h>
  3 #include <cstring>
  4 #include <unistd.h>
  5 using namespace std;
  6 int tickets = 10000;
  7 void* BuyTickets(void* args)
  8 {
  9   std::string name = static_cast<const char*>(args);
 10   while (true)
 11   { 
 12     if(tickets > 0)
 13     {
 14       usleep(2000); //模拟抢票需要的时间
 15       cout << name << " ticket numbers: " << tickets-- << endl;
 16     }
 17     else
 18     {                                                                                                                                                               
 19       break;
 20     }
 21     //模拟抢到票的后续操作
 22     usleep(1000);
 23   }
 24 
 25   return nullptr;
 26 }
 27 int main()
 28 {
 29   pthread_t tids[4];
 30   int n = sizeof(tids)/sizeof(tids[0]);
 31   for (int i = 0;i < n;++i)
 32   {
 33     char* name = new char[64];
 34     snprintf(name,64,"thread-%d",i+1);
 35     pthread_create(tids + i,nullptr,BuyTickets,(void*)name);
 36   }
 37 
 38   for(int i = 0;i < n;++i)
 39   {
 40     pthread_join(tids[i],nullptr);
 41   }
 42   return 0;
 43 }

按照我们的预期来说,每个线程都会抢票,当票数抢到0的时候,每个线程都会自动退出循环,停止抢票
但显示打印出来的结果却非常奇怪
可以看到,线程1,3,在tickets数目已经小于等于0的情况下,仍然进去了循环,这是为什么呢?
在这里插入图片描述

问题分析

我们前面提到过每个线程共享的是同一个虚拟地址空间
这也就意味着,**有些资源是每个线程都共享的!**最基本的,比如我们所说的代码段Text Segment,数据段Data Segment都是共享的

一般来说,线程共享的资源有下面几种:

  1. 全局变量
  2. 文件描述符表
  3. 每种信号的处理方式(SIG_ IGN、SIG_DFL或者自定义的信号处理函数)
  4. 当前工作目录
  5. 用户id和组id

可以看到,我们上述的tickets变量就是一个全局变量,是被所有执行流所共享的!这也是我们模拟实现抢票代码的基础
但是上面的结果已经指出,线程中大部分资源直接共享或间接共享,就可能导致我们的并发问题
对于我们编写的一条简单的自增C++代码语句
实际在底层转成汇编代码后,会被转成三条汇编语句进行实现
在这里插入图片描述

第一行汇编语句,我们要先将数据从内存中load到我们的寄存器中,一般是eax
第二行汇编语句,我们要对eax里面load的数据进行相应的加减操作
第三行汇编语句,将寄存器里面的内容,放回到我们数据所在内存的位置

一切看似合情合理,因为只有CPU里面的寄存器配合ALU才有运算能力,不然假如内存可以直接对变量进行加减操作,那我还要CPU干什么?中间商赚差价吗?
但是问题的出现,也正是这个原因
线程的切换,是随时都有可能发生的
假如存在两个线程A,B,当线程A执行的时候,刚好把tickets减为0,运行到对应的第二行代码,突然操作系统OS大哥说:“你工作时间到了,该要线程B工作了!”,线程A只能带着它的上下文和对应减为0的tickets变量,灰溜溜的走了
但是,注意此时线程A有执行第三行汇编代码吗?
没有!线程B眼中的数据tickets,还是等于1
这就意味着线程B对于if语句的判断,依旧是成立的!
所以线程B仍然会进来
但是操作系统OS大哥又有点不太满意了,说:“线程B你的动作太慢了,还是先让线程A把剩下的活先干完吧,等等再分时间给你”
于是线程A就把tickets == 0的数据加载到内存中(继续运行第三行汇编代码)
那此时再切换回线程B的话,在线程B的眼里,tickets此时等于什么呢?
答案是0
但是我已经过了if那条判断了,因此放到寄存器里进行加减操作,会得到-1!这就是-1的由来

抽离概念

解决一个问题的前提,是先描述准确这个问题
而描述问题,无法避免的就是要引入一些概念和定义
我们把上述不同线程看到的同一份共享资源,我们称作为临界资源
临界资源在任何时刻,都只允许一个执行流进行访问
而访问临界资源的这部分代码,我们称之为临界区;反之不访问的话,我们就称作为非临界区
最后我们在定义一个概念,我们称之为原子性
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成;就像我们高中老师经常说的一句话,你可以不做,要做,就一定要完成
有了这三个概念,我们就可以准确的对上面的问题进行描述了
1.上述的问题,只会发生在临界区中,非临界区中,并不存在访问临界资源的概念
2.问题的出现,正是由于我们所看的一句代码,在底层汇编中,等价于三行代码语句,并不是原子性的!
在这里插入图片描述

解决问题

锁的引入

描述完问题后,我们就要着手解决这个问题
而在linux操作系统中,大佬早就给我们想好解决方案
答案就是加锁
在原生线程库中,已经设计好一种名为**锁(互斥量)**的结构,专门用来解决类似问题
在这里插入图片描述
其中上述两个函数是搭配使用,初始化init,和销毁destroy
(没错和指针类似操作,有创建,用完后,记得及时销毁)
而如果锁是一个静态或者全局变量,按下面的方式进行初始化,则不用销毁,操作系统OS会帮你自动销毁
只要在对应的临界区加锁,解锁,我们就可以解决多线程并发的问题
对应的加锁,解锁函数,分别叫做
pthread_mutex_lock()与pthread_mutex_unlock()

PS:互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

参数都只有一个,就是指向我们锁的指针
在这里插入图片描述
下面,我们简单来编写一段代码体验一下加锁操作

  1 #include <iostream>
  2 #include <pthread.h>
  3 #include <cstring>
  4 #include <unistd.h>
  5 using namespace std;
  6 int tickets = 1000;
  7 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  8 void* BuyTickets(void* args)
  9 {
 10    std::string name = static_cast<const char*>(args);
 11    while (true)
 12    {  
 13       pthread_mutex_lock(&mutex);  //临界区加锁
 14       if(tickets > 0)
 15       {
 16         usleep(2000); //模拟抢票需要的时间
 17         cout << name << " ticket numbers: " << tickets-- << endl;
 18         pthread_mutex_unlock(&mutex); //临界区结束及时解锁
 19       }
 20       else
 21       {
 22         pthread_mutex_unlock(&mutex); //在循环结束break时,也要记得解锁
 23         break;
 24       }                                                                                                                                                             
 25       //模拟抢到票的后续操作
 26       usleep(1000);
 27     }
 28 
 29   return nullptr;
 30 }
 31 int main()
 32 {
 33     pthread_t tids[4];
 34     int n = sizeof(tids)/sizeof(tids[0]);
 35     for (int i = 0;i < n;++i)
 36     {
 37       char* name = new char[64];
 38       snprintf(name,64,"thread-%d",i+1);
 39       pthread_create(tids + i,nullptr,BuyTickets,(void*)name);
 40     }
 41     for (int i = 0;i < n;++i)
 42     {
 43       pthread_join(tids[i],nullptr);
 44     }
 45    return 0;
 46 }
 47

可以看到加锁后,结果就完美符合我们的预期了
票数不会再出现减到-1,-2的情况
在这里插入图片描述

改造锁代码

但是上面的写法,显然非常简单
用C++代码实现,那我们肯定也要试一下封装来实现锁
我们构建一个TData类,其中里面包括线程的名字,还有对应的锁指针

class TData
{
public:
 TData(const string& name,pthread_mutex_t*mutex):_name(name),_pmutex(mutex)
 {}
 ~TData()
 {}
public:
 string _name; //线程对应的名字
 pthread_mutex_t* _pmutex;
};

则上述代码可以改造成这样
这里我们锁并没有设成全局变量或静态变量,而是采用了第一种方式创建,调用init,destroy函数

  1 #include <iostream>
  2 #include <pthread.h>
  3 #include <cstring>
  4 #include <unistd.h>
  5 using namespace std;
  6 int tickets = 1000;
  7 //pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  8 class TData
  9 {
 10 public:
 11   TData(const string& name,pthread_mutex_t* mutex):_name(name),_pmutex(mutex)
 12   {}
 13   ~TData()
 14   {}
 15 public:
 16   string _name; //线程对应的名字
 17   pthread_mutex_t* _pmutex;
 18 };
 19 void* BuyTickets(void* args)
 20 {
 21    TData* td = static_cast<TData*>(args);
 22    while (true)
 23    {
 24       pthread_mutex_lock(td->_pmutex);
 25       if(tickets > 0)
 26       {
 27         usleep(2000); //模拟抢票需要的时间
 28         cout << td->_name << " ticket numbers: " << tickets-- << endl;
 29         pthread_mutex_unlock(td->_pmutex);
 30       }                                                                                                                                                             
 31       else
 32       {
 33         pthread_mutex_unlock(td->_pmutex); 
 34         break;
 35       }
 36       //模拟抢到票的后续操作                                                                                                                                        
 37       usleep(1000);
 38     }
 39   
 40   return nullptr;
 41 }
 42 int main()
 43 {
 44     pthread_t tids[4];
 45     pthread_mutex_t mutex;
 46     pthread_mutex_init(&mutex,nullptr); //对锁进行初始化,第二个参数为所得属性,一般设为nullptr
 47     int n = sizeof(tids)/sizeof(tids[0]);
 48     for (int i = 0;i < n;++i)
 49     {
 50       char* name = new char[64];
 51       snprintf(name,64,"thread-%d",i+1);
 52       TData* td = new TData(name,&mutex);
 53       pthread_create(tids + i,nullptr,BuyTickets,td);
 54     }
 55     for (int i = 0;i < n;++i)
 56     {
 57       pthread_join(tids[i],nullptr);
 58     }
 59    pthread_mutex_unlock(&mutex); //销毁锁
 60    return 0;
 61 }

可以看到结果和我们的预期说一样的,和之前也是相同的
在这里插入图片描述

为什么叫锁呢?

回答这个问题其实很简单,像我们学校总有一些教室
我们称之为公共资源
一间教室,一次肯定只能够一个社团举办活动(除了几个社团联合举办活动等特殊情况外)
但是我们如何能够做到一间教室只能够一个社团使用呢?
假如其他人闯进来强行霸占呢?
这个时候,就需要给教室的门加一把锁
我使用教室的时候(访问临界资源时),别人能够从门里面进来吗?
答案是不能,这也就解决了多个社团(线程)访问同一间教室(临界资源),造成并发问题出现的可能

细节剖析

1.凡是访问同一个临界资源的线程,都要进行加锁保护,并且这一把锁要是同一把锁,这是一个游戏规则,不能有例外! 你不能说同时上两把不一样的锁;或者有部分线程没上锁,一部分线程上锁等情况出现
2.每一个线程访问临界区时都得加锁,加锁后,能够使原本并行执行的代码,转变为串行执行 但这也同样意味着效率下降,因此,我们可以看到运行时间明显提高了不少 串行化的代价无法解决,但可以减弱
那就是加锁的粒度尽量细一点,我们加锁的时候,只给对应的代码加锁,临界区不需要很大!

那临界区可不可以是一行代码呢?答案是可以的!
临界区可以是一行代码,也可以是一批代码,取决于我们哪部分代码访问了临界资源
还有一个常见的误区,在加锁后,线程可以被切换吗?
很多人可能都会回答不可以,而答案恰恰相反,是可以被切换的!
对于加锁和解锁,我们并不需要特殊化它们,在计算机眼里,它们也仅仅是一批普通代码
这就类似于我们大学里面可能会有一个人的VIP自习室
一次只能供一个人预约,一旦有人预约了,只有他自己从系统选择退出,才能有新的人预约,是一个道理
预约了自习室的人,可以没有在自习室里面自习,去吃饭了(没有工作,此时其它线程被OS调度)
但是没有影响!!!其它人进不去自习室里面,因为系统上还显示我预约占领着自习室
这也正体现互斥带来串行化的表现,站在其它线程角度,只有两种状态,锁被我申请了(持有锁),锁被我释放了(不持有锁)

锁的原理

于是就有人质疑了
你说不同线程看到的都是同一把锁,也就意味着锁本身就是公共资源
那锁如何保证自己的安全呢?为什么加锁就能解决并发问题呢?
关键就在于我们前面提到过的原子性
加锁和解锁这个动作都是原子性的,它可和我们的加减操作不同,也就是,只要进行加锁操作,谁都无法打断我,我一定会成功完成!否则就是失败,不存在苟且偷生(做一半),只有破釜沉舟

预备知识1

在大多数体系结构都提供了swap或exchange(XCHG)指令,拿XCHG指令来说,它相当于MOV指令的简化版,但它其中有一个强大的功能,就是把寄存器和内存单元的数据相交换
这是一条指令,换句话说,这条指令基础保证了我们原子性的实现的可能

预备知识2

我们需要意识到寄存器硬件只有一套的,用于临时存储和操作数据,以便在指令级别上执行各种操作
但我们现在是多个线程
这也就意味着这一套寄存器,必定由多个线程所共用
但寄存器又如何区分这多个线程呢?
答案就是每个线程都有自己的寄存器上下文.
当操作系统进行线程上下文切换时,它会保存当前线程的寄存器上下文,然后加载下一个线程的寄存器上下文.
这就意味着每个线程可以独立地使用一组寄存器来执行其指令和操作数据,而不会与其他线程干扰.
这种隔离保证了线程之间的相互独立性
但是寄存器内部的数据,是每一个线程都有的!
就好比图书馆的课桌还有电脑插头,这些都是只有一套的(寄存器只有一套)
但是我们每个人使用它的时候,放置的书本,水壶等等,往往是不同的
(线程之间具有相互独立性)
但是假如有一天在图书馆的课桌上放了一张纸,上面写道:“该课桌要维修,临时不能使用”,则我们每个线程想使用该桌子时,都会看到这个内容,然后自觉离去(寄存器里的内容是每个线程都有的)
换句话说,寄存器不能简单把它等同于寄存器的内容,它只是一个临时存储和操作数据的硬件,对于每一个线程而言,寄存器里的数据+线程自己独有的寄存器上下文,这才构成了线程所拥有的内容

原理讲解

因此,假如我们把mutex,这一把锁,简单看作1
在底层,lock的伪代码是这样实现的
在这里插入图片描述
第一句指令将寄存器al里面的值清0,换言之,对于每个线程来说,每个线程执行这段代码,实际上就是向自己的上下文写0
第二句指令,就是将内存中的mutex与寄存器al里面的值进行交换(XCHG指令),并且该指令操作,是原子性的!只有一条代码
这句指令执行外后,会出现什么情况?
有且只有一个线程顺利得到锁,它上下文的内容会变成1
但是其它线程呢?
只有它上下文的0和内存里面的0进行互换
(不会新增任何的1,而1只会进行流转)
第三条指令判断al寄存器里的内容是否大于0,不是则被挂起
最后的结果也就显而易见了
即便当前有锁的线程被切走,但是其它线程你没有锁啊!对应我们之前的故事,就是没有预约VIP自习室啊!那就算校长来了都没有用,门不会给你打开

交换的本质: 将共享数据交换到自己私有上下文中

这就是加锁的原理,一个不让你通过的策略,来实现在临界区,由并发执行,转为串行执行

那解锁呢?
就是将mutex里面的内容置1,把预约取消,锁放回去的过程 那没有把al寄存器里的内容置0,会不会有什么影响?
反之加锁的第一步,又会全部清0,所以完全不用担心

demo版的线程封装

在了解互斥量后,我们可以尝试对线程进行封装
创建一个Thread.hpp文件

类内成员设计

设计一个类,首先我们设定类内成员是什么
既然是线程封装,那线程id肯定要有吧
那不同线程,肯定会有对应的线程名字,所以也可以加个string类型的name
然后每个线程,也会有对应的运行状态,因此还可以加一个status
在创建的时候,我们还可以先把指定的函数给定,因此还可以加上两个参数,func与args
其中func为线程运行的函数,而args则是一个空指针
在这里插入图片描述
在这里插入图片描述

构造与析构

我们先指定线程id初始化为0,初始运行状态为新线程
构造的时候,只要传对应线程的号码,用来初始化名字
还有传入对应线程运行的函数,以及对应的args参数即可
在这里插入图片描述

类内方法

没有什么好说的,整体就是返回类内的属性,使得我们用户以后创建对象后,能够迅速调用对象的属性进行查看
在这里插入图片描述

线程运行

线程运行,实际上就是进行真正意义上,线程的创建
也就是我们在我们的Run函数中要调用pthread_create函数
其中的第三个参数,是我们之前所提到过用户传进来的参数
但是,我们今天,不直接传进去_func与_args参数
而是换种方法,在类内部实现一个函数,让我们调用pthread_create函数时,调用该函数
在这里插入图片描述
但是程序此时会发生报错
这是因为在类内部的函数,第一个参数实际上会隐含this指针,指向该对象
因此,调用该函数的时候,由于参数不匹配,pthread_create要求的函数的参数只能有void*.
所以我们把它设为静态static函数
但是这又会引发一个新的问题
虽然参数里面没有this指针了,但是静态函数,就不能再访问类内成员了!
这里提供一个解决办法:
调用pthread_create函数时,将对象的this指针传进来
这样运行的时候,只需要对this指针解引用,就可以得到该对象,那就可以访问对象里面的属性了
在这里插入图片描述

线程等待

在这里插入图片描述

整体代码

    1 #include <iostream>
    2 #include <stdlib.h>
    3 #include <pthread.h>
    4 #include <cstring>
    5 #include <string>
    6 class Thread{
    7 public:
    8   typedef enum
    9   {
   10      NEW = 0,
   11      RUNNING,
   12      EXITED
   13   }ThreadStatus;
   14     typedef void* (*func_t)(void*);
   15 public:
   16   Thread(int num,func_t func,void* args):_tid(0),_status(NEW),_func(func),_args(args)
   17   {
   18      //名字由于还要接收用户给的编号,因此在构造函数内进行初始化
   19      char buffer[128];                                                                                                                                            
   20      snprintf(buffer,sizeof(buffer),"thread-%d",num);
   21      _name = buffer;
   22   }
   23   ~Thread()
   24   {}
   25   //返回线程的状态
   26   int status()  {return _status;}
   27   //返回线程的名字
   28   std::string name() {return _name;}
   29   //返回线程的id
   30   //只有线程在运行的时候,才会有对应的线程id
   31   pthread_t GetTid()
   32   {
   33     if (_status == RUNNING)
   34     {
   35       return _tid;
   36     }                                                                                                                                                             
   37     else
   38     {
   39       return 0;
   40     }
   41   }
   42   //类成员函数具有默认参数this
   43   //但是会有新的问题
   44   static void * ThreadRun(void* args)
   45   {
   46     Thread* ts = (Thread*)args;  //此时就获取到我们对象的指针
   47     // _func(args);  //此时就无法回调相应的方法(成员函数无法直接被访问)
   48     (*ts)();
   49     return nullptr;
   50   }
   51   void operator()() //仿函数
   52   {
   53      //假如传进来的线程函数不为空,则调用相应的函数
   54      if(_func != nullptr)  _func(_args);
   55   }
   56   //线程运行
   57   void Run()
   58   {
   59     //线程创建的参数有四个
   60     //int n = pthread_create(&_tid,nullptr,_func,_args);
   61     int n = pthread_create(&_tid,nullptr,ThreadRun,this);
   62     if(n != 0)  exit(0);
   63     _status = RUNNING;
   64   }
   65 
   66   //线程等待
   67   void Join()
   68   {
   69     int n = pthread_join(_tid,nullptr);                                                                                                                           
   70     if (n != 0)
   71     {
   72        std::cerr << "main thread join error :" << _name << std::endl;
   73        return;
   74     }
   75     _status = EXITED;
   76   }
   77 private:
   78    pthread_t _tid;    //线程id
   79    std::string _name; //线程的名字
   80    func_t _func;       //未来要回调的函数
   81    void*_args;
   82    ThreadStatus _status; //目前该线程的状态
   83 };

代码测试

int main()
{
   Thread t1(1,threadRun,(void*)"Hello!");
   Thread t2(2,threadRun,(void*)"Hello!");
   cout << "thread name: " << t1.name() << " thread id: " << t1.GetTid() << " Thread status: "<< t1.status() << endl;
   cout << "thread name: " << t2.name() << " thread id: " << t2.GetTid() << " Thread status: "<< t2.status() << endl;

   t1.Run();
   t2.Run();
   cout << "thread name: " << t1.name() << " thread id: " << t1.GetTid() << " Thread status: "<< t1.status() << endl;
   cout << "thread name: " << t2.name() << " thread id: " << t2.GetTid() << " Thread status: "<< t2.status() << endl;
  
   t1.Join();
   t2.Join();
   cout << "thread name: " << t1.name() << " thread id: " << t1.GetTid() << " Thread status: "<< t1.status() << endl;
   cout << "thread name: " << t2.name() << " thread id: " << t2.GetTid() << " Thread status: "<< t2.status() << endl;                                              
   return 0;
}

运行结果如下:
在这里插入图片描述
当成功实现线程封装,相信我们对C++多线程库的封装,也就有了更深一步的理解

demo版的锁的封装

前面我们提到过,创建一个锁,既要考虑lock,又要考虑unlock问题
那我们能不能封装锁,使其变成一个LockGuard类,能够自动加锁,解锁呢?
答案是可以的!
只要在其创建的时候,调用我们的封装的锁的Lock类方法
析构的时候,调用我们封装的锁的Unlock方法即可实现

  1 #pragma once
  2 
  3 #include <iostream>
  4 #include <pthread.h>
  5 
  6 class Mutex
  7 {
  8 public:
  9   Mutex(pthread_mutex_t* mutex):pmutex(mutex)
 10   {}
 11   ~Mutex()
 12   {}
 13   void Lock()
 14   {
 15      pthread_mutex_lock(pmutex);
 16   }
 17   void Unlock()
 18   {
 19     pthread_mutex_unlock(pmutex);
 20   }
 21 private:
 22    pthread_mutex_t* pmutex;
 23 };
 24 
 25 class LockGuard
 26 {
 27 public:
 28    LockGuard(pthread_mutex_t* mutex):_mutex(mutex)
 29    {
 30      //在创建的时候,就自动上锁                                                                                                                                     
 31      _mutex.Lock();
 32    }
 33    ~LockGuard()
 34    {
 35      //销毁的时候,自动解锁
 36      _mutex.Unlock();
 37    }
 38 
 39 private:
 40   Mutex _mutex;
 41 };

这样以后,我们编写代码,将会变得更为优雅
像我们之前实现的抢票代码,只需要在临界区前创建一个对象
然后用一个花括号将临界区括起来,表示其为临界区
则自动加锁,解锁,解决并发问题
在这里插入图片描述

线程安全和函数重入

首先要意识到
两者谈论的不是一个维度的东西,只能说两者有重叠的部分,但并非是简单的包含与非包含的关系
线程安全是我们必须要保证的!
但大部分函数其实都是不可重入的,因此函数重入并没有好坏之分!仅仅是函数的特征

不可重入函数只是有可能引发线程安全问题,我线程调用的时候不访问全局变量/静态变量,注重各种细节,那就不会引发线程安全问题

常见线程不安全的情况:
1.不保护共享变量的函数
比如我们上述最开始实现的抢票函数
2.函数状态随着被调用,状态发生变化的函数
比如static修饰的静态的函数,每次调用可能都会引发相应状态的改变
3.返回指向静态变量指针的函数
4.调用线程不安全函数的函数

常见线程安全的情况:
1.每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
2.类或者接口对于线程来说都是原子操作(比如说加锁)
3.多个线程之间的切换不会导致该接口的执行结果存在二义性

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值