【Linux】线程&锁&条件变量&信号量&生产消费者模型&线程池

线程概念

  在操作系统的的视角下,Linux 下没有真正意义的线程,而是用进程模拟的线程(LWP,轻量级进程),所以 Linux 不会提供直接创建线程的系统调用,最多提供创建轻量级进程的接口。
  进程是 CPU 分配资源的基本单位,而线程是 CPU 调度的基本单位,线程的执行粒度比进程更细。一条线程指的是进程中的一条单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务,各个间共享进程数据,但也拥有自己的一部分数据。

  • 1、线程 id
  • 2、处理器现场和栈指针(内核栈)
  • 3、独立的上下文和栈空间(用户空间栈)
  • 4、errno 变量
  • 5、信号屏蔽字
  • 6、调度优先级

  上面我们提到,各个线程之间共享数据,并且线程是用进程模拟的。那么当我们现在想要让不同的线程(执行流),访问进程中一部分资源,我们只需要创建 PCB,并让其指向父进程资源。这种只创建 PCB,并从进程中给它分配资源的执行流就叫做线程

  由上图可以很好地理解线程为什么是 CPU 调度的基本单位,在 CPU 看来,它只关心一个独立的执行流,无论进程内部是一个还是多个执行流,CPU 都是以 task_struct 为单位来调度的。在 CPU 看来,Linux 中的进程比传统中的进程更加轻量化,进程的执行流我们叫轻量化进程。也能很好地理解了为什么进程是分配资源的基本单位,因为进程之间是相互独立的,每个进程都有相应的进程地址空间。

线程共享资源

  • 1、文件描述符表
  • 2、每种信号的处理方式
  • 3、当前工作目录
  • 4、用户ID和组ID
  • 5、进程地址空间

线程控制接口和线程id

  线程在运行的时候我们可以通过 ps -aL 查看线程信息 LWP,即轻量型进程 ID。当 PID==LWP 时,该线程是主线程。当 PID!=LWP 时,该线程是新线程。CPU 调度的时候,是以 LWP 表示特定的一个执行流,之前我们以 PID 识别独立的进程并没有问题,当只有一个执行流的时候,PIDLWP 是等价的。
  对于用户来说,用户需要的是线程接口。所以 Linux 提供了用户线程库,对下将 Linux 接口封装,对上给用户提供进行线程控制的接口,也就是 pthread 库(原生线程库),这个库不是 Linux 内核的一部分,而是作为用户空间的库提供的。尽管我们包了头文件 <pthread.h>,但这里只有声明,找不到库中对应的方法。对此,在 Linux 下,我们使用线程库时,需要加 -lpthread 选项来表明你要链接的库名称。

线程控制相关接口:

线程创建:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
  第一个参数 pthread_t 是线程 id,第二个参数可以设置线程属性,一般我们设为 nullptr,第三个是一个函数指针,为线程需要的执行方法、任务。void* 让它可以接受任何类型的参数并返回任何类型的结果,类似于 C++ 中的模板,第四个参数为该方法所需要的参数。

线程等待:int pthread_join(pthread_t thread, void **retval);
  第一个参数为线程 id,第二个参数是一个二级指针,用来获取,线程退出的返回值。

线程退出: void pthread_exit(void *retval);
线程分离: int pthread_detach(pthread_t thread);
线程取消: int pthread_cancel(pthread_t thread);
  这三个函数用法类似,线程退出和取消用来终止线程。线程分离告诉系统,当线程退出时,自动释放线程资源,不必再进行线程等待。

   我们可以通过 pthread_t pthread_self(void) 函数来获取线程 id。这里的 id 和前面说的线程ID(LWP) 不是一回事。前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。pthread_t 这个线程 id 指向一个虚拟内存单元,本质就是进程地址空间上的一个地址。线程库的后续操作,就是根据该线程 id 来操作线程的。

   描述用户级线程的结构体是在用户空间线程库中维护的,即 pthread 库。其中第一个字段 struct pthread 包括了线程的属性。第二个字段 线程局部存储 用于保存用 __thread 修饰的全局变量,让每个线程独有该变量。主线程的栈正常保存在地址空间的栈中,其他线程的独立栈,都在该结构体第三个字段中,用来保存本线程产生的临时数据。

线程优缺点

优点:

  • 1、创建一个新线程的代价要比创建一个新进程小得多,线程占用的资源要比进程少很多
  • 2、进程间切换,需要切换页表、虚拟空间、切换PCB、切换上下文
  • 3、线程间切换,线程都指向同一个地址空间,页表和虚拟地址空间就不需要再进行切换了,只需要切换PCB和上下文,成本较低
  • 4、线程切换不需要更新太多cache(缓存大量经常使用的数据),进程切换要全部更新

缺点:

  • 1、一个线程异常,整个进程也会随之崩溃
  • 2、竞态条件:当多个线程同时访问和修改共享数据时,可能会发生竞态条件,导致数据不一致或错误
  • 3、编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

线程互斥和条件变量

线程互斥相关概念:

  • 临界资源:任何一个时刻,都只允许一个执行流进行访问的共享资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问 题
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

  由上面线程缺点我们知道,多线程并发访问时,会出现竞态条件,导致数据不一致或错误问题。对此,我们需要对临界资源进行保护,用互斥保证每次有且只有一个执行流进入临界区,用同步让线程访问资源具有一定的顺序性!

  锁是实现线程互斥的基本同步机制,确保在多线程环境中,任一时刻仅有一个线程能够访问共享资源。这种机制通过阻止其他线程访问已被锁定的资源,直到持有锁的线程释放它,从而允许其他线程获取锁并进行操作。锁的实现可以通过编程语言提供的库或操作系统的API,例如 Linux 中的互斥锁(mutex),来确保线程安全地管理对共享资源的访问。

  Linux 中的条件变量提供了一种高效的同步机制,允许多个线程在某个特定条件未满足时安全地挂起执行,直到被其他线程触发或条件成立。这种机制避免了不必要的线程轮询和忙等待,从而优化了系统性能。

锁和条件变量相关接口

锁 :pthread_mutex_t
初始化锁:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
  第一个参数传锁的地址,第二个参数设置锁的属性,暂时设为 nullptr。也可以通过下面的方式对全局锁进行初始化,同时全局的锁初始化后,不需要手动销毁:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

阻塞式申请锁:int pthread_mutex_lock(pthread_mutex_t *mutex);
非阻塞式申请锁:int pthread_mutex_trylock(pthread_mutex_t *mutex);
解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex);
锁的销毁:int pthread_mutex_destroy(pthread_mutex_t *mutex);
  pthread_mutex_lock 申请锁时,如果锁已经被其它线程申请了,就会阻塞等待。而 pthread_mutex_trylock 申请锁时,如果没有申请到锁时,会立即返回,而不是进行阻塞等待。申请成功返回0,失败返回错误码。

  加锁的本质是对被加锁的代码区域,让多线程进行串行访问,对此我们应该尽量让临界区代码越少越好。 同时不要销毁一个已经加锁的互斥量,避免后面有其它线程再尝试加锁时,申请不到锁,而产生死锁问题:在一组进程中,其中每个进程都持有一些资源,并且等待其他进程释放它们所占用的资源。由于每个进程都在等待其他进程先释放资源,这导致所有进程都无法继续执行,从而陷入一种永久的等待状态破坏下面四个条件中的任意一个就能解决死锁问题

死锁四个必要条件:

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

条件变量:pthread_cond_t
初始化条件变量: int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
  这个函数其初始化方式和锁类似,第一个参数为条件变量地址,第二个参数设置条件变量属性。也可以直接初始化一个全局的条件变量,不需要手动销毁: pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

等待:int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
  条件变量可以简单抽象为一个阻塞队列,等待特定条件的线程会放入到队列中,合适的时候,通过自己的唤醒机制唤醒它们。特别注意:进入等待的线程会释放掉身上的锁,避免其它线程出现永远拿不到锁而产生的死锁问题。当线程被唤醒时会重新持有锁,执行后面的临界区代码

唤醒一个等待线程:int pthread_cond_signal(pthread_cond_t *cond);
唤醒所有等待线程:int pthread_cond_broadcast(pthread_cond_t *cond);
销毁条件变量: int pthread_cond_destroy(pthread_cond_t *cond);

POSIX 信号量

  当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。但其实我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致问题。而不同区域资源数量的多少我们可以用信号量来保证,信号量的本质是保证PV操作,具有原子性的一把计数器。
  P操作: 申请信号量,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减去一.如果它的值为零,表明没有可以申请的资源了,就挂起该进程。
  V操作: 释放信号量,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时,临界资源中资源的数目就应该加一。PV操作自己已经保证了原子性,不需要我们再加以保护。

初始化信号量:int sem_init(sem_t *sem, int pshared, unsigned int value);
  sem:需要初始化的信号量。pshared:零值表示线程间共享,传入非零值表示进程间共享。value:设置信号量计数器的初始值。剩下的函数使用方式相同,参数都是信号量。
P操作:int sem_wait(sem_t *sem);
V操作:int sem_post(sem_t *sem);
销毁信号量:int sem_destroy(sem_t *sem);

生产消费者模型

  生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题,两者之间没有直接的关系,不直接进行通讯,而是通过容器来进行通讯。所以生产者生产完数据之后,直接将生产的数据放到这个容器当中,消费者直接从这个容器里取数据,这个容器就相当于一个缓冲区。这个容器就是一个交易场所,用来给生产者和消费者解耦的。

模型满足以下简单321原则:

  • 1、三种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系和同步关系)
  • 2、两种角色: 生产者和消费者。(通常由线程承担)
  • 3、一个交易场所: 通常指的是内存中的一段缓冲区
  •   而从编码的角度,每一个消费者或者生产者我们都可以看作一个线程。所有的消费者和生产者都要访问同一个交易场所,于是交易场所就是我们之前谈到的临界资源!显然,它需要一定的互斥和同步进行保护。生产者和生产者、消费者和消费者、生产者和消费者之间也具有明显的互斥关系:因为这里的交易场所是临界资源(目前视作一个整体),一次只能让一个执行流进入。而生产者和消费者之间还存在同步关系:必须先生产才能消费,交易场所空了就不能消费了。同时交易场所是有容量的,当生产满了时必须停下来,让消费者来消费。

    阻塞队列实现生产消费者模型

      我们把阻塞队列这个临界资源被当成了一个大的整体,因此只需要一把锁,维持消费者和生产者之间的互斥关系。当阻塞队列为空或者为满时,需要条件变量来维持生产者和消费者之间的同步关系。 生产者线程之间的互斥竞争关系以及消费者线程之间的互斥竞争关系,只需要上面那把锁便能维护。但是为了避免多线程竞争时的饥饿问题,我们用两个条件变量分别让生产者线程和消费者线程在各自的队列中等待,这样就实现的生产者线程之间的同步,也实现了消费者线程之间的同步。
      由于阻塞队列中这个临界资源被当成了一个大的整体,一次只能让一个线程进来生产或者消费。那么它们之间共同访问临界区时,是互斥串行访问的,并不能体现生产消费者模型的高效性。而它的高效性,恰恰体现在它们的非临界区(准备数据、加工数据)。生产者的临界区代码(生产时)和消费者的非临界区代码(取走数据后,进行加工处理)进行交叉时,是可以并发进行访问的。同样生产者的非临界区代码(获取数据到生产前)和消费者的临界区代码进行交叉时(取走数据时)也是可以并发进行访问的,这才是高效性的体现。

    核心实现:

    BlockQueue.hpp

    #include <iostream>
    #include <unistd.h>
    #
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

杰瑞的猫^_^

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

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

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

打赏作者

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

抵扣说明:

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

余额充值