【Linux】第十篇:线程的创建,等待,终止与分离


在这里插入图片描述

1.线程理解

线程与进程

我们知道,进程间是各自独立的,每个进程有他自己的私有地址空间,这便导致进程之间交换数据很困难。为了让多执行流完成各自的任务同时又能快速共享数据,这便是本文所要介绍的线程。

线程:是操作系统进行运算调度的基本单位,是一个进程内部的控制序列。进程与线程是1:N的关系,所以一个进程至少有一个线程(主执行流)。线程是在进程内部运行,其本质是在进程的地址空间内运行。

  • LWP:light weight process 轻量级进程,Linux下的线程类型(本质为进程)
  • 每个线程拥有自己的线程控制块(TCB),Linux的线程数据块复用的是进程控制块(PCB)。
  • 线程是最小的执行单位(调度的基本单位),它可与同一进程下的其他线程共享一个地址空间。
  • 进程是最小的分配资源单元

有些情况,需要在一个进程中执行多个执行流程,这时线程就派上用场了。比如使用下载软件,一边与用户交互,等待和处理用户的键盘鼠标时间,一边同时下载文件,等待和处理从多个网络主机发来的数据。

在之前的文章中所讨论的进程只有一个控制流,而在进程信号篇,我们知道main函数和信号处理函数sighandler是同一个进程地址空间的多个控制流程,多线程也是如此,但是比信号处理函数更加灵活:信号处理函数的控制流程只能在信号递达时产生,在信号处理完后就结束,而多线程的控制流程可以长期并存,并且操作系统可以在各线程之间调度和切换。

特别注意:同一进程下的多个线程共享同一地址空间:

在这里插入图片描述

Linux中没有专门为线程设计TCB,而是用进程的PCB(task_struct)来模拟线程。对于CPU而言,看到的仍然是一个个PCB,但是已经比传统的进程轻量化了。

因此代码段,数据段都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到

于是在任意一个时间段,同一个进程内的代码和数据,可以被CPU同时处理和推进(得益于多执行流的存在)。

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

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

但还有一些资源是每个线程独自占有的:

  • 线程id
  • 栈与上下文(各种寄存器的值、程序计数器、栈空间和栈指针)
  • errno 变量
  • 信号屏蔽字(block位图)
  • 调度优先级

进程和线程的关系如下图所示:

在这里插入图片描述

线程的特点

优点:

  • 创建一个轻量级进程的成本要比创建一个进程的成本小的多。
  • 与进程之间的切换相比,线程由于共享了资源,其切换需要操作系统做的工作要少很多。
  • 线程自身占用的空间也少于进程。
  • 可充分利用多处理器的并行数量。
  • 在等待慢速I/O的操作同时,程序可在其他线程中执行其他任务。
  • 计算密集型应用,将计算分解到多个线程中实现。(线程不是越多越好,不要超过核数,否则会导致线程被过度调度切换)
  • I/O密集型应用,由于IO的操作很慢,为了提高性能,将等待I/O操作的时间重叠。所以允许多线程可以同时等待不同的I/O操作。

缺点:

  • 编写程序和调试程序的难度将会提高
  • 缺乏访问控制:进程是访问控制的基本单元,在线程中使用OS函数会对整个进程产生影响
  • 降低健壮性:编写多线程需要更多的思考,在一个多线程程序中,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
  • 性能损失:计算密集型线程无法与其他线程共享一个处理器,因为会造成额外的同步和调度开销。

优点相对突出,Linux下由进程模仿出来的线程,导致两者的差别不是很大。

线程常用于:1)

线程异常

  • 单个线程的崩溃(硬件异常等),会导致整个进程的崩溃。

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程。

进程终止,该进程内的所有线程也就随即退出。

Linux线程控制

因为Linux下,线程是由进程模拟的,所以Linux没有直接提供操作线程的接口,而是给我们提供在同一个地址空间内创建PCB的方法,分配资源给指定的PCB接口。好在工程师们在用户层对Linux轻量级进程接口进行了封装,给我们打包成库,让用户可以直接使用库接口(原生线程库)——POSIX线程库。

  • POSIX线程(POSIX Threads,缩写为Pthread)是POSIX的线程标准,定义了一套控制线程的API。

  • 该标准下的线程系统调用名字大多以"pthread_"开头的。

  • 使用线程库需要引用头文件pthread.h,编译时需加上选项-lpthread(小写L指定库名)。

  • ldd 查看依赖库

  • ps -aL :其中L查看轻量级进程(LWP)——OS调度的基本单位

PID与LWD相同的称为主线程,而一个进程下的所有线程称为线程组,一个线程组的组ID也是当前进程的PID。

2.创建进程 —— pthread_create

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);

//Compile and link with -pthread.
  • 返回值:成功返回0,失败返回错误号且thread不定义。

    🚩注意:虽然每个线程拥有自己的errno,但这是为了兼容其他函数接口而提供的,pthread本身并不使用它。而读取返回值要比读取线程内的errno变量更加清晰。

  • 函数参数

    • thread:输出型参数,获取线程的id;
    • attr:用于描述线程属性,本片不深入探讨线程属性。传NULL,表示使用线程默认属性
    • start_routine:函数指针,指向线程主函数(线程体),该函数结束,表示线程结束。该函数的返回值为void*,这个指针的含义由调用者自己定义。
    • arg:线程函数执行期间所需要的参数,类型void*说明这个指针按什么类型解释由调用者自己指定。如要传多个参数,可以使用结构体封装(注意强制类型转换)。

获取线程ID —— pthread_self

#include <pthread.h>

pthread_t pthread_self(void);

pthread_create 成功返回后,新创建的线程id被填写到thread指向的内存中,其作用对应进程中的 getpid() 。

进程id是全局唯一的,而线程id在当前进程中保证唯一。不同的系统中pthread_t类型有不同的实现,可能为整数值,结构体或是地址,
在Linux下对应无符号长整型(unsigned long,%lu)。

调用pthread_self 可以在当前线程中获得线程id,与调用该进程的函数pthread_create中的thread值保持一致。

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

void* thread1(void* argv)
{
    while(1)
    {
        printf("%s(tid:%lu) is running...\n",(char*)argv,pthread_self());
        sleep(1);
    }
    return NULL;
}

int main()
{
    pthread_t tid;

    pthread_create(&tid,NULL,thread1,(void*)"thread1");
    
    while(1)
    {
        printf("i am main thread,i create thread:%lu\n",tid);
        sleep(1);
    }

    return 0;
}

代码示例

实验1

我们创建一个线程,查看主线程与新建线程的tid与pid。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
void* thread1(void* argv)
{
    while(1)
    {
        printf("%s(pid: %d,tid: %lu) is running\n",(char*)argv,getpid(),pthread_self());
        sleep(1);
    }
    return NULL;
}

int main()
{
    pthread_t tid;

    int err;
    err=pthread_create(&tid,NULL,thread1,(void*)"thread1");
    if(err!=0)
    {
        fprintf(stderr,"can't create thread:%s\n",strerror(err));
        exit(1);
    }
    while(1)
    {
        printf("i am main thread(pid: %d,tid: %lu),i create thread1(tid: %lu)\n",getpid(),pthread_self(),tid);
        sleep(1);
    }

    return 0;
}

由于pthread_create的错误码不保存在errno中,因此不能直接用perror(3)打印错误信息,可以先用strerror(3)把错误码转换成错误信息再打印。

为了防止新创建的线程还没有得到执行就终止,我们在main函数return之前使用while循环不断打印,这只是一种权宜之计,后面我们会看到更好的办法。

结果:

在这里插入图片描述

可以看到他们的pid是一致的,说明从属一个进程。

实验2

创建多个线程(省略出错处理):

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

void* thread_run(void* argv)
{
    int num=*(int*)argv;
    while(1)
    {
        printf("thread[%d](pid: %d,tid: %lu) is running\n",num,getpid(),pthread_self());
        sleep(10);
    }
    return NULL;
}

int main()
{
    pthread_t tid[5];
    int i=0;
    for(i=0;i<5;++i)
    {
        pthread_create(tid+i,NULL,thread_run,(void*)(&i));
        sleep(1);
    }
    while(1)
    {
        printf("i am main thread(pid: %d,tid: %lu)\n",getpid(),pthread_self());
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

实验3

在新线程中发送信号,查看进程退出情况(省略出错处理):

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


void* thread_run(void* argv)
{
    while(1)
    {
        printf("thread[%ld](pid: %d,tid: %lu) is running\n",(long)argv,getpid(),pthread_self());
        sleep(3);
        raise(2);//3秒后线程发信号SIGINT
    }
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,thread_run,(void*)0);
    while(1)
    {
        printf("i am main thread(pid: %d,tid: %lu)\n",getpid(),pthread_self());
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

一个线程遇到退出信号,会导致整个进程退出。

3.进程ID和线程ID

通过 ps -aL 可以查看轻量级进程号:

在这里插入图片描述

但是我们发现LWP号和我们函数中打印的tid并不匹配。

  • 我们所查看的线程id是pthread库的,与Linux内核的LWP没有关系
  • 内核的LWP属于进程调度的范畴,作为操作系统最小的调度单位,需要一个数值来唯一标识该线程

线程 ID 具有什么含义呢?

首先得知Linux不提供线程,所以线程id不是来源于内核。

通过 ldd 指令可以得知线程库实际上是调用动态库:

在这里插入图片描述

进程运行时如果需要动态库,那么动态库从磁盘加载到内存,如果进程创建线程,那么可以动态库会把所需的线程通过页表映射到进程地址空间的共享区:

在这里插入图片描述

每个线程都有自己的私有栈来存放自己运行过程中的上下文数据,主线程采用的栈是进程地址空间的原生栈,其他的子线程采用的栈则是动态库映射到共享区开辟的线程结构体 —— struct pthread,其中包含了线程各种属性与数据,这点类似于进程控制块(PCB),但是进程控制块由操作系统维护处于内核态,而线程控制块处于用户层,由动态库维护。我们每新建一个线程,就在共享区中开辟一个线程结构体空间,并映射到内存的线程库里。

内存中的动态共享库会负责管理操作系统中的所有线程,在管理之前必须先记录每个线程控制块的地址,,而线程ID是一个虚拟地址(通过页表映射入线程库),用于找到共享区中的线程结构体(图中的紫框)

大部分线程库函数都需要提供线程ID,本质都是对库内的线程控制块进行各种操作,最后将要执行的代码和线程数据交给对应的内核级LWP去执行就行了。

4.终止线程

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

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

在学习线程退出之前,我们应当先学习线程等待来了解如何获取线程函数的退出状态。

等待线程 —— pthread_join

为什么需要线程等待?

  • 已经退出的线程,其空间没有释放,存在于进程地址空间中。
  • 创建新的线程不会复用刚才退出线程的地址空间。

类似于父进程等待并回收子进程的wait函数,作为主线程也应该知道线程退出的状态,得到线程函数的返回值,并回收资源。

  • pthread_join函数声明
#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);
  • 功能:阻塞等待线程退出,获取线程退出状态。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。

  • 返回值:成功返回0,失败返回错误号。

  • 参数

    • thread:等待的线程ID
    • retval:输出型参数,得到线程的退出状态
      1. 如果线程通过 return 返回——retval所指向的单元(void*)存放的是thread线程函数的返回值(void*)。
      2. 如果线程被别的线程调用 pthread_cancel 异常终止掉,retval所指向的单元存放的是常数 PTHREAD_CANCELED。
      3. 如果线程是自己调用pthread_exit终止的,retval指向的单元存放的是传给pthread_exit的参数。
      4. 如果对线程的终止状态不感兴趣,就将retval置为NULL。

⚠这里有4点需要注意:

🚩 1. 对线程的资源进行回收:如果一个线程是非分离的(默认情况下创建的线程都是非分离)并且没有对该线程使用 pthread_join() 的话,该线程结束后并不会释放其内存空间,这会导致该线程变成了“僵尸线程”。(关于线程分离会在后面说到)

🚩 2.被pthread_join释放的内存空间仅仅是系统空间,动态分配的空间(malloc)后续必须由等待的线程手动清除。

🚩 3.面对代码异常导致的线程退出,pthread_join无法处理,因为有异常导致系统发出信号是进程应该处理的问题,这也是为什么pthread_join不用获取信号。

🚩 4.大多项目中需要子线程计算后的值就需要加join方法。

线程退出 return

线程函数的return可以让线程退出,可以返回共享数据段,全局变量,动态开辟的堆空间数据(注意free),结构体指针以及函数指针等数据(不能是函数中的局部或临时变量)。

线程退出 pthread_exit

终止调用者自身

#include <pthread.h>

void pthread_exit(void *retval);
  • 参数

    • retval:void*类型, 表示线程的退出状态,和线程函数的返回值一样,其他函数可以调用pthread_join获得这个指针。注意不要指向一个局部变量。不关心这个量可以传NULL。
  • 返回值,无返回值,跟进程一样,线程结束的时候无法返回到他的调用者。

🚩注意:我们不能使用exit将指定线程退出,因为这会导致整个进程退出。

多线程环境中,应尽量少用或者不使用exit函数,除非你非常清楚某个线程出了问题其他线程也别想跑了,取而代之使用pthread_exit函数,将单个线程退出。其他线程工作未结束,主控线程退出时不能return或exit。

实验

要求:创建多线程,分别使用return,pthread_exit来退出线程,使用pthread_join来获取,其中在return中返回整数,pthread_exit返回结构体指针:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
pthread_t g_tid;

struct DOOR
{
    int width;
    int length;
};

void* thread1_run(void* argv)
{
    printf("%s (tid: %lu) is running\n",(char*)argv,pthread_self());
    return (void*)111;
}

void* thread2_run(void* argv)
{
    printf("%s (tid: %lu) is running\n",(char*)argv,pthread_self());
    struct DOOR* mydoor=(struct DOOR*)malloc(sizeof(struct DOOR));
    mydoor->width=1000;
    mydoor->length=2000;
    pthread_exit((void*)mydoor);
}

int main()
{
    void* status=NULL;
    pthread_t tid;

    pthread_create(&tid,NULL,thread1_run,(void*)"thread1");
    pthread_join(tid,&status);
    printf("thread 1 exit code = %d\n\n",(int)status);

    pthread_create(&tid,NULL,thread2_run,(void*)"thread2");
    pthread_join(tid,&status);
    struct DOOR* mydoor=(struct DOOR*)status;
    printf("thread 2 exit ,my door's width = %dmm,length=%dmm\n\n",mydoor->width,mydoor->length);
    free(mydoor);

    return 0;
}

在这里插入图片描述

线程退出 pthread_cancel

向目标线程发送cancel信号,请求取消目标线程。

#include <pthread.h>

int pthread_cancel(pthread_t thread);
  • 参数

    • thread: 需要取消的线程id
  • 返回值:成功返回0,失败返回errno。

  • 使用pthread_join将会捕捉到PTHREAD_CANCELED 值为-1

    在这里插入图片描述

使用 pthread_cancel 是比较复杂的,首先介绍一些前置概念。

取消点

在程序运行时间段内,期间程序被挂起(阻塞),是一个可以被取消的时间点。也就是说当线程出现阻塞的时候,这个阻塞的地方就是可被 pthread_cancel 取消的地方。

那我们可以得到结论:如果子线程的程序没有阻塞,那么主线程将无法pthread_cancel取消它

void* thread_run(void* argv)
{
    int i=0;
    while(1)
    {
        i++;
    }
}
int main()
{
    void* status=NULL;
    pthread_t tid;

    pthread_create(&tid,NULL,thread_run,(void*)"thread");
    printf("thread(tid:%lu) begin\n",tid);
    sleep(1);//防止运行速度过快,得先让子线程进入死循环
    pthread_cancel(tid);
    pthread_join(tid,&status);
    printf("thread 3 exit code = %d\n",(int)status);

    return 0;
}

运行后发现,子线程进入死循环,没有阻塞点无法被终止,主线程为了等待子线程的返回也阻塞住:

在这里插入图片描述

使程序出现阻塞点多为系统调用函数,如:printf,sleep,read,write等。

pthread线程库也为我们提供了阻塞点函数(pthread_testcancel()),以及如何让线程选择是否退出的方案,之后介绍。

现在我们知道,pthread_cancel调用并不等待线程终止,只提出撤销请求(发送cancel信号)。线程在撤销请求(pthread_cancel)发出后仍会继续运行,直到到达某个取消点(CancellationPoint)。

库函数 pthread_setcancelstate : 修改cancelstate

改变线程遇到cancel信号的状态

int pthread_setcancelstate(int state,int *oldstate)
  • state :

    • PTHREAD_CANCEL_ENABLE: 线程可被取消,是所有线程的默认状态。
    • PTHREAD_CANCEL_DISABLE: 线程不理会cancel信号,继续执行,而调用pthread_join函数的线程将会阻塞住直到该线程结束。
  • oldstate :输出型参数,备份线程原有的状态,如不关心设为NULL。

  • 返回值 : 成功返回0,失败返回错误码。

  • 实验

void* thread_run(void* argv)
{
    //修改该线程状态为不可被取消
    int oldstate;
    pthread_setcancelstate(PTHREAD_CANCEL_DISABLE,&oldstate);
    printf("%s (tid: %lu) is running\n",(char*)argv,pthread_self());
    int i=5;
    while(i)
    {
        sleep(1);
        i--;
    }
    return (void*)111;
}
int main()
{
    void* status=NULL;
    pthread_t tid;

    pthread_create(&tid,NULL,thread_run,(void*)"thread");
    printf("thread(tid:%lu) begin\n",tid);

    int ret=pthread_cancel(tid);
    printf("%d\n",ret);
    pthread_join(tid,&status);
    printf("thread 3 exit code = %d\n",(int)status);

    return 0;
}

pthread_cancel函数是成功调用的,但是线程不理会。

在这里插入图片描述

库函数 pthread_setcanceltype : 修改canceltype

改变线程的终止类型,前提是cancelstate为enable

int pthread_setcanceltype(int type,int *oldtype)
  • type :

    • RTHREAD_CANCEL_DEFERRED: 运行到下个取消点就退出(默认)
    • PTHREAD_CANCEL_ASYNCHRONOUS: 直接退出
  • oldtype :输出型参数,备份线程原有的退出类型,如不关心设为NULL。

  • 返回值 : 成功返回0,失败返回错误码。

  • 实验

void* thread_run(void* argv)
{
    //不理会取消信号
    pthread_setcancelstate(PTHREAD_CANCEL_DISABLE,NULL);
    printf("%s (tid: %lu) is running\n",(char*)argv,pthread_self());
    
    int i=10;
    while(i)
    {
        sleep(1);
        i--;
        printf("%d\n",i);
        if(i==5)
        {
            //5秒后理会信号
            pthread_setcancelstate(PTHREAD_CANCEL_ENABLE,NULL);
            //立即取消
            pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS,NULL);
        }
    }
     return (void*)111;
}
int main()
{
    //g_tid=pthread_self();
    void* status=NULL;
    pthread_t tid;

    pthread_create(&tid,NULL,thread_run,(void*)"thread");
    printf("thread(tid:%lu) begin\n",tid);
    sleep(1);//防止运行速度过快,得先让子线程进入死循环
    pthread_cancel(tid);
    pthread_join(tid,&status);
    printf("thread 3 exit code = %d\n",(int)status);

    return 0;
}

在这里插入图片描述

创建取消点 pthread_testcancel

在不包含取消点,但又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求。

void pthread_testcancel(void);

线程cancelstate处于启用状态(ENABLE),且canceltype为延迟状态(DEFERRED)时,pthread_testcancel()函数有效。如果在cancel功能处于禁用状态下调用pthread_testcancel(),则该函数不起作用。

子线程 pthread_cancel 取消主线程

🚩注意:我们可以用主线程取消子线程,当然也可以用子线程取消主线程,如下代码:

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

pthread_t g_tid;//全局变量记录主线程id
void* thread_run(void* argv)
{
    while(1)
    {
        printf("i am %s (pid: %d,tid: %lu) is running\n",(char*)argv,getpid(),pthread_self());
        sleep(3);
        pthread_cancel(g_tid);//取消主线程
    }
}
int main()
{
    g_tid=pthread_self();//获取主线程id
    pthread_t tid;
    pthread_create(&tid,NULL,thread_run,(void*)"new thread");
    sleep(50);

    return 0;
}

在这里插入图片描述

我们再来查看进程状态:

在这里插入图片描述

其中的一条子线程仍在运行,我们将主线程取消了,但是发现此进程并没有退出,而是进入了僵尸态。所以这个子进程的资源将不能回收(没有主线程帮他join),导致系统资源浪费!

利用pthread_cancel可以让主线程退出,但是不能让进程退出。我们不建议使用子线程来 pthread_cancel 掉主线程。

5.线程分离

一般情况下,线程终止后,其终止状态一直保留到其他线程调用pthread_join获取他的状态为止。

如果我们对子线程的退出状态不感兴趣,也不想使用pthread_join一直阻塞在那里等待子线程退出,那我们可以使用线程分离,分离之后的线程不需要被join,运行完毕之后,操作系统会立即回收它所占用的所有资源,而不保留终止状态。

pthread_detach

#include <pthread.h>

int pthread_detach(pthread_t thread);
  • 参数

    • thread :欲分离的线程id
  • 返回值 :成功返回0,失败返回错误号。

线程分离后,线程被置为detach状态,与主线程断开关系,这样的线程调用 pthread_join 将会返回 EINVAL(宏,值为22)错误。

在这里插入图片描述

分离线程常用于网络与多线程服务器。

  • 实验代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
pthread_t g_tid;

void* thread1_run(void* argv)
{
    pthread_detach(pthread_self());
    printf("%s (tid: %lu) is running\n",(char*)argv,pthread_self());
    sleep(2);
    return (void*)111;
}

int main()
{
    //g_tid=pthread_self();
    void* status=NULL;
    pthread_t tid;

    pthread_create(&tid,NULL,thread1_run,(void*)"thread1");
    printf("create thread (tid:%lu)\n",tid);
    sleep(3);//留时间给子线程先分离
    int err=pthread_join(tid,&status);
    if(err==0)
        printf("err = %d ,status = %d\n",err,(int)status);
    else 
    {
        fprintf(stderr,"thread join err:%s\n",strerror(err));
        printf("err = %d ,status = %d\n",err,(int)status);
    }

    return 0;
}

在这里插入图片描述


青山不改 绿水长流

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值