【Linux】线程安全

本文详细介绍了多线程编程中的线程互斥概念,包括互斥量的使用和原理,以及如何通过互斥量解决线程安全问题。同时,讨论了可重入函数与线程安全的联系和区别,并解释了死锁的概念和避免方法。最后,探讨了Linux线程同步中的条件变量和避免竞态条件的策略。
摘要由CSDN通过智能技术生成

1. 线程互斥

1.1 相关概念

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

临界资源和临界区

进程之间如果要进行通信需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式又很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

而多线程的大部分资源都是共享的,线程之间进行通信不需要非那么大的劲去创建第三方资源。

而多线程的共享资源就叫做临界资源。在每个线程内部,访问共享资源的代码,就叫做临界区。

互斥和原子性

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

原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。

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

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

int tickets = 1000;

void* ticketGet(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(NULL);
}

int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, ticketGet, (void*)"thread 1");
    pthread_create(&t2, NULL, ticketGet, (void*)"thread 2");
    pthread_create(&t3, NULL, ticketGet, (void*)"thread 3");
    pthread_create(&t4, NULL, ticketGet, (void*)"thread 4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

运行结果如下:

在这里插入图片描述
结果竟然出现了票数剩余为负数的情况!
该代码中记录票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0,打印剩余票数及- -tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。

剩余票数出现负数的原因:

  • if语句判断为真后,代码可以并发地切换到其他进程
  • usleep语句用于模拟漫长业务的过程,在这个过程中,可能会有多个进程会进入该代码段。
  • - -tickets操作本身就不是一个原子操作

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

我们对一个变量进行减减实际需要以下三个步骤:

  1. load:将共享内存tickets从内存加载到寄存器中
  2. update:更新寄存器内的值,执行-1操作
  3. store:将新值从寄存器写回共享变量tickets的内存地址。
    在这里插入图片描述
    对应的汇编代码如下:
    在这里插入图片描述
    既然减减操作需要三个步骤才能完成,那么就有可能当thread1刚把tickets的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了。

在这时,假设thread2被调度,由于thread1只进行了减减操作的第一步,因此thread2看到的值还是1000,而系统给thread2的时间片可能较多,导致htread2一次性执行了一百次减减操作才被切走,最终由1000减到了900。

此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行减减操作的第二步和第三步,却只是将999写回内存了。

所以,最终的结果了,thread1抢了一张票,thread2抢了100张票,而此时票数却未999,也就是多出了100张票。

因此对一个变量进行减减操作并不是原子性的,虽然- -tickets看起来就是一行代码,但这行代码编译之后本质上是三行汇编,想换,对一个变量进行加加操作也需要对应的三个步骤,即加加操作也不是原子操作。

为了解决模拟抢票系统出现的时候,我们引入互斥量mutex。

1.2 互斥量mutex

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

要解决上述抢票系统的问题,需要解决三点:

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

要解决这些问题,本质上就是需要一把锁,Linux称这个锁为互斥量。
在这里插入图片描述

1.3 互斥量相关接口

互斥量的初始化接口

在这里插入图片描述
参数说明:

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

返回值说明:

  • 互斥量初始化成功返回0,失败返回错误码。

调用pthread_mutex_init初始化互斥量为动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

互斥量的销毁接口

在这里插入图片描述
参数说明:

  • mutex:要销毁的互斥量

返回值说明:

  • 销毁成功返回0,否则返回错误码。

销毁互斥量需要注意:

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

互斥量的加锁接口

在这里插入图片描述
参数说明:

  • mutex:要加锁的互斥量

返回值说明:

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

调用pthread_mutex_lock,可能会有以下情况:

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

互斥量的解锁接口

在这里插入图片描述
参数说明:

  • mutex:需要解锁的互斥量

返回值说明:

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

下面尝试使用互斥量和以上的接口函数

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

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

int tickets = 1000;
pthread_mutex_t mutex;

void* ticketGet(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(NULL);
}

int main()
{
    pthread_mutex_init(&mutex, NULL);
    pthread_t t1, t2, t3, t4;
 
    pthread_create(&t1, NULL, ticketGet, (void*)"thread 1");
    pthread_create(&t2, NULL, ticketGet, (void*)"thread 2");
    pthread_create(&t3, NULL, ticketGet, (void*)"thread 3");
    pthread_create(&t4, NULL, ticketGet, (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;
}

运行结果如下,这样就不会出现剩余票数为负数的情况了!
在这里插入图片描述
注意

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

1.4 互斥量原理

加锁后的原子性体现在哪里?

引入互斥量之后,当一个线程申请到锁进入临界区后,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因此只有这两种状态对其他线程才是有意义的。
在这里插入图片描述
对于线程2、3、4来言,它们认为线程1的整个操作是具有原子性的。

临界区内的资源能进行进程切换吗?

临界区内的资源完全可能进行线程切换,但即便该下线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行访问了。

其他想进入临界区进行资源访问的线程,必须等待该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进去临界区。

锁是否需要被保护?

我们说所有的线程在进入临界区之前都必须竞争式地申请锁,因此锁也是被多个执行流所共享的资源,也就是说锁本身就是临界资源。

既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?

锁实际上是自己保护自己的,我们只需保证申请锁的过程是原子的,那么锁就是安全的。

如何保证申请锁的过程是原子的?

  • 上面我们已经说明了减减和加加操作不是原子操作,可能会导致数据不一致的问题。
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange命令,该指令的作用就是把寄存器和内存单元的数据相交换。
  • 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

2. 可重入函数与线程安全

2.1 相关概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
  • 重入:同一个函数被不同的执行流所调用,当前一个进程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数为可重入函数,否则不是可重入函数。

2.2 常见线程不安全的情况

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

2.3 常见线程安全的情况

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

2.4 常见的不可重入的情况

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

2.5 常见的可重入的情况

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

2.6 可重入与线程安全联系

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

2.7 可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的

3. 互斥锁解释

3.1 死锁概念

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

单执行流可能产生死锁吗?

单执行流也可能会产生死锁,如果某一执行流中连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁的时候,因为该锁已经被申请过了,于是申请失败,导致被挂起知道该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流永远不会被唤醒,此时该执行流也就处于一种死锁的状态。

例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁。

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

pthread_mutex_t mutex;

void* routine(void* arg)
{
    pthread_mutex_lock(&mutex);
    pthread_mutex_lock(&mutex);

    pthread_exit(NULL);
}

int main()
{
    pthread_t tid;
    pthread_mutex_init(&mutex, NULL);
    pthread_create(&tid, NULL, routine, NULL);

    pthread_join(tid, NULL);
    pthread_mutex_destroy(&tid);

    return 0;
}

运行代码,此时程序就处于一个被阻塞的状态
在这里插入图片描述
用ps命令可以看到,该进程当前的状态为Sl+,其中的l实际上就是lock的意思,表示当前进程处于一种死锁的状态。
在这里插入图片描述

所以,当一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:

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

进程处于阻塞状态时,等待的不一定就是硬件资源,也有可能是软件资源,比如互斥锁。

3.2 死锁的四个必要条件

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

注意:这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。

3.3 避免死锁

避免死锁有以下几种方法:

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

除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。

4. Linux线程同步

4.1 同步概念与竞态条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件:因为时序问题而造成的程序运行结果不一样的问题,我们称之为竞态条件。

  • 首先需要明确的是,单纯的加锁是会是存在某些问题的,如果个别进程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不作,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到资源。
  • 单纯的加锁是没有错的,它能够保证在同一时间内只有一个线程进入临界区,但它没有高效地让每一个线程使用这份临界资源。
  • 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
  • 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个进程按照某种次序进行临界资源的访问。

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

4.2 条件变量

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

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

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

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

4.3 条件变量函数

条件变量的初始化

初始化条件变量的函数pthread_cond_init,该函数的函数原型如下:
在这里插入图片描述
参数说明:

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

返回值说明:

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

调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

条件变量的销毁

在这里插入图片描述
参数说明:

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

返回值说明:

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

注意PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。

等待条件变量满足

在这里插入图片描述
参数说明:

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

返回值说明:

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

唤醒等待

唤醒等待的函数有以下两个:
在这里插入图片描述
区别:

  • 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_signal函数改为pthread_cond_broadcast函数。

改为之后运行结果如下:
在这里插入图片描述

4.4 为什么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,并将对应的互斥锁加锁。

4.5 条件变量使用规范

等待条件变量的代码:

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

zzu_ljk

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

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

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

打赏作者

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

抵扣说明:

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

余额充值