Linux多线程

Linux多线程

一、线程的概念

1.理解线程

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是”一个进程内部的控制序列“
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

之前的进程创建常画的图:
image-20240212145926760

​ 一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表等的一些数据结构;当把磁盘中的数据和代码加载进内存后,虚拟地址和物理地址就是通过页表建立映射的;
​ 此时我们再创建一批”进程“,并不创建地址空间,只创建task_struct,共用第1个进程的地址空间,创建的效果如下:
image-20240212150248450

此时我们创建的就是3个线程:

  • 其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的”线程是进程内部的一个执行分支“。
  • 线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

如何理解之前的进程?

image-20240212150554913

​ 观察上图,我们从现在理解进程就不能说一个task_struct结构了,一个进程它包含了进程地址空间、文件相关的属性、各种信号、页表等
​ 从内核角度来理解进程:
​ 进程:它是承担分配系统资源的基本实体。
​ 线程:它是CPU调度的基本单位,承担进程资源的一部分的基本实体
​ 换言之,当我们创建进程时是创建一个task_struct、创建地址空间、维护页表,然后在物理内存当中开辟空间、构建映射,打开进程默认打开的相关文件、注册信号对应的处理方案等等。而我们之前接触到的进程都只有一个task_struct,也就是该进程内部只有一个执行流。反之,内部有多个执行流的进程叫做多执行流进程。

CPU如何看待task_struct?
CPU不管有多少条执行流,只看task_struct,你task_struct有1条执行流就是单执行流的task_struct,有多执行流,你就是多执行流的task_struct。如下图:
image-20240212151202903

​ 因此,CPU看到的虽说还是task_struct,但已经比传统的进程要更轻量化了。

Linux下并不存在真正的线程?而是用进程模拟的?
操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多,当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细。
如果Linux实现真的线程,那么就需要对这些线程进行管理。比如说创建线程、终止线程、调度线程、切换线程、给线程分配资源、释放资源以及回收资源等等,搞一套与进程类似的线程管理模块,整个难度就比较大。
相对于其他操作系统,Linux系统内核只提供了轻量级进程的支持,并未实现线程模型。Linux本身只有进程的概念,而其所谓的”线程“本质上在内核里仍然是进程。
进程是资源分配的单位,同一进程中的多个线程共享该进程的资源。Linux中所谓的”线程“只是在被创建时clone了父进程的资源,因此clone出来的进程表现为”线程“,这一点一定要弄清楚。因此,Linux”线程“这个概念只有在打引号的情况下才是最准确的。

2.线程的优点

  1. 创建一个新线程的代价要比创建一个新进程小得多

    ​ 在创建进程的时候,就需要创建相应的进程地址空间,页表,加载相应的代码和数据;而创建线程只需要创建一个PCB,分配进程的资源即可;

  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

    ​ 线程之间的切换,只需要切换线程的上下文,不需要更新页表,加载有效数据;

  3. 线程占用的资源要比进程少很多

    ​ 线程本身就不是主要申请资源的角色,只是分担进程的资源;

  4. 能充分利用多处理器的可并行数量

  5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务

  6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

    ​ 计算密集型应用:比如加密、大数据运算等,主要使用的是CPU资源;贴合实际情况,比如我们经常用的好压,你解压一个文件,它就要涉及到大量的解压算法;

  7. I/O密集型应用,为了提高性能,将I/O等待的时间操作重叠。线程可以同时等待不同的I/O操作

    ​ I/O密集型应用:比如网络下载、云盘、ssh、在线直播、看电影等,主要使用的是内存和I/O资源;

3.线程的缺点

性能缺失

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

健壮性降低

  • 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。比如:进程之间是相互独立的,我们打开各种软件,一个软件的崩溃并不会影响其他软件,变相的也就增加了进程的健壮性,而线程就不同了,因为大部分资源都是共享的,一个线程的崩溃就会导致其他所有线程崩溃,进而导致整个进程崩溃;

缺乏访问控制

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

编程难度提高

  • 编写和调试一个多线程程序比单线程程序困难的多

4.线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
  • 线程是进程的执行分支,线程出现异常,就类似进程出现异常,进而出发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

5.线程用途

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

二、Linux进程VS线程

1.进程和线程

进程是资源分配的基本单位;线程是调度的基本单位

线程共享进程数据,但也拥有自己的一部分数据:

  • 线程ID
  • 一组寄存器(存储自己的上下文信息)
  • 栈(每个线程都要临时数据,都需要压栈出栈,各自独立)
  • errno
  • 信号屏蔽字
  • 调度优先级

2.多线程共享

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

  • 如果定义一个函数,在各线程中都可以调用;
  • 如果定义一个全局变量,在各线程中都可以访问到;

除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每个信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

3.进程和线程的关系

image-20240212155004748

三、Linux线程控制

1.POIX线程库

  • pthread库是由第3方提供的,是一个原生线程库
  • 与线程有关的函数构成了一个完整的系列,绝大部分函数的名字都是以“pthread_”打头的
  • 要使用这些函数库,要通过引入头文件<pthread.h>
  • 连接这些线程函数库要使用编译器命令的“-lpthread”选项

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数都会这样做)。而是将错误代码通过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量开销更小

2.线程创建

函数原型:

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
  • 失败返回错误码

下面用pthread_create来创建线程:

#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void* args){      
    const char* id = (const char*)args;      
    while(1){      
        printf("I am %s thread, %d\n", id, getpid());
        sleep(1);      
    }      
}      
int main(){      
    pthread_t tid; //定义一个线程ID     
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");      
    while(1){      
        printf("I am mian thread, %d\n",getpid());
        sleep(1);      
    }      
    return 0;      
}

​ 程序一旦运行起来,首先进程被创建,于此同时一个线程也被创建,着就是主线程;此时主线程通过pthread函数来创建其他线程

image-20240212175232621

​ 此时使用ps axj的命令查看进程信息:虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的。

​ 使用ps -aL 命令,可以显示当前的轻量级进程。
image-20240212175548730

​ 右图它们的pid是一样的,LWP表示轻量级进程,LWP是不一样的。CPU根据LWP进行调度,系统根据PID来判断是不是同一个进程。
​ 这里需要提一下,一个进程有多个线程,我们把属于同一个进程内的多个线程统称为线程组,那么这个线程组的组ID我们称之为当前进程的PID,也就是主线程的PID值;

​ 如何获取线程ID?

常见获取线程ID的方式有两种:

  • 创建线程时通过输出型参数获得。

  • 通过调用pthread_self函数获得。

    pthread_t pthread_self(void);
    

调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。例如下面的代码,我们通过主线程创建了一个新线程,主线程不断的打印新线程的ID,新线程去执行回调函数,打印出自己的ID:

#include <stdio.h>               
#include <pthread.h>
#include <unistd.h>
void* thread_run(void* args){      
    while(1){//新线程打印自己的ID      
        printf("我是新线程[%s],我线程ID是:%lu", (const char*)args, pthread_self());           
        sleep(1);         
    }                                        
}
int main(){              
    pthread_t tid;  
    pthread_create(&tid, NULL, thread_run, (void*)"new thread");      
    while(1){//主线程先是创建新线程并打印新线程的ID
        printf("我是主线程,我创建的线程ID是:%lu", tid); //1 
        //printf("我是主线程,我创建的线程ID是:%lu, 我的线程ID是:%lu", tid, pthread_self());//2          
        sleep(1);
    }
} 

image-20240212180959507

运行代码(主线程的打印1),可以看到着两种方式获取到的线程ID是一样的。当我们给主线程也去获取自己的ID时(主线程打印2),运行结果如下:
image-20240212181153614

确实当前进程中是有两个线程,因为他们的ID都不一样;

如何创建多个线程呢?
我们让主线程一次性创建五个新线程,并让创建的每个新线程都去执行Routine函数,也就是说Routine函数会被重复进入,即该函数是会被重入的。

#include <stdio.h>                             
#include <pthread.h>
#include <unistd.h>
void* thread_run(void* args){    
    while(1){    
        sleep(1);    
    }    
}
int main(){    
    pthread_t tid[5];    
    for(int i = 0; i < 5; i++){    
        pthread_create(tid + i, NULL, thread_run, (void*)"new thread");    
    }
    while(1){    
        printf("I am main thread ID, %lu\n",pthread_self());    
        printf("################# begin ################\n");    
        for(int i = 0; i < 5; i++){    
              printf("I creat thread [%d] is: %lu\n", i, tid[i]);    
        }    
        printf("#################  end #################\n");    
        sleep(1);    
    }    
    return 0;    
} 

image-20240212194320274

​ 运行代码,可以看到这个五个新线程是创建成功的。并且我们通过ps -aL命令查看当前的轻量级进程,也发现确实创建出5个线程,其中第一个线程是主线程,因为它的PID和LWP是相同的

3.线程等待

​ 一般而言,一个线程被创建出来,就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。所以线程需要被等待,如果不等待会产生类似于”僵尸进程“的问题,也就是内存泄漏。

线程等待的函数:pthread_join()

int pthread_join(pthread_t thread, void **retval);

参数说明:

  • thread:被等待线程的ID
  • retval:它是一个输出型参数,用来获取新线程退出的时候,函数的返回值;新线程函数的返回值是void*,所以要获取一级指针的值,就需要二级指针,也就是void**;

返回值:

  • 成功返回0
  • 失败返回错误码

调用该函数的线程将挂起等待,知道id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元存放的是常数PTHREAD_CANCELED
  3. 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
#include <stdio.h>                            
#include <pthread.h>
#include <unistd.h>
void* thread_run(void* args){
    int num = *(int*)args;
    while(1){
        printf("I am new thread [%d], I creat thread ID: %lu\n", num, pthread_self());
        sleep(3);
        break;
    }
    return (void*)111;
}
int main(){
    pthread_t tid[NUM];
    for(int i = 0; i < NUM; i++){
        pthread_create(tid + i, NULL, thread_run, (void*)&i);
        sleep(1);
    }
    void* status = NULL;
    pthread_join(tid[0], &status);
    printf("ret: %d\n", (int)status);
    return 0;
}

​ pthread_join()函数默认是以阻塞的方式进行线程等待的。它只有等待线程退出后才可以拿到退出码;
image-20240212200846604

我们知道进程退出时有三种状态:

  1. 代码跑完,结果正确
  2. 代码跑完,结果错误
  3. 代码异常终止

​ 那么线程也是一样的,这里就存在一个问题,刚刚上面的代码,是获取线程的退出码的,那么代码异常终止,线程需要获取吗?
​ 答案:不需要;pthread_join函数无法获取到线程异常退出时的信息。因为线程是进程内的一个执行流,如果进程中的某个线程崩溃了,那么整个进程也会因此崩溃,此时我们根本没有办法指向pthread_join函数,因为整个进程已经退出了;

​ 例如:我们在线程的执行例程当中制造一个野指针问题,当某个线程执行到此处时就会崩溃,进而导致整个进程崩溃。

#include <stdio.h>                          
#include <pthread.h>
#include <unistd.h>
void* thread_run(void* args){                  
    int num = *(int*)args;         
    while(1){          
        printf("I am new thread [%d], I creat thread ID: %lu\n", num, pthread_self());    
        sleep(1);          
        //设置一个野指针问题
        if(num == 3){           
            printf("thread number: %d\n quit", num);
            int* p = NULL;    
            *p = 100;    
        }    
    }    
}               
int main(){                                    
    pthread_t tid[5];   
    for(int i = 0; i < 5; i++){    
        pthread_create(tid + i, NULL, thread_run, (void*)&i);    
    }
    void* status = NULL;
    for(int i = 0; i < 5; i++){
        pthread_join(tid[i], &status);
        printf("tid[%d]: %d\n", i, (int)status);     
    }    
    return 0;    
}

​ 运行代码,可以看到 一旦某个线程崩溃了,整个进程也就跟着挂掉了,此时主线程等待新线程的机会都没有,这也说明了多线程的健壮性不太强。所以pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正常并不能获取异常情况。
image-20240212204659237

4.线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适应,从main函数return相当于调用exit。
  2. 线程可以调用pthread_exit函数终止自己。
  3. 一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

线程终止的函数:pthread_exit(让自己终止)

void pthread_exit(void *retval);

retval:线程退出时的退出码信息

  • 该函数无返回值,跟进程一样,线程结束的时候无法返回它的调用者(自身)。
  • pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。
#include <stdio.h>                
#include <pthread.h>
#include <unistd.h>     
#include <stdlib.h> 
#define NUM 5    
void* thread_run(void* args){    
    int num = *(int*)args;    
    while(1){    
        printf("I am new thread [%d], I creat thread ID: %lu\n", num, pthread_self());    
        sleep(3);    
        break;    
    }   
    //exit(111); //1
    pthread_exit((void*)111);  //2  
}
int main(){    
    pthread_t tid[NUM];    
    for(int i = 0; i < NUM; i++){    
        pthread_create(tid + i, NULL, thread_run, (void*)&i);    
        sleep(1);    
    }
    void* status = NULL;    
    for(int i = 0; i < NUM; i++){    
        pthread_join(tid[i], &status);    
        printf("I am thread[%d], I am code: %d\n",i ,(int)status);                            
    }    
    while(1){    
        printf("I am main thread\n");    
        sleep(1);    
    }    
    return 0;    
}

​ 当我们在新线程中调用pthread_exit函数时,只会将新线程终止,不会影响到主线程;
image-20240212211527269

​ 当我们在新线程中调用exit函数时,直接将进程退出了;
image-20240212211709002

线程终止的函数:pthread_cancel(让别人终止)

int pthread_cancel(pthread_t thread);

参数说明:

  • thread:被取消线程的ID

返回值说明:

  • 线程取消成功返回0,失败返回错误码。

线程是可以取消自己的,取消成功的线程的退出码一般是-1;

#include <stdio.h>                                 
#include <pthread.h>
#include <unistd.h>     
#include <stdlib.h> 
void* thread_run(void* args){    
    while(1){    
        printf("I am new thread [%s], I creat thread ID: %lu\n", (const char*)args, pthread_self());    
        sleep(1);    
    }    
}
int main(){    
    pthread_t tid;    
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");
    sleep(3);    
    printf("wait new thread...\n");//主线程休眠3秒后,提示在等待线程
    sleep(10);                                         
    printf("cancel wait new thread...\n");//主线等待10秒之后,提示取消等待
    pthread_cancel(tid); //调用函数,取消新线程
    void* status = NULL;
    pthread_join(tid, &status);  //和获取新线程退出时的退出码  
    printf("I am thread: %d, I am code: %d\n",tid ,(int)status);
    return 0;    
  }

image-20240212214128187

​ 通过运行结果发现,当主线程取消新线程后,新线程终止,返回的退出码是-1;当线程被取消的时候,如果是-1,就表明它是合法的,这里的-1具体是什么呢?
​ 被取消的线程,退出值为常数PTHREAD_CANCELED的值是-1。可在头文件pthread.h中找到它的定义

grep -ER "PTHREAD_CANCELED" /usr/include/pthread.h

image-20240212214410641

当然也可以通过新线程去取消主线程,这时候主线程就处于僵尸状态了;

#include <stdio.h>                    
#include <pthread.h>
#include <unistd.h>     
#include <stdlib.h> 
pthread_t g_tid;
void* thread_run(void* args){    
    while(1){    
        printf("I am new thread [%s], I creat thread ID: %lu\n", (const char*)args, pthread_self());    
        sleep(2); 
        pthread_cancel(g_tid);       
    }    
}
int main(){    
    g_tid = pthread_self();
    pthread_t tid;    
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");
    sleep(30);
    void* status = NULL;
    pthread_join(tid, &status);  //和获取新线程退出时的退出码  
    printf("I am thread: %d, I am code: %d\n",tid ,(int)status);
    return 0;    
  }

image-20240212215431102

​ 我们一般都是用主线程去控制新线程,这才符合我们对线程控制的基本逻辑,虽然实验结果表明新线程可以取消主线程,但是并不推荐该做法。

5.分离线程

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源
  • 可以说线程组内其他线程对目标线程进行分离,也可以是线程自己分离;
void* thread_run(void* args){    
    pthread_detach(pthread_self());//让新线程分离    
    while(1){    
        printf("I am new thread [%s], I creat thread ID: %lu\n", (const char*)args, pthread_self());    
        sleep(2);    
        break;    
    }    
    return (void*)111;    
}
int main(){    
    pthread_t tid;    
    int ret = 0;    
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");    
    sleep(1);    
    void* status = NULL;    
    ret = pthread_join(tid, &status);    
    printf("ret: %d, status: %d\n",ret ,(int)status);
    sleep(3);
    return 0;    
}

image-20240212221352157

​ 从运行结果以及进程监视脚本来看,新线程在分离后的2秒中后,自动退出了,主线程在等待新线程,并且想要获取到它的退出码”111“,但是结果确是0,所以新线程在设置分离后,主线程就不能再去join,会失败,它的资源会被自动回收;
​ 一般线程分离的场景是主线程不退出,新线程完成在对某项任务处理完毕后,自行退出;

6.线程ID及进程地址空间布局

  • pthread_creat函数会产生一个线程ID,存放在第一个参数指向的地址中,该线程ID和内核中的LWP不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_creat函数的第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_self函数,可以获得线程自身的ID;

pthread_t到底是什么类型呢?
它取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
Linux中没有真正意义上的线程,它是用进程来模拟实现的,内核提供LWP,用户使用的线程要由线程库自己来管理,如何管理?先描述,在组织,所以就在线程库中管理。

我们可以通过ldd来查看线程依赖的库:
image-20240212222759607

​ 我们知道动态库的当进程运行时被加载到共享区,此时该进程内的所有线程都可以看到这个动态库。

image-20240212222924140

​ 动态库加载后,要把动态库本身全部信息映射到主线程堆、栈之间的共享区(mmap)还有我们之前学习的共享内存;动态库里除了有代码,还有维护线程创建的数据结构;
​ 每个线程运行时都要有自己的临时数据,意味着就要有私有栈结构;在地址空间中只有一个栈,这个栈是用来给主线程用的,不可能说创建多个线程之后额主线程公用一个栈;
​ 动态库本身还承担了线程的组织、管理工作,即每一个线程地址空间有struct_pthread(线程结构体)、线程局部存储和线程栈,实现”先描述、再组织“,可见每一个线程都有自己的私有栈。所有新线程所使用的栈是在库当中的,由库来维护它的栈结构;
​ 每一个新线程在共享区都有这样一块区域堆其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。
​ 在内核中的LWP和struct_pthread(线程结构体)是1:1的;在用户层如果存在多个这样的结构体,为了和内核的LWP一一对应,那么在这些线程结构体中一定是包含了LWP。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Li小李同学Li

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

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

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

打赏作者

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

抵扣说明:

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

余额充值