linux就该这么学【线程基本概念】

在这里插入图片描述

重点学习:

  1. 了解线程概念,理解线程与进程区别与联系。
  2. 学会线程控制,线程创建,线程终止,线程等待。
  3. 了解线程分离与线程安全概念。
  4. 学会线程同步。
  5. 学会使用互斥量,条件变量,posix信号量,以及读写锁。
  6. 理解基于读写锁的读者写者问题。

进程总结、线程初步认识:

创建一个进程OS需要为你创建数据结构、task_struct、建立页表中虚拟地址和物理内存的映射关系,并将代码和数据装载到内存当中,CPU会去执行进程PCB所关联的代码,我们以往将的进程是一个单执行流的进程,而在今天我们先来灌输一下多进程多执行流的概念,进程其实还有一个这样的概念:进程是承担系统资源的基本实体,而线程是调度的基本单位,线程是在进程中的一条执行流,线程是在进程的地址空间执行,进程和线程的关系是1:N

在linux中没有真正意义上的线程,线程是使用进程模拟的。因为linux在设计线程时并没有为线程设计数据结构,那么OS是如何管理多线程的呢?OS会使用进程模拟线程,会使用task_struct来管理线程 , linux在多线程这块与windows不同的地方是,windows是有真正意义的线程,因为windows为了管理多线程会为他设计数据结构(tcb控制块),但是如果设计了专门管理线程的数据结构就需要考虑到维护线程和进程的关系,进程与线程资源占用的问题。。。一旦设计的复杂了,那么效率就会降低。

如何看待task_struct ?

进程和线程都是使用task_struct 来管理的,那么我们如何看待task_struct呢?我们依然可以将task_struct看作是一个进程控制块,如果进程中有单个PCB我们就将他看作是单个执行流的进程,如果有多个PCB我们就将他看作是多个执行流的进程,而每一个PCB只有一份对应的地址空间和同一份代码、页表,而在CPU看来每一个task_struct 都是只有进程地址空间其中的一部分,但是每一个线程看到的都是同一个进程地址空间,只是拥有其中的一部分都是并不完全拥有!,所以也可以把看做是一个轻量级进程
在这里插入图片描述

Linux线程概念

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

线程的优点

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

线程的缺点

  • 性能损失

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

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

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

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

线程异常

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

如果新线程出现了除0操作,那么就会产生硬件异常,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程,最终将进程给终止。
在这里插入图片描述

线程用途

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

进程和线程的区别

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位, 线程共享进程数据
  • 但也拥有自己私有的一部分数据:
  • 线程ID
  • 一组寄存器因为每个线程程需要拥有自己的一份上下文,任务状态段。
  • 每一个新线程都有一份自己的线程栈,保存在动态库中,而主线程的栈是存放在进程地址空间中的
    在这里插入图片描述
  • errno :错误码
  • 信号屏蔽字 :block(负责记录信号是否需要阻塞)
  • 调度优先级

进程的多个线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

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

进程和线程的关系如下图:
在这里插入图片描述

POSIX线程库

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

pthread_create创建线程

Linux 中的 pthread_create() 函数用来创建线程,它声明在<pthread.h>头文件中,语法格式如下:

int pthread_create(pthread_t *thread,  //指向线程变量
                   const pthread_attr_t *attr,  //默认填NULL
                   void *(*start_routine) (void *), //线程执行的接口函数
                   void *arg);//传递给start_routine函数的实参 

pthread_t *thread:传递一个 pthread_t 类型的指针变量,也可以直接传递某个 pthread_t类型变量的地址。pthread_t 是一种用于表示线程的数据类型,每一个 pthread_t 类型的变量都可以表示一个线程。

const pthread_attr_t *attr:用于手动设置新建线程的属性,例如线程的调用策略、线程所能使用的栈内存的大小等。大部分场景中,我们都不需要手动修改线程的属性,将 attr 参数赋值为 NULL,pthread_create() 函数会采用系统默认的属性值创建线程。

void * (*start_routine) (void *):以函数指针的方式指明新建线程需要执行的函数,该函数的参数最多有 1个(可以省略不写),形参和返回值的类型都必须为 void* 类型。void* 类型又称空指针类型,表明指针所指数据的类型是未知的。使用此类型指针时,我们通常需要先对其进行强制类型转换,然后才能正常访问指针指向的数据。

void *arg:指定传递给 start_routine 函数的实参,当不需要传递任何数据时,将 arg 赋值为 NULL 即可。

如果成功创建线程,pthread_create() 函数返回数字 0,反之返回非零值。各个非零值都对应着不同的宏,指明创建失败的原因,常见的宏有以下几种:

pthread_create创建线程失败返回值注意:
EAGAIN:系统资源不足,无法提供创建线程所需的资源。
EINVAL:传递给 pthread_create() 函数的 attr 参数无效。
EPERM:传递给 pthread_create() 函数的 attr 参数中,某些属性的设置为非法操作,程序没有相关的设置权限。74326

使用:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
void *thread_run (void *p)  //自定义新线程执行函数
{
  while(1){
    printf("p:%s, 线程所属的进程 进程id:%d\n", (char*)p, getpid());  
    sleep(1);
  }  

  return NULL;
}

int main()
{
      
   pthread_t tid; //创建出来的线程tid
   pthread_create(&tid, NULL,thread_run,(void*)"创建新线程");//父进程创建执行流
    
   /*while(1){
      printf("主线程所属进程:%d",getpid());
      sleep(2);
   }*/
   return 0;
}

在使用pthread系列的系统调用时,我们在编译的时候一定要指定pthread动态库的存放路径,不然在运行时就会出现链接错误。
在这里插入图片描述
解决办法:

在编写makefile的时候将指定动态库名字带上,那么就是告诉编译器在链接的时候去找到这个动态库,执行动态库中的代码执行完返回结果。

在这里插入图片描述

结果:
在这里插入图片描述
ps -aL :查看一个线程的id

通过实验现象可以观察到主线程创建新线程后,他们的进程ID是同一个,但是他们的线程ID不是同一个,OS也是通过 LWP(轻量级进程ID)去区分两个执行流的,并完成线程调度。

PID和LWP的区别:
1、PID是对一个进程起标识作用
2、LWP是对线程(轻量级进程)起标识作用
3、结论:同一个进程中的轻量级进程他们的PID是相同的,但是他们的LWP是不同的,OS在调度的时候是使用LWP的

pthread_self

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

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。

  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程,该值就是LWP

  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

  • 线程库NPTL(原生线程库)提供了pthread_ self函数,可以获得线程自身的ID:

pthread_t pthread_self(void);

pthread_t到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
在这里插入图片描述

线程等待 pthread_join

这个程序有没有什么错误的地方呢?
在这里插入图片描述

在主线程中创建新线程后,如果新线程执行完直接return 而主线程却不等待新线程的话会导致内存泄漏,和出现僵尸进程类似的情况,所以新线程退出的时候主线程需要等待新线程,接下来我们学习线程等待

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
thread:线程ID 
value_ptr:它指向一个指针,后者指向线程的返回值 返回值:
成功返回0;失败返回错误码

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

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_CANCELED。
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数

在这里插入图片描述

主线程等待新线程获取他的退出码并回收线程,其实线程的退出码是会存放在PCB中的exit_code变量中的,之前我们学习进程的时候,父进程在退出的时候会返回一个退出码,而这个退出码也是存放在PCB中的exit_code里的,

在这里插入图片描述

以不同方式退出新线程后,线程的退出码是什么样的?

1、如果以return的方式退出,刚刚也看到了就是return时候的返回值
2、使用pthread_exit退出的时候,线程的退出码是实际传递给pthread_exit函数的参数
在这里插入图片描述
运行结果:
在这里插入图片描述
3、在主函数中使用pthread_cancel函数终止掉新线程,线程的退出码是PTHREAD_CANCELED,这是一个常数。
在这里插入图片描述
程序运行结果:异常终止后线程的退出码是0。
在这里插入图片描述

线程分离pthread_detach

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

代码:
在这里插入图片描述

程序运行结果:从现象可以看出,当线程分离后,主线程就拿不到新线程的返回值了。因为新线程的退出码是10,但是主线程获取的退出码却是0
在这里插入图片描述

虽然线程分离之后,主线程不需要回收新线程的资源了,但是有一种情况还是需要重视起来的,就是即使线程分离后,新线程如果出现了异常错误,那么整个进程也就退出了,主线程也跟着完蛋。

void *thread_run (void *p)  //自定义新线程执行函数
{ 
    pthread_detach(pthread_self()); //将线程与主线程进行分离
    printf("p:%s, 线程所属的进程 进程id:%d, 新线程的线程id:%p\n", (char*)p, getpid(),pthread_self());  
    sleep(1);
    int a = 1 / 0; //除零错误导致引起硬件异常,
    return (void*) 10;  //由于新线程和主线程分离后,就不需要主线程去获取返回值并回收该资源了
}

程序运行结果:
在这里插入图片描述

Linux线程互斥

进程线程间的互斥相关背景概念

  • 临界资源: 多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

演示多线程下去访问同一块临界资源
在这里插入图片描述
运行结果:
在这里插入图片描述
那么如果主线程对全局变量的值进行修改之后,线程中看到的globalval全局变量的值就都被修改了。
在这里插入图片描述
运行结果:
在这里插入图片描述

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
  • 多个线程并发的操作共享变量,会带来一些问题,接下来列举一些情况

我们可以将每一个用户看作是一个线程,而车票看作是一份临界资源,用户买票的方式就是对临界资源进行访问的过程,这个过程会消耗已有的票数,可以在我们实际买车票的过程中是需要排队的,而如果不对用户进行管理起来,那么就会存在用户拥挤买票的过程,可能会对车票数量有一定的影响,所以实际生活中需要用户排队买票,而对应线程也是一样的,

在这里插入图片描述
模拟用户买票的过程:
在这里插入图片描述

当我们不对临界资源进行保护的时候,那么线程就可以随时对临界资源进行修改,最终造成的结果就是票都已经没有了,可是用户还在买票

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • - -ticket操作本身就不是一个原子操作
    在这里插入图片描述
    解决办法对临界区加锁,当一个用户在买票的时候另一个用户不得买票,也就是让用户排队,也就是通过对临界区加锁的方式确保临界资源的原子性。

在这里插入图片描述
在这里插入图片描述

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

pthread_mutex_t lock; //定义锁
int ticketscount = 100;
void *buyticket(void *p)
{
    int num = (int)p;
    while(1){
        pthread_mutex_lock(&lock); //加锁 
        if(ticketscount > 0){
            usleep(1000);
            printf("用户%ld 正在抢票,剩余票数:%d\n", num, ticketscount);
            ticketscount--;
            pthread_mutex_unlock(&lock);  //解锁
        }
        else{
            pthread_mutex_unlock(&lock); //解锁 
            break;
        }
    }  
} 

int main()
{
    //初始化锁
    pthread_mutex_init(&lock, NULL);
    //创建多个用户
    pthread_t arr[4];
    int i = 0;
    for(i= 0 ;i < 4; i++){
        pthread_create(arr + i, NULL, buyticket, (void*)i);
    }
    
    for(i = 0; i < 4; i++){
        pthread_join(arr[i], NULL);
    }
    
    //将锁释放
    pthread_mutex_destroy(&lock);
    return 0;
}

对用户进行管理后,此时就不会出现票已经没有了,却还是有用户在抢票的情况发生了。
在这里插入图片描述
总结:
想要保护临界资源,就要对临界区进行加锁,当访问完临界资源后再解锁,锁的粒度一定要细,才能尽可能 的保证线程的效率问题,

互斥量mutex实现原理探究

  • 经过上面的例子,大家已经意识到单纯的ticketscount- -或者- -ticketscount都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的 数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理 器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一 下

在这里插入图片描述
movb $o , %al 会将寄存器的值设置为0 。

在这里插入图片描述
xchgb %al, mutex 将EAX寄存器的值和内存中mutex的值交换。

在这里插入图片描述

假设当线程1到来的时候,第一次看到的是EAX寄存器中val值为0,而当EAX寄存器的值和mutex的值交换完了之后此时表示mutex的值已经被分配出去了,此时EAX 值为1,而mutex的值为0(表示被占用),而在线程中是需要保存上下文的,也就是当线程2到来的时候会恢复线程2的上下文发现EAX的值还是0,可是mutex的值却依旧是0,那么线程2是获取不到mutex的,因为mutex已经被线程1占用了!

在这里插入图片描述

而在往后处理的过程中,当线程拿到了mutex之后(中间涉及值交换过程),寄存器是会存放1值的,所以线程就会执行return 0,否则就执行挂起等待

总结多线程下的加锁解锁过程:

  • 1、对临界区进行保护,所有的执行线程都必须遵守这个规则(编码)
  • 2 、lock ->访问临界区-> unlock
  • 3、所有的线程必须先看到同一把锁,锁本身就是临界资源!锁本身得先保证自身安全!申请锁的;过程,不能有中间状态,也就是两态的,lock->原子性 unlock->原子性
  • 4、lock -> 访问临界资源 ->unlock,在特定线程 / 进程拥有锁的时候,期间有新线程过来申请锁,一定是申请不到的!那么该线程该如何呢? 可以通过加锁的方式保证线程此时在访问临界资源的时候其他的线程需要排队(被阻塞),而当该线程将临界资源访问完了,就需要解锁,那么其他的线程就可以访问该临界资源,而这个过程中OS需要将该线程的PCB投入到等待队列中,unlock之后,进行对线程的唤醒工作,将线程PCB由S状态设置为R状态,最后让OS对线程调度
  • 5、如何理解POSIX pthread的mutex?

锁其实也是需要被管理的,那么OS就需要先描述再组织,我们可以将一把锁看作是一个结构体,他的成员主要包含两个最重要的
struct mutex{
int lock; // 值为0表示被占有,值为1表示不被占有,可以被申请
wait_queue *head; //如果线程被阻塞,需要将该线程的PCB放入进等待队列当中。
}
pthread_mutix_init:对mutex的成员进行初始化
pthread_mutix_destory:对mutex对象释放他申请的内存
pthread_mutix_lock:将成员lock的值由1设置为0,表示该锁申请之后被占有
pthread_mutix_unlock:将成员lock的值由0设置为1,表示对锁进行解锁

  • 7、一次只保证有一个线程进入临界区,访问该临界资源,叫做互斥。
  • 8、加锁为什么一般效率会比较低?或者影响效率?

1、当所有的任务都串行了。
2、当线程被切换的时候,会导致OS的压力,运行情况会直接影响后续所有进程/线程的执行效率

可重入VS线程安全

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

常见的线程不安全的情况
1、不保护共享变量的函数
2、函数状态随着被调用,状态发生变化的函数
3、返回指向静态变量指针的函数
4、调用线程不安全函数的函数

常见的线程安全的情况
1、每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
2、类或者接口对于线程来说都是原子操作
3、多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况
1、调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
2、调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
3、可重入函数体内使用了静态的数据结构

常见可重入的情况
1、不使用全局变量或静态变量
2、不使用用malloc或者new开辟出的空间
3、不调用不可重入函数
4、不返回静态或全局数据,所有数据都有函数的调用者提供
5、使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系
1、函数是可重入的,那就是线程安全的
2、函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
3、如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别
1、可重入函数是线程安全函数的一种
2、线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3、如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态

死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

避免死锁算法

  • 死锁检测算法(了解)
  • 银行家算法(了解)

Linux线程同步

条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

为什么要存在同步? 同步的目的是让线程执行的效率更高
如何使用编码实现?

  • 1、如果条件不满足,等待,释放锁。
  • 2、通知机制

条件变量函数

初始化

int pthread_cond_init(pthread_cond_t *restrict cond,
					const pthread_condattr_t *restrict attr);
参数:cond:要初始化的条件变量 
attr:NULL

销毁

int pthread_cond_destroy(pthread_cond_t *cond) //销毁cond条件变量

等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond,
					pthread_mutex_t *restrict mutex); 
参数:cond:要在这个条件变量上等待 
mutex:互斥量,后面详细解释

唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond); 
唤醒多个线程
int pthread_cond_signal(pthread_cond_t *cond);
唤醒单个线程

案例:

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


//定义锁
pthread_mutex_t lock; 
//定义条件变量
pthread_cond_t cond;  

//线程1
void *pthreadrun1(void *arg)
{
    const char* str = (char*) arg;
    while(1){
      //等待通知
      pthread_cond_wait(&cond, &lock);  //等待条件变量满足时,申请锁
      printf("get cond, %s 活动...\n",str);
    }

}

//线程2
void *pthreadrun2(void *arg)
{
    const char* str = (char*) arg;
    while(1){
       //时隔两秒,发送通知
       sleep(rand() % 4);
       pthread_cond_signal(&cond);
       printf("%s signal done...\n",str);
    }

}

int main()
{
    pthread_mutex_init(&lock, NULL);
    pthread_cond_init(&cond, NULL);
    //创建线程
    char ch = '0';
    pthread_t t1, t2, t3, t4, t5;
    //pthread_t arr[4]; 
    //pthread_create(arr + 0, NULL, pthreadrun2, (void*)&ch);
    pthread_create(&t1, NULL, pthreadrun2, "t1");
    pthread_create(&t2, NULL, pthreadrun1, "t2");
    pthread_create(&t3, NULL, pthreadrun1, "t3");
    pthread_create(&t4, NULL, pthreadrun1, "t4");
    pthread_create(&t5, NULL, pthreadrun1, "t5");
    /*for(int i = 1; i < 4; i++){
        ch = i + '0';
        pthread_create(arr + i, NULL, pthreadrun1, (void *)&ch);
    }*/

    //等待 
    /*for(int i = 0; i < 4; i++){
        pthread_join(arr[i], NULL);
    }*/ 
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_join(t5, NULL);

    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
    return 0;
}

程序运行结果:

当线程1给其他线程发生通知之后,收到通知的线程由原来的等待状态变成了运行状态,所以可以通过这种同步的方式,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,而这里的临界资源就是这把锁。

请添加图片描述

生产者消费者模型:

为何要使用生产者消费者模型 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题生产 者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯所以生产者生产完数据之后不用等待消费者处理, 直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的

生产者和消费者对应的3种关系:
1、生产者和生产者的关系是互斥关系,我们可以将生产者看作是一个线程,当一个线程往阻塞队列中写入数据时,另外一个线程就需要等待,因为阻塞队列是属于临界资源的,为了保护临界资源需要对临界区加锁和解锁,所以他们之间的关系是互斥的。

2、生产者和消费者的关系是同步关系,因为当生产者生产完数据后放入阻塞队列中,再通知消费者去阻塞队列中取出数据,而消费者线程需要收到生产者线程发生的信号,如果不满足条件那么消费者线程就需要被阻塞了,所以生产者和消费者的关系是同步的。

3、消费者和消费者属于互斥关系,大家可以想一想我们买车票的过程,我们都是属于消费者是不是也要排队买票,当A在买票的时候B就不能再买票了。

生产者消费者模型优点:

  • 解耦
  • 支持并发
  • 支持忙闲不均

基于BlockingQueue的生产者消费者模型

BlockingQueue 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会 被阻塞直到队列中被放入了元素当队列满时,往队列里存放元素的操作也会被阻塞直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
在这里插入图片描述

C++ queue模拟阻塞队列的生产消费模型 代码

queue.hpp头文件

#ifndef  __QUEUE_HPP__
#define  __QUEUE_HPP__ 
#include <iostream> 
#include <queue>
#include <stdio.h>
#include <pthread.h>


template <class T>
class BlockQueue
{
  private: 
    bool IsFull()  { return _q.size() ==  _capacity ;}
    bool IsEmpty() { return _q.empty(); }

  public:
    BlockQueue(int capacity) 
        :_capacity(capacity)
    {
        pthread_cond_init(&_IsFull, NULL);
        pthread_cond_init(&_IsEmpty, NULL);
        pthread_mutex_init(&_lock, NULL);
    }
    ~BlockQueue()
    {
        pthread_cond_destroy(&_IsFull); 
        pthread_cond_destroy(&_IsEmpty);
        pthread_mutex_destroy(&_lock);
    }
    void Push(const T &val)
    {
      pthread_mutex_lock(&_lock);
      
       while(IsFull()){ //当条件满足时执行
          pthread_cond_signal(&_IsEmpty); //通知消费者
          pthread_cond_wait(&_IsFull, &_lock); //生产者进入等待
       }
        _q.push(val);

      pthread_mutex_unlock(&_lock);
    }

    void Pop(T &val)
    {
      
      pthread_mutex_lock(&_lock);
       while(IsEmpty()){  //当条件满足时执行
          pthread_cond_signal(&_IsFull); //向生产者发生通知
          pthread_cond_wait(&_IsEmpty, &_lock);//消费者被挂起
       }
       val = _q.front();
       _q.pop();
      pthread_mutex_unlock(&_lock);
    }

  private:
    std::queue<T> _q;    
    size_t _capacity;
    pthread_mutex_t _lock; //定义互斥锁,
    pthread_cond_t _IsFull; //生产者生产满  
    pthread_cond_t _IsEmpty;  //当消费者者消费完
    
};

main函数

#include "queue.hpp"
#include <stdlib.h>
#include <unistd.h>
using namespace std;


pthread_mutex_t sclock;
pthread_mutex_t xflock;
//生产者
void * run1(void *arg)
{
    BlockQueue<int> *p = (BlockQueue<int> *)arg; 
    while(1){
       usleep(10000);
       pthread_mutex_lock(&sclock);
       int data = 0;
       data = rand() % 10 + 1;  
       p->Push(data);
       pthread_mutex_unlock(&sclock);

       cout<< "生产者" << pthread_self() << "生产的数据是" << data << endl;
    }
}
//消费者
void *run2(void *arg)
{
    BlockQueue<int> *p = (BlockQueue<int> *) arg;
    while(1){
       usleep(10000);
       pthread_mutex_lock(&xflock);
       int n = 0;
       p->Pop(n);
       pthread_mutex_unlock(&xflock);

       cout << "消费者" << pthread_self() << "消费了" << n <<" 数据" << endl;
    }
}

void test()
{
    pthread_t t1, t2, t3, t4;
    pthread_mutex_init(&sclock ,NULL);
    pthread_mutex_init(&xflock ,NULL);

    BlockQueue<int> *q = new BlockQueue<int>(5); //创建阻塞队列
    pthread_create(&t1, NULL, run1,  (void*)q);
    pthread_create(&t2, NULL, run1,  (void*)q);
    pthread_create(&t3, NULL, run2,  (void*)q);
    pthread_create(&t4, NULL, run2,  (void*)q);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    delete q; 
    pthread_mutex_destroy(&sclock);
    pthread_mutex_destroy(&xflock);
}

int main()
{
    test();   

    return 0;
}

程序执行结果:
在这里插入图片描述
阻塞队列改写完成简易版解题神器

头文件

#ifndef  __QUEUE_HPP__
#define  __QUEUE_HPP__ 
#include <iostream> 
#include <queue>
#include <stdio.h>
#include <pthread.h>


class Add 
{
  private:
    int _x;
    int _y;
  public:
    Add(int x, int y) : _x(x), _y(y) {}
    int computers(){ return _x + _y; } 
    Add(){}
    int getx(){ return _x; }
    int gety(){ return _y; }
};

template <class T>
class BlockQueue
{
  private: 
    bool IsFull()  { return _q.size() ==  _capacity ;}
    bool IsEmpty() { return _q.empty(); }

  public:
    BlockQueue(int capacity) 
        :_capacity(capacity)
    {
        pthread_cond_init(&_IsFull, NULL);
        pthread_cond_init(&_IsEmpty, NULL);
        pthread_mutex_init(&_lock, NULL);
    }
    ~BlockQueue()
    {
        pthread_cond_destroy(&_IsFull); 
        pthread_cond_destroy(&_IsEmpty);
        pthread_mutex_destroy(&_lock);
    }
    void Push(const T &val)
    {
      pthread_mutex_lock(&_lock);
      
       while(IsFull()){
          pthread_cond_signal(&_IsEmpty); //通知消费者
          pthread_cond_wait(&_IsFull, &_lock); //生产者进入等待
       }
        _q.push(val);

      pthread_mutex_unlock(&_lock);
    }

    void Pop(T &val)
    {
      
      pthread_mutex_lock(&_lock);
       while(IsEmpty()){
          pthread_cond_signal(&_IsFull); 
          pthread_cond_wait(&_IsEmpty, &_lock);
       }
       val = _q.front();
       _q.pop();
      pthread_mutex_unlock(&_lock);
    }

  private:
    std::queue<T> _q;    
    size_t _capacity;
    pthread_mutex_t _lock; //定义互斥锁,
    pthread_cond_t _IsFull; //生产者生产满  
    pthread_cond_t _IsEmpty;  //当消费者者消费完
    
};

main函数

#include "queue.hpp"
#include <stdlib.h>
#include <unistd.h>
using namespace std;


pthread_mutex_t sclock;
pthread_mutex_t xflock;
//生产者
void * run1(void *arg)
{
    BlockQueue<Add> *p = (BlockQueue<Add> *)arg; 
    while(1){
       usleep(10000);
       pthread_mutex_lock(&sclock);
       int x = rand() % 10 + 1;
       int y = rand() % 10 + 1;
       Add data(x, y);  
       p->Push(data);
         
       cout << x << " + " << y << "结果值是:" 
         <<  "?" << endl;
       pthread_mutex_unlock(&sclock);
        
    }
}
//消费者
void *run2(void *arg)
{
    BlockQueue<Add> *p = (BlockQueue<Add> *) arg;
    while(1){
       usleep(10000);
       pthread_mutex_lock(&xflock);
       Add data;
       p->Pop(data);
       pthread_mutex_unlock(&xflock);
       cout << data.getx()<< " + " << data.gety() << "=" << data.computers() << endl;
    }
}

void test()
{
    pthread_t t1, t2, t3, t4;
    pthread_mutex_init(&sclock ,NULL);
    pthread_mutex_init(&xflock ,NULL);

    BlockQueue<Add> *q = new BlockQueue<Add>(5); //创建阻塞队列
    pthread_create(&t1, NULL, run1,  (void*)q);
    pthread_create(&t2, NULL, run2,  (void*)q);
    pthread_create(&t3, NULL, run2,  (void*)q);
    pthread_create(&t4, NULL, run2,  (void*)q);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    delete q; 
    pthread_mutex_destroy(&sclock);
    pthread_mutex_destroy(&xflock);
}

int main()
{
    test();   

    return 0;
}

makefile文件
在这里插入图片描述
程序运行结果:
在这里插入图片描述

POSIX信号量

信号量是什么?
信号量的本质是一个计数器!是描述临界资源有效个数的计数器,

  • 当用户申请一个临界资源后计数器会- - ,也叫做P操作( Passeren(通过))
  • 当用户归换临界资源后,计数器会++,也叫做V操作(Vrijgeven(释放))

PV操作也是原子操作,因为在使用临界资源的时候会对计数器- -,而当归还临界资源后会对计数器++,PV操作一定自己本身属于原子的才能做到保护其他临界资源。

在这里插入图片描述
为什么信号量需要被设计成临界资源计数器呢?
我们看待临界资源的方式变了,临界资源可以看成是多份,并且临界资源不会冲突,可以让线程在并行访问临界资源的时候提高效率。

怎么使用信号量?
其中有关于信号量的几个接口函数是:

  • 初始化信号量
#include <semaphore.h> 
int sem_init(sem_t *sem, int pshared, unsigned int value); 
参数:pshared:0表示线程间共享,非零表示进程间共享 
value:信号量初始值
  • 销毁信号量
int sem_destroy(sem_t *sem);
  • 等待信号量 ,p操作
int sem_wait(sem_t *sem);
功能:等待信号量,会将信号量的值减1
  • 发布信号量,v操作
int sem_post(sem_t *sem);
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1

基于环形队列的生产者消费者模型:

  • 环形队列采用数组模拟,用模运算来模拟环状特性
  • 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态
  • 但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程

在设计循环队列模型我们先来梳理生产者和消费者之间的关系吧。

  • 身份:消费者
    p()操作: 消费者申请数据,数据会减少p(sem_data),当消费者消费完了之后,进行v()操作,
    就会有多出来的空格子v(sem_blank)

  • 身份:生产者
    v()操作: 当生产者要生产数据,对应的格子就会减少p(sen_blank),当生产者申请完数据后,
    而消费者的数据就会增多v(sem_data)

  • 情况1:生产者生产快,消费者消费慢,生产者会将数据全部生产完后p(sem_blank), 他的格子也就减完了
    ,此时生产者需要被挂起等待,而消费者需要开始消费数据,当消费者消费完数据后p(sem_data),
    在进行v(sem_blank)操作,将格子资源归还生产者。

  • 情况2:生产者生产慢,而消费者消费快,那么消费者需要先申请数据p(sem_data)当消费者消费完数据后,
    在进行v(sem_blank)操作,生产者的格子就会增多,如果当数据没有的时候消费者就会被挂起

RingQueue.hpp头文件

#ifndef __RING_QUEUE_hpp_
#define __RING_QUEUE_hpp_
#include <iostream>
#include <pthread.h> //线程库
#include <assert.h>
#include <semaphore.h> //信号量头文件
#include <vector>
using namespace std;


namespace mzt
{
  template <class T>
  class RingQueue
  {
  private:
    size_t _limit; // 循环队列的限度
    size_t _begin; // 数据队头出
    size_t _end;   // 数据入队尾
    size_t _size;  // 数据个数
    vector<T> _v;
    sem_t consumer_data;  //消费者关心的数据
    sem_t producer_blank; //生产者关心的格子

  public:
    RingQueue(size_t size = 10) : _limit(size), _begin(0), _end(0), _size(0)
    {
      sem_init(&consumer_data, 0, size); // size表示格子的数量
      sem_init(&producer_blank, 0, 0);   // 0表示数据量
      _v.resize(size);
    }

    //生产者生产数据
    void push(const T &val)
    {
      //生产者先生产,那么就需要申请格子用于存放数据
      sem_wait(&producer_blank);

      //生产者开始生产
      _size++;
      _v[_end++] = val;
      _end %= _limit;

      //生产者生产完数据,进行v操作,消费者的数据会增多
      sem_post(&consumer_data);
    }

    //消费者消费数据
    T front(T &val)
    {
      //消费者消费数据,先进行申请数据
      sem_wait(&consumer_data);

      //开始消费
      _size--;
      val = _v[_begin++];
      _begin %= _limit;

      //当消费者消费完数据后,生产者的格子会增多
      sem_post(&producer_blank);

      return val;
    }

    ~RingQueue()
    {
      sem_destroy(&consumer_data);
      sem_destroy(&producer_blank);
      _begin = _end = _size = 0;
    }

    bool empty() { return size() == 0; }
    bool isFull() { return size() == _limit; }
    size_t size() { return _size; }
  };

}
#endif

main函数

#include "RingQueue.hpp"
#include <stdio.h>
#include <stdlib.h>
void *consumer_run(void *arg)
{
    mzt::RingQueue<int> *q = (mzt::RingQueue<int> *)arg;
    while (1)
    {
        int val = 0;
        val = q->front(val);
        cout << "consumer done ... " << val << endl;
    }
}
void *producer_run(void *arg)
{
    mzt::RingQueue<int> *q = (mzt::RingQueue<int> *)arg;
    int rand_val = 0;
    while (1)
    {
        rand_val = rand() % 100 + 10;
        q->push(rand_val);
        cout << "producer done ..." << rand_val << endl;
    }
}

void test()
{
    pthread_t c, p;
    mzt::RingQueue<int> *q = new mzt::RingQueue<int>();
    pthread_create(&c, NULL, consumer_run, q);
    pthread_create(&p, NULL, producer_run, q);

    pthread_join(c, NULL);
    pthread_join(p, NULL);
}

int main()
{
    test();

    return 0;
}

程序运行结果:
在这里插入图片描述

多生产者多消费者环境下的并发处理

#include "RingQueue.hpp"
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

pthread_mutex_t lock1, lock2;
void *consumer_run(void *arg)
{
    pair<mzt::RingQueue<int> *, string> *p = (pair<mzt::RingQueue<int> *, string> *)arg;
    while (1)
    {
        usleep(10000);
        int val = 0;
        pthread_mutex_lock(&lock1);
        val = p->first->front(val);
        pthread_mutex_unlock(&lock1);

        cout << p->second << " consumer done ... " << val << endl;
    }
}
void *producer_run(void *arg)
{
    pair<mzt::RingQueue<int> *, string> *p = (pair<mzt::RingQueue<int> *, string> *)arg;
    int rand_val = 0;
    while (1)
    {
        usleep(10000);
        rand_val = rand() % 100 + 10;

        pthread_mutex_lock(&lock2);
        p->first->push(rand_val);
        pthread_mutex_unlock(&lock2);

        cout << p->second << " producer done ..." << rand_val << endl;
    }
}

void test()
{
    pthread_mutex_init(&lock1, NULL);
    pthread_mutex_init(&lock2, NULL);

    pthread_t c, p, c1, p1;
    mzt::RingQueue<int> *q = new mzt::RingQueue<int>();

    pair<mzt::RingQueue<int> *, string> c_1(q, "c");
    pair<mzt::RingQueue<int> *, string> p_1(q, "p");
    pair<mzt::RingQueue<int> *, string> c_2(q, "c1");
    pair<mzt::RingQueue<int> *, string> p_2(q, "p1");

    pthread_create(&c, NULL, consumer_run, &c_1);
    pthread_create(&p, NULL, producer_run, &p_1);
    pthread_create(&c1, NULL, consumer_run, &c_2);
    pthread_create(&p1, NULL, producer_run, &p_2);

    pthread_join(c, NULL);
    pthread_join(p, NULL);
    pthread_join(c1, NULL);
    pthread_join(p1, NULL);

    pthread_mutex_destroy(&lock1);
    pthread_mutex_destroy(&lock2);
}

int main()
{
    test();

    return 0;
}

程序运行结果:
在这里插入图片描述

线程池

我们可以粗略的想象一下server在接受client请求后的处理问题的过程,一共有以下几点:
1、客户端发送请求,等待服务端处理
2、服务端接受请求,创建线程处理,返回结果给用户
在这里插入图片描述
问题?
我们都知道线程的创建是有成本的,空间和时间,而站在用户的角度,用户关心的是时间,但是每次创建线程去处理用户请求都需要花费时间,用户的延迟就会比较多,有没有什么好的办法,让用户等待的时间降低呢?批量式创建线程,做一个线程池,每次创建大量的线程供处理用户请求

在这里插入图片描述

线程池:
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个 线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内 存、网络sockets等的数量。
线程池的应用场景:

  1. 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使 用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于 长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大 多了。
  2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没 有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程 可能使内存到达极限,出现错误.

线程池的种类:

  • 线程池示例:

1、创建固定数量线程池,循环从任务队列中获取任务对象,
2、获取到任务对象后,执行任务对象中的任务接口

代码:
pthread_pool.hpp头文件

#pragma once
#include <iostream>
#include <pthread.h>
#include <vector>
#include <queue>
#include <math.h>
using namespace std;

namespace mzt
{
    template <class T>
    struct task
    {
        T _data;
        void Runtask()
        {
            cout << "task val is " << _data << "算出平方:" << pow(_data, 2) << endl;
        }
        task(const int &val) : _data(val) {}
        task() {}
    };
    template <class T>
    class Pthread_Pool
    {
    public:
        Pthread_Pool *front()
        {
            Pthread_Pool *ret = _q.front();
            _q.pop();
            return ret;
        }

        void push(const T &val)
        {
            //生产者生产数据的过程需要保护临界资源
            pthread_mutex_lock(&_lock);
            _q.push(new task<T>(val));
            pthread_mutex_unlock(&_lock);

            pthread_cond_signal(&_cond);
        }

        static void *Routine(void *arg)
        {
            Pthread_Pool *this_p = (Pthread_Pool *)arg;
            while (1)
            {
                pthread_mutex_lock(&this_p->_lock);
                while (this_p->_q.empty()) //如果任务队列已经为空,那么就需要将消费者给挂起
                {
                    pthread_cond_wait(&this_p->_cond, &this_p->_lock);
                }

                //取出队列数据,执行该任务
                task<T> *p = this_p->_q.front();
                this_p->_q.pop();
                pthread_mutex_unlock(&this_p->_lock);
                p->Runtask();
            }
        }
        Pthread_Pool(const int &val = 10) : _max(val) {}
        void Pthread_Pool_Init()
        {
            //初始化,
            pthread_mutex_init(&_lock, NULL);
            pthread_cond_init(&_cond, NULL);

            //创建线程
            pthread_t t;
            for (size_t i = 0; i < _max; i++)
            {
                pthread_create(&t, NULL, Routine, this);
            }
        }
        ~Pthread_Pool()
        {
            pthread_mutex_destroy(&_lock);
            pthread_cond_destroy(&_cond);
            _max = 0;
        }

    private:
        size_t _max;
        std::queue<task<T> *> _q; //任务队列,存储任务指针
        pthread_mutex_t _lock;
        pthread_cond_t _cond; //条件变量,用来阻塞消费者
    };

}

main函数:

#include "pthread_pool.hpp"
#include <stdlib.h>
#include <stdio.h>
void test()
{
    //创建线程池
    mzt::Pthread_Pool<int> *q = new mzt::Pthread_Pool<int>();
    q->Pthread_Pool_Init();
    
    //往线程中任务队列放入数据
    while (1)
    {
        int val = rand() % 90 + 10;
        q->push(val);
    }
}

int main()
{
    test();
    while (1)
        ;
    return 0;
}

程序运行结果:
在这里插入图片描述

在这里插入图片描述

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

爱生活,爱代码

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

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

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

打赏作者

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

抵扣说明:

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

余额充值