『Linux』线程安全

线程安全

  • 线程安全多个线程同时操作临界资源而不会出现数据二义性。则认为该程序是线程安全的
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则成该函数为可重入函数否则称之为不可重入函数

常见的线程安全情况

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

线程不安全情况

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

常见的可重入情况

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

常见的不可重入情况

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

可重入与线程安全的联系

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

可重入与线程安全的区别

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

死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
在这里插入图片描述
死锁产生的四个必要条件

  • 互斥条件。我操作的时候,别人不能操作
  • 不可剥夺条件。我加的锁别人不能解
  • 请求与保持条件。拿着手里的,请求其他的。其他的请求不到,手里的也不放
  • 环路等待条件。若干执行流之间形成一种头尾相接的循环等待资源的关系

产生场景加锁/解锁顺序不同
避免死锁破坏必要条件、加锁顺序一致、避免锁未释放的场景、资源一次性分配


避免死锁的算法

  • 银行家算法
  • 死锁检测算法

线程互斥

相关概念

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

互斥量

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

下面我们来看一个售票的例子

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 一共100张票
int tickets = 10;

// 售票
void* thr_start(void* arg){
	while(1){
		// 还有余票
		if(tickets > 0){
			usleep(1000);
			printf("%s sells ticket: %d\n", arg, tickets);
			--tickets;
		}
		// 票卖光了
		else{
			break;
		}
	}

	pthread_exit(0);
}

int main(){
	pthread_t t1, t2, t3, t4;

	// 创建线程
	pthread_create(&t1, NULL, thr_start, (void*)"thread 1");
	pthread_create(&t2, NULL, thr_start, (void*)"thread 2");
	pthread_create(&t3, NULL, thr_start, (void*)"thread 3");
	pthread_create(&t4, NULL, thr_start, (void*)"thread 4");

	// 线程等待
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);

	pthread_exit(0);
}

编译运行程序,效果如下
在这里插入图片描述
可以看到结果明显不对,负数票都卖出来了。为什么会出现这种情况呢?

  • if条件判断为真以后,代码可以并发的切换到其他线程
  • usleep这个漫长的过程中,可能有多个线程进入该代码段
  • − − t i c k e t s --tickets tickets本身不是原子操作

我们取出tickets部分汇编代码看一下,首先使用下面命令生成汇编文件

[sss@aliyun thread_safe]$ objdump -d tickets > tickets.objdump

看一下 − − t i c k e t s --tickets tickets汇编指令
在这里插入图片描述
可以看到 − − t i c k e t s --tickets tickets不是原子操作,而是通过三条汇编指令完成的:

  1. 共享变量tickets从内存加载到寄存器
  2. 更新寄存器里面的值,执行 − 1 -1 1操作。
  3. 将新值,从寄存器写回到内存中

如何解决上述问题呢

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

要做到上述三点,我们需要一把锁Linux上提供的这把锁叫做互斥量
在这里插入图片描述


互斥量接口介绍

功能:初始化互斥量(静态)。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
功能:初始化互斥量(动态)。
int pthread_mutex_init(
	pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr
);
参数:
	mutex:要初始化的互斥量。
	attr:互斥量属性,通常置NULL。
返回值:成功返回0,失败返回错误码。
功能:销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
	mutex:要销毁的互斥量。
返回值:成功返回0,失败返回错误码。
功能:阻塞加锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:
	mutex:互斥量。
返回值:成功返回0,失败返回错误码。
功能:非阻塞加锁。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数:
	mutex:互斥量。
返回值:成功返回0,失败返回错误码。
功能:解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
	mutex:互斥量。
返回值:成功返回0,失败返回错误码。

我们对售票程序进行改进

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 一共100张票
int tickets = 10;

// 互斥量
pthread_mutex_t mutex;

// 售票
void* thr_start(void* arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);

		// 还有余票
		if(tickets > 0){
			usleep(1000);
			printf("%s sells ticket: %d\n", arg, tickets);
			--tickets;

			// 解锁
			pthread_mutex_unlock(&mutex);
		}
		// 票卖光了
		else{
			// 解锁
			pthread_mutex_unlock(&mutex);

			break;
		}
	}

	pthread_exit(0);
}

int main(){
	pthread_t t1, t2, t3, t4;

	// 初始化互斥量
	pthread_mutex_init(&mutex, NULL);

	// 创建线程
	pthread_create(&t1, NULL, thr_start, (void*)"thread 1");
	pthread_create(&t2, NULL, thr_start, (void*)"thread 2");
	pthread_create(&t3, NULL, thr_start, (void*)"thread 3");
	pthread_create(&t4, NULL, thr_start, (void*)"thread 4");

	// 线程等待
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);

	// 互斥量销毁
	pthread_mutex_destroy(&mutex);

	pthread_exit(0);
}

程序编译运行,结果如下:
在这里插入图片描述


互斥锁实现原理

  • 互斥锁本质就是一个0/1计数器1表示可以加锁,加锁就是计数器减一,操作完毕之后要解锁。解锁就是计数器加一,并唤醒等待0表示不可以加锁,不能加锁则等待
  • 经过前面的例子,我们可以知道单纯的i++或++i都不是原子操作,有可能会导致数据二义性问题。
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据互相交换,由于只有一条指令,保证了原子性。这是后对互斥量的加减就可以转化为交换命令

线程同步

同步和静态

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避饥饿问题,叫做同步
  • 竞态条件因为时序问题,导致程序异常,我们称之为竞态条件

什么是条件变量

  • 条件变量是线程的一种同步机制,这些同步对象为线程提供了会合的场所,理解起来就是两个(或者多个)线程需要碰头(或者说进行交互,一个线程给另外的一个或者多个线程发送消息),我们指定在条件变量这个地方发生一个线程用于修改这个变量使其满足其它线程继续往下执行的条件,其它线程则接收条件已经发生改变的信号
  • 条件变量同锁一起使用使得线程可以以一种无竞争的方式等待任意条件的发生。所谓无竞争就是,条件改变这个信号会发送到所有等待这个信号的线程。而不是说一个线程接受到这个消息而其它线程就接收不到了
  • 当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个结点添加到队列中。这种情况就要用到条件变量

条件变量接口介绍

功能:动态初始化条件变量。
int pthread_cond_init(pthread_cond_t *restrict cond,
	const pthread_condattr_t *restrict attr);
参数:
	cond:条件变量。
	attr:属性,通常置空。
返回值:成功返回0,失败返回错误码(errno > 0)
功能:静态初始化条件变量。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
功能:限时等待条件满足。
int pthread_cond_timedwait(
	pthread_cond_t *restrict cond,
	pthread_mutex_t *restrict mutex,
	const struct timespec *restrict abstime
);
参数:
	cond:条件变量。
	mutex:互斥量(必须有,否则段错误)。条件变量和互斥锁一起使用。
	abstime:等待时长。
返回值:成功返回0,失败返回错误码。
功能:阻塞等待条件满足。
int pthread_cond_wait(
	pthread_cond_t *restrict cond,
	pthread_mutex_t *restrict mutex
);
参数:
	cond:条件变量。
	mutex:互斥量(必须有,否则段错误)。条件变量和互斥锁一起使用。
返回值:成功返回0,失败返回错误码。
注意:wait为死等,集合了解锁后挂起的操作(原子操作),有可能还没来得及挂起,就已经有人唤醒(白唤醒)导致死等。
功能:广播唤醒。
int pthread_cond_broadcast(pthread_cond_t *cond);
参数:
	cond:条件变量。
返回值:成功返回0,失败返回错误码。
功能:至少唤醒一人。
int pthread_cond_signal(pthread_cond_t *cond);
参数:
	cond:条件变量。
返回值:成功返回0,失败返回错误码。
功能:销毁条件变量。
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
	cond:条件变量。
返回值:成功返回0,失败返回错误码。

为什么pthread_cond_wait需要参数metex
从上面的接口我们可以看出,条件变量的使用要配合锁,这是为什么呢

  • 条件变量本身只提供了等待与唤醒功能。具体什么时候等待需要用户来进行判断这个条件的判断通常涉及临界资源的操作应该受保护,因此搭配互斥锁一起使用

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

// 上锁
pthread_mutex_lock(&mutex);

// 条件变量不满足
while(condition_is_false){
	// 解锁
	pthread_mutex_unlock(&mutex);
	
	// 等待在条件变量上
	pthread_cond_wait(&cond);
	
	// 上锁
	pthread_mutex_lock(&mutex);
}
// 解锁
pthread_mutex_unlock(&mutex);

这也是不行的,因为解锁和等待不是原子操作解锁之后,等待之前,如果已经有其他线程获取到互斥量,条件满足,发送了信号,那么接下来的等待就错过这个信号可能会导致线程永远阻塞等待上。所以解锁和等待必须是一个原子操作

int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);

进入该函数后,先去看条件变量是否为0,若为0,就把互斥量变为1(解锁)直到cond_wait返回,把条件改为1把互斥量恢复成原样(加锁)
所以说,pthread_cond_wait结合了三个操作解锁、休眠、被唤醒后加锁。


下面,我们来看一段代码演示,我们模拟一个顾客吃面的场景

/*
 * 模拟面条售卖,老板会提前备一碗面
 * 如果这碗面被吃了,老板会再做一碗
 */

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 互斥量
pthread_mutex_t mutex;
// 条件变量
pthread_cond_t cond;

int noodles = 1;

// 卖面的饭店
void* thr_restaurant(void* arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		if(noodles == 1){
			// 面没有被吃
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面被吃了
		printf("The noodles has been eaten!\n");
		// 做一碗面
		++noodles;
		// 唤醒等待吃面的人
		pthread_cond_signal(&cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}

	pthread_exit(0);
}

// 吃面的顾客
void* thr_customer(void* arg){
	while(1){
		// 上锁
		pthread_mutex_lock(&mutex);
		if(noodles == 0){
			// 面卖完了
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面做好了,吃面
		printf("eatting noodles!\n");
		--noodles;
		// 解锁
		pthread_mutex_unlock(&mutex);
		// 唤醒做面的老板
		pthread_cond_signal(&cond);
	}

	pthread_exit(0);
}

int main(){
	pthread_t tid_res, tid_cus;

	// 条件变量初始化
	pthread_cond_init(&cond, NULL);
	// 互斥量初始化
	pthread_mutex_init(&mutex, NULL);

	// 线程创建
	pthread_create(&tid_res, NULL, thr_restaurant, NULL);
	pthread_create(&tid_cus, NULL, thr_customer, NULL);

	// 线程等待
	pthread_join(tid_res, NULL);
	pthread_join(tid_cus, NULL);

	// 互斥量销毁
	pthread_mutex_destroy(&mutex);
	// 条件变量销毁
	pthread_cond_destroy(&cond);

	pthread_exit(0);
}

编译运行程序,结果如下
在这里插入图片描述
从上述运行结果可以看出,一个吃面一个做面,场面非常和谐


我们将代码修改一下,我们来创建两个做面的和两个吃面的再来看看

/*
 * 模拟面条售卖,老板会提前备一碗面
 * 有两个吃面的和连个做面的
 */

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 互斥量
pthread_mutex_t mutex;
// 条件变量
pthread_cond_t cond;

int noodles = 1;

// 卖面的饭店
void* thr_restaurant(void* arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		if(noodles == 1){
			// 面没有被吃
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面被吃了
		printf("The noodles has been eaten! noodles: %d\n", noodles);
		// 做一碗面
		++noodles;
		// 唤醒等待吃面的人
		pthread_cond_signal(&cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}

	pthread_exit(0);
}

// 吃面的顾客
void* thr_customer(void* arg){
	while(1){
		// 上锁
		pthread_mutex_lock(&mutex);
		if(noodles == 0){
			// 面卖完了
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面做好了,吃面
		printf("eatting noodles! noodles: %d\n", noodles);
		--noodles;
		// 解锁
		pthread_mutex_unlock(&mutex);
		// 唤醒做面的老板
		pthread_cond_signal(&cond);
	}

	pthread_exit(0);
}

int main(){
	pthread_t tid_res[2], tid_cus[2];

	// 条件变量初始化
	pthread_cond_init(&cond, NULL);
	// 互斥量初始化
	pthread_mutex_init(&mutex, NULL);

	// 线程创建
	for(int i = 0; i < 2; ++i){
		pthread_create(&tid_res[i], NULL, thr_restaurant, NULL);
	}
	for(int i = 0; i < 2; ++i){
		pthread_create(&tid_cus[i], NULL, thr_customer, NULL);
	}

	// 线程等待
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_res[i], NULL);
	}
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_cus[i], NULL);
	}

	// 互斥量销毁
	pthread_mutex_destroy(&mutex);
	// 条件变量销毁
	pthread_cond_destroy(&cond);

	pthread_exit(0);
}

编译运行程序,结果如下
在这里插入图片描述
从上述运行结果可以看到一个顾客吃碗面后,出现了多次做面的情况,这是为什么呢?

  • 主要是因为顾客将面吃完后,pthread_cond_signal唤醒至少一个线程,又因为条件判断是if语句从而造成多次做面的情况。
  • 第一个做面的人加锁做完面之后解锁,第二个被唤醒的做面的人,等待在锁上刚好拿到锁,继续向下走,做面,因此需要将条件判段需要使用while循环判断

修改代码,将if改为while

/*
 * 模拟面条售卖,老板会提前备一碗面
 * 有两个吃面的和连个做面的
 */

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 互斥量
pthread_mutex_t mutex;
// 条件变量
pthread_cond_t cond;

int noodles = 1;

// 卖面的饭店
void* thr_restaurant(void* arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		while(noodles == 1){
			// 面没有被吃
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面被吃了
		printf("The noodles has been eaten! noodles: %d\n", noodles);
		// 做一碗面
		++noodles;
		// 唤醒等待吃面的人
		pthread_cond_signal(&cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}

	pthread_exit(0);
}

// 吃面的顾客
void* thr_customer(void* arg){
	while(1){
		// 上锁
		pthread_mutex_lock(&mutex);
		while(noodles == 0){
			// 面卖完了
			pthread_cond_wait(&cond, &mutex);
		}
		sleep(1);
		// 面做好了,吃面
		printf("eatting noodles! noodles: %d\n", noodles);
		--noodles;
		// 解锁
		pthread_mutex_unlock(&mutex);
		// 唤醒做面的老板
		pthread_cond_signal(&cond);
	}

	pthread_exit(0);
}

int main(){
	pthread_t tid_res[2], tid_cus[2];

	// 条件变量初始化
	pthread_cond_init(&cond, NULL);
	// 互斥量初始化
	pthread_mutex_init(&mutex, NULL);

	// 线程创建
	for(int i = 0; i < 2; ++i){
		pthread_create(&tid_res[i], NULL, thr_restaurant, NULL);
	}
	for(int i = 0; i < 2; ++i){
		pthread_create(&tid_cus[i], NULL, thr_customer, NULL);
	}

	// 线程等待
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_res[i], NULL);
	}
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_cus[i], NULL);
	}

	// 互斥量销毁
	pthread_mutex_destroy(&mutex);
	// 条件变量销毁
	pthread_cond_destroy(&cond);

	pthread_exit(0);
}

编译运行,结果如下
在这里插入图片描述
从上述结果可以看出,程序阻塞住了,这是为什么呢

  • 因为面被吃后,pthread_cond_wait唤醒的是所有等待在条件变量上的线程,有可能吃面后唤醒的还是一个吃面的线程,因为没有面,条件不满足而陷入等待,导致死等
  • 本质原因唤醒了错误的角色。(因为不同的角色等待在同一个条件变量上)。因此,线程有多少种角色,就应该有多少个条件变量,分别等待,分别唤醒
    我们继续修改代码,创建两个条件变量,同时明确区分一下两个做面的和两个吃面的
/*
 * 模拟面条售卖,老板会提前备一碗面
 * 有两个做面的和两个吃面的
 */

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 互斥量
pthread_mutex_t mutex;
// 条件变量:做面
pthread_cond_t res_cond;
// 条件变量:吃面
pthread_cond_t cus_cond;

int noodles = 1;

// 卖面的饭店
void* thr_restaurant(void* arg){
	while(1){
		// 加锁
		pthread_mutex_lock(&mutex);
		while(noodles == 1){
			// 面没有被吃
			pthread_cond_wait(&res_cond, &mutex);
		}
		sleep(1);
		// 面被吃了
		printf("The noodles has been eaten by %s! noodles: %d\n", arg, noodles);
		// 做一碗面
		++noodles;
		// 唤醒等待吃面的人
		pthread_cond_signal(&cus_cond);
		// 解锁
		pthread_mutex_unlock(&mutex);
	}

	pthread_exit(0);
}

// 吃面的顾客
void* thr_customer(void* arg){
	while(1){
		// 上锁
		pthread_mutex_lock(&mutex);
		while(noodles == 0){
			// 面卖完了
			pthread_cond_wait(&cus_cond, &mutex);
		}
		sleep(1);
		// 面做好了,吃面
		printf("%s eatting noodles! noodles: %d\n", arg, noodles);
		--noodles;
		// 解锁
		pthread_mutex_unlock(&mutex);
		// 唤醒做面的老板
		pthread_cond_signal(&res_cond);
	}

	pthread_exit(0);
}

int main(){
	pthread_t tid_res[2], tid_cus[2];

	// 条件变量初始化
	pthread_cond_init(&res_cond, NULL);
	pthread_cond_init(&cus_cond, NULL);
	// 互斥量初始化
	pthread_mutex_init(&mutex, NULL);

	// 线程创建
	pthread_create(&tid_res[0], NULL, thr_restaurant, (void*)"thr_res_1");
	pthread_create(&tid_res[1], NULL, thr_restaurant, (void*)"thr_res_2");
	pthread_create(&tid_cus[0], NULL, thr_customer, (void*)"thr_cus_1");
	pthread_create(&tid_cus[1], NULL, thr_customer, (void*)"thr_cus_2");

	// 线程等待
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_res[i], NULL);
	}
	for(int i = 0; i < 2; ++i){
		pthread_join(tid_cus[i], NULL);
	}

	// 互斥量销毁
	pthread_mutex_destroy(&mutex);
	// 条件变量销毁
	pthread_cond_destroy(&res_cond);
	pthread_cond_destroy(&cus_cond);

	pthread_exit(0);
}

编译运行,结果如下
在这里插入图片描述
可以看到做面吃面场面和谐多了


互斥和同步区别

  • 互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的
  • 同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源
  • 互斥和同步都可以实现线程安全
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值