Linux-线程(LWP)

文章目录

  • 线程
    • 线程概念
        • 进程
          • 今天的进程 vs之前的进程
          • 私有和共享资源
          • 实验验证
            • 线程的优点:
            • 线程的缺点:
            • 线程异常
            • 线程的用途:
    • 线程控制
      • 创建线程
          • (1)先创建两个线程:链接时要引入第三方库。
          • (2)创建多个线程:
          • (3)线程的健壮性不强验证
      • 线程等待
          • 实验:获取退出信息
      • 线程中止
          • (1) 函数中return
          • (2) 新线程通过`pthread_exit(void* retval );`中止自己
          • (3) 取消目标线程
      • 线程分离
          • 实验验证:一个线程被设置分离之后,绝对不能再进行join了
    • 线程ID的理解
    • 线程互斥
          • 实验:抢票的逻辑
            • 加锁mutex之后的效果
          • 多线程申请锁是原子性的原理
          • 可重入函数
          • 可重入函数和线程安全的联系
          • 可重入函数和线程安全的区别
        • 死锁
          • 死锁的必要条件
    • 线程同步
      • 条件变量
            • 实验:一个线程控制另一个线程
        • 生产者消费者模型
            • 举例:超市的存在
          • 生产者和消费者的”321原则“
        • 基于阻塞队列的生产者消费者模型
            • 实验:完成一个任务派发的功能
      • POXSI信号量
        • 认识信号量对应的函数
        • 环形队列
            • 一 基本原理
            • 二 基本实现
        • 结合信号量和唤醒队列编写生产消费模型
          • 单生产者单消费者的基于信号量的没有互斥锁的模型
            • 改成多生产多消费者模型该怎么改?
    • 线程池
    • 线程安全的单例模式
          • 实验:懒汉方式实现单例模式版本的线程池
    • STL&&智能指针线程安全

线程

线程概念

  • 教材上:

​ 线程:在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更细和轻量化。

常规操作系统:Windows:

一个进程可能会存在多个线程。1:n。

操作系统要不要管理线程呢?先描述在组织。所以线程也应该要有线程控制块
TCBstruct tcb{};

  • Linux:

只创建task_struct共享一个地址空间,当前进程的资源划分为若干份。CPU现在在调度的时候看到的task_struct<=之间讲的PCB。

所以,linux中没有专门的为线程设计的TCB,而是用进程的PCB来模拟线程,不用维护复杂的进程和线程之间的关系,不用为线程设计任何算法,直接使用进程的一套相关方法。OS只需要聚焦在线程的资源分配上。

  • 总结:
  1. 线程在进程的地址空间内运行。
  2. 执行分支:CPU调度的时候只看PCB,每一个PCB曾经被指派过指向方法和数据,CPU可以直接调度。

进程

今天的进程 vs之前的进程

之前的进程,内部只有一个执行流的进程。今天的进程,内部可以具有多个执行流。

现在创建进程:需要PCB,地址空间,页表,加载在内存中的代码和数据以及构建之间的各种关系

创建进程的成本是非常高的,成本:时间+空间。创建进程要使用的资源是非常多的(0-1)
内核视角:进程是承担分配系统资源的基本实体。
线程是CPU调度的基本单位,承担进程资源的一部分实体,进程划分资源给线程。

Linux: PCB <= 传统意义(Windows)上的进程PCB,更轻量化在OS创建线程以及CPU调度上。

  • Linux进程,轻量级进程

Linux因为是用进程模拟的,所以Linux下不会给我们提供直接操作线程的接口(fork()),而给我们提供的是在同一个地址空间内创建PCB的方法,分配资源给指定的PCB的接口。
Linux本身是不会给提供线程操作的接口,对用户十分不友好。
系统级别的工程师,在用户层对LInux轻量级进程接口进行封装给我们打包成库
让用户直接使用库接口,原生线程库(用户层)

私有和共享资源

所有的轻量级进程(线程)都是在进程的内部进行(地址空间:表示进程所能看到的大部分资源)
进程,独立性,可以有部分共享资源(管道,ipc资源)
线程大部分资源是共享的,可以有部分资源是私有的(pcb,栈结构和调度上下文产生的临时数据)

私有的:线程ID,一组寄存器,栈,errno,信号屏蔽字(block表),调度优先级

公有的:文件描述符表,每种信号的处理方式,当前工作目录,用户id和组id

实验验证

pthread_create();

NAME
       pthread_create - create a new thread
SYNOPSIS
       #include 
       int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);
       Compile and link with -pthread.

Linux-线程(LWP)_第1张图片

信号发送是给进程发的,一个进程中含有两个执行流也就是两个线程,getpid()获取的就是进程ID。

ps -aL:L叫做查看轻量级进程 LWP:light weight process

Linux-线程(LWP)_第2张图片

线程的优点:

创建一个新线程的代价要比创建一个新进程小的多。

CPU+IO密集型这样的应用:网络游戏

  • 计算密集型应用:加密大数据运算,主要使用CPU资源
    线程并不是越多越好,尽量保证线程数不多于CPU的核数,比如双CPU,八核就是一个最大值。
    如果线程过多,会导致线程间被过度调度切换(有成本的)

  • I/O密集型应用:内存和外设的IO资源。网盘下载,ssh,在线直播,看电影。带宽
    IO是允许多一些线程,IO是比较慢的,大部分的时间都是在等输入。
    线程多了一些,将IO操作时的等待时间重叠。将等女孩子答应的时间进行重叠,提高效率。一次找200个女生,成功效率更高。

线程的缺点:

健壮性降低:线程之间是缺乏保护的,而进程之间是有独立性的。
缺少访问控制:进程之间不可不想访问,线程互相是可以访问的,存在误操作影响更大。
性能损失:

线程异常

一个线程出现问题,OS发出信号是给进程的,进程也就中止了,其他当中的线程也会跟着崩溃,资源也就释放了。

线程的用途:

合理的使用多线程,能提高IO密集型程序的用户体验(看电影,边下边播)和CPU密集型程序的执行效率。

线程控制

创建线程

函数pthread_create();

NAME
       pthread_create - create a new thread
SYNOPSIS
       #include 
       int pthread_create(pthread_t *thread, //无符号长整型,线程ID
                          const pthread_attr_t *attr,//线程属性直接默认NULL,OS最懂
                          void *(*start_routine) (void *), //函数指针,线程调用的方法
                          void *arg);//想给线程传入的参数,强转为void *
       Compile and link with -pthread.

pthread_self();谁调用这个函数,就获取他的线程id,并不是使用操作系统的接口,而是使用的是第三方的库pthread (LinuxOS默认已经装了)

(1)先创建两个线程:链接时要引入第三方库。
  • 为什么线程id是不一样的呢?

Linux-线程(LWP)_第3张图片

(2)创建多个线程:

Linux-线程(LWP)_第4张图片

(3)线程的健壮性不强验证

一旦一个线程发生错误,整个进程都会被杀掉。

Linux-线程(LWP)_第5张图片

线程等待

一个线程也是需要被等待的,如果不等待可能会造成“僵尸进程”得问题。

创建线程办事,然后等待他完成,得到结果
int pthread_join(pthread_t pthread,void** retval);

NAME
       pthread_join - join with a terminated thread

SYNOPSIS
       #include 
       int pthread_join(pthread_t thread, 
                     void **retval);//输出型参数用来获取新线程退出的时候,thread_run函数的返回值。
								//线程函数返回值是void*x,那么我们在用它的时候后就是*retval=x;
								//所以传入时就是retval=&x (void**)
       Compile and link with -pthread.

结果有三种,跑完结果对,跑完结果不对,没跑完就异常中止了,由退出码决定,retval传给函数的就是这个信息。

实验:获取退出信息

Linux-线程(LWP)_第6张图片

  • 我们能够获得退出信息,那异常呢?
    pthread_join()根本不需要处理;不是线程的问题,是进程的问题。

  • 不要认为这里的返回值只是所谓的整数,也可以是其他变量对象的地址,不能是临时的。
    64位机器超过8个字节,要封装成结构体来处理。
    现在只能是创建一批线程,也得是循环式的等待,不能同时等待。

线程中止

(1) 函数中return

main函数中return代表的是主线程进程退出
其他线程函数return,只代表当前线程退出

(2) 新线程通过pthread_exit(void* retval );中止自己

exit()是终止进程,不要在其他线程中使用,如果你只想中止一个线程的话。

(3) 取消目标线程

pthread_cancel(thread);线程被取消,退出码是-1(PTHREAD_CANCELED).

  • 取消新线程

Linux-线程(LWP)_第7张图片

  • 用新线程取消主线程,显示僵尸,但是进程不退出(return的话进程会退出)

    Linux-线程(LWP)_第8张图片

线程分离

进程存在替换,线程就分离就行。分离之后的线程不需要被join等待,运行完毕之后会自动释放Z,pcb。相当于signal(SIGCHLD,SIGIGN);
pthread_detach();谁分离就传谁的ID.

实验验证:一个线程被设置分离之后,绝对不能再进行join了

Linux-线程(LWP)_第9张图片主线程不退出,新线程处理业务完毕再退出。

线程ID的理解

LWP:OS内核LWP,和线程ID是不一样的。

  • 内个线程ID是啥啊?
    是pthread库的线程ID,不是Linux内核中的LWP,pthread库的线程ID是一个内存地址,虚拟地址。

首先,pthread库本质就是磁盘上的一个文件,如果要使用这个库就需要将他加载到内存中,内存中只要有一份就行,不同线程根据页表各自找到这个地方就行,所以是共享库 。

Linux-线程(LWP)_第10张图片

每个线程都有运行时的临时数据,所以每个线程都有自己的私有栈结构
线程属性在库里面,描述线程的用户级控制块pthead_self()获得的线程ID是属性,就是从库里面拿。
描述多个线程属性的结构体struct pthread{},线程局部存储数据栈结构,线程控制块tcb:都存储在动态库当中。

所以,新线程所使用的栈在库当中,库来维护栈结构。

Linux-线程(LWP)_第11张图片

  • 怎么找到特定线程呢?

线程在库当中的虚拟内存地址1234来充当线程id,方便用户层调用。只要拿到了线程ID,就拿到了线程在库中的地址,然后就能按到线程的所有用户级数据(栈结构存数据,属性结构体,线程控制块tcb)

pthread库是属于用户层的描述线程的属性和数据结构。
Linux实现线程的方案:内核中的pcb的LWP,一定要在在内核当中对应的创建一个LWP,用户级线程和内核轻量级进程1:1对应,由OS的LWP实现线程的调度。

就像FILE里面的fd,struct pthread {}里面需要包含LWP,原理上可能一个上层线程对应内核中多个执行流,LWP来实现在众多执行流中,实现一一对应。

  • 线程崩溃的影响是有限的,因为在进程内部,进程具有独立性。

线程互斥

因为多个线程是共享地址空间的,也就是很多的资源都是共享的。优点:通信方便;缺点:缺乏访问控制
因为一个线程的操作问题,给其他线程造成了不可控的问题,引发线程安全问题
thread_run 函数在写的时候被多个线程同时访问,就是多个执行流,所以函数被重入是很常见的。
创建一个函数没有线程安全问题的话,不适用全局,stl malloc new 等会在全局有效的数据(访问控制)
因为都是局部变量,线程有自己的独立栈结构

  • 为什么需要后续的访问控制?互斥同步

​ 为了避免一个线程因对于与其他线程共享区域的处理导致其他执行流异常或者崩溃的现象,

临界资源:凡是被线程共享访问的资源都是临界资源(多线程,打印数据到显示器(临界资源))

不是所有的代码都是在访问临界资源的。而访问临界资源的代码区域我们称之为临界区。
临界区:我的代码中,访问临界资源的代码。

对于临界区进行保护的功能,本质就是对于临界资源的保护,方式:互斥或者同步。

  • 互斥:在任意时刻,只允许一个执行流访问某段代码(某部分资源)就可以成为互斥。

打印的时候完成互斥就需要申请锁释放锁的过程->一个事情,要么不执行,要么就执行完毕,原子性。

但是在符合互斥(一直某刻只给一个人),符合原子性的时候可能某一个竞争力强,一直只有他一个执行流获得资源,别人得不到,所以,应该让他情景合理性。

  • 同步:防止饥饿问题的产生(某一线程竞争性强,过多占用资源)
    让访问资源的过程在安全的前提之下(一般都是互斥and原子性的),让访问资源具有一定的顺序性。再nb你也得重新排队。
实验:抢票的逻辑

5个线程同时在抢,全局变量tickets就是临界资源,多个线程之间要进行切换。为了体现出问题,需要将线程切换的速度增大,切换的越快越有可能抢到同一张票(线程不安全)。

  • 线程什么时间进行切换呢?

    • 时间片到了
    • 在从内核态到用户态的时候(系统调用接口比如printf())
  • 现象:ticket出现负数

    Linux-线程(LWP)_第12张图片

  • tickets–不是安全的,不是原子性的,为啥啊?

    首先:我们需要知道内存当中的数据,经过–计算需要将他加载到CPU当中,进行运算之后再将返回值重新写入会内存当中,在汇编的时候,tickets–可能是多行代码,在任何一行代码处都有可能发生切换线程,假如A线程刚挂起,B线程就切换进来,此时A线程带着他的上下文数据(比如tickets=1000)走了,B线程进入CPU走while循环不断的将票数–,直到某一个时刻比如tickets=100了,将数据写入到内存当中,此时A线程又回来了,带着上下文数据到CPU当中,执行–,假如到tickets=666回到内存的时候,将剩余票数从100又更改为666,造成剩余票数的混乱。

现象:
购票的时候,不能出现负数的情况。
解决方式:对临界区进行加锁。代码中的临界区就是if()条件判断和–操作的部分。加锁之后执行这部分代码的执行流就是互斥的,串行执行的。

原生线程库更靠近底层系统级别。语言层面上都有对于类的封装。

加锁mutex之后的效果

初始化:pthread_mutex_init();

Linux-线程(LWP)_第13张图片

C++11中已经存在了mutex锁,只不过我们使用的是原生线程库中的锁需要自己封装类初始化啥的。

Linux-线程(LWP)_第14张图片

Linux-线程(LWP)_第15张图片

我要访问的临界资源,先访问mtx,前提是所有线程先看到锁

  • 那么锁本身就是临界资源,如何保证锁是安全的呢?本身就是原子性的
    原理:加锁和解锁是原子的。
  • 这就是为什么需要锁是原子的。
  • 规定:一行代码是原子的,是一行汇编语句就是原子的。
多线程申请锁是原子性的原理

movb $0,%al(CPU寄存器)

当CPU执行进程A的代码时,CPU中寄存器的数据是线程A私有的执行流的上下文。
在内存当中,mutex的内容是1.
当A线程先进来CPU执行交换语句,

xchgb %al,mutex

使用一行汇编原子性的完成共享的内存数据交换线程A的上下文中,从而实现私有的过程。
也就是将内存中的mutex 的1(代表可用),和线程A寄存器%al中默认的0进行交换,

if(al寄存器中的内容>0)//申请锁成功
    return 0;
else
    挂起等待
goto lock;

经过代码处理,实现了A获取到mutex的上锁。A竞争锁成功,你就可以继续往下走。

当此时下一个线程B过来的时候,同样执行交换语句,只不过此时内存中mutex是0,是之前A交换过来的,
代表锁已经被A用了,你就不能用了。然后交换之后同样都是0,%al就执行挂起等待,等到A线程解锁
之后,B线程等在进行加锁操作。竞争能力强的,就可以竞争成功。

mutex的本质是通过一条汇编,将锁数据交换到自己的上下文中

if(al寄存器中的内容>0)//申请锁成功
    //......
    return 0;

return 0之前是临界区,处理代码有很多,

  • 临界区可能被切换走呢?
    完全有可能
  • 线程被切走的时候要做什么?
    上下文数据的保护,锁数据也是在上下文当中。
    拥有锁,被切走的线程,抱着锁走的。别的线程在此期间,休想申请锁成功,休想进入临界区。
    对其他线程有意义的状态是A线程要么没有申请,要么A使用完锁释放,完成了线程A的原子性。

为了保证临界区的安全,必须保证每个线程都必须遵守编码规范。A有锁,别的线程都得申请锁。

也可以设置一把静态锁:static pthread_mutex_t mtx=PTHREAD_MUTEX_INITIALIZER;

线程安全说的是线程之间得对于临界资源的处理。不可重入是对函数层面的问题。

可重入函数
  • 不使用全局变量或者静态变量
  • 不适用用malloc或者new开辟出来的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据
可重入函数和线程安全的联系
  • 函数是可重入的就是线程安全的。
  • 不可重入函数就不能被多个线程使用,有可能引发线程安全的问题
  • 如果函数中有全局变量,既不是线程安全也不是可重入函数
可重入函数和线程安全的区别
  • 可重入函数是线程安全的函数的一种
  • 线程安全不一定是可重入的,但是可重入函数一定是线程安全的。
  • 对临界资源加锁,这个函数线程安全,但是如果这个重入函数锁不释放就会产生死锁因此是不可重入的

死锁

执行流不会得以推进的情况就叫做死锁。
只有一个执行流,一把锁的时候,可能会出现死锁。骑驴找驴的过程。
申请完锁得到之后,又申请了一次,但是没成功。也就是你抱着锁等待别人释放锁而你就是那个别人。你等待的过程就被挂起了。

死锁的必要条件

Linux-线程(LWP)_第16张图片

互斥(一个资源只能被一个执行流使用)

请求与保持(一个执行流因为请求资源而阻塞时,对已经获得的资源保持不放)

不剥夺条件(不让强行剥夺,只能口头催促)

循环等待条件(三个人拿着三把锁,形成环路的要别人的锁)

锁过多使用会造成效率的降低

线程同步

条件变量

一般而言,只有锁的情况,我们其实是比较难知道临界资源的状态!要想知道临界资源的状态,除了轮询访问资源的情况,另一种就是设置条件变量(通知另一方,我这已经就绪的机制)

原生线程库有pthread_cond_t,主要函数有pthread_cond_wait(),等待消息就绪,条件变量内部一定存在等待队列!
pthread_cond_signal(),唤醒cond等待队列里面等待的第一个线程
pthread_cond_brodcast(),唤醒所有等待的线程

实验:一个线程控制另一个线程

Linux-线程(LWP)_第17张图片

我们发现是有顺序的,那么他跑完了肯定是去队列后面等去了,所以验证了条件变量内部存在队列。

struct cond
{
	int status;
	task_struct* queue;
}

生产者消费者模型

函数和函数之间的交互本质其实就是数据通信,你要想调用我的函数,你得给我传参,我给你返回值之类的。
单进程下的函数交互就是串行。

  • 那么函数A和B之间能否并行运行呢?有可能->生产者和消费者模型提高效率。
举例:超市的存在

生产者是供货商,超市只是中介。超市收集需求增大需求,方便供应商提供货物,减少交易成本。
生产者消费者模型将生产环节和消费环节进行解耦。超市本身就是一份临界资源。

生产者和消费者的”321原则“

三种关系:

供货商之间关系:竞争,互斥
消费者之间关系:竞争,互斥
供货商和消费者之间:互斥(同一份资源的先后问题),同步

生产者和消费者就是一个个的线程,执行流,分为两种角色。
超市是二者的交易场所,一段缓冲区(内存空间,容器) ,1个交易场所

基于阻塞队列的生产者消费者模型

.hpp函数的声明和实现放在一个文件当中,开源软件中使用。

让两个线程识别同一个阻塞队列,实现一个消费者一个生产者,通过传参,数据交互,阻塞队列就是一个临界资源,我们需要给临界资源上锁。

当生产满了的时候,就应该不要竞争锁老一直生产了,应该把锁给消费者进行消费了。同理,当消费没了的时候,就不要竞争锁了。所以需要条件变量full 和empty,来提醒CP两个线程做出处理,满了就应该消费者来消费了。所以我们让线程分别在这两个条件变量等待队列中等待条件就绪。

在将内容依次插入的过程中,一直都在询问阻塞队列是否已经满了,一直在占用锁资源,所以生产这要进行等待,等消费者消费空了,然后再进行生产填入。

等待的时候你还拿着锁呢,消费者得不到锁进行消费,锁死了,已经被这个函数设计好了pthread_cond_wait(&is_empty,&mtx)
调用的时候,会先将锁释放掉然后再挂起,返回的时候,会首先自动竞争锁,竞争到锁之后再返回。

只有生产者知道什么时候消费者什么时候可以消费,只要消费者知道生产者什么时候该生产,不是交易场所的任务,而是对方的任务。所以需要添加一部分条件变量,就是让双方互相知晓当前状况。
pthread_cond_signal();唤醒的时机在释放锁的前后都可以。

为了测试用随机数产生strand();产生数据。生产进入阻塞队列。控制速度:谁先运行都是随机的。

  • 同理,消费者慢一点,生产者按照消费者的节奏来,先生产满,然后消费一条插入一条。因为是队列,消费的第一个数据一定是第一个

    Linux-线程(LWP)_第18张图片

  • 生产者慢一点,消费者必须按照生产者的节奏来。

    Linux-线程(LWP)_第19张图片

​ 进程间通信的本质就是生产消费模型。
​ 生产和消费互斥的功能实现了一部分。

  • 生产者的数据从哪里来呢?一定是数据吗?可以是任务吗?

pthread_cond_wait(&is_empty_,&mtx);

  1. 调用的时候会首先自动释放mtx_锁,然后再将自己挂起
  2. 返回的时候,会首先自动竞争锁,获得锁之后才能返回。
    如果挂起的时候不释放锁,就会死锁,影响后续都无法使用。
  • Push中使用if()判断是不那么安全的,这时线程

    • 可能被伪唤醒(条件并没有满足)(多核CPU很多进程同时发就绪的指令)

    • 线程挂起失败(也就是wait函数调用失败)

所以,我们在需要进行条件检测的时候,这里需要使用循环方式来保证退出循环一定是因为条件不满足导致的

生产者产生一批任务,放到任务队列中交给另一个线程完成任务抽象为一个类。

  • 实验:完成一个任务派发的功能
    • 单消费者单生产者

Linux-线程(LWP)_第20张图片

  • 多消费者单生产者

Linux-线程(LWP)_第21张图片

数据的获取和处理都是需要花费时间的,
有可能就是在获取数据时,正在处理数据,也有可能再进行数据计算时,正在获取数据并且放在任务队列当中
所以,在生产消费模型当中,谁拿数据谁放数据谁快慢并不是主要矛盾,
而是,在获取数据投入队列时,如果队列当中存在数据,消费者就可以获取内个数据,进行同步的一种处理。提高了并发性。可以将各种算法任务塞入队列当中就可以。

POXSI信号量

信号量本质就是一把计数器,描述临界资源中资源数目的大小,最多能有多少资源分配给线程。买票的本质是预定资源。临界资源如果可以被划分为一个个小资源,如果处理得当,我们也有可能让多线程同时访问临界资源的不同区域,从而实现并发。

我们可以用信号量实现多线程预定资源的手段。

每个线程想要访问临界资源,都得申请信号量资源(相当于买到票了)代表一定会有你的一小块资源,通过他可以实现对临界资源的一种预定,就是信号量。

认识信号量对应的函数

//创建信号量
#include 
int sem_init(sem_t *sem, 
             int pshared, //默认0
             unsigned int value);//信号量初始值
sem_destroy(sem_t *sem);//销毁信号量
sem_wait(sem_t *sem);//申请信号量P(操作)
sem_post(sem_t *sem);//释放信号量V(操作)

//P()操作(线程安全的保证申请信号量)
start:
lock();
if(count<=0)
{
    wait();
    unlock();
    gotostart;
}
else
{
    count--;
}
unlock();
//V()操作(线程安全的保证释放信号量)
start:
lock();
if(count>0)
{
    wait();
    unlock();
    gotostart;
}
else
{
    count++;
}
unlock();

环形队列

环形队列什么时候为空?什么时候是满?
开始为空的时候,拿和放是同一个位置、现在满了拿和放也是一个位置,用计数器。

用镂空一个位置
放之前先做判断,当前位置+1!=拿,那么就可以放。有数据?放!=拿

Linux-线程(LWP)_第22张图片

我们的环形结构使用数组通过%运算来模拟环形结构,到结尾之后可以立马回到开头。

一 基本原理

多线程情况来进行环形队列的并发访问,实现一个基于环形队列的生产者消费者模型

生产者和消费者开始的时候指向的就是同一个位置,

队列为空的时候生产者和消费者在队列为满的时候指向的也是同一个位置。

当队列不为空不未满时,生产者和消费者指向的不是同一个位置

环形队列又是临界资源,那么就可以在不未满的时候完成生产者和消费者的并发执行

当队列为空为满的时候,不能让生产和消费同时进行(互斥+同步),为空或者为满的并且访问同一块资源时时候分别让生产者和消费者先访问临界资源。

二 基本实现

生产者关心的是空的位置。消费者关心的是队列中的数据。

规则:生产者不能把消费者套圈。消费者不能超过生产者。当指向同一个位置时,要根据空满状态来判定让谁走。

除此之外,生产消费可以并发执行。

用两个信号量标识队列中的资源数目,sem_blank空格资源数目,sem_data数据资源数目。生产者和消费者都需要P()操作申请信号量,当为空的时候,消费者申请不来sem_data就自动挂起,生产者就可以生产资源。V(sem_data)之后,有资源了,消费者就唤醒了P(sem_data)就可以消费了,消费完成之后V(sem_blank)代表多了一个空格资源,生产者此时再来,就完成了循环。

结合信号量和唤醒队列编写生产消费模型

单生产者单消费者的基于信号量的没有互斥锁的模型

现在是维护了环形队列中存在两个执行流各自代表一种角色,也就是单生产单消费。

  • 生产者快

    Linux-线程(LWP)_第23张图片

  • 消费者快:生产一个立马消费一个

    Linux-线程(LWP)_第24张图片

管道的底层可以是环形队列。

改成多生产多消费者模型该怎么改?
  • 多线程时,多生产者多消费者之间的关系都是需要维护的。
    允许多个消费者同时申请信号量,或者多个生产者同时申请信号量来进行生产。这时p_step和c_step++也会变成生产者之间的临界资源就可能会因为多线程的多个生产者出现问题,这时就可以申请两把互斥锁,来维护生产者和消费者各自内部的之间的关系。

  • 加锁是应该在P()操作之前还是在P()操作之后?

前后加锁都可以,但是先加锁没有体现多生产多消费大面积占用资源的优点,只有竞争锁成功的那个执行流才能申请信号量,跟之前的单生产者单消费者没有区别。放在后面的话,信号量一下被申请完毕,谁先拿到锁谁执行。

用前面这种并发的获取和处理任务没有体现,所以,申请信号量要在申请锁之前,将多个信号量预先分配给多个线程,当其中一个线程完成之后,其他进程可以在准备好信号量的情况下去直接申请锁,更高效率。

多生产和多消费的优势不再这里而在于并发的获取和处理任务。

其实,放数据拿数据还是一个一个放,毕竟有锁嘛。
申请和释放信号量之间,就保证了只有一个生产者或者消费者在生产区域生产或者消费。
保证生产者之间的互斥关系。

  • 实验:多消费多生产基于环形队列的完成任务模型
      1. 生产者慢
      1. 消费者慢

Linux-线程(LWP)_第25张图片

Linux-线程(LWP)_第26张图片

也可将数据的来源升级为task任务(不做了)

线程池

  • 内存池就是一种提高效率的方式。

用户malloc申请空间的时候,调用了系统调用接口。首先就会有状态的变化,然后有可能执行OS内的内存处理算法。是比较耗时的。频繁的调用会浪费成本,那就一次多要点,申请的一大块资源就叫做内存池。大块内存池在用户空间,需要用户来进行管理。

  • 线程池就是预先创建出一把线程,当有客户请求时就可以直接进行指派线程就行。

    线程服务,就不用再创建线程,提高效率。这些准备好的线程我们就称之为线程池。
    目的是实现可以用别的进程向这个线程池当中push任务。

  • 在类中要让你创建的线程执行thread_pool类成员的方法是不可行的,因为每一个类内成员都会多传一个this指针,造成pthread_create()传参多一个,所以必须让线程使用静态方法。

  • 线程池中的每个线程都竞争式的从任务队列当中拿任务,任务队列就是临界资源,就涉及到锁维护线程之间的安全。

  • 需要检测任务队列当中是否有任务,但是静态方法就无法使用类内的成员变量,所以在Rountine函数中的判空时就需要使用pthread_create()传参时传this指针来使用类内成员变量或者函数。

    • 如果任务队列是空,线程应该解锁,然后下一次再竞争锁,就会导致其中一个线程可能一直在竞争锁,虽然没错,但是这种只有一个人抢的逻辑是有问题的。所以,如果队列为空,线程直接挂起,条件变量pthread_cond_wait()。此时注意你是有锁的,需要先释放他拿到的这个锁。
    • 线程自身处理任务的函数应该在解锁之外。取出任务时,这个任务就不属于临界资源了,这样就可以实现多个线程同时处理任务。
    • 为了避免伪唤醒的状态,所以改为while()再一次判断条件。
    • PushTask()一旦插入了任务就需要唤醒线程pthread_cond_signal(&cond);
  • 类内实现线程的创建

主线程不断的获取任务并且插入到队列中。

Linux-线程(LWP)_第27张图片

Linux-线程(LWP)_第28张图片

线程安全的单例模式

经典的常用的常考的设计模式,固定的套路。

定义对象:先开辟空间,然后向他写入初始值。定义对象的本质就是将对象加载到内存

单例:一个类只有一个对象,只让对象在内存中加载一次。

  • 为什么设置为单例:
  1. 语义上只需要一个,
  2. 对象内部存在大量空间,保存大量数据,如果允许该对象发生拷贝使得数据存在多份,造成内存中存在冗余数据。
  • 什么时候加载创建呢?
    饿汉模式:赶紧做了,避免下一次要等
    懒汉模式:需要的时候再做,延时加载,写实拷贝。优化服务器启动速度。
//饿汉方式实现单例
template
class Singleton
{
    static T data;//属于这个类而不属于这个对象,静态成员初始化得在类外初始化
public:
    static T GetInstance()//只能通过这个函数获取对象data地址
    {
        return &data;
    }
};
//懒汉方式实现单例
template
class Singleton
{
    static T* inst_data;//属于这个类而不属于这个对象,
public:
    static T GetInstance()//只能通过这个函数获取对象data地址
    {
       if(inst_data==NULL)
           inst=new T();
        return inst;
    }
};
实验:懒汉方式实现单例模式版本的线程池

我们实现的线程池其实存在一个线程池就够了

  • 类不能定义对象了,将构造函数设置为私有。不想再发生写时拷贝,赋值语句也得私有

  • 定义一个类内的静态指针,只能通过这个指针获取对象,类外进行静态变量的初始化。

  • GetInstance()如果这个静态指针是nullptr,说明单例对象还没有被创建,就创建一个。

  • 因为Ins是静态变量,只有GetInstance()他是static方法才能访问类内的静态成员。

Linux-线程(LWP)_第29张图片

  • 单例本身会在任何场景和任何环境下调用,为了避免单例被多线程调用造成的可重入问题,线程安全问题。

    静态的锁,直接宏直接初始化锁不需要destroy()。这样在判断是否是第一次之前上锁,整完之后返回之前释放锁。但是,争取锁的过程态消耗了太多,我们可以在加锁之前先判断是不是第一次,省得多个执行流来的时候还得先竞争锁再判断自己是否是第一次来,双判定的方式,减少锁的征用,提高获取单例的活动。

STL&&智能指针线程安全

  • 智能指针是否是线程安全,是的。

    unique_ptr只在当前代码范围生效不涉及线程安全。shared_ptr多个对象需要使用一个引用计数变量,但是标准库已经实现了原子操作的引用计数。

  • STL容器不是线程安全,

​ 需要我们自己去做。做出来就是为了追求极致的性能。

你可能感兴趣的:(Linux操作系统,linux,运维,服务器)