linux多线程

线程概念

ps:线程分为用户级线程和内核级线程。下面基本讲的是用户级线程。

  • 进程是承担分配系统资源的基本实体

  • 线程是调度的基本单位,线程是进程里面的执行流。(线程在进程的地址空间里面运行)
    进程:线程 = 1 : n

  • 一切进程至少都有一个执行线程

  • 线程在进程内部运行,本质是在进程地址空间内运行

  • CPU在管理进程的时候只看pcb(因为一个pcb代表一个执行流),在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。原因就是因为线程没有很多的数据结构去管理

  • linux中,没有真正意义上的线程。(没有为线程设计数据结构,windows里面是有TCB这种数据结构的),线程是用进程模拟的

线程优点

  • 创建一个新线程的代价要比创建一个新进程小得多(共有地址空间)
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(切换的时候不用更换地址空间)
  • 关于线程切换这一点,针对的是用户级线程。因为用户级线程的调度工作由进程做。并且用户级线程共用一个地址空间,线程之间的切换并不需要保留上下文数据和加载上下文数据,因此效率提升了很多。对于内核级线程的切换还是很费时间的。
  • 线程占用的资源要比进程少很多(线程是进程的一部分)

线程缺点

  • 性能损失 线程之间可以很有可能可以看到同一块资源,看到同一块资源意味着这些资源都是临界资源。因此要为临界资源加上很多的锁,会影响性能。
  • 健壮性(鲁棒性)降低 线程之间可能会互相影响。线程之间没有独立性
  • 缺乏访问控制 进程之间面对数据的时候会发生写时拷贝,是独立的。线程访问的时候很多时候都是临界资源,很容易把数据搞乱
  • 编程难度高 调试起来很难,因为不知道是哪个线程导致的问题。

线程异常

单个线程出现错误,系统发信号给进程(信号面对的对象是进程),进程就挂了。其他线程无辜躺枪。因此这也能说明多线程的健壮性不强。

线程和进程对比(重点)

进程是资源分配的基本单位

线程是调度的基本单位(其实就是一个pcb一个执行流的意思,一个核cpu只能跑一个执行流)

线程私有的数据有

  • 线程ID(tid)
  • 一组寄存器(硬件上下文)线程切换的时候加载的数据 tss
  • 线程栈
  • errno
  • 信号屏蔽字
  • 调度优先级

线程共享的数据有

  • 文件描述符表
  • handler表
  • 当前工作目录
  • 用户id和组id

创建线程

pthread库是第三方库,遵守POSIX标准。(linux自己是没有关于线程的系统调用的)。编译的时候要链接pthread库。
在这里插入图片描述
强调一下:posix库里面的函数都是在操作用户级线程。

功能:在用户态创建一个用户级线程
原型
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;失败返回错误码

代码:

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

void* thread_run(void* arg)
{
  while(1)
  {
    printf("i am %s\n", (char*)arg);
    sleep(1);
  }
}

int main()
{
  pthread_t tid;
  pthread_create(&tid, NULL, thread_run,(void*)"thread 1");
  while(1)
  {
    printf("i am main thread\n");
    sleep(2);
  }
}

要链接pthread库
在这里插入图片描述
在这里插入图片描述
运行结果
在这里插入图片描述

linux线程创建原理

linux内部并没有实现创建线程的系统调用接口。创建线程是用户写的第三方库来描述管理线程的。

内核部分,系统负责创建一个pcb,这个pcb系统会给他一个lwp编号(这个编号是给内核用的)。因为线程是调度的最小单位。因此系统需要一个标识码来进行调度工作。cpu根据lwp进行调度

在用户层面,第三方库会创建一个tcb(thread control block)来管理内核创建出来的新pcb。并提供一些函数给用户使用。tcb的管理方式是放在进程地址空间的共享区里面,管理形式是用数组来存放各个tcb。并把每一个tcb的虚拟地址记录下来,并称为tid(这个编号是用户级的),给用户使用。

在这里插入图片描述

补充一下:

  1. 如果是单线程的进程,pcb的lwp和pid是一样的。所以说进程也可以被调度其实是对的。
  2. 主线程的lwp和pid是一样的

tid是什么?

tid到底是什么,我们可以打印出来看一下。这里介绍一个函数

pthread_self()   ---- 可以打印出自己线程的tid

在这里插入图片描述
tid很像是地址,且看地址的大小,猜测管理线程的结构体是放在共享区的。且tid是管理线程的结构体的地址。

查看线程

多线程可以用ps -aL来看
在这里插入图片描述
LWP — light weight process 轻量级进程

线程等待

int pthread_join(thread_t thread, void **retval)

第一个参数:等待的线程的tid
第二个参数:等待的线程的返回值

为什么第二个参数是二级指针呢?
我们知道,线程是的形式是函数。返回值是void*,因此如果线程返回了一个值,那么它的类型就是void*.
在这里插入图片描述
而这个retval被设计成了一个输出型参数
它传给join这个函数,然后从这个函数得到退出的线程的返回值。因此需要传二级指针。

在这里插入图片描述
结果:主线程进行阻塞等待,直到新线程退出之后,主线程才退出。

注:如果主线程没有进行等待,会造成新线程类似于僵尸的情况。

线程等待的情况

进程等待的情况有三种。但线程只有两种,一种是执行完没有问题,一种是执行完出现问题。总结来说就是,线程只有执行完成的情况。

原因:
线程退出和进程退出的情况不同。线程退出只有执行完之后退出才可以得到退出码。如果线程在执行过程当中出现错误,就是进程负责如何退出的问题了,与线程无关了。

也可以这么解释:信号是发给进程的。当线程异常退出的时候,进程收到信号可能也退出了,线程也不存在了。

线程退出

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

参数是返回值,在线程函数中和直接return一个返回值没有区别。
在主线程中调用这个函数就是终止当前执行流的。直接return是终止整一个进程的。


关闭某一个线程,参数就是要关闭的那个线程的tid

pthread_cancel(pthread_t tid)

一个线程如果是被cancel的,退出码就是-1

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

void* thread_run(void* arg)
{
  while(1)
  {
    printf("i am %s,tid : %p\n", (char*)arg, pthread_self());
    sleep(5);
  }
  return (void*)10;
}

int main()
{
  pthread_t tid;
  pthread_create(&tid, NULL, thread_run,(void*)"thread 1");
  printf("i am main thread, tid : %p\n", pthread_self());
  sleep(5);
  pthread_cancel(tid);//cancel
  printf("new thread %p be canceled\n", tid);
  void* res = NULL;//输出型参数
  pthread_join(tid, &res);//记得取地址
  printf("thread quit code : %d\n", (int)res);
}

分离线程

分离线程的意思就是以后主线程就不管分离后的新线程了。

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

和其他线程分离
int pthread_detach(pthread_t thread);
线程自己分离
pthread_detach(pthread_self());

在这里插入图片描述

结果:没有拿到退出码。因为线程已经分离了。当他退出的时候,自己的资源就释放了,也就不用把退出码写给pcb了
在这里插入图片描述

注:线程分离之后,如果分离的线程出现错误了,整个进程还是会退出的。


线程互斥

线程的资源很多都是共享的。比如全局变量。一个线程修改了全局变量的值,所有线程看到的这个全局变量都被改变了。这种对临界资源的修改是不安全的。

原子性:一件事情只有0和1的状态,没有中间状态。

代码具有原子性是什么意思?
这一条代码只有一条汇编语句的就是原子的。

比如a--这个操作就不是原子的。
它的汇编语句是

mov   从内存mov到寄存器
sub  在寄存器进行减操作
mov 再从寄存器放回内存当中

我们可以写一个不具有原子性的临界区代码来验证一下这种不安全性。

写一个简单的抢票功能。票是全局变量,几个进程同时抢。由于临界区不具有原子性,这段代码是会出问题的。

问题就是当ticket为1的时候,有两个线程同时进入了临界区访问ticket,并减减。然后ticket就变成-1了

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

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100;

int get_ticket(void* arg)
{
  int num = (int)arg;

  while(1)
  {
    if(ticket > 0)
    {
      usleep(1000);//1000微秒,即1ms
      printf("thread :  %d, get a ticket, no : %d\n", num, ticket);
      ticket --;
    }
    else
    {
      break;
    }
  }
}

int main()
{
  pthread_t tid[4];
  int i = 0;
  for(; i < 4; i++)
  {
    pthread_create(tid + i,NULL, get_ticket, (void*)i);
  }

  for(i = 0; i < 4; i++)
  {
    pthread_join(tid[i], NULL);
  }
  return 0;
}

结果:票数出现了负数。
在这里插入图片描述
为了解决这种问题,要加互斥锁。

在这里插入图片描述

加锁的步骤:
1.初始化锁
动态版本:用init接口,第一个参数是锁变量的地址,第二个参数是锁的信息,填NULL即可,让系统帮你填。
静态版本:用宏PTREAD_MUTEX_INITIALIZER
destroy接口,释放锁资源
在这里插入图片描述
2.加解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

代码:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 10000;
pthread_mutex_t lock;

int get_ticket(void* arg)
{

  int num = (int)arg;
  usleep(10000);
  while(1)
  {
    pthread_mutex_lock(&lock);
    if(ticket > 0)
    {
      usleep(10000);//1000微秒,即1ms
      printf("thread :  %d, get a ticket, no : %d\n", num, ticket);
      ticket --;
      pthread_mutex_unlock(&lock);
    }
    else
    {
      pthread_mutex_unlock(&lock);
      break;
    }
  }
}

int main()
{
  pthread_t tid[4];
  pthread_mutex_init(&lock,NULL);//加锁
  int i = 0;
  for(; i < 4; i++)
  {
    pthread_create(tid + i,NULL, get_ticket, (void*)i);
  }

  for(i = 0; i < 4; i++)
  {
    pthread_join(tid[i], NULL);
  }
  pthread_mutex_destroy(&lock);
  return 0;
}

注:

  1. 加锁的时候粒度要小,只对临界区加锁即可。这里的临界区就是while循环里面的内容。if判断也是非原子的。汇编语句不只一句。
  2. 加完锁要解锁,否则这个线程就一直占着这个资源不给别人使用了。

加锁的常见问题(重点)

1.对临界区的保护,所有的执行线程都必须遵守这个规则
2.lock -> 访问临界区 -> unlock
3.所有的线程必须先看到同一把锁,锁本身就是临界资源。锁本身得先保证自身安全:一把锁不能同时被两个线程拥有。因此申请锁的过程不能有中间状态,是原子的。


问题来了:lock->访问临界区->unlock这个过程是要花时间的,当特定线程拥有锁的时候,期间有其他线程过来申请锁,一定申请不到的!新线程该如何呢?
答:阻塞—将线程对应的pcb从running queue拿到等待队列。 unlock之后进行线程的唤醒操作,又把线程从等待队列放回运行队列。


如何理解当前POSIX thread中的mutex呢?

struct mutex
{
	int lock;//0, 1
	wait_queue* head;//如果有人来申请锁且当前锁已经被占用,丢它去等待队列
}

在这里插入图片描述


pthread_mutex_init()/pthread_mutex_destroy()/pthread_mutex_lock()/pthread_mutex_unlock()它们具体都干了什么?

答:init是创建了一个mutex对象、destroy是销毁一个mutex对象。lock是把锁由1变成0,unlock是把锁由0变成1.


互斥是什么?
答:一次保证只有一个线程进入临界区,访问临界资源,叫做互斥。


一个线程在临界区中的多行代码中,线程时间片到了,当前线程被切换了,其他线程可以申请到锁吗?

答:不可以。虽然这个拿着锁的线程被切换了。但是我们之前说过一个前提,锁的申请是具有原子性的。虽然线程切换了,但是这个锁仍然没有unlock,既然没有unlock,那锁就还是lock的状态。


为什么加锁效率比较低,或者影响效率?
答:因为本来多线程是并发的,并行运行。但是加锁之后就变成串行的了。

互斥锁底层实现原理

上下文数据实现

先将一下上下文数据是怎么实现的。有一个东西叫TSS(task state segment)任务状态段。
在这里插入图片描述
实现上下文的示意图:
在这里插入图片描述
现在开始讲互斥锁的底层实现:

首先先讲一个前提:执行周期的最小刻度是执行一条汇编语句的时间。也就是说一个线程或者进程在执行一条汇编语句的时候,别的进程或者线程只能等待,执行一条汇编语句的时候cpu也不会把该线程切换掉。

这是互斥锁的汇编伪代码

在这里插入图片描述
第一句的意思是把al这个寄存器的值变成0.
第二句的意思是交换内存里面的mutex值(其实就是mutex里面的lock的值,一开始没有申请锁的时候是1)。这一条语句就是互斥锁加锁的语句。就是一句汇编,因此具有原子性

模拟一个场景:
A线程先把al寄存器的值变成0,此时被cpu切换走了。
图示:
在这里插入图片描述
此时B线程进来了,然后也执行第一条语句,由于B的寄存器al和A线程的是不一样的(前面讲过线程之间有一组独立的寄存器),因此执行后结果和上图一样。

这时候A线程被切换回来了,执行第二条语句。交换mutex和al的值。此时al = 1, mutex = 0了,然后A线程又被切走了。

B线程回来了,重点来了:B线程的al = 0很好理解(因为线程切换要加载上下文数据),此时mutex是多少??? 答:mutex还是0!!!因为mutex的lock是临界资源,线程之间共享的!!!因此B线程的状态是:al = 0, mutex也等于0.

然后A线程又回来了,由于此时al = 1,mutex = 0,al > 0,因此加锁成功。
B线程此时al = 0, mutex = 0,被挂起等待

unlock的汇编伪代码
在这里插入图片描述
把mutex的0变成1即可。这样下一个要加锁的线程就可以通过交换把al的值变成1了。

可重入和线程安全

可重入就是多个执行流执行这个函数,不会出现问题。
线程安全指线程之间尽量独立,如果线程不独立可能会导致线程安全问题
线程安全不一定可重入,可重入一定线程安全。(背住就好了)
举个例子,虽然这个例子很傻:
确实是线程安全的,但并不是可重入的。
在这里插入图片描述

常见不可重入的情况

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

常见可重入的情况

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

常见锁概念

死锁

A线程有a锁,B线程有b锁。A线程一直申请b锁,且自己的锁也一直保持不unlock。B线程一直申请a锁,且自己的锁也一直不unlock,这就叫死锁。

满足死锁有四个必要条件:

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

如何避免死锁

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

注:只有一个线程也可能会死锁。自己申请了一把锁之后不释放继续申请同意把锁。(形成了环路)

线程同步

复习一下:互斥是指只允许一个执行流访问临界资源。如果只有互斥没有同步,极大可能造成饥饿问题。因为有可能有某一个线程抢任务的能力太强了,一次都是它在处理任务,其他线程由于争不到锁,因此就饥饿了。

在保证数据安全的情况下(一般使用加锁),让多个执行流按照特定的顺序进行临界资源的访问,以缓解饥饿问题。称之为同步。
其实也可以理解成事件完成的同步性。**你完成之后我立刻接上,我完成之后你立刻接上,**使工作具有同步性(与拖拉相对)

线程同步和线程互斥的对比(超级重点)

互斥是站在竞争的角度看问题的。如果两个线程要干的事情是一样的,他们就是互斥。必须一个人先干,后一个再干。
同步是站在配合的角度看问题的。如果两个线程干的是配合工作,他们就是同步。一个人干完前面的准备工作,后面的人才可以干接下来的工作。

在这里插入图片描述
在这里插入图片描述
cond是用于实现同步的,lock是用于实现互斥的。


为什么要存在同步?多线程协调高效完成某些事情。

如何编码实现?
1.如果条件不满足,等待,释放锁(pthread_cond_wait)
2.通知机制(pthread_cond_signal)

条件变量

条件变量就是一个类型为pthread_cond_t的变量,和锁是一个类型为pthread_mutex_t的变量是一个道理。

条件变量函数 初始化:

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)

等待条件满足

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

为什么等待的时候要传一把锁进去?
答:由于在多线程的时候,条件变量 (一般设计的时候,条件变量是临界资源的一个成员) 也可以被多个线程看见。因此也是临界资源。进入临界资源应该先申请锁(保护临界资源),进入临界资源看到cond变量是否为true。为true就释放锁,然后进行等待操作(不能一直拥有锁并且等待,这样会让所有线程都无法申请到这把锁了)。等待完之后继续加锁。等所有操作结束之后,又手动解锁。因此总共lock两次,unlock两次。
在这里插入图片描述

唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

用条件变量的几个函数写一个线程同步的代码(最简单的同步):
没有加条件判断的线程同步,也就是一旦signal,就一定会唤醒的代码。

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

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

void* routine_r1(void* arg)
{
  const char* name = (char*)arg;
  while(1)
  {
    pthread_cond_wait(&cond, &lock);//等待信号
    printf("%s 收到, 活动......\n",name);
  }
  
}

void* routine_r2(void* arg)//让线程2控制线程1
{
  const char* name = (char*)arg;
  while(1)
  {
    sleep(rand() % 3 + 1);
    pthread_cond_signal(&cond);//发送信号
    printf("%s signal done ...\n", name);
  }
}

int main()
{
  pthread_mutex_init(&lock, NULL);
  pthread_cond_init(&cond, NULL);

  pthread_t t1, t2;
  pthread_create(&t1, NULL, routine_r1,(void*)"thread 1");
  pthread_create(&t2, NULL, routine_r2,(void*)"thread 2");

  pthread_join(t1, NULL);
  pthread_join(t2, NULL);

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

在这里插入图片描述

条件变量的类型pthread_cond_t 其实是一个结构体。

struct cond
{
	int val;//判断是否符合条件
	wait_queue* head;//条件不满足的时候,要把线程放进等待队列当中。
}

生产者消费者模型

生产者是生产数据的,生产的数据放在了一块空间里。消费者是拿数据的,从这一块空间里拿数据。这种模型效率高。

生产者,消费者:线程或者进程
空间,交易场所:一块“内存块”
产品:数据

生产者消费者模型遵守”321“规则。
3:3种关系。

  • 生产者和生产者互斥关系。 空间可以被所有进程看到,因此是临界资源。临界资源应该是让线程互斥保证数据安全的。
  • 消费者和消费者的互斥关系。
  • 生产者和消费者 同步关系+互斥关系(互斥关系可有可无,建议有)。生产者产生完数据消费者才能拿走,生成者没产生完数据消费者不能拿走 。生产者和消费者不能同时进入缓冲区队列。
    两个对象:生产者和消费者
    一个场所:缓冲区队列。

优点:

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

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

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

这里写单个生产者和单个消费者的阻塞队列

block_queue.hpp

#pragma once

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

using namespace std;


class Task
{
  public:
    int x, y;

    int run() {return x + y;}
    Task(int _x = 0, int _y = 0) :x(_x), y(_y){}
};

template<class T>
class BlockQueue
{
  private:
     pthread_mutex_t lock;
     pthread_cond_t c_cond;
     pthread_cond_t p_cond;
     queue<T> q;
     int cap;
 public:
     BlockQueue(int _cap) :cap(_cap)
     {
        pthread_mutex_init(&lock, nullptr);
        pthread_cond_init(&c_cond, nullptr);
        pthread_cond_init(&p_cond, nullptr);
     }

     ~BlockQueue()
     {
       delete q;
       pthread_mutex_destroy(&lock);
       pthread_cond_destroy(&c_cond);
       pthread_cond_destroy(&p_cond);
     }

  public:
     T& Put()
     {
       int x, y;
       x = rand() % 10 + 1, y = rand() % 100 + 1;
       T t(x, y);
       pthread_mutex_lock(&lock);
       while(q.size() >= cap)
	    pthread_cond_wait(&p_cond, &lock);//两个问题,1.为什么wait要传lock参数。2.为什么这里循环要写成while
       q.push(t);
       pthread_cond_signal(&c_cond);
       pthread_mutex_unlock(&lock);
       return t;
     }

     T& Get()
     {
       pthread_mutex_lock(&lock);
      while(q.size() <= 0)
	    pthread_cond_wait(&c_cond, &lock);//两个问题,1.为什么wait要传lock参数。2.为什么这里循环要写成while
       T t =  q.front();
       q.pop();
       pthread_cond_signal(&p_cond);
       pthread_mutex_unlock(&lock);
       return t;
     }
};

main.cc

#include "block_queue.hpp"
#include <stdio.h>
using namespace std;

void* consumer(void* arg)
{
  BlockQueue<Task>* bq = (BlockQueue<Task>*)arg;
  while(1)
  {
    Task t = bq->Get();
    printf("%d + %d = %d\n", t.x, t.y, t.run());
  }
}

void* productor(void* arg)
{
  BlockQueue<Task>* bq = (BlockQueue<Task>*)arg;
  while(1)
  {
    Task t = bq->Put();
    printf("%d + %d = ???\n", t.x, t.y);
    sleep(2);
  }
}

int main()
{
  BlockQueue<Task>* bq = new BlockQueue<Task>(5);
  pthread_t c, p;
  pthread_create(&c, nullptr, consumer, (void*)bq);
  pthread_create(&c, nullptr, productor,(void*)bq);

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

makefile

main:main.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm main

效果图:(我让生产者是慢的那一方,消费者是快的那一方)
先是生产者慢慢的生产一些数据,然后当容量满了的时候,生产者唤醒消费者,并在自己wait。然后消费者一下子就把信息读完了,然后消费者唤醒生产者,然后自己wait…循环这个过程
在这里插入图片描述

注:为什么这里的条件判断要用循环,而不是if

while(q.size() >= cap)
{
  pthread_cond_wait(&p_cond, &lock);//两个问题,1.为什么wait要传lock参数。2.为什么这里循环要写成while
}
pthread_cond_signal(&c_cond);

答:有可能等待失败。如果等待失败了,线程没有挂起。这时候阻塞队列满了还继续往里面加数据。

POSIX信号量

信号量又称信号灯,本质上是一个计数器。它是描述临界资源里面有效资源个数的计数器。

关于信号量和条件变量的比较

个人认为最重要的点是:

信号量允许多个线程同时访问一块临界资源(对于信号值的减减或者加加可以看成是原子的,具体实现不清楚,可能加了锁)。
条件变量是不允许多个线程同时访问一块临界资源的,因为条件变量是就二元的信号量,只有0和1两种状态,一旦A线程满足了某条件,其他线程肯定是无法满足的,因此会陷入等待状态(甚至挂起)。


当申请了一块资源,计算机资源个数就少一,计数器减1(P操作)
当申请的资源用完了,要还给系统的时候,计数器加1(V操作)

由于信号量也是临界资源,因此对信号量操作也必须是原子的。又因为加法和减法不是原子的,因此要加上锁。

pv操作大致实现过程:(参考王道操作系统)

struct sem
{
	int value;
	wait_queue* head;

	p()
	{	
		value--;
		如果value 小于 0,证明没有资源了,无法拿到
				把该线程丢带等待队列里面
				阻塞ing
	}
	V()
	{
		value++;
		如果value 小于等于0(等于0),证明等待队列里有一个线程可以被唤醒了
				把最早进入等待队列的线程拿出来,并唤醒
	}
}

信号量接口(传参传引用)

重点!!!!
信号量被创建出来之后不允许进行拷贝构成,因此如果传参要传信号量的时候全部都要传引用!!!

初始化信号量

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值

销毁信号量

int sem_destroy(sem_t *sem);

等待信号量(P)
功能:等待信号量,会将信号量的值减1,相当于申请一个临界资源。
如果申请不到,就阻塞等待直到申请到了。 因此这个P操作有等待申请两个作用。

int sem_wait(sem_t *sem); //P()

发布信号量(V)
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。

int sem_post(sem_t *sem);//V()

二元信号量

二元信号量等价于互斥锁。可以实现互斥。
二元指的是一个信号量最大有效资源为1.当这个资源被申请了就相当于加了锁。

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

环形队列可以用数组模拟。环形队列有两种,一种头尾指针指向同一个格子。这一种描述方法,当队列里面空和满的判定条件是都是head == tail,因此要加入size来判定倒是到底是空还是满在这里插入图片描述一种是队列里面留一个空位在这里插入图片描述
我们这里采用第一种描述方式。
为了实现线程同步,提高效率。我们把临界资源进行划分。我们将环形队列里面的空间和数据都设置成信号量。

sem_t sem_data;
sem_t sem_block;

一开始队列为空,sem_data = 0, sem_block = capacity;
当生产者put数据的时候,先申请sem_block(其实就是看一下还有没有空的空间),申请到了之后就可以放数据进去了。放完数据之后,数据的信号量要加1.
因此就是

P(sem_block);//空的空间 - 1
xxx
xxx
xxx
V(sem_data);//数据数量 + 1

消费者也是同理,得出的结论是

P(sem_data);//数据数量 + 1
xxx
xxx
xxx
V(sem_block);//空的空间 - 1

ring_queue.hpp代码:

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

class Task
{
  public:
    int x, y;
  public:
    Task(int _x, int _y) :x(_x), y(_y) {}
    int add()
    {
      return x + y;
    }
};

template<class T>
class RingQueue
{
  private:
    vector<T> v;
    sem_t sem_data;//consumer
    sem_t sem_block;//productor
    int c_i;
    int p_i;
    int cap;

  public:
    RingQueue(int _cap = 10) :cap(_cap), c_i(0), p_i(0)
  {
    v.reserve(10);
    sem_init(&sem_data, 0, 0);//一开始数据的信号量
    sem_init(&sem_block, 0, cap);//一开始空间的信号量
  }

    ~RingQueue()
    {
      sem_destroy(&sem_data);
      sem_destroy(&sem_block);
    }

    void P(sem_t &sem)
    {
      sem_wait(&sem);
    }

    void V(sem_t &sem)
    {
      sem_post(&sem);
    }

    void Get(T &out)
    {
      P(sem_data);
      out = v[c_i];
      c_i++;
      c_i = c_i % cap;
      V(sem_block);
    }

    void Put(T &out)
    {
      P(sem_block);
      v[p_i++] = out;
      p_i = p_i % cap;
      V(sem_data);
    }
};

main.cc代码

#include "ring_queue.hpp"

using namespace std;

void* consumer(void* arg)
{
  RingQueue<Task>* rq = (RingQueue<Task>*) arg;
  while(1)
  {
    sleep(1);
    Task t(0, 0);
    rq->Get(t);
    printf("%d + %d = %d\n", t.x, t.y, t.add());
    printf("consumer done ...\n");
  }
}

void* productor(void* arg)
{
   RingQueue<Task>* rq = (RingQueue<Task>*) arg;
   while(1)
   {
     int x = rand() % 100 + 1, y = rand() % 10 + 1;
     Task t(x, y);
     rq->Put(t);
     printf("%d + %d = ???\n", x, y);
     printf("productor done...\n");
   }
}

int main()
{
  pthread_t c, p;
  RingQueue<Task>* rq = new RingQueue<Task>(10);
  pthread_create(&c, nullptr, consumer, (void*)rq);
  pthread_create(&p, nullptr, productor, (void*)rq);

  pthread_join(c, nullptr);
  pthread_join(p, nullptr);

  delete rq;
}

makefile代码:

main:main.cc
	g++ -o $@ $^ -lpthread -std=c++11
.PHONY:clean
clean:
	rm -f main

结果:
在这里插入图片描述
讲一下过程:情况是生产快,消费慢。
一开始队列为空,生产者一下让队列满了。然后生产者进行P操作的时候,由于空的空间没有了(sem_block = 0),开始阻塞等待。

此时消费者开始读取数据。读取完一个数据,进行V操作,使sem_block + 1,因此生产者又开始生产了。

形成了生产一个,消费一个的循环。

线程池

线程池就是把很多线程提前创建出来,然后用一个类管理它们。
在这里插入图片描述

因此线程池里面要创建几个线程,并且管理一个任务队列。由于任务队列是临界资源,因此还需要加上锁。

当任务队列为空的时候,线程还要集体wait。因此还需要一个条件变量。

代码:
threadpool.hpp

#pragma once

#include <iostream>
#include <stdio.h>
#include <pthread.h>
#include <queue>
using namespace std;

class Task
{
  public:
  int base;
  public:
  Task(int _base) :base(_base) {}
  Task() {}

  int run() {return base * base;}
};

template<class T>
class ThreadPool
{
  private:
    int quit;
    queue<T> q;
    pthread_cond_t cond;//队列为空的时候消费者等待的条件
    pthread_mutex_t lock;
  public:
    ThreadPool()
    {
      quit = 0;
      ThreadPoolInit();
    }

    bool IsEmpty()
    {
      return q.size() == 0;
    }
    static void* consumer(void* arg)
    {
       ThreadPool<T> *p = (ThreadPool<T>*) arg;
       while(!p->quit)
       {
         pthread_mutex_lock(&p->lock);
         if(!p->IsEmpty())
         {
           while(!p->quit && p->IsEmpty())
              pthread_cond_wait(&p->cond, &p->lock);
            T t;

            p->Get(t);
            printf("%d square = %d\n", t.base, t.run());
         }
         pthread_mutex_unlock(&(p->lock));
       }
    }

   void Get(T &out)
   {
      out = q.front();
      q.pop();
   }

   void Put(T &in)
   {
      pthread_mutex_lock(&lock);
      q.push(in);
      pthread_mutex_unlock(&lock);
      pthread_cond_signal(&cond);
   }

    void ThreadPoolInit()
    {
       pthread_cond_init(&cond, nullptr);
       pthread_mutex_init(&lock, nullptr);
       for(int i = 0; i < 5; i++)
       {
         pthread_t tid;
         pthread_create(&tid, nullptr, consumer, (void*)this);
       }
    }

    void ThreadsExit()
    {
       if(!IsEmpty()) return;
       quit = 1;
       cout << "All threads are quit\n";
       pthread_cond_broadcast(&cond);
    }
    ~ThreadPool()
    {
      pthread_mutex_destroy(&lock);
      pthread_cond_destroy(&cond);
    }
    
};

main.cc

#include "threadpool.hpp"
#include <unistd.h>
#include <stdlib.h>
void* productor(void* arg)
{
   ThreadPool<Task>* p = (ThreadPool<Task>*) arg;
   int count = 5;
   while(count --)
   {
     sleep(2);
     int x = rand() % 10 + 1;
     Task t(x);
     printf("%d square is ???\n", t.base);
     p->Put(t);
   }
   p->ThreadsExit();
}

int main()
{
  ThreadPool<Task>* p = new ThreadPool<Task>();
  pthread_t tid;
  pthread_create(&tid, nullptr, productor, (void*)p);

  pthread_join(tid, nullptr);
}

有几个问题要注意的:

1.为什么线程执行的代码要写成静态的函数。
因为如果写成非静态成员函数,里面会有一个this指针占位,这样就和原先的函数类型不匹配了。
static成员函数无法访问非static成员函数,只有实体才可以访问非static成员函数,因为实体才有this指针。

 static void* consumer(void* arg)
    {
       ThreadPool<T> *p = (ThreadPool<T>*) arg;
       while(!p->quit)
       {
         pthread_mutex_lock(&p->lock);
         if(!p->IsEmpty())
         {
           while(!p->quit && p->IsEmpty())
              pthread_cond_wait(&p->cond, &p->lock);
            T t;

            p->Get(t);
            printf("%d square = %d\n", t.base, t.run());
         }
         pthread_mutex_unlock(&(p->lock));
       }
    }

2.线程池有那么多线程,可以一有任务就把所有线程唤醒吗(用broadcast)
答:这样不好,会造成惊群效应。
在这里插入图片描述
3.等待server唤醒的时候,为什么要用while循环来wait?
答:因为有可能wait失败了,这样就有可能多线程同时访问临界资源,造成线程安全。

4.为什么线程退出的时候,要用broadcast而不是signal?
答:线程池的退出,要把所有线程唤醒,因为有一些线程还在cond的等待队列里面休眠。

线程池的价值:

1.有任务,立马有线程进行服务,省掉了线程创建的时间
2.有效防止server中线程过多,导致系统过载的问题

但是线程池在server端放数据的时候,消费者端还是在阻塞,因此线程池在大型项目中还是不好用

线程池 vs 进程池

(进程池可以用system V 共享内存shm来实现消息队列)

1.线程池占用的资源更少,但是健壮性不如进程池。(线程一个崩溃全部崩溃)
2.进程池占用的资源更多,但是健壮性很强

线程安全的单例模式

什么是设计模式

前人总结的代码设计风格,这种设计风格是特别优秀的。也就是说,写代码是有固定的套路的。

单例模式的特点

一个类只能有一个对象

饿汉方式和懒汉方式

饿汉方式是当程序跑起来之后才创建对象,可以提高效率。
懒汉方式是静态成员变量,程序跑起来之前就就创建对象了,比较慢

饿汉是没有线程安全问题的,懒汉是有线程安全问题的,因此懒汉模式需要加锁。

STL和线程安全

stl不是线程安全的,因为stl是追求效率的,但是加锁会让性能下降,因此没有锁。

读写问题

在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁

读写问题和消费者生产者很像,唯一的区别是消费者会从队列里面拿走数据,而读者不会。


读写行为有读优先,写优先和公平三种行为。读优先就是如果临界资源里面有读者,那么写者永远进不来

读者和写者之间的关系:
读读共享,读写互斥同步,写写互斥
和生产者消费者之间的关系差不多。

读写者接口:

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

读写锁实现原理(读优先

//读优先
int rc = 0;//有几个读者在访问这个临界资源
mutex r, w;//读者锁和写者锁

pthread_rwlock_rdlock()
{
//第一个读者加写锁,这样读的时候就无法写入了实现同步
//为什么要先加读锁呢?原因是rc是临界资源,且rc++不是原子的,因此要加锁。
	P(r)
	rc++;
	if(rc == 1) P(w);//第一个读者才申请写锁,后面就不用申请了
	V(r)
//读取数据,此时不用加锁了。读者共享资源

//读者退出
P(r)
rc--;
if(rc == 0) V(w);//如果最后一个读者退出了,写者就可以进来了。因此释放写锁
V(r)
}

--------------------------------------------------------
pthread_rwlock_wrlock()
{
	P(w)
	//写入数据
	V(w)
}

其余常见锁

悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。

上面讲的都属于悲观锁。

乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

自旋锁:占有临界资源的线程在临界区内呆的时间特别短,基于效率考虑,无需挂起,让当前线程处于自旋状态。不断去检测锁的状态,而其中自旋锁为我们提供上述功能。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值