Linux开发学习第五天——条件变量、信号量

0.restrict:

c99中新增加了一个类型定义,就是restrict。

概括的说,关键字restrict只用于限定指针;该关键字用于告知编译器,所有修改该指针所指向内容的操作全部都是基于(base on)该指针的,即不存在其它进行修改操作的途径;这样的后果是帮助编译器进行更好的代码优化,生成更有效率的汇编代码。

一、条件变量

1.核心特性

  • 必须与互斥锁配合:避免 “条件检查” 和 “休眠” 之间的竞态;
  • 无锁等待:等待时释放互斥锁,不占用 CPU;
  • 唤醒机制:支持单线程唤醒(signal)、多线程唤醒(broadcast);
  • 虚假唤醒:线程可能无原因被唤醒,需循环检查条件。

2.核心接口

接口作用关键说明
pthread_cond_init(&cond, NULL)初始化条件变量NULL = 默认属性,全局变量可静态初始化 PTHREAD_COND_INITIALIZER
pthread_cond_wait(&cond, &mutex)等待条件满足原子操作:释放 mutex → 休眠 → 被唤醒后重新加 mutex
pthread_cond_signal(&cond)唤醒一个等待线程从等待队列中选一个线程唤醒,不确定哪一个
pthread_cond_broadcast(&cond)唤醒所有等待线程适用于多个线程等待同一条件(如生产者 - 消费者)
pthread_cond_destroy(&cond)销毁条件变量需确保无线程等待,否则未定义行为

为什么必须原子?如果 “释放锁” 和 “休眠” 拆分,可能导致:线程释放锁后、休眠前,其他线程发送唤醒信号 → 信号丢失,线程永久休眠。

3.底层原理(基于futex)

typedef union {
    struct {
        int __lock;          // 条件变量内部锁
        unsigned int __futex; // futex 等待队列(核心)
        int __total_seq;     // 唤醒序列号(避免信号丢失)
        int __wakeup_seq;    // 已唤醒序列号
        int __mutex;         // 绑定的互斥锁地址
        // 其他调试/统计字段
    } __data;
    char __size[__SIZEOF_PTHREAD_COND_T];
    long int __align;
} pthread_cond_t;

核心执行流程(pthread_cond_wait

  1. 加内部锁:保护条件变量的等待队列;
  2. 记录唤醒序列号:避免信号丢失;
  3. 释放外部互斥锁:原子操作,由 futex 保证;
  4. futex 阻塞:调用 futex(FUTEX_WAIT) 进入内核休眠;
  5. 被唤醒后
    • 重新加外部互斥锁;
    • 检查序列号,确认是有效唤醒(非虚假唤醒);
    • 释放内部锁,函数返回。

核心执行流程(pthread_cond_signal

  1. 加内部锁
  2. 更新唤醒序列号
  3. futex 唤醒:调用 futex(FUTEX_WAKE) 唤醒一个等待线程;
  4. 释放内部锁

4.常见坑

4.1虚假唤醒(最常见)

  • 现象:线程被 pthread_cond_wait 唤醒,但条件并未满足;
  • 原因:内核调度、信号中断等导致无意义唤醒;
  • 解决:用 while 循环检查条件(而非 if):
  • // 错误:if 检查(无法处理虚假唤醒)
    if (q->count == 0) pthread_cond_wait(...);
    
    // 正确:while 检查(唤醒后重新验证条件)
    while (q->count == 0) pthread_cond_wait(...);
    

4.2未加锁直接等待 / 唤醒

  • 现象:程序崩溃或未定义行为;
  • 原因pthread_cond_wait 要求必须持有传入的互斥锁;
  • 解决:等待前必须 pthread_mutex_lock,唤醒可在锁内 / 锁外(建议锁内)。

4.3唤醒信号丢失

  • 现象:线程永久休眠;
  • 原因:释放锁后、休眠前,其他线程发送唤醒信号;
  • 解决:依赖 pthread_cond_wait 的原子性(释放锁 + 休眠是不可拆分的操作)。

4.4销毁有等待线程的条件变量

  • 现象:程序崩溃或资源泄漏;
  • 解决:确保所有线程退出等待后,再调用 pthread_cond_destroy

二、信号量

信号量(Semaphore)是 Linux 中最通用的同步原语,是一个计数器:申请资源时计数器 - 1(P 操作),释放资源时计数器 + 1(V 操作);计数器≤0 时,申请资源的线程会阻塞,直到计数器> 0。

类型核心结构体适用场景底层依赖
内核态信号量struct semaphore内核驱动、进程 / 线程同步内核等待队列 + 自旋锁
用户态信号量sem_t(POSIX 信号量)用户态进程 / 线程同步futex(同互斥锁 / 条件变量)

1.内核信号量(struct semaphore)

内核态信号量是 Linux 内核核心同步机制,用于内核态代码(如驱动、进程调度)的同步,不能直接在用户态使用

1.1核心结构体

struct semaphore {
    raw_spinlock_t      lock;    // 保护计数器的自旋锁
    unsigned int        count;   // 资源计数器:>0=可用资源数,≤0=等待线程数
    struct list_head    wait_list;// 等待队列:存放阻塞的线程
};

1.2核心接口

// 初始化信号量:count为初始资源数
#define sema_init(sem, val)  do { ... } while (0)

// P操作:申请资源(count-1),count≤0则阻塞
void down(struct semaphore *sem);

// 非阻塞P操作:申请失败立即返回非0
int down_trylock(struct semaphore *sem);

// 可中断P操作:阻塞时可被信号中断
int down_interruptible(struct semaphore *sem);

// V操作:释放资源(count+1),唤醒等待队列中的线程
void up(struct semaphore *sem);

1.3底层流程(伪代码)

void down(struct semaphore *sem) {
    // 1. 加自旋锁保护计数器
    spin_lock(&sem->lock);
    // 2. 计数器>0:直接占用资源(count-1)
    if (sem->count > 0) {
        sem->count--;
        spin_unlock(&sem->lock);
        return;
    }
    // 3. 计数器≤0:加入等待队列,阻塞
    __down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
    spin_unlock(&sem->lock);
}

void up(struct semaphore *sem) {
    // 1. 加自旋锁保护计数器
    spin_lock(&sem->lock);
    // 2. 计数器+1
    sem->count++;
    // 3. 有等待线程:唤醒队列中的一个线程
    if (list_empty(&sem->wait_list) == false) {
        wake_up_process(list_first_entry(&sem->wait_list, struct task_struct, wait_list));
    }
    spin_unlock(&sem->lock);
}

1.4核心特性

  • 可重入性:若计数器初始值 > 1,同一线程可多次执行 down(直到 count=0);
  • 阻塞方式:线程进入内核休眠(TASK_UNINTERRUPTIBLE/TASK_INTERRUPTIBLE),不占用 CPU;
  • 适用场景:内核驱动、进程间同步(如内核态共享资源)。

2.用户态信号量(sem_t,POSIX 信号量)

用户态信号量分两种:无名信号量(线程间同步)、有名信号量(进程间同步),底层均基于 futex 实现。

2.1. 核心接口(<semaphore.h>)

接口作用关键说明
sem_init(&sem, pshared, value)初始化信号量pshared=0:线程间同步;pshared=1:进程间同步;value:初始计数器
sem_wait(&sem)P 操作(阻塞)计数器 - 1,≤0 则阻塞
sem_trywait(&sem)P 操作(非阻塞)失败立即返回 - 1
sem_post(&sem)V 操作计数器 + 1,唤醒等待线程
sem_destroy(&sem)销毁无名信号量仅用于线程间信号量
sem_open(name, oflag, mode, value)创建 / 打开有名信号量进程间同步,需指定名称(如 "/my_sem")
sem_close(&sem)关闭有名信号量释放文件描述符
sem_unlink(name)删除有名信号量移除文件系统中的信号量名称

2.2示例(生产者-消费者模型)

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5
#define PRODUCER_CNT 2
#define CONSUMER_CNT 2

// 共享缓冲区
int buffer[BUFFER_SIZE];
int in = 0, out = 0;

// 信号量定义
sem_t empty;  // 空缓冲区数量(初始=BUFFER_SIZE)
sem_t full;   // 满缓冲区数量(初始=0)
pthread_mutex_t mutex; // 保护缓冲区操作的互斥锁

// 生产者线程
void *producer(void *arg) {
    int tid = *(int*)arg;
    for (int i = 0; i < 10; i++) {
        // P操作:申请空缓冲区(empty-1)
        sem_wait(&empty);
        // 加锁保护缓冲区
        pthread_mutex_lock(&mutex);

        // 生产数据
        buffer[in] = i;
        printf("生产者%d:生产%d,放入位置%d\n", tid, i, in);
        in = (in + 1) % BUFFER_SIZE;

        // 解锁
        pthread_mutex_unlock(&mutex);
        // V操作:释放满缓冲区(full+1)
        sem_post(&full);

        sleep(1); // 模拟生产耗时
    }
    free(arg);
    return NULL;
}

// 消费者线程
void *consumer(void *arg) {
    int tid = *(int*)arg;
    for (int i = 0; i < 10; i++) {
        // P操作:申请满缓冲区(full-1)
        sem_wait(&full);
        // 加锁保护缓冲区
        pthread_mutex_lock(&mutex);

        // 消费数据
        int val = buffer[out];
        printf("消费者%d:从位置%d消费%d\n", tid, out, val);
        out = (out + 1) % BUFFER_SIZE;

        // 解锁
        pthread_mutex_unlock(&mutex);
        // V操作:释放空缓冲区(empty+1)
        sem_post(&empty);

        sleep(2); // 模拟消费耗时
    }
    free(arg);
    return NULL;
}

int main() {
    pthread_t prod_tids[PRODUCER_CNT], cons_tids[CONSUMER_CNT];
    
    // 初始化信号量
    sem_init(&empty, 0, BUFFER_SIZE); // 初始空缓冲区=5
    sem_init(&full, 0, 0);            // 初始满缓冲区=0
    pthread_mutex_init(&mutex, NULL);

    // 创建生产者线程
    for (int i = 0; i < PRODUCER_CNT; i++) {
        int *tid = malloc(sizeof(int));
        *tid = i + 1;
        pthread_create(&prod_tids[i], NULL, producer, tid);
    }

    // 创建消费者线程
    for (int i = 0; i < CONSUMER_CNT; i++) {
        int *tid = malloc(sizeof(int));
        *tid = i + 1;
        pthread_create(&cons_tids[i], NULL, consumer, tid);
    }

    // 等待线程退出
    for (int i = 0; i < PRODUCER_CNT; i++) {
        pthread_join(prod_tids[i], NULL);
    }
    for (int i = 0; i < CONSUMER_CNT; i++) {
        pthread_join(cons_tids[i], NULL);
    }

    // 销毁资源
    sem_destroy(&empty);
    sem_destroy(&full);
    pthread_mutex_destroy(&mutex);
    return 0;
}

2.3有名信号量

#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

#define SEM_NAME "/my_sem"

int main() {
    // 创建有名信号量(初始值=1)
    sem_t *sem = sem_open(SEM_NAME, O_CREAT | O_EXCL, 0644, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open failed");
        return -1;
    }

    // 子进程
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程P操作
        sem_wait(sem);
        printf("子进程:占用信号量\n");
        sleep(3);
        sem_post(sem);
        printf("子进程:释放信号量\n");
        sem_close(sem);
        return 0;
    }

    // 父进程
    sleep(1); // 让子进程先执行
    sem_wait(sem);
    printf("父进程:占用信号量\n");
    sem_post(sem);
    printf("父进程:释放信号量\n");

    // 清理资源
    sem_close(sem);
    sem_unlink(SEM_NAME); // 删除有名信号量
    return 0;
}

2.4对比

特性信号量(sem_t)互斥锁(pthread_mutex_t)条件变量(pthread_cond_t)
核心功能资源计数(互斥 + 同步)互斥访问(二进制信号量)等待 - 唤醒(同步)
计数器可设任意非负值二进制(0/1)无计数器
适用场景多资源同步(如缓冲区)单资源互斥条件等待(如队列空 / 满)
所有权无(任意线程可释放)有(谁加锁谁解锁)无(配合互斥锁使用)
底层实现futexfutexfutex

关键区别

  • 信号量是 “无所有权” 的:线程 A 执行 sem_wait,线程 B 可执行 sem_post
  • 互斥锁是 “有所有权” 的:必须由加锁的线程解锁,否则返回 EPERM
  • 条件变量需绑定互斥锁:仅用于 “等待条件”,不能单独实现互斥。

2.5底层原理

typedef union {
    struct {
        unsigned int __value;  // 信号量计数器
        int __futex;           // futex 等待队列(核心)
        // 其他字段:进程间同步标识、锁等
    } __data;
    char __size[__SIZEOF_SEM_T];
    long int __align;
} sem_t;

核心执行流程(sem_wait/sem_post)

  1. sem_wait(P 操作)
    • 用户态原子减计数器:__value--
    • __value ≥ 0:直接返回(快速路径);
    • __value < 0:调用 futex(FUTEX_WAIT) 进入内核休眠;
  2. sem_post(V 操作)
    • 用户态原子加计数器:__value++
    • __value ≤ 0(有等待线程):调用 futex(FUTEX_WAKE) 唤醒一个等待线程。

2.6模拟 “负数计数”

static noinline void __down(struct semaphore *sem) {
    struct semaphore_waiter waiter;
    list_add_tail(&waiter.list, &sem->wait_list); // 加入等待队列
    waiter.task = current;
    waiter.up = false;

    // 核心:循环等待,直到被唤醒
    for (;;) {
        if (waiter.up) // 被唤醒(有资源了)
            break;
        // 将线程设为休眠状态,让出CPU
        set_task_state(current, TASK_UNINTERRUPTIBLE);
        spin_unlock_irq(&sem->lock);
        schedule(); // 调度其他线程执行
        spin_lock_irq(&sem->lock);
    }

    list_del(&waiter.list); // 移出等待队列
    // 唤醒后:count 逻辑上 +1(实际是其他线程执行 up 操作时加的)
}

3.进程间同步—— 信号量+共享内存

3.1有名信号量

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/mman.h>

// 定义共享内存和信号量名称
#define SHM_NAME "/my_shm"
#define SEM_EMPTY "/sem_empty"  // 空缓冲区信号量
#define SEM_FULL "/sem_full"    // 满缓冲区信号量
#define SEM_MUTEX "/sem_mutex"  // 互斥信号量
#define BUFFER_SIZE 5

// 共享缓冲区结构体(需与消费者一致)
typedef struct {
    int data[BUFFER_SIZE];
    int in;
    int out;
} SharedBuffer;

int main() {
    // 1. 创建/打开共享内存(大小为SharedBuffer)
    int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0644);
    ftruncate(shm_fd, sizeof(SharedBuffer));
    SharedBuffer *buf = mmap(NULL, sizeof(SharedBuffer), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
    buf->in = 0;
    buf->out = 0;

    // 2. 创建/打开有名信号量
    sem_t *sem_empty = sem_open(SEM_EMPTY, O_CREAT | O_EXCL, 0644, BUFFER_SIZE);
    sem_t *sem_full = sem_open(SEM_FULL, O_CREAT | O_EXCL, 0644, 0);
    sem_t *sem_mutex = sem_open(SEM_MUTEX, O_CREAT | O_EXCL, 0644, 1);

    // 3. 生产数据(循环5次)
    for (int i = 0; i < 10; i++) {
        // P操作:申请空缓冲区
        sem_wait(sem_empty);
        // P操作:申请互斥锁(保护缓冲区)
        sem_wait(sem_mutex);

        // 写入共享缓冲区
        buf->data[buf->in] = i;
        printf("生产者:写入数据 %d 到位置 %d\n", i, buf->in);
        buf->in = (buf->in + 1) % BUFFER_SIZE;

        // V操作:释放互斥锁
        sem_post(sem_mutex);
        // V操作:释放满缓冲区
        sem_post(sem_full);

        sleep(1); // 模拟生产耗时
    }

    // 4. 清理资源
    sem_close(sem_empty);
    sem_close(sem_full);
    sem_close(sem_mutex);
    munmap(buf, sizeof(SharedBuffer));
    close(shm_fd);

    // 注意:sem_unlink 一般由最后退出的进程执行
    // sem_unlink(SEM_EMPTY);
    // sem_unlink(SEM_FULL);
    // sem_unlink(SEM_MUTEX);
    // shm_unlink(SHM_NAME);

    return 0;
}
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/mman.h>

#define SHM_NAME "/my_shm"
#define SEM_EMPTY "/sem_empty"
#define SEM_FULL "/sem_full"
#define SEM_MUTEX "/sem_mutex"
#define BUFFER_SIZE 5

typedef struct {
    int data[BUFFER_SIZE];
    int in;
    int out;
} SharedBuffer;

int main() {
    // 1. 打开共享内存
    int shm_fd = shm_open(SHM_NAME, O_RDWR, 0644);
    SharedBuffer *buf = mmap(NULL, sizeof(SharedBuffer), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);

    // 2. 打开已存在的有名信号量(无需O_CREAT)
    sem_t *sem_empty = sem_open(SEM_EMPTY, 0);
    sem_t *sem_full = sem_open(SEM_FULL, 0);
    sem_t *sem_mutex = sem_open(SEM_MUTEX, 0);

    // 3. 消费数据(循环5次)
    for (int i = 0; i < 10; i++) {
        // P操作:申请满缓冲区
        sem_wait(sem_full);
        // P操作:申请互斥锁
        sem_wait(sem_mutex);

        // 读取共享缓冲区
        int val = buf->data[buf->out];
        printf("消费者:从位置 %d 读取数据 %d\n", buf->out, val);
        buf->out = (buf->out + 1) % BUFFER_SIZE;

        // V操作:释放互斥锁
        sem_post(sem_mutex);
        // V操作:释放空缓冲区
        sem_post(sem_empty);

        sleep(2); // 模拟消费耗时
    }

    // 4. 清理资源(最后退出的进程执行unlink)
    sem_close(sem_empty);
    sem_close(sem_full);
    sem_close(sem_mutex);
    munmap(buf, sizeof(SharedBuffer));
    close(shm_fd);

    // 释放内核资源
    sem_unlink(SEM_EMPTY);
    sem_unlink(SEM_FULL);
    sem_unlink(SEM_MUTEX);
    shm_unlink(SHM_NAME);

    return 0;
}

3.2无名信号量

sem_init的时候,pshared参数一定要为1。如果为0表示线程间同步,会卡死,1表示进程间同步。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>

// 共享内存中存储信号量和共享数据
typedef struct {
    sem_t sem;       // 无名信号量
    int shared_data; // 共享数据
} SharedData;

int main() {
    // 1. 创建共享内存(匿名映射,父子进程共享)
    SharedData *shm = mmap(NULL, sizeof(SharedData), PROT_READ | PROT_WRITE,
                           MAP_SHARED | MAP_ANONYMOUS, -1, 0);

    // 2. 初始化无名信号量:pshared=1(进程间共享),初始值=1
    sem_init(&shm->sem, 1, 1);
    shm->shared_data = 0;

    // 3. 创建子进程
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:修改共享数据(5次)
        for (int i = 0; i < 5; i++) {
            sem_wait(&shm->sem); // P操作:申请资源
            shm->shared_data += 10;
            printf("子进程:修改后 shared_data = %d\n", shm->shared_data);
            sem_post(&shm->sem); // V操作:释放资源
            sleep(1);
        }
        munmap(shm, sizeof(SharedData));
        exit(0);
    } else if (pid > 0) {
        // 父进程:读取共享数据(5次)
        for (int i = 0; i < 5; i++) {
            sem_wait(&shm->sem); // P操作:申请资源
            printf("父进程:读取 shared_data = %d\n", shm->shared_data);
            sem_post(&shm->sem); // V操作:释放资源
            sleep(1);
        }
        // 等待子进程退出
        wait(NULL);
        // 清理资源
        sem_destroy(&shm->sem);
        munmap(shm, sizeof(SharedData));
    }

    return 0;
}

三、扩展

1.加入等待队列后,等到资源释放出来时,等待队列里的多个等待任务会按顺序去获取信号量资源吗?

这个问题触及了信号量等待队列的调度公平性核心—— 结论先明确:默认情况下,等待队列中的线程并非严格按 “先入先出(FIFO)” 顺序获取资源,而是受内核调度策略、等待类型(排他 / 非排他)、CPU 抢占等因素影响

semaphore_waiter 是信号量专属的等待节点(区别于通用的 wait_queue_entry_t),本质是对通用等待队列节点的封装,定义如下(简化版):

struct semaphore_waiter {
    struct list_head list;  // 链接到 sem->wait_list 的链表节点
    struct task_struct *task; // 等待的线程
    bool up;                // 标记是否被唤醒(up=true 表示可获取资源)
};

1. 链表层面:等待线程按 FIFO 入队

当多个线程因 sem->count=0 调用 down 时:

  • 线程 A → 加入等待队列尾部 → 队列:[A]
  • 线程 B → 加入等待队列尾部 → 队列:[A→B]
  • 线程 C → 加入等待队列尾部 → 队列:[A→B→C]从链表结构上,节点的排列顺序是严格 FIFO 的(先到的在队头,后到的在队尾)。

2. 资源获取层面:并非严格 FIFO

当资源释放(调用 up)时,内核会执行以下逻辑:

// up 操作的核心唤醒逻辑(简化版)
if (!list_empty(&sem->wait_list)) {
    // 1. 取出队列头部的第一个等待节点(线程A)
    struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, ...);
    // 2. 从队列中删除该节点
    list_del(&waiter->list);
    // 3. 标记为“可唤醒”
    waiter->up = true;
    // 4. 唤醒线程A(设置为 TASK_RUNNING)
    wake_up_process(waiter->task);
}

从代码上看,内核确实优先唤醒队列头部的线程(FIFO),但这只是 “唤醒顺序”,而非 “资源获取顺序”—— 关键差异在:

(1)唤醒 ≠ 立即获取资源
  • 线程 A 被 wake_up_process() 标记为 TASK_RUNNING 后,只是 “进入 CPU 就绪队列”,并非立即执行;
  • 如果此时有更高优先级的线程在运行,或 CPU 核心被占满,线程 A 可能延后执行;
  • 而后续被唤醒的线程(如线程 B),若刚好抢占到 CPU,可能 “插队” 先获取资源(但信号量本身是互斥的,最终只有一个线程能拿到)。
(2)排他等待的 “批量唤醒”(惊群优化)

Linux 信号量的等待是排他等待(WQ_FLAG_EXCLUSIVE)

  • 调用 up 时,内核默认只唤醒队列头部的一个线程(而非所有),避免 “惊群”(多个线程被唤醒但只有一个能拿到资源,其余重新休眠);
  • 这个 “只唤醒队头” 的逻辑,让信号量的等待队列尽可能接近 FIFO,但仍受调度器影响。
(3)内核调度策略的干扰
  • 若等待线程的调度策略SCHED_RR(轮转)或 SCHED_FIFO(实时 FIFO),高优先级的实时线程会优先抢占 CPU,即使它在等待队列中排后面;
  • 普通线程(SCHED_OTHER)则受动态优先级、CPU 亲和性等影响,不一定按唤醒顺序执行。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值