【Linux】Linux下多线程

需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)

1. 前置:进程地址空间和页表

1.1 如何看待进程地址空间和页表

我们之前了解到的进程的概念,对于每个进程,有一个PCB对象,对应的虚拟进程地址空间,对应的用户级页表

进程所有能看到的资源都包括在其中了,所以我们可以有以下结论:

1. 进程地址空间是进程能看到的资源窗口

2. 页表决定了进程真正拥有的资源

3. 合理的利用进程地址空间+页表能够将进程拥有的资源进行分类

1.2 虚拟地址和物理地址之间的转换

要知道虚拟地址和物理地址之间的转换,就得先明确映射结构,也就是页表的结构

首先页表中除了物理地址和虚拟地址之外,每个地址项还有一些其他的内容:

image-20240127145112702

存储内容的解释:

  • 是否命中:如果在访问资源的时候,目标资源不在内存中,就会触发缺页中断,OS将目标资源加载到物理内存,建立映射

  • RWX权限:举个例子:我们在C语言中写出这样的语句char *str = "hello world";*str = 'H',在运行时就会报错,这是因为用户没有对str指向的空间的写权限,这里对指定空间的读写权限就是存放在此位置

  • UK权限:这里的UK指的就是User和Kernel,有些地址是用户级的,有些是内核级的,这里用UK权限做区分

在之前的认知里面,页表就是一个KV的映射结构,一个虚拟地址对应一个物理地址的映射表格。但是如果再想细一点,如果一个虚拟地址的内存单元对应一个物理地址,那么理论上(不考虑用户级页表和内核级页表的区分),一个页表在32位机器下,最大的大小就是 4G个内存单位 * (一个物理地址大小 + 一个虚拟地址大小)。这太大了,如果想要保存一个页表,就需要非常大的内存空间,这显然是不合理的。所以实际上页表并不是一个单纯的映射表格结构。

多级页表结构

在32位机器下,地址有32个比特位,这里我们将这个32个比特位分成了3个部分:前10位,中间10位,后12位

其中前10位表示一级页表的页表中查找,我们把一级页表叫做页目录,中间10位在二级页表中查找,我们把二级页表叫做页表,找到对应的物理内存的起始地址,后12位表示在对应的物理内存起始地址的偏移量,12位刚好表示212B,也就是4KB,这也就是为什么物理内存在从磁盘上加载数据的单位是4KB,我们把4KB称为一个页帧

img

映射过程,由MMU这个硬件完成的,该硬件是集成在CPU内的,页表是一种软件映射,MMU是一种硬件映射,虚拟地址转成物理地址实际上是软硬件结合的

拓展:当然物理内存也是需要管理起来的,所以也需要先描述再组织。其中物理内存的管理算法是伙伴算法,感兴趣的可以自行搜索研究

2. 线程的理解

2.1 线程概念

什么是线程?

在操作系统的教材里面的解释是:线程是一个进程内部的控制序列。这句话挺难理解,我们换一个说法

进程信号这篇文章中我们讲到过可重入函数的概念,其中提到了执行流的概念,所谓的执行流就是一个执行顺序。控制序列也就是这个执行流

一个进程至少有一个执行流

线程在进程内运行,本质就是在进程地址空间内运行

这里我们引入了一个新的概念:线程。线程在OS内部也是需要被管理起来的,既然要做管理,就要先描述再组织

事实上有操作系统就是这么做的,比如Windows就是真正在OS内部实现了

但是在Linux系统内核中,是没有线程这个概念的,Linux下只有进程的概念。

  • 那我们说的线程在Linux下是什么呢?

在Linux下,”线程“是CPU调度的基本单位

  • 如何看待我们之前学习进程时对应的进程的概念,和现在说的不冲突吗?

首先我们给进程重新下一个定义,在内核的视角,进程就是承担分配的系统资源的基本实体, 包括内核数据结构和进程对应的代码与数据。我们之前讲的进程可以理解成内部只有一个执行流的进程。但是一个进程内部实际上可以有多个执行流


上面我们说线程在Linux下是CPU调度的基本单位,是怎么实现的呢?

在内核的角度下,OS不关心线程和进程的概念区分,OS看到的就是进程PCB。实际上在Linux下一个进程可以有多个task_struct,我们称之为轻量级进程至此我们明白了,在Linux内核视角下,只有轻量级进程的概念,没有线程的概念

image-20240128185339528

2.2 Linux下线程的实现

  • **如果OS真的要专门设计“线程”这个概念,那么未来OS要不要对线程做管理呢?**显而易见,当然是要的

  • 那OS要怎么管理线程呢? 先描述再组织

  • 怎么描述? 需要定义一些属性,描述现成的被执行情况和被调度情况(id,上下文,状态,栈…)

单纯从调度的角度来说,线程调度和进程调度有很多重合的地方,所以Linux在开发的时候,就没有单独设计对应的线程数据结构,而是复用了进程的task_struct,用其表示“线程”。


接下来我们来明确几个共识和理解:

1. Linux内核中没有真正意义上的线程,Linux是用进程PCB来模拟线程的,是一套完全属于自己的线程解决方案

2. 站在CPU的视角,CPU看不到进程和线程的分别,每一个PCB都可以看作是一个“轻量级进程”

3. Linux下线程是CPU调度的基本单位,进程是承担系统分配资源的基本实体

4. Linux下进程用来向OS申请资源,线程伸手向进程要资源

5. Linux下没有真正意义上的线程的优点是:代码结构简单,好维护,可靠。缺点是OS只认线程,程序员(用户)只认线程,但是Linux没办法直接给我们提供创建线程的系统调用接口,只能提供创建轻量级进程的系统调用接口

Linux下创建轻量级进程有很多系统调用,最典型的就是vfork,vfork和fork的用法基本一致,只是vfork创建的进程(轻量级线程)和父进程共享进程地址空间(mm_struct)。

image-20240128221019971

见一见“猪跑”

image-20240128222222921

2.3 线程的优缺点&&线程的用途&&线程异常

线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

线程的缺点

  • 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
    线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
    同步和调度开销,而可用的资源不变。

  • 健壮性降低
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了
    不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

  • 缺乏访问控制

    ​ 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

  • 编程难度提高

    ​ 编写与调试一个多线程程序比单线程程序困难得多

线程异常

当线程发生异常的时候,OS会向这个线程所在的进程发送信号,这个进程就会退出,而不是结束一个线程

线程的用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率

  • 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

2.4 Linux进程vs线程

2.4.1 进程和线程

  • 线程是CPU调度的基本单位;进程是承担OS分配资源的基本实体
  • 进程具有独立性,相互通信难度较高;同一进程内的线程共享绝大多数数据

虽然同一进程内多个线程的数据大多是共享的,但是也有一些私有的数据

  • 线程ID
  • 一组寄存器(上下文)
  • errno
  • 信号屏蔽字(block位图结构)
  • 调度优先级

2.4.2 进程内多线程

进程内多线程共享同一地址空间,因此代码段(Text Segment)、数据段(Data Segment)都是共享的

  • 如果定义一个函数,在各线程中都可以调用;
  • 如果定义一个全局变量,在各线程中都可以访问到
  • 除此之外,各线程还共享以下进程资源和环境:
    • 文件描述符表
    • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
    • 当前工作目录
    • 用户id和组id

2.4.3 进程和线程的关系

image-20240128224511137

我们之前学习过程中的单进程实际上就是具有一个线程执行流的进程

3. 线程控制

3.1 POSIX线程库

在上面的内容中,我们有了两个共识:

  1. Linux下没有真正意义上的线程,没有办法直接提供线程控制的系统调用,它只认轻量级进程
  2. OS和程序员(用户)只认线程,要操作线程

所以就会出现冲突,程序员和系统没有办法交互了?!!

所以在用户层和内核层之间,Linux提供了一个库,就是我们说的原生的用户级线程库pthread

在任意一个Linux系统下的lib64目录下都能找到

image-20240128225655451

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的 要使用这些函数库,要通过引入头文件<pthread.h> 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

3.2 线程创建

pthread_create

image-20240128231039850

函数描述:创建一个新线程
头文件:
#include <pthread.h>
函数原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
参数解释:
	thread:输出型参数,向其中填充线程id返回给主线程
	attr:设置线程的属性,attr为NULL表示使用默认属性
	start_routine:是一个函数指针(回调函数),表示新线程启动后要执行的函数
	arg:传给线程启动函数的参数
返回值:
	成功返回0;失败返回错误码

关于多线程的错误检查的补充

  • 在我们使用传统的函数的时候,都会通过返回值表示函数执行情况,使用全局的错误码errno表示错误类型
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误码通过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误, 建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小

实例:

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
void *start_routine(void *arg)
{
    while (true)
    {
        std::cout << "我是" << (char *)arg << "我正在运行" << std::endl;
        sleep(1);
    }
}
int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");
    while(true)
    {
        std::cout << "我是主线程,我正在运行..." << std::endl;
        sleep(1);
    }
    return 0;
}

Linux下查看当前线程ps -aL

写一个监测脚本查看指定线程的情况

while :; do ps -aL | head -1 && ps -aL | grep mythread; sleep 1; done

image-20240128233443920

这里的LWP表示的就是light weight process,轻量级进程。CPU在进行调度的时候,对每个进程的识别依靠的就是LWP

  • 我们之前说的CPU在调度的时候调度的是进程id和这句话冲突吗?

    当然不冲突,对于任意一个进程,其中都会存在一个主线程,这个主线程的LWP和PID是一样的。同一进程中所有线程的PID都是相同的,CUP调度使用LWP来区分不同线程

3.3 线程终止

我们知道,线程是进程内部的一个执行流,当一个进程被终止,其内部的所有线程都将被终止,但是如果想要只终止一个线程,不影响其他线程的话,有三种方法。

3.3.1 线程函数return

这种方法对主线程不适用,因为从main函数return相当于调用exit直接终止整个进程

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>

void *start_routine(void *arg)
{
    int cnt = 5;
    while (true)
    {
        std::cout << "我是" << (char *)arg << "我正在运行" << std::endl;
        if(cnt-- == 0) break;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");
    while(true)
    {
        std::cout << "我是主线程,我正在运行..." << std::endl;
        sleep(1);
    }
    return 0;
}

image-20240128234451881

3.3.2 pthread_ exit

线程可以调用pthread_ exit终止自己

image-20240128234541570

函数描述:终止调用这个函数的线程
头文件:
#include <pthread.h>
函数原型:
void pthread_exit(void *retval);
参数解释:
	retval:保存该线程退出的时候返回的值的地址,其指向的内存单元必须是全局的或者在堆上的
返回值: 这个函数无返回值
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
void *start_routine(void *arg)
{
    int cnt = 5;
    while (true)
    {
        std::cout << "我是" << (char *)arg << "我正在运行 " << cnt << std::endl;
        if(cnt-- == 0)
        {
            std::cout << "新线程退出..." << std::endl;
            pthread_exit(NULL);
        }
        sleep(1);
    }

    return nullptr;
}

int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");
    while(true)
    {
        std::cout << "我是主线程,我正在运行..." << std::endl;
        sleep(1);
    }
    return 0;
}

image-20240129140450794

3.3.3 pthread_ cancel

一个线程可以调用pthread_ cancel终止同一进程中的另一个线程

image-20240129140600209

头文件:
#include <pthread.h>
函数原型:
int pthread_cancel(pthread_t thread);
函数描述:
	发送一个取消请求给指定线程,终止该线程
参数解释:
	thread:要取消的线程
返回值:调用成功返回0,否则返回一个非0的错误码

3.4 线程id && 线程在进程地址空间内布局

我们知道,使用pthread_create函数可以创建一个进程,同时产生一个pthreat_t类型的数据,以输出型参数的方式返回给主线程,也就是线程id,这个id是什么呢?

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <pthread.h>
void *start_routine(void *arg)
{
    while (true)
    {
        std::cout << "我是" << (char *)arg << "我正在运行 " << std::endl;
        printf("我是%s我正在运行\n", (char *)arg);
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");

    while (true)
    {
        printf("我是主线程,新线程的id是%d 0x%x\n", id, id);
        sleep(1);
    }
    return 0;
}

image-20240129165127549

按照十进制和十六进制分别输出,这个东西看起来像是一个地址。那么它到底是什么呢?

这个id是什么取决于不同的实现,对于Linux目前实现的NPTL(Native POSIX Thread Library)而言,pthread_t类型的线程ID本质上是进程地址空间中的一个地址

我们之前说同一进程的线程之间绝大多数的的资源是共享的,但是还有一些资源是自己私有的。每个线程都有自己独立的栈,主线程的栈是进程地址空间中原生的栈,其他线程采用的是共享区中的栈。

我们知道Linux内核是没有线程这个概念的,线程的实现是依赖于原生线程库pthread,所以pthread中需要对线程做管理,所以在这个动态库中就定义了struct pthread结构体。

线程中私有的资源包括三个部分:struct pthread,线程局部存储,线程栈

image-20240129172241462

线程函数起始是在库内部对线程属性进行操作,最后将要执行的代码交给对应的内核级LWP去执行。所以线程数据的管理本质在共享区


我们知道在主线程中,调用pthread_create的时候可以在主线程中拿到线程id,那么在新线程中怎么样能够找到自己的线程id呢?

通过函数pthread_self

image-20240129172537571

头文件:
	#include <pthread.h>
函数原型:
	pthread_t pthread_self(void);
函数描述:
	获取当前线程的线程id
返回值:
	返回当前线程的线程id
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <pthread.h>
void *start_routine(void *arg)
{
    while (true)
    {
        pthread_t id = pthread_self();
        printf("我是%s我正在运行,线程id是0x%x\n", (char *)arg, id);
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");

    while (true)
    {
        printf("我是主线程,新线程的id是%d 0x%x\n", id, id);
        sleep(1);
    }
    return 0;
}

image-20240129173053674

局部性存储的验证:

#include <iostream>
#include <string>
#include <sys/types.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <pthread.h>

int g_val = 100;

void *start_routine(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        std::cout << name << " running ... "
                  << "g_val: " << g_val << "&g_val: " << &g_val << std::endl;
        sleep(1);
        ++g_val;
    }
    return nullptr;
}
int main()
{
    pthread_t id = 0;
    pthread_create(&id, NULL, start_routine, (void *)"thread 1");
    while (true)
    {
        printf("main thread g_val: %d &g_val: 0x%x\n", g_val, &g_val);
        sleep(1);
    }
    return 0;
}

image-20240129173635239

但是在g_val前面加上__thread就可以让全局变量在各个线程内私有

__thread int g_val = 100;

image-20240129174005583

3.5 线程等待

进程需要被等待,防止出现僵尸进程的情况,导致资源泄漏,当然线程也是需要被等待的,这是因为

  • 已经退出的线程,其私有空间没有被释放,仍然在进程的地址空间内
  • 创建的新线程不会附庸刚才退出的现成的地址空间

使用pthread_join函数来进行线程的阻塞式等待

image-20240129174516234

头文件:
	#include <pthread.h>
函数原型:
	int pthread_join(pthread_t thread, void **retval);
函数描述:
	
参数解释:
	thread:要被等待的线程id
	retval:指向一个存放线程返回值的指针,(线程返回值的类型是void*)要保存这个结果的输出型参数的类型是void**
返回值:
	成功返回0;失败返回错误码

在前面我们说到线程退出的三种方式,实际上三种退出方式退出的线程,使用pthread_join得到的终止状态是不同的

  • 如果是通过return退出的,retval所指向的内存单元存放的是线程调用函数的返回值即start_routine函数的返回值
  • 如果是被别的进程调用pthread_cancel异常终止的话,retval所指向的内存单元存放的是常数PTHREAD_CANCELED
  • 如果是自己调用pthread_exit终止的话,retval所指向的内存单元存放的是传给pthread_eixt的参数
  • 如果不关心线程的终止状态,直接传NULL即可

image-20240129180752207

线程整个生命周期的时间线:

image-20240129163451342

3.5 分离线程

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

告诉线程的方式就是通过函数pthread_detach

image-20240129180953872

头文件:
	#include <pthread.h>
函数原型:
	int pthread_detach(pthread_t thread);
参数解释:
	thread:要分离的线程id
函数描述:
	将指定线程进行线程分离,告诉OS线程退出只会自动释放线程资源
返回值:
	调用成功返回0,否则返回错误码

注意:

  • 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离
  • joinable和分离是冲突的,一个线程不能既是joinable又是分离的
#include <iostream>
#include <string>
#include <sys/types.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <pthread.h>

void *start_routine(void *arg)
{
    pthread_detach(pthread_self());
    printf("%s\n", (char *)arg);
    return NULL;
}
int main()
{
    pthread_t tid;
    if (pthread_create(&tid, NULL, start_routine, (void *)"thread 1") != 0)
    {
        printf("create thread error\n");
        return 1;
    }
    sleep(1); // 注意这里一定要让线程先分离,再等待,因为我们不能保证主线程和新线程谁先执行
    if (pthread_join(tid, NULL) == 0)
    {
        printf("pthread wait success\n");
    }
    else
    {
        printf("pthread wait failed\n");
    }
    return 0;
}

image-20240129181555405

4. 原生线程库的二次封装

原生线程库是C语言实现的,所有语言如果想使用多线程,在底层都需要调用这个原生线程库pthread

那么现在我们可以来实现一个C++版本的线程库

库代码实现:

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <cassert>

class Thread;

class Context
{
public:
    Thread *this_;
    void *args_;

public:
    Context(Thread *thread = nullptr, void *args = nullptr)
        : this_(thread), args_(args)
    {}
    ~Context() {}
};

class Thread
{
public:
    using func_t = std::function<void *(void *)>; // 定义func_t类型
    static int number;                            // 线程编号,按照一次运行时的调用次数计数
public:
    Thread(func_t func, void *args) : func_(func), args_(args)
    {
        char *buffer = new char[64];
        name_ = "thread-" + std::to_string(++number);
    }
    // 问题2:这里如果实现成普通的类内方法,还是会报错,这是因为类内方法默认传了this指针
    // 解决方案:可以把这个函数设计成static的或者在类外构建,然后在类内声明成友元函数,这里采用static的方式
    static void *start_routine(void *args)
    {
        // 问题3:static的函数没有办法访问到类成员变量,就没有办法调用func_和args
        // 解决方案:通过args传入线程运行时所需要的全部内容(构造成一个对象传入)Context
        Context *ctx = static_cast<Context *>(args);
        // 问题4:在static函数内无法访问类内私有成员,也就没有办法调用func_方法,所以在类内封装一个run函数用于调用func_
        void *ret = ctx->this_->run(ctx->args_);
        delete ctx;
        return ret;
    }
    void *run(void *arg)
    {
        return func_(arg);
    }
    void start()
    {
        // 问题1:这里如果直接传func_发现会报错,这是因为func_的类型是 std::function<void *(void *)>,但是这里需要的参数类型是void*(*)(void*)
        // 解决方案:在类里面实现一个void*(*)(void*)类型的函数,在这个函数中调用func_

        Context *ctx = new Context(this, args_);

        int n = pthread_create(&tid_, nullptr, start_routine, ctx);
        assert(n == 0);
        (void)n;
    }
    void join()
    {
        int n = pthread_join(tid_, nullptr);
        assert(n == 0);
        (void)n;
    }
    ~Thread() {}

private:
    std::string name_; // 线程名
    pthread_t tid_;    // 线程id
    func_t func_;      // 线程调用的函数
    void *args_;       // 线程调用函数的参数
};
int Thread::number = 0;

测试代码:

#include "Thread.hpp"
#include <unistd.h>

void *thread_run(void *args)
{
    std::string arg = static_cast<const char*>(args);
    while (true)
    {
        std::cout << arg << std::endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    Thread *thread1 = new Thread(thread_run, (void *)"thread 1");
    Thread *thread2 = new Thread(thread_run, (void *)"thread 2");
    Thread *thread3 = new Thread(thread_run, (void *)"thread 3");

    thread1->start();
    thread2->start();
    thread3->start();

    thread1->join();
    thread2->join();
    thread3->join();

    return 0;
}

image-20240129224421221


本节完…

  • 22
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

凌云志.

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

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

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

打赏作者

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

抵扣说明:

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

余额充值