Linux线程安全

Linux线程安全

一、Linux线程互斥

1.进程线程间的互斥相关背景概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临时资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
1.临界资源与临界区的理解

​ 进程之间要通信需要先创建第三方资源,硬件有打印机、磁带机等,软件有消息缓冲队列、变量、数组、缓冲区等。然后让2个进程看到同1份资源。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。多线程是共享资源的,不需要像进程那么麻烦。比如,我们定义一个全局的变量,主线程和新线程都去访问它,代码如下:

int num = 0;
void* thread_run(void* args){    
    while(1){    
        num++;    
        sleep(1);    
        printf("I am new thread, num = %d\n", num);
    }    
}
int main(){    
    pthread_t tid;    
    pthread_create(&tid, NULL, thread_run, NULL);    
    while(1){    
        printf("I am main thread, num = %d\n", num);
        sleep(2);
    }    
    pthread_join(tid, NULL);
    return 0;    
}

运行结果如下:

image-20240213161610759

image-20240213161728800

2.互斥与原子性的理解

什么是互斥?
为了更好的去理解它,举个例子,假如现在只有一台电脑,张三想玩电脑,李四也想玩电脑,如果没有任何条件干涉,张三李四一定会打起来;如果规定了一次只能有一个人玩,那么这种就叫做互斥。很明显张三和李四就可以看做是线程,电脑就是临界资源;解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
什么是原子性及原子性操作?
为了更好的去理解它,举个例子,张三想要从自己的账户转账1000到李四的账户,如果转账成功,表明张三和李四同时成功了,反之则是同时失败,所以我们把要么一起成功,要么一起失败的操作称为原子性操作;
结合到我们的程序,如果这个程序要保证原子性,那么这个程序要么被完整执行,要么完全不执行,执行过程中,只要被打断,就是非原子性的;
原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只要两态,要么完成,要么未完成。

​ 接下来我们实现一个抢票机制,按照我们正常的逻辑,票抢完之后一定是为0的,编写如下代码,并运行看效果;

#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000;//定义一个全局变量,这就是临界资源,1000张票  
void* ThreadRotinue(void* args){  
    int id = *(int*)args;  
    delete (int*)args;
    while(true){  
        if(tickets > 0){  
            usleep(10000); //usleep函数能把线程挂起一段时间, 单位是微秒(千分之一毫秒)。 
            printf("我是[%d] 我抢的票是:%d\n", id, tickets);  
            tickets--;  //抢票,票数递减
        }  
        else{  break; }  
    }                                                 
}                                 
int main(){
    pthread_t tid[5];     
    for(int i = 0; i < 5; i++){//主线程创建出5个线程去抢票
        int* id = new int(i);
        pthread_create(tid + i, nullptr, ThreadRotinue, id);                        
    }             
    for(int i = 0; i < 5; i++){        
        pthread_join(tid[i], nullptr); //等待线程
    } 
    return 0;                                 
} 

我们将程序运行得到的结果如下:

image-20240213165847276

​ 我们发现票数抢到了负数,这就出现了问题,如果在显示生活中出现这样的情况,是绝对不允许的,为什么会出现负数呢?首先,该代码中记录剩余票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及–tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。

  1. if语句判断条件为真以后,代码可以并发的切换到其他线程
  2. usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  3. –tickes操作本身就不是一个原子操作

- -tickets 为什么不是原子操作?

  1. - -操作并不是原子操作,而是对应三条汇编指令:
    1. load:将共享变量tickets从内存加载到寄存器中
    2. update:更新寄存器里面的值,执行-1操作
    3. store:将新值,从寄存器写回共享变量ticket的内存地址

image-20240213171045568

​ 假设现在有两个线程thread1和thread2,thread1处于运行队列中,thread2在等待队列中
image-20240213171200109

​ 当thread1把tickets的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了,放到了等待队列。
image-20240213171748857

​ 并且此时thread2被调度了,由于thread1只执行了- -的操作的第一步,因此thread2此时看到tickets的值还是1000,又可能系统给thread2的时间片较多,导致thread2一次性执行了990次- -操作才被切走,最终ticket由1000减到了10。
image-20240213172019867

​ 此时thread2时间片到了,被挂起了,又切换到了thread1,thread1就带着它的上下文过来了,此时就是对1000进行- -操作,然后再写回内存中,此时内存中的值由10变成了999
image-20240213172217108

​ 在上述的过程中,好不容易将票数抢到了10,可惜又回到了999,这就是多线程带来的数据不一致问题;因此对一个变量进行- -操作并不是原子的,虽然 - -ticket看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编,相反,对一个变量进行 + +也需要对应的三个步骤,即+ +操作也不是原子操作。

2.互斥量(锁)mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。

要解决以上问题,需要做到三点:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

image-20240213174310704

3.互斥量的接口

1.互斥量初始化

初始化操作有两种方式:

  1. 静态分配:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
  2. 动态分配:

    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
    

参数:

  • mutex:需要初始化的互斥量
  • attr:初始化互斥量的属性,一般设置为NULL即可

返回值:

  • 互斥量初始化成功返回0,失败返回错误码。
2.互斥量销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:

  • mutex:需要销毁的互斥量

返回值:

  • 互斥量销毁成功返回0,失败返回错误码

销毁互斥量需要注意:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
  • 不要销毁一个以及加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
3.互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:

  • mutex:需要加锁的互斥量

返回值:

  • 互斥量加锁成功返回0,失败返回错误码

调用pthread_mutex_lock时,可能会遇到以下情况:

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

参数:

  • mutex:需要解锁的互斥量

返回值:

  • 互斥量解锁成功返回0,失败返回错误码
5.互斥量的使用

​ 例如,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只要申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。

#include <iostream>                                   
#include <unistd.h>
#include <pthread.h>
#include <string>
using namespace std;
//为了解决刚才的问题,我们需要对临界区进行加锁
class Ticket{
public:
    Ticket():tickets(1000){
        pthread_mutex_init(&mtx, nullptr);
    }
    bool GetTicket(){
        bool res = true;
        pthread_mutex_lock(&mtx);
        if(tickets > 0){
            usleep(1000);
            printf("我是[%lu] 我抢的票是:%d\n", pthread_self(), tickets);
            tickets--;
        }
        else{
            printf("票已经被抢空了!\n");
            res = false;
        }
        pthread_mutex_unlock(&mtx);
        return res;
    }
    ~Ticket(){
        pthread_mutex_destroy(&mtx);
    }
private:
    int tickets;
    pthread_mutex_t mtx; 
};
void* ThreadRotinue(void* args){
    Ticket* t = (Ticket*)args;
    while(true){
        if(!t->GetTicket()){
            break;
        }
    }
    return (void*)1111;                
}
int main(){
    Ticket* t = new Ticket();
    pthread_t tid[5];
    for(int i = 0; i < 5; i++){
        pthread_create(tid + i, nullptr, ThreadRotinue, (void*)t);
    }
    for(int i = 0; i < 5; i++){
        pthread_join(tid[i], nullptr);
    }
    return 0;
}

​ 运行结果如下图所示,此时在抢票过程中就不会出现票数剩余为负数的情况了。

image-20240213181707620

4.互斥量实现原理探究

1.加锁是如何保证原子性

​ 我要访问临界资源的时候,也就是tickets,需要先访问锁,前提就是所有线程要先看到它那个锁本身,也就是临界资源!!!
image-20240213181930493

​ 当线程1看临界区没有锁,此时线程1就可以申请到锁。线程2,3此时申请不到就处于阻塞的状态。当线程1执行完后,解锁走了,线程2,3可以申请锁。线程2,3看到线程1就是要么线程1申请到锁,要么没有申请到锁,不会有其他的状态。所以加锁是原子的。

​ 注意:又可能线程1在临界区被切走,但线程2,3也无法进入临界区进行资源访问了,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他进程无法申请到锁,也就无法进入临界区进行资源访问了。
​ 其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

2.如何保证锁是原子性的

​ 经过上面的例子,单纯的i++或者i- -都不是原子的,有可能会有数据一致性问题
​ 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先有后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。我们来看一下加锁和解锁的伪代码:
image-20240213203333207

​ 接下来我们对上面的伪代码做一些详细的解释:我们可认为mutex的初始值为1,al是CPU中的寄存器
image-20240213203511774

​ 上图很明显thread1申请锁成功了,表明有权限进入临界区并访问临界资源,thread2申请锁失败,无法访问临界区;再进行if条件判断时,只有thread1可以去访问临界资源,thread2只会被挂起等待,直至thread1访问临界资源结束后释放锁了,thread2才会被唤醒,再次竞争锁;

申请锁和释放锁的过程:

申请锁包含三步:

  1. 先将al寄存器中的值设置为0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
  2. 然后交换al寄存器和mutex中的值。xchgb可以完成寄存器和内存单元之间数据的交换;它使用一行汇编,原子性的完成共享的内存数据mutex交换到线程的上下文中,从而实现私有的过程!
  3. 最后判断al寄存器中的值是否大于0。如果大于0则申请锁成功,就可以进入临界区访问临界资源;反之申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

释放锁:

  1. 将内存中的mutex值置1。使下一个申请锁的线程在执行交换指令后能够得到1;
  2. 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让他们继续竞争申请锁

mutex的本质:其实是通过一条汇编,将锁数据交换到自己的上下文中!
以上所说都证明了锁本身是原子的;

二、可重入vs线程安全

1.概念

线程安全:

  • 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:

  • 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

2.常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

3.常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的指向结果存在二义性

4.常见的不可重入的情况

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

5.常见的可重入的情况

  • 不使用全局变量或静态变量
  • 不使用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

6.可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

7.可重入与线程安全的区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数一定是线程安全的
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

三、常见锁概念

1.死锁

​ 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于一种永久等待状态。
​ 比如刚刚的抢票系统,我们在申请了一把锁后,再次申请锁,就会导致死锁;
image-20240213211131298

2.死锁的四个必要条件

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

3.避免死锁

  • 破坏死锁和四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

四、Linux线程同步

1.同步概念与竞争条件

同步:

  • 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步;

    ​ 假设有一个临界资源,线程A和线程B都会修改它,为了保护资源所以要加锁,此时它们之间是互斥的关系。
    ​ 在我们的代码中,假设逻辑是:线程A先对数据操作,操作完成之后线程B再操作,线程B操作完之后再次轮到线程A,也就是应该有顺序的。但是由于线程A加锁的能力特别强,可能1000次中有900次是A加锁成功了,但是即便加锁成功了这么多次,对于A也是没有意义的,因为A加锁之后要对数据进行修改,然后让B操作,但是在这种情况下,B被阻塞了很多次,所以虽然数据是安全的,但是效率却十分低下,没有完成我们逻辑中按顺序执行的效果。
    ​ 因此,在保证数据安全的情况下(一般指的就是加锁),让多个执行流按照特殊顺序访问临界区的资源,称为线程同步。
    ​ 因为互斥虽然保证了数据不出错,但是有时容易导致低效,所以需要同步完成高效。

竞态条件:

  • 因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解;

2.条件变量

​ 当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个节点添加到队列中。这种情况就需要用到条件变量。

条件变量

​ 它是利用线程间共享的全局变量进行同步的一种机制,是用来等待线程而不是上锁的,条件变量通常和互斥锁一起使用。我们把线程指定在条件变量这个地方进行等待,一个线程用于修改这个变量使其满足其他线程继续往下执行的条件,其他线程则接收条件已经发生改变的信号。

主要包括两个动作

  1. 一个线程因等待“条件变量的条件成立”而挂起
  2. 另一个线程使“条件成立”(给出条件成立信号)

3.条件变量函数

1.初始化

初始化分为两种:

//动态分配
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
//静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

参数说明:

  • cond:需要初始化的条件变量
  • attr:初始化条件变量的属性,一般设置为NULL。

返回值说明:

  • 成功返回0,失败返回错误码
2.销毁
int pthread_cond_destroy(pthread_cond_t *cond);

参数说明:

  • cond:需要销毁的条件变量。

返回值说明:

  • 成功返回0,失败返回错误码。

使用静态分配初始化的条件变量不需要销毁;

3.等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

参数说明:

  • cond:需要等待的条件变量
  • mutex:当前进程所处临界区对应的互斥锁

返回值说明:

  • 成功返回0,失败返回错误码。
4.唤醒等待

唤醒等待的函数有以下两个:

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

参数说明:

  • cond:唤醒在cond条件变量下等待的线程。

返回值说明:

  • 函数调用成功返回0,失败返回错误码。

区别:

  • pthread_cond_signal()函数用于唤醒等待队列中的第一个线程。
  • pthread_cond_broadcast()函数用于唤醒等待队列中的全部线程。

4.利用条件变量实现线程同步

​ 例如,我们在主函数中创建出master线程和worker线程(数组,循环创建3个线程),我们将实现master线程控制3个worker线程的定期运行

#include <iostream>    
#include <pthread.h>    
#include <string>    
#include <unistd.h>    
using namespace std;    
//ctrl线程 控制 work线程,让它定期运行    
#define NUM 3    
pthread_mutex_t mtx;    
pthread_cond_t cond;    
void* ctrl(void* args){
    string name = (char*)args;    
    while(true){       
        cout << "master say : begin work..." << endl; 
        pthread_cond_signal(&cond);//1.唤醒在条件变量下一个线程       
        //pthread_cond_broadcast(&cond); //2.唤醒所有线程                
        sleep(1);    
    }    
}    
void* work(void* args){    
    int number = *(int*)args;    
    delete (int*)args;    
    while(true){    
        pthread_cond_wait(&cond, &mtx);//3个worker线程阻塞在这里,直到被master线程唤醒    
        cout << "worker: " << number << " is working..." << endl;    
    }    
}
int main(){
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cond, nullptr);
    pthread_t master;//创建master线程用来控制worker中三个线程的运行顺序
    pthread_t worker[NUM];//创建3个worker线程
    pthread_create(&master, nullptr, ctrl, (void*)"boss");
    for(int i = 0; i < NUM; i++){
        int* num = new int(i);
        pthread_create(worker + i, nullptr, work, (void*)num);
    }
    for(int i = 0; i < NUM; i++){
        pthread_join(worker[i], nullptr);
    }
    pthread_join(master, nullptr);
    pthread_mutex_destroy(&mtx);
    pthread_cond_destroy(&cond);
    return 0;
}                                                     

调用pthread_cond_signal(&cond);时的运行结果:|
image-20240213225742382

​ 此时我们会发现这三个线程时具有明显的顺序性,这是因为这3个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的第一个线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行等待,所以我们能够看到一个轮换的现象。这样就实现了线程的同步

​ 当然我们也可以一次唤醒所有的线程,调用pthread_cond_broadcast(&cond);时的运行结果:
image-20240213230124451

​ 此时我们每一次唤醒都会将所有在该条件变量下等待的线程进行唤醒,也就是每次都将这三个线程唤醒。

5.为什么pthread_cond_wait需要互斥量

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程
  • 条件不会无缘无故的突然变得满足了,必须会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:

//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
	pthread_mutex_unlock(&mutex);
	//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
	pthread_cond_wait(&cond);
	pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

为什么使用while循环?就是怕发生虚假唤醒

  1. while整体都是一个临界区,所以需要受到锁的保护。
  2. 在wait阻塞之前进行解锁就是为了其他线程也可以进来while循环。
  3. wait阻塞会把线程加入等待队列,等待signal信号才可以继续向下执行
  4. 如果线程获取了信号,继续上锁,就是为了防止多个线程操作临界区
  5. 最后条件满足解锁。

​ 但是上述存在一个严重问题就是在wait之前,解锁之后可能中间过程恰好产生了signal信号,造成wait错过了信号。所以需要保证解锁、wait和加锁是一个原子操作。所以在wait函数上多了一个互斥锁。

总结:

  • 当线程进入临界区时需要加锁,然后判断内部资源的情况,若不满足当前线程执行的条件,需要在改变量条件下进行等待,但是该线程拿着锁等待的,这个锁就不会倍被释放,此时产生死锁的问题。
  • 所以在调用pthread_cond_wait()需要将互斥锁传入,在等待时将锁自动释放
  • 因为是在临界区等待的,该线程等待结束后还是要被返回到临界区的,该线程会重新持有锁。

6.条件变量使用规范

等待条件代码

pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//修改条件
pthread_mutex_unlock(&mutex);

给条件发送信号代码

pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
  • 18
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Li小李同学Li

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

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

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

打赏作者

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

抵扣说明:

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

余额充值