Linux 线程安全

文章介绍了Linux环境中多线程编程中的线程安全问题,重点讨论了临界资源、原子性和互斥量的概念。通过示例展示了如何使用互斥量解决并发访问临界资源导致的数据不一致性,并解释了死锁的四个必要条件。此外,还提到了条件变量在线程同步中的应用。
摘要由CSDN通过智能技术生成

传统艺能😎

小编是双非本科大二菜鸟不赘述,欢迎米娜桑来指点江山哦
在这里插入图片描述
1319365055

🎉🎉非科班转码社区诚邀您入驻🎉🎉
小伙伴们,满怀希望,所向披靡,打码一路向北
一个人的单打独斗不如一群人的砥砺前行
这是和梦想合伙人组建的社区,诚邀各位有志之士的加入!!
社区用户好文均加精(“标兵”文章字数2000+加精,“达人”文章字数1500+加精)
直达: 社区链接点我


在这里插入图片描述

Linux线程安全😊

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

说明一下,我们如果需要进行进程间通信,我们就需要先创建一个第三方资源,让不同的进程看到同一份资源,但是由于第三方资源可以由不同的模块来提供,所以就有了不同的通信方式,这个第三方资源部就是临界资源,访问第三方资源的地方就是临界区。

为什么说线程是神就是因为线程不需要创建第三方资源就可以进行通信,大部分资源时共享的,比如线程进行计数时我们在全局区定义count 变量,每隔一秒打印一次:

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

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

结果如下:

在这里插入图片描述
这里的 count 就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问

原子性&互斥🤣

原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。

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

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

int tickets = 1000;
void* TicketGrabbing(void* arg)
{
	const char* name = (char*)arg;
	while (1){
		if (tickets > 0){
			usleep(10000);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
		}
		else{
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	return 0;
}

结果其中出现了剩余票数负数,很明显不符合实际情况了:

在这里插入图片描述

首先分析一下剩余票数负数的原因:

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

而判断 tickets 是否大于0、打印剩余票数以及 – tickets 这些代码就是临界区,因为这些代码对临界资源进行了访问,那为什么 – ticket 不是原子操作

对一个变量进行 --,实际要进行三个步骤:

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

在这里插入图片描述
虽然 --tickets 只是一行代码,但这行代码被编译器编译后本质上是三行汇编,相反 ++ 操作也不是原子的!

mutex😊

mutex 即互斥量,大部分时候线程使用的数据是局部变量,变量地址空间在线程栈空间内,此时变量归属单个线程,其他线程无法获得这种变量。但有很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据共享完成交互。多个线程并发的操作共享变量,就会带来一些问题。就上面场景讲,设计一个完整的抢票系统必须做到三点:

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

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

在这里插入图片描述

互斥量初始化🙌

初始化互斥量的函数叫做 pthread_mutex_init,原型如下:

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

mutex 为需要初始化的互斥量,attr 为初始化互斥量的属性,一般设置为NULL即可,最后初始化成功返回0,失败返回错误码。

调用 pthread_mutex_init 函数初始化互斥量叫做动态分配,除此之外,还可以用静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁!

互斥量销毁🙌

销毁互斥量的函数叫做 pthread_mutex_destroy,原型如下:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

mutex 即需要销毁的互斥量,销毁成功返回0,失败返回错误码。注意不要销毁一个已经加锁的互斥量,已经销毁的互斥量要确保不会再尝试加锁

互斥量加锁🙌

互斥量加锁函数 pthread_mutex_lock,原型如下:

int pthread_mutex_lock(pthread_mutex_t *mutex);

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

互斥量解锁🙌

互斥量解锁函数 pthread_mutex_unlock,原型如下:

int pthread_mutex_unlock(pthread_mutex_t *mutex);

mutex 即需要解锁的互斥量,解锁成功返回 0,失败返回错误码。现在在上面的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁

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

int tickets = 1000;
pthread_mutex_t mutex;
void* TicketGrabbing(void* arg)
{
	const char* name = (char*)arg;
	while (1){
		pthread_mutex_lock(&mutex);
		if (tickets > 0){
			usleep(100);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
			pthread_mutex_unlock(&mutex);
		}
		else{
			pthread_mutex_unlock(&mutex);
			break;
		}
	}
	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}
int main()
{
	pthread_mutex_init(&mutex, NULL);
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
	pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
	pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
	pthread_create(&t4, NULL, TicketGrabbing, "thread 4");

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

这样抢票过程中就不会出现票数剩余为负数的情况了。

在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行。在合适的位置进行加锁和解锁尽可能减少性能开销成本。进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。

互斥量原理😊

引入互斥量后,当一个线程申请到锁进入临界区时,其他线程眼中只有没有申请锁或者锁已经释放,因为只有这两种状态对其他线程才是有意义的。

例如线程1进入临界区后,在线程2、3、4看来他要么没有申请锁,要么已经将锁释放了,因为如果线程 2、3、4 检测到其他状态时也就被阻塞了
在这里插入图片描述
此时我们就认为线程 1 的整个操作过程是原子的! \color{red} {此时我们就认为线程1的整个操作过程是原子的!} 此时我们就认为线程1的整个操作过程是原子的!

那么临界区内的线程可能进行线程切换吗?答案是完全可以的,但是意义不大因为此时进程还带着锁,意味着其他线程无法申请到锁,也就无法进行资源访问了。

所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。既然锁是临界资源,临界资源必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?

锁实际上是自己保护自己的,只需要保证申请锁的过程是原子的,不需要对锁进行保护

锁的申请🙌

为了实现互斥锁操作,大多数都提供了 swap 或 exchange 指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性,即使是多处理器平台访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

这是 lock 和 unlock 的伪代码:
在这里插入图片描述
我们可以认为 mutex 初始值为 1,al 是寄存器,在线程申请锁时会有下面的步骤

  1. 先将 al 寄存器中的值清0,该动作可以被多个线程同时执行
  2. 然后交换 al 寄存器和 mutex 中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
  3. 最后判断 al 寄存器中的值是否大于0。大于0则申请锁成功,否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁

同样的,当线程释放锁时需要执行以下步骤

  1. 内存中的 mutex 置回1,使下一个申请锁的线程在执行交换指令后能够得到 1
  2. 唤醒等待 mutex 的线程。唤醒竞争锁失败而被挂起的线程,让它们继续竞争
  1. 申请锁本质上就是哪一个线程先执行交换指令哪一个就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  2. 在线程释放锁时没有将 al 寄存器清 0,这不会造成影响,因为每次线程在申请锁时都会先将自己 al 寄存器中的值清 0
  3. CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际是把内存中 mutex 通过交换指令原子性的交换到自己的 al 寄存器中

线程安全🤣

要保证线程安全首先是要知晓常见的线程不安全的情况:

  1. 不保护共享变量的函数。
  2. 函数被调用状态发生变化的函数。
  3. 返回指向静态变量指针的函数。
  4. 调用线程不安全函数的函数

这又涉及到一个 可重入概念 \color{red} {可重入概念} 可重入概念可重入是指同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数

常见的不可重入的情况:

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

可重入与线程安全的联系
4. 函数是可重入的那线程就是安全的,反之有可能引发线程安全问题。
5. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别

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

死锁🤣

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

不仅是多进程,即使在单执行流也有可能产生死锁,如果连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁成功了,于是第二把锁申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就是自己的,现在处于被挂起状态根本没机会释放锁,所以该执行流永远不会被唤醒,这就是一种死锁情景:

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

pthread_mutex_t mutex;
void* Routine(void* arg)
{
	pthread_mutex_lock(&mutex);
	pthread_mutex_lock(&mutex);
	
	pthread_exit((void*)0);
}
int main()
{
	pthread_t tid;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&tid, NULL, Routine, NULL);
	
	pthread_join(tid, NULL);
	pthread_mutex_destroy(&mutex);
	return 0;
}

很明显此时结果就是被挂起了:

在这里插入图片描述
用 ps 命令查看该进程时可以看到,该进程当前的状态是 Sl+,l 实际上就是 lock ,表示该进程当前处于一种死锁的状态:

在这里插入图片描述

进程阻塞😍

进程运行时是被 CPU 调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列,也就是常说的 runqueue,CPU 就是从该队列中调度进程的。

在这里插入图片描述

运行等待队列中的进程本质上就是在等待CPU资源,实际上等待其他资源也是如此,比如锁、磁盘、网卡的资源等等,它们都有各自对应的资源等待队列。

例如,某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
那么该进程状态就会由 R 状态变为某种阻塞状态,比如 S 状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
直到使用锁的进程已经使用完毕,此时就会从资源等待队列中唤醒一个进程,将状态由 S 状态改为 R 状态,并重新链接到运行等待队列

总结:

  1. 操作系统角度,进程等待某种资源,就是将进程的 task_struct 放入对应的等待队列,称之为进程挂起等待。
  2. 用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
  3. 这里所说的资源可以是硬件也可以是软件,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。

死锁的四个必要条件😘

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

必要条件,缺一不可!

要避免死锁的话可以破坏四个必要条件,让加锁顺序一致,避免锁未释放的场景,做到资源一次性分配,除此之外还有一些避免死锁的算法,比如死锁检测算法和银行家算法

线程同步😁

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

首先需要明确的是,单纯的加锁是存在问题的,个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以这个线程就一直在申请和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题

单纯加锁没有错,它保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。现在我们增加一个规则,当一个线程锁释放后不能立马再次申请,该线程必须排到这个锁的资源等待队列的最后!

例如有两个线程访问一块临界区,一个写入数据,另一个读取数据,但负责写入的线程的竞争力特别强,每次都能竞争到锁,那么该线程就一直在执行写入操作直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。

条件变量😘

条件变量是利用线程间共享的全局变量进行同步的一种机制,用来描述某种资源是否就绪的一种数据化描述。

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

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

条件变量通常需要配合互斥锁一起使用。

初始化条件变量👌

初始化条件变量函数叫 pthread_cond_init,原型如下:

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

cond 即需要初始化的条件变量,attr 为初始化条件变量的属性,一般设置为NULL即可。初始化成功返回 0,失败返回错误码。和互斥锁一样,初始化提交不了也有静态分配的方式:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

销毁条件变量👌

销毁条件变量函数叫 pthread_cond_destroy,函数原型如下:

int pthread_cond_destroy(pthread_cond_t *cond);

cond 即需要销毁的条件变量,销毁成功返回0,失败返回错误码。使用 PTHREAD_COND_INITIALIZER 不需要进行销毁

等待条件变量👌

等待条件变量满足的函数叫 pthread_cond_wait,函数原型如下:

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

cond 即需要等待的条件变量,mutex 为当前线程所处临界区对应的互斥锁,调用成功返回 0 失败返回错误码。

唤醒等待👌

唤醒等待的函数有两个,函数原型如下:

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 <pthread.h>

pthread_mutex_t mutex;
pthread_cond_t cond;
void* Routine(void* arg)
{
	pthread_detach(pthread_self());
	std::cout << (char*)arg << " run..." << std::endl;
	while (true){
		pthread_cond_wait(&cond, &mutex); //阻塞在这里,直到被唤醒
		std::cout << (char*)arg << "活动..." << std::endl;
	}
}
int main()
{
	pthread_t t1, t2, t3;
	pthread_mutex_init(&mutex, nullptr);
	pthread_cond_init(&cond, nullptr);
	
	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){
		getchar();
		pthread_cond_signal(&cond);
	}

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

唤醒这三个线程时具有明显顺序性,根本原因是当若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,执行完后会继续排到尾部进行 wait,所以就形成一个周转。

在这里插入图片描述

条件等待与互斥量😘

为什么 pthread_cond_wait 需要互斥量,条件等待是线程同步的一种手段,如果只有一个线程,条件不满足会一直等下去,所以必须要有一个线程通过某些操作改变共享变量,使原先不满足的条件变得满足,并且通知等待的线程。

条件不会无缘无故的突然变满足,必然会牵扯到数据变化,所以一定要用互斥锁来保护,当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足执行条件,则需要进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁不会被释放,此时就会发生死锁问题。

所以在调用 pthread_cond_wait 函数时还需要将对应的互斥锁传入,当线程因为条件不满足而进行等待时,就会自动释放该互斥锁。当该线程被唤醒时,会接着执行临界区的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当线程被唤醒时会自动获得对应的互斥锁

所以 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);

调用解锁之后,因为解锁和等待非原子操作,在调用 pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时 pthread_cond_wait 将会错过这个信号,最终可能会导致线程永远不会被唤醒。

实际进入 pthread_cond_wait 后,会先判断条件变量是否等于0,若等于 0 则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait 返回时再将条件变量改为1,再将对应的互斥锁加锁

模板😁

等待条件变量模板:

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);

aqa 芭蕾 eqe 亏内,代表着开心代表着快乐,ok 了家人们

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

乔乔家的龙龙

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

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

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

打赏作者

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

抵扣说明:

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

余额充值