【Linux】第十三章 多线程(线程互斥+线程安全和可重入+死锁+线程同步+条件变量)

🏆个人主页企鹅不叫的博客

​ 🌈专栏

⭐️ 博主码云gitee链接:代码仓库地址

⚡若有帮助可以【关注+点赞+收藏】,大家一起进步!

💙系列文章💙


【Linux】第一章环境搭建和配置

【Linux】第二章常见指令和权限理解

【Linux】第三章Linux环境基础开发工具使用(yum+rzsz+vim+g++和gcc+gdb+make和Makefile+进度条+git)

【Linux】第四章 进程(冯诺依曼体系+操作系统+进程概念+PID和PPID+fork+运行状态和描述+进程优先级)

【Linux】第五章 环境变量(概念补充+作用+命令+main三个参数+environ+getenv())

【Linux】第六章 进程地址空间(程序在内存中存储+虚拟地址+页表+mm_struct+写实拷贝+解释fork返回值)

【Linux】第七章 进程控制(进程创建+进程终止+进程等待+进程替换+min_shell)

【Linux】第八章 基础IO(open+write+read+文件描述符+重定向+缓冲区+文件系统管理+软硬链接)

【Linux】第九章 动态库和静态库(生成原理+生成和使用+动态链接)

【Linux】第十章 进程间通信(管道+system V共享内存)

【Linux】第十一章 进程信号(概念+产生信号+阻塞信号+捕捉信号)

【Linux】第十二章 多线程(线程概念+线程控制)



💎一、线程互斥

🏆1.相关概念

线程互斥指的是在多个线程间对临界资源进行争抢访问时有可能会造成数据二义

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

临界资源和临界区

实例:全局区定义一个count变量,让新线程每隔一秒对该变量加一操作,让主线程每隔一秒获取count变量的值进行打印

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

int count = 0;
void* Routine(void* arg)
{
	while (1){
		count++;
		sleep(1);
	}
	pthread_exit(nullptr);
}
int main()
{
	pthread_t tid;
	pthread_create(&tid, nullptr, Routine, nullptr);
	while (1){
		printf("count: %d\n", count);
		sleep(1);
	}
	pthread_join(tid, NULL);
	return 0;
}

结果:主线程每一秒打印一次,新线程每一秒增加一次

[Jungle@VM-20-8-centos:~/lesson34]$ ./main
count: 0
count: 1
count: 2
count: 3
count: 4
count: 5

全局变量count就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问。

线程互斥和原子性

多个执行流都对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。

实例:实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。

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

int tickets = 1000;
void* TicketGrabbing(void* arg)
{
	char* name = (char*)arg;
	while (1){
		if (tickets > 0){
			usleep(10000);
			printf("[%s] 拿到票了, 还剩下: %d\n", name, --tickets);
		}
		else{
         printf("[%s]没拿到票\n", name);
			break;
		}
	}
	pthread_exit(nullptr);
}
int main()
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, (void*)"thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, (void*)"thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, (void*)"thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, (void*)"thread 4");

	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	return 0;
}

结果:出现了剩余票数为负数的情况

[thread 1] 拿到票了, 还剩下: 3
[thread 4] 拿到票了, 还剩下: 2
[thread 2] 拿到票了, 还剩下: 1
[thread 3] 拿到票了, 还剩下: 0
[thread 3]没拿到票
[thread 1] 拿到票了, 还剩下: -1
[thread 1]没拿到票
[thread 4] 拿到票了, 还剩下: -2
[thread 4]没拿到票
[thread 2] 拿到票了, 还剩下: -3
[thread 2]没拿到票

原因:

  • if语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep中可能有很多个线程会进入该代码段。
  • --ticket操作本身就不是一个原子操作。

--ticket实际操作:

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

对应汇编操作:

movl  ticket(%rip), %eax     # 把ticket的值加载到eax寄存器中                                                                                               
subl  $1, %eax               # 把eax寄存器中的值减1
movl  %eax, ticket(%rip)     # 把eax寄存器中的值赋给ticket变量

当一个线程正准备执行第三条指令时,另一个线程恰好执行了第二条指令,此时寄存器中的值又减了一次,当第一个线程执行完第三条指令时,ticket其实已经减了两次。所以这个操作不是一个原子操作。

🏆2.互斥量mutex

解决:

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

用🔒可以实现上述需求,这把锁叫互斥量

在这里插入图片描述

🏆3.互斥量接口

pthread_mutex_init

互斥量其实就是一把锁,是一个类型为pthread_mutex_t 的变量

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

功能:初始化互斥量

参数:

restrict mutex:要初始化的锁
restrict attr:不关心,置空
返回值: 成功返回0,失败返回错误码

注意:

  1. 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  2. 函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁再去竞争锁
  3. 静态分配:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;,不用再初始化和销毁锁了

pthread_mutex_lock

int pthread_mutex_lock(pthread_mutex_t *mutex);

功能:互斥量加锁

参数:

  • mutex:要加的锁

返回值: 成功返回0,失败返回错误码

注意:

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

pthread_mutex_unlock

int pthread_mutex_unlock(pthread_mutex_t *mutex);

功能:互斥量解锁

参数:

  • mutex:要解的锁

返回值: 成功返回0,失败返回错误码

pthread_mutex_destroy

int pthread_mutex_destroy(pthread_mutex_t *mutex);

功能:互斥量销毁锁

参数:

  • mutex:要销毁锁

返回值: 成功返回0,失败返回错误码

注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
  • 加锁的粒度要够小

实例:每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁

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

int tickets = 1000;
pthread_mutex_t mutex;// 创建锁变量

void* TicketGrabbing(void* arg)
{
	char* name = (char*)arg;
	while (1){
     pthread_mutex_lock(&mutex);// 加锁
		if (tickets > 0){
			usleep(10000);
			printf("[%s] 拿到票了, 还剩下: %d\n", name, --tickets);
         pthread_mutex_unlock(&mutex);// 解锁
		}
		else{
         printf("[%s]没拿到票\n", name);
         pthread_mutex_unlock(&mutex);// 解锁
			break;
		}
	}
	pthread_exit(nullptr);
}
int main()
{
 // 初始化锁
 pthread_mutex_init(&mutex, NULL);
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, (void*)"thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, (void*)"thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, (void*)"thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, (void*)"thread 4");

	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
 // 销毁锁
 pthread_mutex_destroy(&mutex);
	return 0;
}

结果:此时在抢票过程中就不会出现票数剩余为负数的情况

[thread 3] 拿到票了, 还剩下: 5
[thread 3] 拿到票了, 还剩下: 4
[thread 3] 拿到票了, 还剩下: 3
[thread 3] 拿到票了, 还剩下: 2
[thread 3] 拿到票了, 还剩下: 1
[thread 3] 拿到票了, 还剩下: 0
[thread 3]没拿到票
[thread 1]没拿到票
[thread 4]没拿到票
[thread 2]没拿到票
  • 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行
  • 锁的作用: 进行临界资源的保护,是所有执行流都应该遵守的标准。
  • 锁本身就是临界资源,所以锁本身需要先保证自身安全申请锁的过程不能出现中间态,必须保证原子性

🏆4.互斥量原理

加锁后的原子性:引入互斥量之后,当一个线程申请到所之后,另一个线程也要申请锁的话只能被阻塞,对于第二个线程而言,第一个线程的操作是原子的

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

锁就必须被保护起来:锁本身也是临界资源,也是需要被保护的,锁是自己保护自己的

下面是lock和unlock

lock:
	movb $0, %a1     # 把0值放进寄存器a1里
	xchgb %a1, mutex # 交换a1寄存器的内容和锁的值(无线程使用锁时,metux的值为1if (%a1 > 0)
		return 0; # 得到锁
	else
		挂起等待;
	goto lock;
unlock:
	movb $1 mutex  #把1赋给锁	
	唤醒等待的线程;
	return 0;

申请过程:

  1. 对寄存器的内容进行清0
  2. 把mutex的值(被使用值为0,未被使用值为1)和寄存器的内容进行交换
  3. 最后判断al寄存器中的值是否大于0,寄存器的内容为1代表得到了锁,为0代表未得到锁,要挂起等待

释放过程:

  1. 将内存中的mutex置回1。
  2. 唤醒等待mutex的线程,让它们继续竞争申请锁。

💎二、线程安全和可重入

🏆1.概念

  • 线程安全:在多线程编程中,多个线程对临界资源进行争抢访问而不会造成数据二义或程序逻辑混乱的情况,线程安全的实现,通过同步与互斥实现,通过互斥锁和信号量实现.
  • 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。

🏆2.线程不安全的情况

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

🏆3.线程安全的情况

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

🏆4.不可重入的情况

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

🏆5.可重入的情况

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

🏆6.可重入和线程安全的联系

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

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

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

💎三、死锁

🏆1.概念

概念: 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

单执行流也可能会产生死锁,一次性申请两个锁,第一个锁申请成功,第二个锁申请时,由于已经申请过了,所以第二个锁会挂起,但是第一个锁还没有释放,而第二个锁一直在自己手上,所以执行流不会唤醒

🏆2.死锁的四个必要条件

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

🏆3.避免死锁

  • 破坏死锁的四个必要条件。
  • 加锁顺序一致。
  • 避免锁未释放的场景。
  • 资源一次性分配。
  • 死锁检测算法和银行家算法

实例:创建两个线程申请锁

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

pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;
void* Routine1(void* arg)
{
    char* name = (char*)arg;
    while(1){
        pthread_mutex_lock(&mutex1);
        printf("%s is running...\n", name);
    }
    pthread_mutex_unlock(&mutex1);
	pthread_exit(NULL);
}
void* Routine2(void* arg)
{
    char* name = (char*)arg;
    while(1){
        pthread_mutex_lock(&mutex2);
        printf("%s is running...\n", name);
    }
    pthread_mutex_unlock(&mutex2);
	pthread_exit(NULL);
}
int main()
{
	pthread_t tid1, tid2;
	pthread_create(&tid1, NULL, Routine1, (void*)"thread 1");
	pthread_create(&tid2, NULL, Routine2, (void*)"thread 2");

	pthread_join(tid1, NULL);
	pthread_join(tid2, NULL);
	return 0;
}

结果:互相不释放锁

[Jungle@VM-20-8-centos:~/lesson35]$ ./main
thread 1 is running...
thread 2 is running...

💎四、线程同步

🏆1.同步与竞态条件

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

🏆2.条件变量

概念: 利用线程间共享的全局变量进行同步的一种机制

条件变量主要包括两个动作:

  • 一个线程等待条件变量的条件成立而被挂起。
  • 另一个线程使条件成立后唤醒等待的线程。

条件变量和互斥锁结合使用

条件变量和互斥锁结合使用:

  • 条件变量会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据
  • 在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。

🏆3.条件变量函数

pthread_cond_init

int pthread_cond_init(pthread_cond_t *restrict cond,  const pthread_condattr_t *restrict attr);

功能:初始化条件变量,动态分配

参数:

  • restrict cond:要初始化的条件变量
  • restrict attr:不关心,置空

返回值说明:

  • 条件变量初始化成功返回0,失败返回错误码。

以下是静态分配,不需要初始化函数和销毁函数

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_destroy

int pthread_cond_destroy(pthread_cond_t *cond);

功能:销毁条件变量

参数说明:

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

返回值说明:

  • 条件变量销毁成功返回0,失败返回错误码。

pthread_cond_wait

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

功能:等待条件变量满足

参数说明:

  • cond:需要等待的条件变量。
  • mutex:当前线程所处临界区对应的互斥锁。条件变量是实现线程同步的一种手段,如果一个线程进入等待队列还不释放锁资源,这样其他线程也不能够得到锁资源,这样唤醒线程的条件变量永远不可能满足,那么这个线程也将一直等待下去。所以一个线程进入等待队列需要释放自己手中的锁资源来实现真正地同步

返回值说明:

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

pthread_cond_broadcast和pthread_cond_signal

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

功能:唤醒等待

区别:

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

参数说明:

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

返回值说明:

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

实例:用主线程创建三个新线程,让主线程控制这三个新线程活动。这三个新线程创建后都在条件变量下进行等待,直到主线程每一秒发送对应的信息就会唤醒线程

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <pthread.h>

using namespace std;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* Routine(void* arg)
{
	pthread_detach(pthread_self());
	cout << (char*)arg << " run..." << endl;
	while (true){
		pthread_cond_wait(&cond, &mutex); //阻塞在这里,直到被唤醒
		cout << (char*)arg << "活动..." << endl;
	}
}
int main()
{
	pthread_t t1, t2, t3;

	pthread_create(&t1, nullptr, Routine, (void*)"thread 1");
	pthread_create(&t2, nullptr, Routine, (void*)"thread 2");
	pthread_create(&t3, nullptr, Routine, (void*)"thread 3");

	while (true){
		sleep(1);
		//pthread_cond_signal(&cond);//一次唤醒一个进程
     pthread_cond_broadcast(&cond);//一次唤醒所有进程
	}

	return 0;
}

结果:唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。

[Jungle@VM-20-8-centos:~/lesson35]$ ./main
thread 1 run...
thread 3 run...
thread 2 run...
thread 1活动...
thread 3活动...
thread 2活动...
thread 1活动...
thread 3活动...
thread 2活动...

使用规范

等待条件变量的代码

pthread_mutex_lock(&mutex);
while (条件为假){
	pthread_cond_wait(&cond, &mutex);
	//输出信息
 sleep(1);
	pthread_mutex_unlock(&mutex);
}

唤醒等待线程的代码

pthread_mutex_lock(&mutex);
while(true){
	sleep(1);
	pthread_cond_signal(&cond);
	//输出信息
	pthread_mutex_unlock(&mutex);
}

实例:创建五个线程,四个线程执行run1,上来就在条件变量下等待,另一个线程执行run2,然后无脑唤醒等待队列下的线程

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

pthread_cond_t cond;// 条件变量
pthread_mutex_t mutex;// 锁

void* threadrun1(void* arg)
{
char* name = (char*)arg;
pthread_mutex_lock(&mutex);
while (1){
 pthread_cond_wait(&cond, &mutex);// 挂起,释放锁,当该函数返回时,进入到临界区,重新持有锁
 printf("%s is waked up...\n", name);
 sleep(1);
 pthread_mutex_unlock(&mutex);
}
}

void* threadrun2(void* arg)
{
char* name = (char*)arg;
pthread_mutex_lock(&mutex);
while (1){
 sleep(1);
 // 唤醒一个等待队列中的线程
 pthread_cond_signal(&cond);
 //pthread_cond_broadcast(&cond);
 printf("%s is wakeing up a thread...\n", name);
 pthread_mutex_unlock(&mutex);
}
}

int main()
{
pthread_t pthread1, pthread2, pthread3, pthread4, pthread5;
// 初始化条件变量
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mutex, NULL);

pthread_create(&pthread1, NULL, threadrun1, (void*)"pthread 1");
pthread_create(&pthread2, NULL, threadrun1, (void*)"pthread 2");
pthread_create(&pthread3, NULL, threadrun1, (void*)"pthread 3");
pthread_create(&pthread4, NULL, threadrun1, (void*)"pthread 4");
pthread_create(&pthread5, NULL, threadrun2, (void*)"pthread 5");

pthread_join(pthread1, NULL);
pthread_join(pthread2, NULL);
pthread_join(pthread3, NULL);
pthread_join(pthread4, NULL);
pthread_join(pthread5, NULL);

pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}

结果:第五个进程按顺序将前面的四个进程唤醒

[Jungle@VM-20-8-centos:~/lesson35]$ ./main
pthread 5 is wakeing up a thread...
pthread 1 is waked up...
pthread 5 is wakeing up a thread...
pthread 2 is waked up...
pthread 5 is wakeing up a thread...
pthread 3 is waked up...
pthread 5 is wakeing up a thread...
pthread 4 is waked up...

  • 17
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 16
    评论
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

penguin_bark

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

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

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

打赏作者

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

抵扣说明:

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

余额充值