Linux系统编程——多线程[上]:线程概念和线程控制

0.关注博主有更多知识

操作系统入门知识合集

目录

1.再谈页表

2.Linux线程概念

2.1pthread原生库的基本使用

2.2PID和LWP

2.3Linux线程的资源以及优缺点

2.4Linux线程健壮性问题

2.5可重入函数和线程独立栈

3.Linux线程控制

3.1Linux线程终止

3.2Linux线程等待

3.3线程取消

3.4线程分离

使用线程库的注意事项

4.pthread原生线程库的理解

4.1从语言的角度理解原生线程库

4.2从底层的角度理解原生线程库

5.对pthread原生线程库进行封装

1.再谈页表

在通用操作系统和进程的部分当中,介绍了一个容易理解但不正确的有关页表的概念"CPU执行访存指令时通过查询页表得到物理地址",事实上这是一个很笼统的过程。在Linux中,页表的实现并不是单一级的,而是多级页表,Linux的形式表现为三级页表,但实际上可用的页表为二级。那么页表的属性不仅仅只有页号、页框号、访问位、修改位、中断位以及我们在之前讲到的所有属性,它还具有描写读写执行权限的位、具有描写用户态和内核态的位。那么在这里就可以解释一个问题,为什么在C/C++编程时,对一些指针作解引用是非法的,会在运行时崩溃:

int main()
{
        char* str = "Hello World";
        *str = 'H';
        return 0;
}

在以前的语言角度来说,我们试图修改一个存在于常量区的数据,但是现在,我们可以站在操作系统的角度来解释这个问题。那么在操作系统当中,通常称常量区为代码段,实际上以用户和进程的角度来说,因为进程地址空间的存在使得我们能够使用所有内存(甚至让我们觉得能够使用的空间大于物理内存),实际上在操作系统映射物理地址的途中会发现我们的访存行为存在越界或越权,那么操作系统是如何发现的?问题就出现在页表当中:当CPU执行访存指令时分离出页号和页内偏移(暂时还以单一级页表来理解)之后,通过查询页表得到物理页框号,此时查询页表的工作并不只是单单查询物理页框号,还会查询RWX权限,那么在这段代码当中,我们的共识是对存在于代码段的数据作修改,那么在查询页表的时候就会发现当前页所在的物理页框是不可修改的,也就是只有R权限,此时就会发生越权访问,这个越权访问由CPU上的MMU(内存管理单元)发出一个段错误信号,操作系统接收到该信号并找到引发该信号产生的进程,然后终止该进程。

Linux的二级页表:

  1.虚拟内存管理的方式将内存分页,即物理内存和进程地址空间(也可以理解是程序本身,我是认为程序加载到内存后就以进程的方式看待了)都被划分成等大小的块,块的大小可以是1KB、2KB、4KB......主流的硬件体系都以4KB划分这个块。物理内存的块称为页框,进程的块称为页(也可以叫页帧、页面)

  2.为什么不是单一级页表:假设当前的操作系统是32位操作系统,那么一个进程能看到4G的虚拟内存,那么该进程的页就有一百多万个页,就算一个页表项是1字节,那么一个进程要配备4G的页表,很显然这种单一级页表的方案是不可行的。

  3.二级页表具体划分:32位操作系统下,地址(虚拟地址)也是32位的,那么Linux将该地址划分为10-10-12的三个区域分别表示一级页表-二级页表-偏移地址,其中一级页表称为页目录,二级页表称为页表。页目录只有一份,页目录有2^10个页表项,也就是说页目录的每一个页表项对应一个页表,每个页表有2^10个页表项。页目录的每个页表项的记录有页表号和页表的页框号,页表的每个页表项记录有页框号、修改位、RWX权限......

以一个简单的例子来说:假设CPU访问的地址为[0001 0010 0011 0100 0101 0110 0111 1000],那么前十位的值为72,就说明可以通过页目录的第72个页表项找到对应页表的页框,找到页框就可以找到页表;中间十位的值为345,就说明可以通过72号页表的第345个页表项找到CPU访问的地址的对应页框的起始地址;最后十二位的值为678,该值加上页框的起始地址就是要虚拟地址对应的物理地址。

那么如何看待地址空间和页表:

  1.地址空间是进程能看到资源的窗口,每个进程都以为自己能看到4G内存(32位操作系统)

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

  3.合理的对地址空间和页表进行资源划分,就可以对一个进程所拥有的资源进程分类

以上三个点对于理解Linux的线程是有很大帮助的。

实际上在虚拟地址映射到物理地址过程是十分复杂的,光靠软件支持(页表)的效率是非常低的,所以必须采用软硬结合的方式实现地址映射。即Linux软件方面使用页表来记录虚拟地址和物理地址之间的映射关系,硬件方面使用MMU来完成地址映射。

2.Linux线程概念

看过最多、听过最多的线程概念一定是线程是进程当中的一个执行流,线程是CPU调度的基本单位

在Linux当中,可以通过fork函数来创建一个子进程,在不考虑写时拷贝的情况下,子进程的地址空间、页表等等资源都是来自父进程的拷贝,也就是说此时子进程做到了与父进程的资源共享。但是线程是进程当中的一个执行流,执行进程所拥有的一部分代码,这就意味着线程必须长期共享进程的地址空间、页表等资源,事实上在Linux上也确实是那么做的,进程将所占资源合理的分配给每个执行流(线程)。我们也说过程序加载到内存当中就形成了一个执行流,即进程,那么描述该进程的对象就是task_struct,组织这些对象的数据结构是双向循环链表。那么线程也作为一个执行流,是不是也需要单独的使用某个对象来描述线程?使用一种独立的数据结构来组织这些对象?Windows操作系统确实那么做了,但是Linux却没有。

轻量级进程:

  1.再谈进程:我们知道进程=内核数据结构+代码和数据,CPU在调度进程时是调度这些内核数据结构和代码、数据吗?当然不是,CPU调度的是描述进程的task_struct结构体对象,那么该结构体对象能不能表示一个完整的进程?当然不能

由此我们可以推出进程是分配系统资源的基本实体

  2.Linux线程:我们可以看到上图,CPU调度的是task_struct,即进程控制块(PCB),该进程控制块指向了一个进程地址空间,透过页表确定了自己所占的资源。那么我们在该进程内部,多加几个task_struct,让这些task_struct指向同一个地址空间,再将这些地址空间合理划分区域分配给每个task_struct:

这样一来我们便可以发现,这三个task_struct都指向了同一个地址空间,但是在该地址空间内部,只能真正一拥有一部分资源,这不就做到"执行进程的一部分代码"了吗?没错!这就是Linux的线程实现!但是更加严谨的在于,Linux并没有为线程创建独立的描述对象,也没有创建独立的组织数据结构,它们都在与进程公用一套体系,所以,在Linux当中,没有线程的概念!只有轻量级进程!也就是说CPU调度的都是task_struct结构体,但是与传统的进程比较更加轻量化了。所以我们说CPU调度的基本单位是线程,只不过在Linux当中,以轻量级进程替代线程罢了!所以Linux的这种做法是一种很高明的做法(谁叫它是开源的呢?),因为将复用的特性体现的淋漓尽致,线程作为一个执行流它也必须有唯一标识符、优先级、上下文等等信息,Linux直接复用了进程控制块,这种简单的设计提高了Linux的稳定性和效率,虽然会有一些小毛病,但Linux完善了它的线程机制;Windows对于线程的定义时严格的,即有独立的描述对象和独立的组织数据结构,这就意味着Windows的线程设计一定比Linux复杂,也代表着维护成本增高、系统出错的概率也相对较高。

  3.站在用户(程序员)的角度:Linux的线程设计有点特立独行的感觉,但是用户不关心Linux到底是采用什么方法实现的线程,用户要的就是线程,Linux只有轻量级进程,这就意味着Linux只能提供有关轻量级进程的接口而不能提供线程接口,但这总不能让用户去银行大饭吧?所以Linux在用户层设计了一套原生线程库(原生pthread库),用户使用的接口都是来自于原生线程库,该库接收用户请求再在其内部转化成轻量级进程的接口发送给操作系统内核。

那么以前所谈论的进程概念是错误的吗?并没有。我们以前谈论进程其内部只有一个task_struct结构体,也就是说这个进程内部只有一个线程;而现在谈论的进程内部有多个task_struct结构体,所以就构成了多线程。也就是说多线程至少有一个执行流,只有一个执行流的进程我们称为单进程单执行流;进程内部有多个执行流,我们称它为单进程多执行流

2.1pthread原生库的基本使用

线程创建接口,使用[man pthread_create]命令查看该接口:

可以看到pthread_create的头文件为<pthread.h>,该头文件就是原生线程库。注意该接口的参数,"phtread_t *thread"代表该线程的tid;"const pthread_attr_t *arrt"表示线程的属性,我们直接设为空指针即可;"void *(*strat_routine)(void *)"是一个函数指针,表示该线程创建之后通过回调的方式直接执行该函数,该函数是写在进程当中的,所以说线程执行进程的一部分代码;"void *arg"表示函数指针指向的函数所需要的参数。

我们可以通过该接口来创建一个线程来执行一个简单的任务:

#include <pthread.h>
#include <unistd.h>
#include <string>
#include <iostream>
using namespace std;

void* test(void* args)
{
    string name = static_cast<char*>(args);
    while(true)
    {
        cout << name << " running..." << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;// 线程tid
    pthread_create(&tid,nullptr,test,(void*)"pthread one");// 创建新线程
    
    // 主线程
    while(true)
    {
        cout << "main pthread running..."  << endl;
        sleep(1);
    }
    return 0;
}
# makefile
test:test.cpp
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f test

上面的代码当中,通过pthread_create创建的线程我们称为新线程,新线程从创建之初便直接执行我们通过函数指针指定的函数;那么新线程创建之前一直是主线程在执行,执行完pthread_create后继续向后执行,该执行流称为主线程。所以这段代码看到的效果应该是两个执行流分别执行一个死循环(单执行流不可能执行两个死循环),而死循环当中的输出信息应该是交替打印。但是我们使用[make]命令编译该源文件,会发生以下事情:

无法通过编译。给出的有用信息为"undefined reference to `pthread_create'",即不认识pthread_create接口。这里不得不介绍一下原生线程库怎么一回事:Linux无法提供有关线程的系统调用接口,它只能提供轻量级进程的系统调用接口;而用户(程序员)只需要线程,所以Linux被迫封装出了一个库,即pthread原生线程库,该库与系统调用有区别,它是一个处于用户层的库;那么使用gcc/g++编译器时,它只能认识系统调用接口和语言本身自带的库,第三方库则不认识,所以在使用pthread原生线程库时,必须指明需要链接的库,即添加[-l pthread]选项

# makefile
test:test.cpp
	g++ -o $@ $^ -std=c++11 -l pthread

.PHONY:clean
clean:
	rm -f test

编译通过之后,运行可执行性文件,并观察结果:

可以看到主线程、新线程各自的死循环在同步进行,即它们的输出信息交替、并发的打印在控制台上(可能由于缓冲区的原因打印出的效果不好看),也就是说,这两个线程在计算机是并发执行的。

那么pthread原生线程库的位置逻辑图应该是这样的:

我们可以在"/lib64"路径下找到原生线程库的动态库:

使用[ldd 可执行文件]命令也可以确定所依赖的库:

2.2PID和LWP

查看操作系统正在运行的进程可以通过[ps ajx]命令:

当前进程正在运行。那么如何查看该进程内部的线程?使用[ps -aL]命令:

红色方框标记的便是进程PID为31395中的两个线程,它们有不同的LWP,其中一个线程的LWP与PID一样。LWP(Light Weight Process)即轻量级进程的唯一标识符,即线程的"PID"。其中,线程的LWP与PID相同的话,该线程称为主线程;反之不同则为从线程(新线程)。CPU调度的基本单位是线程,那么CPU就是通过LWP找到找到对应的执行流而完成调度。此时不得不再强调一下,在没有接触多线程之前,即单执行流的进程调度,也是调度其LWP,只不过该LWP与PID一样,所以在宏观上可以看作是调度进程。PID是进程的唯一标识符,进程是分配系统资源的实体,即资源通过PID去查找,调度通过LWP去调度

需要注意的是,这里的LWP与使用pthread_t类型定义的变量(tid)是不一样的,两者之间的差距在后面会进行介绍,现在来简单看一下tid是什么东西。我们将上面创建线程的代码稍作修改:

#include <pthread.h>
#include <unistd.h>
#include <string>
#include <iostream>
using namespace std;

void* test(void* args)
{
    string name = static_cast<char*>(args);
    while(true)
    {
        cout << name << " running..." << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;// 线程tid
    pthread_create(&tid,nullptr,test,(void*)"pthread one");// 创建新线程
    
    // 主线程
    while(true)
    {
        printf("main pthread running...tid: 0x%x\n",tid);
        sleep(1);
    }
    return 0;
}

可以看到使用十六进程打印出tid的值也非常的大,由此可以推测,tid代表一个地址,这个地址的具体作用,在后面的内容将会介绍。

2.3Linux线程的资源以及优缺点

即使Linux没有线程的概念,但我们作为用户,仍然愿意把轻量级进程称为线程。

线程一旦被创建,那么线程拥有的资源一定是绝大部分与进程资源共享,也就是说进程内的所有线程共享绝大部分资源。也就是说进程内的线程共享同一地址空间中的数据段、代码段等等,如果定义一个函数,那么所有线程都可以调用;定义一个全局变量,那么所有线程都可以访问到。除此之外,线程还共享进程的文件描述符表、信号以及信号的处理方式、工作目录等等。

既然资源是绝大部分共享,那么就一定有线程各自私有的资源

  1.PCB属性私有:例如线程的优先级、状态、LWP、调度信息等等。PCB属性私有是很容易想到的,因为线程的创建的手段就是拷贝一份已有的PCB,但绝不可能是完全相同的拷贝

  2.上下文私有:线程作为调度的基本单位,那么它一定也是CPU执行的基本单位,而线程想要被正确执行,那么就一定要保证上下文的正确,因为上下文的完整是执行流正常执行的必要条件,所以上下文必须私有,线程与线程之间是不共享的

  3.每个线程都有自己的独立栈结构:如果我们在线程创建之初指定执行的函数当中定义一些变量,那么其他线程是无法直接访问的。这一点看似容易理解,实则不然,进程当中的所有PCB都映射同一个地址空间,也就是说都映射同一个栈,那么线程之间是如何做到栈结构独立的?这个问题现在暂时无法回答,在后面的内容当中将会介绍

线程切换时操作系统要做的工作要比进程切换时所做的工作要少很多

  1.进程切换时需要切换页表、地址空间、PCB、上下文

  2.线程切换只需要切换PCB、上下文

  这两点实际上并不能体现线程切换时操作系统所做的工作更小,因为页表、地址空间的切换实际上就是切换CPU寄存器当中保存的页表、地址空间的地址,但因为寄存器很小、很快,所以这两步的差距很小很小,甚至没有差距。实际上重点不在这里,而在于高速缓存中的内容的切换,进程的代价要比线程大很多

  3.线程高速缓存切换和进程高速缓存切换:我们首先要知道三级存储结构以及CPU的访问顺序

  CPU并不是直接访问内存,而是直接访问寄存器和高速缓存(cache),寄存器太小,我们不考虑。如果CPU在高速缓存当中得到了想要的指令或数据,我们称为命中;反之没有得到数据就要去内存中加载数据,并将该数据更新到高速缓存。那么随着时间推移,高速缓存当中保留的数据一定是热点数据,即经常被访问的数据,这也是局部性原理的证实,即访问一条指令或一些数据时,其周围的、后续的指令或数据都有可能被访问。

  进程之间是相互独立的,那么发生进程切换时一定要把高速缓存中的内容给切走,并且操作系统会将这些内容给保护起来,以保证两个进程之间的数据不冲突;在进程切回时CPU时,操作系统又需要将已保存的高速缓存的内容恢复,所以进程切换的代价是比较大的

  那么线程之间的资源绝大部分都是共享的,这也就意味着高速缓存的内容也是绝大部分共享的,也就是说线程切换时,操作系统无需处理高速缓存,或者说只需处理一部分内容(这一部分非常少),所以线程切换的代价相对于进程切换的代价是比较小的

线程的优点:

  1.创建一个新线程的代价要比创建一个新进程的代价要少:线程的创建只需要创建一个PCB即可;而进程的创建需要创建地址空间、页表等等

  2.线程切换的代价要比进程切换的代价小

  3.线程占用的资源要比进程少很多:线程占用了进程的一部分资源

  4.能充分利用多处理器的可并行数量:这一点进程也可以做到,但这不能说明这不是线程的优点

  5.在等待I/O时,程序可以执行其他的计算任务:这也是并发的优点体现,当一个进程内部有多个执行流时,可以一部分执行流执行I/O操作,一部分执行流执行计算任务,这两种互不冲突,在一定程度上提高了程序的执行效率

线程的缺点:

  1.性能损失:如果线程的数量超过了CPU的数量,那么程序的执行效率不增反降,因为这会增加一些额外的资源开销;也并不是说一定要线程的数量超过CPU的数量才引发性能损失,凡是线程被创建,那么就一定要兼顾其额外的同步和调度开销,所以性能损失时不可避免的

  2.健壮性降低:线程之间的资源绝大部分是共享的,这就意味着一个线程发生崩溃极有可能会引发其他线程也发生崩溃;站在信号的角度来说,信号是给进程发送的,一个线程引发了崩溃就会引发信号的产生,从而导致进程终止,继而所有的线程都终止

  3.缺乏访问控制:正如上面所说,线程共享进程资源的绝大部分,所以当多个线程同时对共享资源进行存取时,很有可能会引发数据冲突、数据不完整等等问题,那么对线程的访问控制,将时多线程编程当中的一个大重点

  4.编程难度提高:显而易见,多线程的编程难度和调试难度是比较高的

2.4Linux线程健壮性问题

线程与线程之间、线程与进程之间的绝大部分资源都是共享的,这就意味着进程当中的某个线程发生异常时会导致所有线程发生异常,我们以一段代码为例:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
using namespace std;

void* test(void* args)
{
    string name = static_cast<char*>(args);
    while(true)
    {
        cout << name << " running..." << endl;
        sleep(1);
        int* p = nullptr;
        *p = 333;// 对空指针解引用会导致运行崩溃
    }
}

int main()
{
    pthread_t tid;// 线程tid
    pthread_create(&tid,nullptr,test,(void*)"pthread one");// 创建新线程
    
    // 主线程
    while(true)
    {
        printf("main pthread running...\n");
        sleep(1);
    }
    return 0;
}

可以看到主线程和新线程交替运行大约1秒之后,新线程执行空指针的解引用,然后发生一个段错误,导致进程退出,进程退出导致进程内的所有线程退出。那么我们可以站在两种视角去理解这种现象

  1.站在进程的角度:在此例中一个线程执行了一条非法的访存指令,导致MMU硬件产生异常信号,操作系统接收到该信号就要去查找导致该异常信号产生的进程,而线程作为进程的一个执行流,它具有对应进程的PID,所以操作系统通过此PID直接终止进程,进程的终止就导致了所有线程被终止

  2.站在线程的角度:线程作为进程的一个执行流,线程被执行就代表进程被执行,线程出现异常就代表进程出现异常,而进程出现异常就会终止,然后释放资源,所以理所应当的将所有线程的资源一并释放掉

所以线程的健壮性(鲁棒性)较低是有根据的。

2.5可重入函数和线程独立栈

我们编写一份代码,创建10个新线程去执行同一个函数,我们的初步模型是这样的:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
using namespace std;

void* start_routine(void* args)
{
    string name = static_cast<char*>(args);
    while(true)
    {
        cout << name << endl;
        sleep(1);
    }
}
int main()
{
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        pthread_t tid;
        char nameBuffer[64];
        
        // 给每个线程传入一个整型编号
        snprintf(nameBuffer,sizeof(nameBuffer),"%s:%d","pthread ",i+1);
        pthread_create(&tid,nullptr,start_routine,(void*)nameBuffer);
    }

    while(true)
    {
        cout << "main pthread running..." << endl;
        sleep(1);
    }
    return 0;
}

然而执行效果却是这样的:

注意一个小细节,我执行程序的时候使用的命令为[taskset -c 1 ./test],这不是因为我搞特殊,因为我的机器的CPU是多核的,如果不这样做就会出现下面这种输出效果,不利于观察和实验:

解释一下[taskset -c 1 ./test]这条命令,这条命令的作用是在程序启动时确定好要占用的CPU,即进程运行时占用的CPU,"taskset -c 1"表示将该进程绑定到第二个CPU(CPU从0号开始),"taskset -c 0,1"表示绑定两个CPU,即第一个和第二个。

结束题外话,以第一张图的输出结果为准。为什么这10个新线程最后拿到的整型编号都是10?我们需要确定一个点,即新线程被创建之后,它与主线程之间谁先调度是随机的,即我们无法确定谁先调度。那么从输出的结果来看,是因为新线程没来得及执行start_routine函数,主线程就已经把for循环执行完了,也就是说这10个新线程都没来得及开始执行,主线程就已经开始执行while死循环了。而主线程在调用pthread_create接口时传递的最后一个参数看似是数组,实则是一个指针被传递过去,这个指针是指向栈中开辟的一个数组(即nameBuffer数组),那么由于主线程被先行调度,所以nameBuffer这块空间不断地在for循环中创建、销毁......但是传递给pthread_create的最后一个参数,即指针依然指向nameBuffer这块空间,因为这段代码非常稳定,所以nameBuffer的开辟都在栈的同一个位置上,所以当主线程调度完毕要调度新线程时,该指针指向的内容早已被覆盖成了"pthread : 10",所以10个新线程最后都输出相同的结果。

这样的代码不仅仅只有这一个问题,"pthread_t"定义的tid也有问题,因为出了该for循环作用域就销毁了,那么后续工作就无法正常进行(后面会讲到的线程控制)。我们将上面的代码修改一下:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

class pthread_data// 每个线程的数据块
{
public:
    pthread_t _tid;
    char _name[64];
};

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    while(true)
    {
        cout << pd->_name << endl;
        sleep(1);
    }
}
int main()
{
    vector<pthread_data*> vec;
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        // 即使pd一直在创建、销毁,但是pd当中保存的地址是堆上的不同位置
        pthread_data* pd = new pthread_data();
        
        // 给每个线程传入一个整型编号
        snprintf(pd->_name,sizeof(pd->_name),"%s : %d","pthread",i+1);
        pthread_create(&pd->_tid,nullptr,start_routine,(void*)pd);

        vec.push_back(pd);// 将信息保存下来
    }

    while(true)
    {
        cout << "main pthread running..." << endl;
        sleep(1);
    }
    return 0;
}

那么start_routine这个函数,被多个执行流同时执行,那么它就处于一种被重入状态,那么它在严格意义上来将并不是一个可重入函数(因为有输入输出流),但是多个执行流执行它时,又没有产生实质性的错误,所以它也可以说是一个可重入函数。

那么我们在start_routine函数中定义一些临时变量,并且让不同的线程打印出它们的地址,以证明线程拥有自己的独立栈结构:

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 10;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
    }
    delete pd;// 防止内存泄漏
    return nullptr;
}

可以看到,对于start_routine当中的cnt变量,每个线程对其取地址并打印,输出的结果都是不一样的。所以可以证明,线程具有自己的私有栈结构

3.Linux线程控制

上面说过,pthread原生线程库在用户层,我们使用该库提供的接口创建线程,这个库在内部进行一些工作,调用操作系统内核提供的系统调用创建轻量级进程。实际上在内核的角度看来,用户所谓的创建线程,内核创建的都是进程,它们底层都会调用一个名为clone()的系统调用接口,根据不同的标志参数来创建不同的进程,那么在创建所谓线程的时候,操作系统调用clone()创建一个进程,根据传进来的标志参数指定与父进程共享地址空间、文件系统资源、文件描述符和信号处理程序等等。

我们作为应用层编码,不需要考虑这么底层的东西。

线程的创建上面的代码已经演示过了,这里就不赘述了。

3.1Linux线程终止

在没有接触多线程的时候,也就是单执行流的编程当中,想要在任意位置终止执行,可以调用exit函数。还是以上面的多线程代码为例,在start_routine函数内部调用exit会出现这样的效果:

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 10;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
        exit(0);// 在此退出
    }
    delete pd;// 防止内存泄漏
    return nullptr;
}

所以exit是用于进程退出的,进程退出之后就要回收其所占用的资源,而线程恰好是进程的资源,所以一并回收。线程正确的退出方式有两种

  1.在线程执行的函数当中使用return返回

  2.调用pthread_exit()接口

  该接口的参数是一个void*类型,恰好对应了pthread_create创建线程时,线程指定执行的函数的返回类型:

  不难猜测出,pthread_exit的参数是返回值

我们修改上面的代码,用pthread_exit()接口代替return语句:

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 10;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
    }
    delete pd;// 防止内存泄漏
    //return nullptr;
    pthread_exit(nullptr);
}

可以看到(此处的输出结果不完整,截取最后的一段输出结果),每个线程执行10此while循环后调用pthread_exit,终止线程的执行,所以到最后只有主线程执行,也就是说,哪个执行流调用了pthread_exit,哪个执行流就退出;调用pthread_exit的执行流不会影响其他执行流的运行

那么start_routine是使用pthread_create()接口创建线程时指定执行的函数,它具有返回值,那么该返回值如何拿到?接下来我们介绍线程等待。

3.2Linux线程等待

如同进程退出一样,线程退出时也需要主线程等待。进程退出时,短暂地处于僵尸状态,由其父进程回收、释放占用的资源;线程退出时也处于一种类似于进程僵尸状态的状态(只是类似,线程没有僵尸态),如果线程退出而主线程没有对其所占的空间回收和释放,就会造成内存泄漏。主线程等待线程退出的接口为pthread_join()

  pthread_join的第一个参数非常好理解,它就是tid,用来指定等待哪一个线程;而第二个参数是一个二级指针,这个参数的作用是获取线程退出的返回值,我们稍后分析它。需要注意的是,pthread_join等待线程时,是阻塞式等待,也就是说,线程不退出,等待线程退出的主线程会一直阻塞在该接口上。那么现在就要解释一下为什么上面的代码中的主线程非要写一个死循环的原因:

int main()
{
    vector<pthread_data*> vec;
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        // 即使pd一直在创建、销毁,但是pd当中保存的地址是堆上的不同位置
        pthread_data* pd = new pthread_data();
        
        // 给每个线程传入一个整型编号
        snprintf(pd->_name,sizeof(pd->_name),"%s : %d","pthread",i+1);
        pthread_create(&pd->_tid,nullptr,start_routine,(void*)pd);

        vec.push_back(pd);// 将信息保存下来
    }

    while(true)// 死循环
    {
        cout << "main pthread running..." << endl;
        sleep(1);
    }
    return 0;
}

  可以看到主线程是在main函数当中创建的,那么执行该程序时的入口就是从main函数进入,也就是主线程开始执行;创建完新线程后,如果没有死循环,那么主线程很快就执行"return 0"然后退出main函数,main函数退出就意味着进程退出,进程退出意味着进程当中的所有线程都要退出。现在我将main函数当中的死循环注释掉,那么执行结果将会是这样的:

  我运行了多次程序,但是都没有输出结果,这就是一种"bug",原因就在于新线程创建好之后,新线程还没来得及执行,主线程就执行"return 0",然后进程退出。这个问题侧面强调了线程等待的重要性

  因为pthread_join是阻塞式等待,以上面的多线程代码为例,10个线程并发执行,主线程等待这个10个线程;等待完成之后,主线程退出,最终程序终止运行:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

class pthread_data// 每个线程的数据块
{
public:
    pthread_t _tid;
    char _name[64];
};

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 3;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
    }
    // 在外部释放动态开辟的资源
    //delete pd;// 防止内存泄漏
    //return nullptr;
    pthread_exit(nullptr);
}
int main()
{
    vector<pthread_data*> vec;
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        // 即使pd一直在创建、销毁,但是pd当中保存的地址是堆上的不同位置
        pthread_data* pd = new pthread_data();
        
        // 给每个线程传入一个整型编号
        snprintf(pd->_name,sizeof(pd->_name),"%s : %d","pthread",i+1);
        pthread_create(&pd->_tid,nullptr,start_routine,(void*)pd);

        vec.push_back(pd);// 将信息保存下来
    }

    for(auto& tid:vec)// 线程的tid已经被保存在了vector容器中
    {
        pthread_join(tid->_tid,nullptr);
        delete tid;// 释放动态开辟的资源
        cout << "main pthread running..." << endl;
    }
    return 0;
}

  上面就是一份标准的多线程入门代码,线程创建、退出、等待,五脏俱全。现在我们来探讨一下如何拿到线程指定对应函数的返回值,首先介绍pthread_join的第二个参数:

  一般在这种接口当中出现二级指针,都要先联想到输出型参数。因为start_routine的返回值类型为void*,而pthread_join要求的是二级指针,这就要求我们单独定义一个void*类型的变量(或者是其他类型,在传参时强转即可),然后将该变量的地址传递过去,由pthread_join内部将start_routine的返回值写到我们定义的变量当中。举一个例子:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

class pthread_data// 每个线程的数据块
{
public:
    pthread_t _tid;
    char _name[64];
};

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 3;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
    }
    // 在外部释放动态开辟的资源
    //delete pd;// 防止内存泄漏
    //return nullptr;
    pthread_exit((void*)333);// 将整数333作为返回值
}
int main()
{
    vector<pthread_data*> vec;
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        // 即使pd一直在创建、销毁,但是pd当中保存的地址是堆上的不同位置
        pthread_data* pd = new pthread_data();
        
        // 给每个线程传入一个整型编号
        snprintf(pd->_name,sizeof(pd->_name),"%s : %d","pthread",i+1);
        pthread_create(&pd->_tid,nullptr,start_routine,(void*)pd);

        vec.push_back(pd);// 将信息保存下来
    }

    for(auto& tid:vec)// 线程的tid已经被保存在了vector容器中
    {
        void* ret = nullptr;
        pthread_join(tid->_tid,&ret);// 在内部将返回值写入ret
        cout << tid->_name << " return value: " << (long long)ret << endl;
        delete tid;// 释放动态开辟的资源
    }
    return 0;
}

    上面输出时将ret强转为long long类型是因为我的机器是64位的,所以指针有8个字节。

  返回值现在是拿到了,现在我们应该思考为什么需要通过pthread_join去拿线程退出时的返回值?线程退出时的返回值存放在哪里?其实线程退出时返回值放在了pthread原生线程库当中,因为我们不知道返回值具体放在线程库的哪个位置(因为我们处于自己的用户空间),所以我们统一使用pthread_join接口去获取它;下面的示意图可以帮助理解这部分:

区别于进程,线程退出的时候pthread_join()接口是拿不到线程的退出信号的,因为信号的发送是以进程为单位,进程接收到信号之后大部分情况都是立即退出,那么线程的退出信号就没有任何意义了。

3.3线程取消

上面我们介绍了两种线程终止的方式,即直接执行return和调用pthread_exit,这两种终止方式都是主动终止,即线程主动执行的终止方式。那么现在介绍一种线程被动终止的方式,即线程可以被其他线程终止,通常被主线程终止,我们更习惯称为线程取消。线程取消的接口为pthread_cancel():

  这个接口使用起来是非常简单的,调用此接口时,只需要指定的线程tid即可。那么对于线程被取消的情况,线程也会有返回值,这个返回值为-1,也就是说,线程被取消也是一种线程退出,加上前面介绍的两种退出方式就有三种了,线程一旦退出,pthread_join()会立马停止阻塞。我们还是以上面的代码为例,先让10个新线程执行一段时间,然后主线程取消一般新线程,观察这些被取消的线程的返回值和线程主动退出的返回值:

#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

class pthread_data// 每个线程的数据块
{
public:
    pthread_t _tid;
    char _name[64];
};

void* start_routine(void* args)
{
    pthread_data* pd = static_cast<pthread_data*>(args);
    int cnt = 3;
    while(cnt)
    {
        cout << pd->_name << " cnt = " << cnt-- << " &cnt:" << &cnt << endl;
        sleep(1);
    }
    // 在外部释放动态开辟的资源
    //delete pd;// 防止内存泄漏
    //return nullptr;
    pthread_exit((void*)333);// 将整数333作为返回值
}
int main()
{
    vector<pthread_data*> vec;
#define NUM 10
    for(int i=0;i<NUM;i++)
    {
        // 即使pd一直在创建、销毁,但是pd当中保存的地址是堆上的不同位置
        pthread_data* pd = new pthread_data();
        
        // 给每个线程传入一个整型编号
        snprintf(pd->_name,sizeof(pd->_name),"%s : %d","pthread",i+1);
        pthread_create(&pd->_tid,nullptr,start_routine,(void*)pd);

        vec.push_back(pd);// 将信息保存下来
    }
    sleep(1);// 先让新线程运行
    for(int i=0;i<vec.size()/2;++i)// 取消一半新线程
    {
        pthread_cancel(vec[i]->_tid);
        cout << vec[i]->_name << " cancel success" << endl;
    }

    for(auto& tid:vec)// 线程的tid已经被保存在了vector容器中
    {
        void* ret = nullptr;
        pthread_join(tid->_tid,&ret);// 在内部将返回值写入ret
        cout << tid->_name << " return value: " << (long long)ret << endl;
        delete tid;// 释放动态开辟的资源
    }
    return 0;
}

  事实上线程被取消的返回值为-1就是为了表明该线程是被其他线程取消的,这个-1实际上是一个宏:

3.4线程分离

首先我们有一个共识,那就是线程退出如果不进行等待回收其资源,那么线程就会处于一种类似于进程僵尸态的状态,从而导致内存泄漏。但是线程等待的接口为pthread_join,是一种阻塞的式的等待,这就意味着鱼和熊掌不可兼得:如果新线程被创建后第一时间主线程第一时间调用pthread_join进行阻塞等待,那么主线程要执行的其他工作必须等待新线程退出之后才能执行;如果新线程创建后先执行主线程的任务,而主线程要执行的任务消耗的时间很长,并且新线程的执行周期很短,那么就可能造成主线程还没有调用phtread_join()新线程就退出,进而造成内存泄漏。为了应付这两个问题,我们必须知道一个新的概念,即线程分离。

线程分离指的是让指定的线程与当前进程中的其他线程分离,分离的表现就在于被分离的线程退出时,自动释放、回收其占用的资源。那么线程分离的接口为pthread_detach():

调用该接口的线程可以是当前要分离的线程主动调用,也可以是其他线程调用此接口指定一个需要分离的线程。

默认情况下,线程被创建时的状态是joinable的,即可以被pthread_join()等待;而如果线程一旦被分离,那么不能再被pthread_join()等待,如果确实使用pthread_join()等待,那么就会产生异常:

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
using namespace std;
#include <pthread.h>
#include <unistd.h>

/*
 *让线程主动调用phtread_detach()分离
 *获得线程tid的接口为pthread_self()
*/
void *thread_run(void *args)
{
    pthread_detach(pthread_self());
    string name = static_cast<const char*>(args);
    int cnt = 3;
    while(cnt)
    {
        cout << name << " running... cnt: " << cnt-- << endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,thread_run,(void *)"thread 1");

    /*
     *线程创建后调度是随机的,所以主线程暂停2秒,
     *保证新线程能够将pthread_detach()执行完毕
    */
    sleep(2);

    /*
     *pthread_join()是有返回值的
     *为0表示pthread_join()等待线程成功
     *其他则表示pthread_join()等待失败,错误码被设置
    */
    int n = pthread_join(tid,nullptr);
    cout << "pthread_join fail,code: " << n << " " << strerror(n) << endl;
    return 0;
}

  在这个例子中,如果主线程创建新线程后没有"sleep(2)"语句,那么很有可能是主线程先执行,主线程在新线程还未调用pthread_detach()时就已经进入pthread_join()阻塞等待了,那么新线程再分离就没有意义了。

实际上,我们一般都习惯让主线程去分离其他线程,即主线程创建新线程后,立刻将新线程分离,然后主线程可以执行自己要执行的任务:

void *thread_run(void *args)
{
    string name = static_cast<const char*>(args);
    int cnt = 3;
    while(cnt)
    {
        cout << name << " running... cnt: " << cnt-- << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,thread_run,(void *)"thread 1");

    /*主线程分离新线程*/
    pthread_detach(tid);
    
    /*主线程可以继续执行自己的任务*/
    while(true)
    {
        cout << "main thread running..." << endl;
        sleep(1);
    }
    return 0;
}

因为被分离的线程是不能被pthread_join()等待的,被分离的线程退出时我们拿不到它的返回值。所以使用线程分离时,需要考虑清楚线程的返回值我们需不需要

使用线程库的注意事项

其实上面的代码我并没有写完整,因为我忽略了接口的返回值,这是一种不好的习惯。但因为上面的代码都是实例代码,场景比较简单,所以我直接忽略了返回值。希望读者注意这个点,在练习或者应用到开发环境的时候注意这些接口的返回值。

4.pthread原生线程库的理解

4.1从语言的角度理解原生线程库

我们使用C++11的线程库写一份简单的多线程代码:

#include <iostream>
#include <thread>// C++11的线程库
using namespace std;
#include <unistd.h>

void start_routine()
{
    int cnt = 4;
    while(cnt)
    {
        cout << "new thread...cnt :" << cnt-- << endl;
        sleep(1);
    }
}

int main()
{
    thread t1(start_routine);

    t1.join();
    cout << "new thread exit success" << endl;
    return 0;
}
# makefile
test:test.cpp
	g++ -o $@ $^ -std=c++11 -g

.PHONY:clean
clean:
	rm -f test

编译该程序,可以通过(我的机器是这样,不同的机器可能不一样),但是运行时发生异常:

启用GDB调试,配合核心转储文件定位一下错在哪里:

大概意思就是创建线程失败。那么我们修改一下makefile文件,也就是g++在编译时链接上线程库:

# makefile
test:test.cpp
	g++ -o $@ $^ -std=c++11 -g -l pthread

.PHONY:clean
clean:
	rm -f test

再次编译,通过;运行:

由此可见,使用C++11线程库在Linux下编译时,需要链接上pthread原生线程库,这也就意味着C++11的线程库调用的接口就是原生线程库,意味着在Linux当中,C++11的线程库就是对pthread原生线程库的一次封装。那么在不同的操作系统下,C++11的线程库封装相应的线程接口,例如在Linux当中封装pthread原生线程库,而在Windows当中C++11的线程就封装Windows的那一套线程体系,这就是一种可移植性的体现。

所以在Linux当中,看待C++11的线程库,就应该把它看成是对pthread原生线程库的封装。不止是C++11,任何语言的多线程库,本质就是对相应的开发平台的多线程体系进行封装。

4.2从底层的角度理解原生线程库

上面讲过,线程的返回值保存在pthread原生线程库当中的某个地方,那么现在我们就要来从底层的角度去看待原生线程库,并且解答tid是什么以及线程独立栈在哪里。

Linux内核当中没有线程的概念,我们所使用的Linux线程是在原生线程库对轻量级进程封装之后表现出来的,所以也叫做用户级线程,而Linux轻量级进程的实现原理我们已经介绍过了,这里就不再赘述。那么同理,在原生线程库中,原生线程库也要对用户级线程进行描述和组织,因为用户有时候需要知道线程的属性和状态(例如调用pthread_self()接口获得当前线程的tid),只不过线程的属性相对于进程来说是少很多的。那么用户、原生线程库、Linux内核的关系就是这样的:用户使用原生线程库的接口创建用户级线程,原生线程库会调用Linux提供的系统调用创建轻量级进程,而Linux内核负责轻量级进程的调度。前面说过,创建轻量级进程的接口为clone():

  注意箭头所指的那个参数,这个参数表示进程使用的栈。也就是说,原生线程库调用clone时,会自动传递一些标志参数来创建进程,并且还会自动传递箭头所指的参数以告诉内核进程创建后使用的栈是哪个。

至此我们可以得出几个结论:

  1.Linux采用的线程方案非常特殊,用户关心的线程实现在库中,而Linux内核只负责轻量级进程的调度

  2.原生线程库的用户级线程与Linux内核中的轻量级进程是一一对应的

  所以可以用一张图片来理解这些结论:

至此我们可以推测,既然用户级线程实现在原生线程库当中,那么tid和线程独立栈也一定与原生线程库有关。事实上,确实如此。首先我们要知道,原生线程库是以动静态库的方式被进程链接的,那么线程库被加载到内存后,一定会通过页表映射到进程对应的地址空间中,也就是映射到地址空间的共享区:

那么线程库的结构是这个样子的:

所以我们可以理解以前pthread_t定义的变量(tid)的最终打印结果是一个地址了,原因就在这里,通过tid去线程库当中找到TCB,找到TCB之后,TCB一定会有一个字段描述用户级线程与轻量级进程的关系(上面的图没有体现出来),所以就可以解释,为什么使用原生线程库提供的结构时,大部分都需要tid的原因。上图体现出了线程栈,由此不难想到线程的栈结构实现在原生线程库中,即共享区中,所以原生线程库调用clone()时,就会将线程栈的地址作为参数交给clone(),从何让内核知道,创建出的进程的栈结构在线程库当中。因此也可以推出,线程的返回值就在线程栈中。当然了,需要区别于主线程,主线程的栈结构是直接使用进程地址空间的独立栈

最后介绍一下线程局部存储,首先来看一份代码:

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
using namespace std;
#include <pthread.h>
#include <unistd.h>

/*定义一个全局变量*/
int global = 0;

/*新线程对global递增,并且输出其值和地址*/
void *thread_run(void *args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        cout << name << " running...global = " << global++ << " &global: " << &global << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,thread_run,(void *)"thread 1");

    /*主线程打印global的值和地址但是不递增*/
    while(true)
    {
        cout << "main thread running...global = " << global << " &global: " << &global << endl;
        sleep(1);
    }
    return 0;
}

从输出结果可以看出,全局变量被进程当中的所有线程共享,因为新线程对其递增主线程也能看到递增之后的值,并且打印出的地址都是一样的。但是我们想让该变量被各个线程独立私有一份,就可以使用线程局部存储,它的语法就是在定义变量之前加上__thread:

/*线程局部存储变量*/
__thread int global = 0;

/*新线程对global递增,并且输出其值和地址*/
void *thread_run(void *args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        cout << name << " running...global = " << global++ << " &global: " << &global << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,thread_run,(void *)"thread 1");

    /*主线程打印global的值和地址*/
    while(true)
    {
        cout << "main thread running...global = " << global++ << " &global: " << &global << endl;
        sleep(1);
    }
    return 0;
}

可以看到本次的输出结果与上一次的输出结果不同,就地址而言就可以证明每个线程都私有一份变量了。但是为什么这个地址会变得这么大呢?原因就在于当global作为全局变量时,存储在数据段中,而数据段处于低地址位置;而定义了一个线程局部存储变量之后,每个线程都私有一份,那么该私有的变量就存储在了线程的私有栈当中,而栈的地址在内存当中属于高地址。线程局部存储的作用主要就是为了方便吧(这样就不用在线程里面单独定义这些变量了...个人愚见)。

5.对pthread原生线程库进行封装

我们非常"羡慕"C++11当中的线程库,因为它使用起来非常方便,那么我们可以想办法,自主对pthread原生线程库进行一个简单的封装:

// Thread.hpp
#pragma once

#include <functional>
#include <iostream>
#include <cstring>
#include <cerrno>
#include <pthread.h>

namespace ly
{
    using namespace std;

    /*
     *外部创建线程时需要指定一个函数指针和一个参数(void *类型)
     *Thrad类将该回调函数用包装器包装
    */
    class Thread
    {
    public:
        typedef function<void *(void *)> func_t;
    public:
        Thread(const func_t& func,void *args)
            :_func(func),_args(args)
        {}

        /*
         *线程的运行我们想手动控制
         *但是pthread_create指定的回调函数为void *start_routine(void *)类型
         *所以无法给pthread_create传递包装器类型,故在此函数之后定义一个静态成员函数
         *将this指针传递给该静态成员函数,在静态成员函数里面再回调包装器包装的函数
        */
        void start()
        {
            int n = pthread_create(&_tid,nullptr,start_routine,(void *)this);
            if(n != 0)
            {
                cout << "error code:" << n << " " << strerror(n) << endl;
                exit(n);
            }
        }

        /*
         *如果该函数不是静态成员函数,那么就有一个隐藏的this指针
         *因为没有this指针,所以无法直接使用成员变量_func
         *所以我们在pthread_create()中手动传递一个this指针
        */
        static void *start_routine(void *args)
        {
            Thread *_this = static_cast<Thread *>(args);
            return _this->_func(_this->_args);
        }


        /*线程等待,将返回值一并带出*/
        void *join()
        {
            void *ret;
            int n = pthread_join(_tid,&ret);
            if(n != 0)
            {
                cout << "error code:" << n << " " << strerror(n) << endl;
                exit(n);
            }
            return ret;
        }
    private:
        pthread_t _tid;
        void *_args;
        func_t _func;
    };
}// the namespace ly ends here

然后进行简单的测试:

#include <iostream>
#include <string>
#include <memory>
using namespace std;
#include "Thread.hpp"
using namespace ly;

#include <unistd.h>

void *start_routine(void *args)
{
    string name = static_cast<const char *>(args);
    int cnt = 5;
    while(cnt)
    {
        cout << name << " running...cnt: " << cnt-- << endl;
        sleep(1); 
    }
    pthread_exit((void *)666);
}
int main()
{
    unique_ptr<Thread> up(new Thread(start_routine,(void *)"thread 1"));

    up->start();
    
    void *ret = up->join();
    cout << "return value: " << (long long)ret << endl;
    return 0;
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小龙向钱进

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

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

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

打赏作者

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

抵扣说明:

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

余额充值