文章目录

  • 一、线程基本概念
    • 1. 并发和并行
    • 2. 线程的引入
    • 3. 什么是线程
    • 4. Linux下的线程
    • 5. 线程的优点和缺点
      • (1)计算密集型应用
      • (2)IO密集型应用
      • (3)计算密集型程序创建多少个线程合适?
      • (4)I/O密集型程序创建多少个线程合适?
      • (5)优点总结
      • (6)缺点总结
      • (7)线程用途
  • 二、进程与线程
    • 1. 进程的概念
    • 2. 进程的特点
    • 3. 线程概念
    • 4. 线程特点
    • 5. 进程和线程的关系
    • 6. 线程的独有和共享
      • (1)线程私有数据
      • (2)线程共享数据
      • (3)注意点
  • 三、线程控制
    • 1. POSIX线程库
    • 2. 线程创建
      • (1)pthread_create接口
      • (2)pthread_self接口
      • (3)gettid接口
      • (4)pthread_self()和gettid()的区别
      • (5)pthread_t 类型ID 与 线程ID(LWP / tid)区别
      • (6)线程的pthread_t类型ID && 线程地址空间
      • (7)进程ID && 线程ID(pid与tid)
      • (8)线程中的各种ID总结
    • 3. 线程终止
      • (1)pthread_exit接口
      • (2)pthread_cancel接口
    • 4. 线程等待
      • (1) pthread_join接口
      • (2)线程等待4种情况
      • (3)线程的健壮性体现(不需要处理异常情况)
    • 5. 线程分离
      • (1)pthread_detach接口
  • 四、线程互斥
    • 1. 相关背景知识
    • 2. 互斥的引入
    • 3. 互斥锁mutex
    • 4. 互斥的基本原理
    • 5. 互斥锁的特性
    • 6. 互斥量的接口
      • (1)初始化互斥量(pthread_mutex_init)
        • A. 静态初始化
        • B. 动态初始化
      • (2)销毁互斥量(pthread_mutex_destroy)
      • (3)互斥量加锁(pthread_mutex_lock)
      • (4)互斥量解锁(pthread_mutex_unlock)
    • 7. 互斥量具体操作(改进买票程序)
    • 8. 死锁及避免
      • (1)死锁概念
      • (2)死锁产生俩种情况
        • A. 互相竞争对方的可消耗资源
        • B. 资源锁定顺序非法导致互相竞争对方锁定资源
      • (3)产生死锁的必要条件
      • (4)死锁的避免
  • 五、线程同步之条件变量
    • 1. 同步的概念与竞态条件
    • 2. 条件变量的引入
    • 3. 条件变量概述
    • 4. 条件变量的接口
      • (1)条件变量的初始化(pthread_cond_init接口)
        • A. 静态初始化
        • B. 动态初始化
      • (2)销毁条件变量(pthread_cond_destroy接口)
      • (3)等待条件变量满足(pthread_cond_wait)
        • A. 无条件等待(pthread_cond_wait接口)
        • B. 计时等待(pthread_cond_timedwait接口)
        • C. 伪唤醒的避免
        • D. 注意
      • (4)唤醒等待
        • A. 单个线程唤醒(pthread_cond_signal接口)
        • B. 多个线程唤醒(pthread_cond_broadcast接口)
    • 5. 为什么pthread_cond_wait需要互斥量(重要)
    • 6. 条件变量基本使用
    • 7. 条件变量使用规范
  • 六、生产者与消费者模型
    • 1. 生产者消费者模型概念
    • 2. 生产者消费者模型特点
    • 3. 生产者与消费者模型优点
    • 4. 基于阻塞队列(BlockingQueue)的生产者消费者模型
    • 5. 用 C++ queue 模拟阻塞队列的生产消费模型
    • 6. 条件变量的优点
  • 七、POSIX信号量
    • 1. 信号量概念
    • 2. 信号量的操作接口
      • (1)PV操作
      • (2)信号量的初始化(sem_init接口)
      • (3)信号量的申请/等待(sem_wait接口)
      • (4)信号量的释放/发布(sem_post接口)
      • (5)信号量的销毁(sem_destroy接口)
    • 3. 基于环形队列的生产者消费者模型
      • (1)单生产者单消费者代码
      • (2)多生产者多消费者模型
        • 注意!!!
  • 八、线程池
    • 1. 线程池的引入
    • 2. 线程池的应用场景
    • 3. 线程池的优点
    • 4. 模拟实现一个线程池
      • (1)线程池基本框架
      • (2)线程池的简单实现
        • 内部多线程拿取任务并处理(重点)
        • 外部多线程输入任务
        • 测试代码
  • 九、单例模式与线程池
    • 1. 什么是单例模式
    • 2. 单例模式的类型
      • (1)饿汉实现方式
      • (2)懒汉实现方式
    • 3. 懒汉模式下的线程池
  • 十、读写锁与自旋锁
    • 1. 读写锁的引入
    • 2. 读写锁概念
    • 3. 读写锁特点
      • (1)读者写者的加锁特点
      • (2)读者和写者的优先级
    • 4. 读写锁接口和使用
    • 5. 自旋锁概念
  • 十一、其它锁


一、线程基本概念

1. 并发和并行

  • 并行:多个任务不抢占CPU资源,在多个CPU下分别,同时进行运行;即多个任务在同一时刻发生。
  • 并发:多个任务在一个CPU下采用不断切换的方式,在同一时间段内,让多个任务都得以推进;即多个任务在同一个时间段内发生

在操作系统中,安装了多个程序,并发指的是在⼀段时间内宏观上有多个程序同时运⾏,这在单 CPU 系统中,每⼀时刻只能有⼀道程序执⾏,即微观上这些程序是分时的交替运⾏,只不过是给⼈的感觉是同时运 ⾏,那是因为分时交替运⾏的时间是⾮常短的。

⽽在多个 CPU 系统中,则这些可以并发执⾏的程序便可以分配到多个处理器上( CPU ),实现多任务并⾏执⾏即利⽤每个处理器来处理⼀个可以并发执⾏的程序,这样多个程序便可以同时执⾏。⽬前电脑市场 上说的多核 CPU ,便是多核处理器,核越多,并⾏处理的程序越多,能⼤⼤的提⾼电脑运⾏的效率。

注意:单核处理器的计算机肯定是不能并⾏的处理多个任务的,只能是多个任务在单个 CPU 上并发运⾏。同理 , 线程也是⼀样的,单核处理器的计算机中,从宏观⻆度上理解线程是并⾏运⾏的,但是从微观⻆度上分析却是串⾏运⾏的,即⼀个线程⼀个线程的去运⾏,当系统只有⼀个 CPU 时,线程会以某种顺序执⾏多个线程, 我们把这种情况称之为线程调度

2. 线程的引入

现在的操作系统大多采用时间片轮转的方式工作,需要频繁的切换进程,由于每个进程都占有一份独立的内存空间,所以每次切换进程时都需要切换内存空间(程序上下文),这将造成很大的开销此时操作系统的响应速度很慢,为了解决操作系统响应速度慢的问题,操作系统引入了更轻量的进程——线程!

因为线程不占有内存空间,它包括在进程的内存空间中,共享进程的资源,所以同一个进程中的线程切换的开销要小很多,又由于线程相比进程更加轻量,操作系统可以启动更多的线程来执行任务(程序段),这进一步提高了操作系统的并发能力。

现在的操作系统一般都是采用以进程为单位进行资源分配,以线程为单位进行调度这样的工作方式大大提高了操作系统的响应速度,准确来说是减少程序在并发执行时所付出的时空开销,提高了操作系统的并发性能。

3. 什么是线程

定义:线程是进程中执行运算的最小单位,是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。

4. Linux下的线程

一个进程至少有一个执行线程,这个线程就是主执行流。一个进程的多个执行流是共享进程地址空间内的资源,也就是说进程的资源被合理分配给了每一个执行流,这些样就形成了线程执行流。所以说线程在进程内部运行,本质是在进程地址空间内运行。

需要注意的是,Linux下没有真正意义上的线程,线程是通过进程来模拟实现的。

OS系统中存在大量的线程,所以在windows操作系统中存在专门的线程控制块来描述线程,然后还需要维护线程和进程之间的关系。但是Linux中进程和线程的本质都是执行流,所以它不会单独为线程创建线程控制块,而是直接复用进程控制块(PCB),所以Linux中是使用进程模拟线程(使用PCB来充当线程控制块TCB),这样就不需要维护进程和线程之间的复杂关系了,不需要单独为线程设计太多的复杂算法,直接使用进程的一套相关方法。

我们知道一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表等的一些数据结构;当把磁盘中的数据和代码加载进内存中后,虚拟地址和物理地址就是通过页表建立映射的 ;

也就是说,进程是承担分配系统资源的基本实体

而此时我们创建“进程”,不独立创建地址空间,用户级页表,甚至不进行IO将程序的数据和代码加载到内存,我们只创建task_struct,然后让新的PCB指向和老的PCB指向同样的mm_struct。然后,通过合理的资源分配(当前进程的资源),让每个task_struct都能使用进程的一部分资源。此时,我们的每个PCB被CPU调度的时候,执行的“粒度”是不是比原始进程执行的“粒度”要更小一些。(线程)

创建效果如下:

此时在CPU看来一个task_struct总是小于传统意义的进程控制块的,所以线程被称为轻量级进程。一个线程就是一个执行流,每一个线程有一个task_struct的结构体,与进程共享地址空间。

线程在CPU看来其实就是一个个执行流,也就是说,线程是调度的基本单位

CPU曾经看到的task_struct是一个只有一个执行流的进程,现在看到的task_struct不一定是一个完整的进程,可能只是一个多执行流进程的执行流的分支----线程。所以进程其实是共用一份资源的多个线程的整体

OS系统将进程的资源指派给线程,所以线程在进程内部用心的本质是线程在进程的地址空间内运行。

线程是进程内的执行流本质是因为当前地址空间包含了若干个task_struct,每一个task_struct都可以被调度执行,所以就可以保证在任意一个事件段同一个进程内的代码和数据可以被CPU调度,实现了线程的并发。

相关概念总结:

  1. 进程:它是承担分配系统资源的基本实体
  2. 线程:轻量级进程在内核中就是一个PCB,内核通过对PCB的调度实现了对线程的调度;它是CPU调度的基本单位,承担进程资源的一部分的基本实体。
  3. 一个程序里的一个执行路径就叫做线程(thread)
  4. 线程是操作系统能够进行运算调度的最小单位,被包含在进程之中,是进程中的实际运作单位
  5. 线程在进程内部运行,本质是在进程地址空间内运行
  6. 一个进程至少有一个线程
  7. 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

5. 线程的优点和缺点

使用多线程就是在正确的场景下通过设置正确个数的线程来最大化程序的运行速度

将这句话翻译到硬件级别就是要充分的利用 CPU 和 I/O 的利用率;

场景+线程个数 -> 运行速度

所以下面来介绍俩种场景:

(1)计算密集型应用

一个完整请求,I/O操作可以在很短时间内完成, CPU还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分

假如我们要计算 1+2+…100亿 的总和,很明显,这就是一个 CPU 密集型程序

情况1:在【单核】CPU下,如果我们创建 4 个线程来分段计算,即:

线程1计算 [1,25亿)
… 以此类推
线程4计算 [75亿,100亿]

我们来看下图他们会发生什么?


由于是单核 CPU,所有线程都在等待 CPU 时间片。按照理想情况来看,四个线程执行的时间总和与一个线程5独自完成是相等的,实际上我们还忽略了四个线程上下文切换的开销

所以,单核CPU处理CPU密集型程序,这种情况并不太适合使用多线程

情况2:此时如果在 4 核CPU下,同样创建四个线程来分段计算,看看会发生什么?

每个线程都有 CPU 来运行,并不会发生等待 CPU 时间片的情况,也没有线程切换的开销。理论情况来看效率提升了 4 倍

所以,如果是多核CPU 处理 CPU 密集型程序,我们完全可以最大化的利用 CPU 核心数,应用并发编程来提高效率

(2)IO密集型应用

与 CPU 密集型程序相对,一个完整请求,CPU运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分

我们都知道在进行 I/O 操作时,CPU是空闲状态,所以我们要最大化的利用 CPU,不能让其是空闲状态

同样在单核 CPU 的情况下:

从上图中可以看出,每个线程都执行了相同长度的 CPU 耗时和 I/O 耗时,如果你将上面的图多画几个周期,CPU操作耗时固定,将 I/O 操作耗时变为 CPU 耗时的 3 倍,你会发现,CPU又有空闲了,这时你就可以新建线程 4,来继续最大化的利用 CPU。

综上两种情况我们可以做出这样的总结:

线程等待时间所占比例越高,需要越多线程;线程CPU时间所占比例越高,需要越少线程。

(3)计算密集型程序创建多少个线程合适?

对于 CPU 密集型来说,理论上 线程数量 = CPU 核数(逻辑)就可以了,但是实际上,数量一般会设置为 CPU 核数(逻辑)+ 1, 为什么呢?

计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。

所以对于CPU密集型程序, CPU 核数(逻辑)+ 1 个线程数是比较好的经验值的原因了

(4)I/O密集型程序创建多少个线程合适?

如上图所示(你可以动手将I/O耗时与CPU耗时比例调大,比如6倍或7倍),这样你就会得到一个结论,对于 I/O 密集型程序:

最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗时/CPU耗时))

(5)优点总结

  1. 创建一个新线程的代价要比创建一个新进程小得多(因为不需要分配太多系统资源,大部分共享进程资源)
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(只需要切换上下文数据,不需要切换页表,不需要更新缓存)
  3. 线程占用的资源要比进程少很多
  4. 能充分利用多处理器的可并行数量(减少了程序在并发时的时空开销)
  5. 在等待慢速I/O操作结束的同时,CPU是空闲的,CPU可执行其他的计算任务
  6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  7. IO密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

(6)缺点总结

  • 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 缺乏访问控
    抢占式执行:不同的线程抢占同一资源,可能造成程序结果二义性。
  • 编程难度提高
    编程难度高,多个执行流可以并发的执行,也就可能会访问到同一临界资源,我们需要对访问临界资源的顺序进行控制,防止程序产生二义性。
  • 健壮性/鲁棒性 降低
    单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃,线程是进程的执行分支,所以线程出现异常就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有进程也就随即退出。
    比如:进程之间是相互独立的,我们打开各种软件,一个软件的崩溃并不会影响其他软件,变相的也就增加了进程的健壮性,而线程就不同了,因为大部分资源都是共享的,一个线程的崩溃就会导致其他所有线程崩溃,进而导致整个进程崩溃。

(7)线程用途

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

二、进程与线程

1. 进程的概念

进程:是指⼀个内存中运⾏的应⽤程序,每个进程都有⼀个独⽴的内存空间。进程也是程序的⼀次执⾏过程,是系统运⾏程序的基本单位;系统运⾏⼀个程序即是 ⼀个进程从创建、运⾏到消亡的过程。

2. 进程的特点

  • 独立性
    进程是系统中独立存在的实体,它可以拥有自己独立的资源,每个进程都拥有自己私有的地址空间,在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
  • 动态性
    进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合,程序加入了时间的概念以后,称为进程,具有自己的生命周期和各种不同的状态,这些概念都是程序所不具备的。
  • 并发性
    多个进程可以在单个处理器CPU上并发执行,多个进程之间不会互相影响.

3. 线程概念

线程:线程是进程的一个执行路径,是进程中的⼀个执⾏单元,它被包含在进程之中,是进程中的实际运作单位,负责当前进程中程序的执⾏,⼀个进程中⾄少有⼀个线程。⼀个进程中是可以有多个线程的,其中有一个主线程来调用本进程中的其他线程,这个应⽤程序也可以称之为多线程程序。

4. 线程特点

上文已写

5. 进程和线程的关系

  1. 进程是资源分配的基本单位,线程是CPU调度和分派的基本单位
  2. 线程是进程的一部分,一个线程只能属于一个进程,一个进程可以有多个线程,但至少有一个线程
  3. 每个进程都有独立的代码和数据空间(程序上下文),进程间的切换开销大;线程可看做轻量级的进程,同一个进程中的线程共享进程代码和数据,但是每个线程也有自己独立的一部分资源,比如独立的运行栈和程序计数器(PC)等等,线程间切换开销小
  4. 在操作系统中能同时运行多个进程(程序)在同一个进程(程序)中多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)
  5. 系统在运行的时候会为每个进程分配不同的内存空间,所以进程是系统资源分配的实体;而线程除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。
  6. 没有现成的进程可以看做单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,多条线(线程)共同完成。线程是进程的一部分,故线程被称为轻权进程/轻量级进程

6. 线程的独有和共享

线程拷贝进程的PCB并创建,共用同一块虚拟地址空间,在虚拟地址空间的共享区中有一块独有的空间。

(1)线程私有数据

线程拥有这许多共性的同时,还拥有自己的个性。有了这些个性,线程才能实现并发性。这些个性包括:

  1. 线程ID
    每个线程都有自己的线程ID,这个ID在本进程中是唯一的。进程用此来标识线程。
  2. 寄存器组里面的“值”
    由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线程切换到另一个线程上时,必须将原有的线程的寄存器集合的状态保存,以便将来该线程在被重新切换到时能得以恢复。
  3. 线程的栈
    栈是保证线程独立运行所必须的。线程函数可以调用函数,而被调用函数中又是可以层层嵌套的,所以线程必须拥有自己的函数栈,使得函数调用可以正常执行,不受其他线程的影响。
  4. 错误返回码
    由于同一个进程中有很多个线程在同时运行,可能某个线程进行系统调用后设置了errno值,而在该线程还没有处理这个错误,另外一个线程就在此时被调度器投入运行,这样错误值就有可能被修改。所以,不同的线程应该拥有自己的错误返回码变量。
  5. 线程的信号屏蔽码
    由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器。
  6. 线程的优先级
    由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。

(2)线程共享数据

线程共享的环境包括:进程代码段、进程的公有数据(利用这些共享的数据,线程很容易的实现相互之间的通讯)、进程打开的文件描述符信号的处理方式当前目录用户ID与组ID

  1. 共享当前进程的虚拟地址空间(内核,环境参数,数据段,代码段):因此使用静态变量、全局变量的函数是不可重入的
  2. 文件描述符表:因此调用I/O库的函数是不可重入的
  3. 当前进程的工作路径
  4. 用户ID和用户组ID
  5. 信号的处理方式(SIG_DFL,SIG_IGN,自定义)
  6. 堆空间:堆是在进程空间后开辟出来的,是被所有线程共享;因此申请堆上空间的函数也不可重入

(3)注意点

  1. 线程都拥有各自的栈因此可以并行,不用担心调用栈混乱,而堆上空间是所有线程共享的。
  2. 若函数内存在共享内容,一般都是不可重入的。

三、线程控制

1. POSIX线程库

由于Linux没有真正意义上的线程,它的线程其实就是轻量级进程,所以Linux并没有给开发者提供创建线程的接口,我们要写多线程的代码,就得借助第三方库来实现,这个库就是POSIX线程库NPTL(Native POSIX Thread Library)

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

2. 线程创建

(1)pthread_create接口

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

功能:

pehread_create用来创建一个新线程

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做主线程。

  • 主线程是产生其他子线程的线程。
  • 通常主线程必须最后完成某些执行操作,比如各种关闭动作

参数:

  • 第一个参数thread:线程标识符;输出型参数,新创建的pthread_t类型id会保存到里面
  • 第二个参数attr:设置线程的属性,一般不进行设置,NULL表示默认
  • 第三个参数start_routine:线程入口函数,接收一个函数地址,这个函数线程入口函数从这里传
  • 第三个参数arg,入口函数的参数从这里传

返回值

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

注意

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通 过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误, 建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小



我们的程序中两个死循环中的代码被同时打印,说明我们的程序一定有两个或以上的执行流。

这时我们再打开一个终端,用ps -aL命令查看进程信息

有了-L选项,进程查看列表里面有一项 LWP(Light Weight Process),也就是内核表示的轻量级进程的ID,这两个轻量级进程就是两个不同的执行流,也就是两个线程。

两个轻量级进程的PID是一样的,说明一个进程里可以有多个LWP,LWP是调度的基本单位,一个个的执行流用LWP来标识,而进程的标识PID,则来标识这些轻量级进程属于哪些进程。

注意

  1. pthread_t线程ID和LWP并不是一回事,pthread_t线程ID是库级别线程标识符,本质就是线程在共享区独有空间的首地址,通过这个标识符可以对当前线程进行操作。而LWP是tid。后文详解
  2. void* arg 不能接收临时变量的地址,这是因为主线程和线程是并行式运行,而临时变量在出了主线程的作用域,就会在栈帧上被释放资源,此时线程入口函数中使用的就是一块非法的地址,可能造成程序崩溃。
  3. void* arg可以接收堆上开辟的,如结构体指针,this指针。因为堆上动态开辟的资源需要手动释放,不要忘记在线程退出之前释放掉堆上的资源,防止内存泄露。

(2)pthread_self接口

#include <pthread.h>
pthread_t pthread_self(void);

功能:获得线程自身的 pthread_t 类型的 ID

返回值:线程自身的pthread_t类型 ID

(3)gettid接口

Linux提供了gettid系统调用来返回其线程ID,可是glibc并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程ID,可以采用如下方法

#include<sys/syscall.h>
syscall(SYS_gettid); //该函数和gettid等价,都是获取线程tid。//在编写程序时可以使用上述函数。也可以将其封装一下。
pid_t gettid()
{return syscall(SYS_gettid);
}

功能:获取线程自身的内核ID,即tid/LWP

返回值:线程自身的内核ID,即tid/LWP

(4)pthread_self()和gettid()的区别

对于pthread_self()函数

  • pthread_self()函数是线程库POSIX Phtread实现函数,它返回的线程ID是由线程库封装过然后返回的。既然是线程库函数,那么该函数返回的ID也就只在进程中有意义,与操作系统的任务调度之间无法建立有效关联。
  • 返回值类型为pthread_t

对于gettid()函数

  • 该函数就是Linux提供的函数,它返回的ID就是"线程"(轻量级进程)ID,相当于内核线程ID,该内核线程ID也被称为 tid/LWP
  • 返回值类型为pid_t

(5)pthread_t 类型ID 与 线程ID(LWP / tid)区别

pthread_ create函数会产生一个 (pthread_t类型 id),存放在第一个参数指向的空间中;
pthread_self它返回一个 pthread_t 类型的变量,指代的是调用 pthread_self 函数的pthread_t类型 id)

pthread_create的第一个参数值和pthread_self的返回值是相同的,都是一个在库中标识线程的ID

修改刚才的代码,分别打印新线程的pthread_self(),和主线程的创建线程的pthread_t类型ID,以及主线程的pthread_self():

可以验证pthread_self()多返回值和pthread_create()的第一个参数都是到当前线程的线程库ID(pthread id)

但是现在所讨论的线程pthread_t类型ID和前面说的LWP不是同一个ID!

这个“ID”是 pthread 库给每个线程定义的进程内唯一标识,是 pthread 库维持的,线程库的后续操作,就是根据该ID来进行操作线程的由于每个进程有自己独立的内存空间,故此“ID”的作用域是进程级而非系统级(内核不认识)

内核中的LWP属于轻量级进程调度的范畴,是操作系统调度器的最小单位,在内核中需要一个数值来唯一表示该线程。

查看主线程和子线程的 pthread_t类型 ID 和 线程ID/tid/LWP

那么这个线程ID,也就是pthread_t类型是个什么类型?
答:pthread_t的类型取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

(6)线程的pthread_t类型ID && 线程地址空间

我们知道,Linux没有线程的概念,只有LWP,而我们想要有线程相关的操作,就得通过LWP来模拟线程,就有了POSIX线程库来对LWP进行描述和管理,而这些描述和管理的相关数据结构都只能在用户空间,也就是在库中实现。

通过ldd命令查看编译好的程序,发现线程库是一个动态库。

而动态库的代码是程序运行过程中被加载到内存,通过页表映射到堆栈中间的共享区。

线程的栈都是私有的,为了防止多个线程之间运行时产生的临时变量不会相互影响,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的

而加载到共享区的库,就帮我们描述了线程,也就是给每个线程创建了struct pthread,包含了线程的属性和私有栈,还有线程切换时的上下文信息。

线程ID,也就是pthread_self的返回值,pthread_t所保存的指针,其实就是共享区中对每个线程描述结构体struct pthread的起始地址。

在在bash中查看一个进程的调用栈:pstack+pid

可以知道 pthread id 是线程库中线程标识符,在内核的本质就是线程独有空间的首地址!用pstack+pid,可以看到LWP前面那一串就是线程标识符。

(7)进程ID && 线程ID(pid与tid)

在Linux中,目前的线程实现是NativePOSIXThreadLibaray,简称NPTL。在这种实现下,线程又被称为轻量级进程(LightWeightedProcess),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct结构体)。

没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用getpid函数时返回相同的进程ID,如何解决上述问题呢?

Linux内核引入了线程组的概念。

struct task_struct
{ ... pid_t pid; pid_t tgid; ... struct task_struct *group_leader; ... struct list_head thread_group;···
}

多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct)与之对应。进程描述符结构体中的pid,表面上看对应的是进程ID,其实不然,它对应的是线程ID,其含义是Thread ID,也就是我们所说的tid;进程描述符中的tgid含义是Thread Group ID,该值对应的是用户层面的进程ID,也就是线程组ID

线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量。

  • LWP:线程ID,既gettid()系统调用的返回值。
  • NLWP:线程组内线程的个数

从上面可以看出,test进程的ID为1006,下面有一个线程的ID也是1006,这不是巧合。线程组内的第一 个线程,在用户态被称为主线程(main thread),在内核中被称为group leader,内核在创建第一个线程时,会将线程组的ID的值设置成第一个线程的线程ID,group_leader指针则指向自身,既主线程的进程描述符。所以线程组内存在一个线程ID等于进程ID,而该线程即为线程组的主线程。

至于线程组其他线程的ID则有内核负责分配,其它线程组ID总是和主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。

(8)线程中的各种ID总结

通过Linux的top和ps命令中,默认看到最多的是pid (process ID),也许你也能看到lwp (thread ID)tgid (thread group ID for the thread group leader)等等,而在Linux库函数和系统调用里也许你注意到了pthread idtid等等。还有更多的ID,【比如pgrp (process group ID), sid (session ID for the session leader)和 tpgid (tty process group ID for the process group leader)。】

Linux下的各种ID:

  1. pid:进程ID。
  2. lwp:线程ID。在用户态的命令(比如ps)中常用的显示方式。
  3. tid:线程ID,等于lwp。tid在系统提供的接口函数中更常用,比如syscall(SYS_gettid)和syscall(__NR_gettid)。
  4. tgid:线程组ID,也就是线程组leader的进程ID,等于pid。
  5. pgid:进程组ID,也就是进程组leader的进程ID。
  6. pthread id:pthread库提供的ID,生效范围不在系统级别

3. 线程终止

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

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

(1)pthread_exit接口

#include <pthread.h>
void pthread_exit(void *retval);

参数

  • retval:线程退出时的退出码信息,线程函数返回值的用法一样。其它线程可以调用pthread_join(稍后介绍)获得这个指针。

返回值

  • 无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

注意:

  • pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。
  • 当我们在新线程中调用pthread_exit函数时,只会将新线程终止,不会影响到主线程;
  • 当我们在新线程中调用exit函数时,直接将进程退出了;

(2)pthread_cancel接口

#include <pthread.h>
int pthread_cancel(pthread_t thread);

参数

  • thread:被取消线程的库级别ID,既pthread ID。

返回值

  • 线程取消成功返回0,失败返回错误码。

pthread_cancel可以终止同一个进程中的其他线程,也可以终止自身线程,一般用于主线程结束新线程;取消成功的线程的退出码一般是-1;(可以通过线程等待获取线程退出码)

运行结果发现,当主线程取消新线程后,新线程终止,返回的退出码是-1;当线程被取消的时候,如果是-1,就表明它是合法的,这里的-1具体是什么呢?

关于PTHREAD_ CANCELED,其实是库中定义的一个宏本质是-1

4. 线程等待

一般而言,一个线程被创建出来,就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。所以线程需要被等待,如果不等待会产生类似于“僵尸进程”的问题,也就是内存泄漏。

(1) pthread_join接口

#include <pthread.h>
int pthread_join(pthread_t thread, void** retval)

参数说明

  • thread:被等待线程的ID
  • retval:它是一个输出型参数,用来获取新线程退出的时候,函数的返回值;新线程函数的返回值是void*,所以要获取一级指针的值,就需要二级指针,也就是void**;

返回值:

  • 成功返回0
  • 失败返回错误码

(2)线程等待4种情况

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

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

关于PTHREAD_ CANCELED,其实是库中定义的一个宏本质是-1


pthread_join函数默认是以阻塞的方式进行线程等待的。它只有等待线程退出后才可以拿到退出码;

(3)线程的健壮性体现(不需要处理异常情况)

我们知道进程退出时有三种状态:

  1. 代码跑完,结果正确
  2. 代码跑完,结果错误
  3. 代码异常终止

那么线程也是一样的,这里就存在一个问题,刚刚上面的代码,是获取线程的退出码的,那么代码异常终止,线程需要获取吗?

答案:不需要;pthread_join函数无法获取到线程异常退出时的信息。因为线程是进程内的一个执行流,如果进程中的某个线程崩溃了,那么整个进程也会因此而崩溃,此时我们根本没办法执行pthread_join函数,因为整个进程已经退出了;

例如,我们在线程的执行例程当中制造一个野指针问题,当某一个线程执行到此处时就会崩溃,进而导致整个进程崩溃。


运行代码,可以看到一旦某个线程崩溃了,整个进程也就跟着挂掉了,此时主线程连等待新线程的机会都没有,这也说明了多线程的健壮性不太强

所以pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正确,而不能获取异常情况。

5. 线程分离

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

在线程等待的时候,主线程是被阻塞的,这时主线程不能进行其他工作,若不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

(1)pthread_detach接口

#include <pthread.h>
int pthread_detach(pthread_t thread);

功能:

  • 分离一个线程,让这个线程退出时OS系统来清理资源,不需要再等待

参数:

  • 要分离的线程库级别ID,即pthread ID。

返回值:

  • 线程分离成功返回0,失败返回错误码

如果不进行线程分离,那么就要线程等待,可以获取新线程的退出码
如果进行线程分离,线程执行完后自动释放资源,就算等待也不会有线程的退出信息

注意:线程被分离,只是会自动清理不需要等待,并不是完全和主线程脱离关系,当被分离的线程异常退出时,整个进程都会退出。


四、线程互斥

1. 相关背景知识

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

2. 互斥的引入

并发在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个进程都是在同一个CPU上运行,但任一个时刻点上只有一个程序在CPU上运行。

并发可能存在某一时刻会对临界资源进行同时访问,如果在对这些临界资源不遵循某种规则,那么就可能对这些共享资源造成破坏,导致程序产生二义性。接下来我们使用一个最简单的代码来阐述并发出现的问题。

下面有一个抢票的代码,用四个线程同时抢10张票,票的总数是一个全局变量,也就是临界资源,对票数的判断和票数的减少,则为临界区:


多线程情况下,如果有一个全局变量,也就是多个线程都能访问的临界资源多个线程对这个临界资源进行操作,那么就可能会出现这个数据的不确定性

该程序就出现了数据的错误,多个线程枪同一张票,票数会多出来,并且票数出现了负数的情况!这是不合理的。

不合理的原因:tickets–并不是原子性的;

查看Linux环境下tickets–的汇编代码


-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量tickets从内存加载到CPU的寄存器中
  • update : 更新寄存器里面的值,进行运算
  • store :将新值从寄存器写回共享变量ticket的内存地址中

出现负数原因:当线程A进入了if语句,但是在执行tickets之前线程A就被切换走了;另一个线程B进入if,然后执行了tickets–,将数据写入内存,执行完毕;之后线程A被切换回去,继续执行,将线程B得到的数据从内存加载到CPU的寄存器,执行–,写回内存,这样就可能出现负数的情况。

票数被重复卖出原因:线程A将ticket的值加载进了寄存器,线程A切换时这个CPU内寄存器内的数据称为该线程的上下文数据。这时时间片轮转到了其他线程(线程的切换事件是任意的),此时线程A就在等待队列里面等待中。另一个线程B被CPU调度,再次执行tickets–这条语句,最终线程B将票数从10变为1,将1写入内存中。此时线程B的时间片到了,线程B保存了上下文数据后进入等待队列,线程A被CPU调度,继续执行上次的动作,最终将票数从10变为了4,将数据加载到内存,此时tickets的值为4。然而线程A和B都进行了抢票,共抢了15张票,于是就出现票被重复卖出的情况。

这样的操作似乎每一个线程都有一个自己的ticket值,但是我们想要俩个线程共享一个资源,这样的操作是不合理的。

这一切不合理的原因都是:tickets–操作不是原子性的!

3. 互斥锁mutex

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线进入该临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量

通过加锁(lock)和解锁(unlock)来实现互斥。采用加锁和解锁可以直接创立一个仅允许持有锁的单个线程操作,而不允许多个线程同时操作的区域,叫做临界区。

注意:当某个线程对临界资源加锁时,并不是该线程不会在临界区内被切换为其它线程,而是完全有可能的;即使线程A在临界区域被切走,其它线程由于锁已经被线程A申请走了,在进行线程切换的时候没有被释放,其它线程不会访问临界区。

互斥定义:是指散布在不同进程之间的若干程序片断,当某个进程运行其中一个程序片段时,其它进程就不能运行它们之中的任一程序片段,只能等到该进程运行完这个程序片段后才可以运行。如果用对资源的访问来定义的话,互斥某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的

4. 互斥的基本原理

我们用原子交换原语(atomic exchange或atomic swap)来建立基本互斥机制。这个原语是将寄存器中的一个值和内存中的一个值相互交换

互斥锁的本质其实是一个0/1计数器,用来标记资源状态,是否被加锁

加锁的本质是让这个计数器由1变为0
解锁的本质是让这个计数器由0变为1

假定有一个临界资源为锁变量,该变量的值为“1”;同时每一个线程也含有一个上下文数据----锁变量来表示自己是否持有锁,该变量的值默认为0

一个线程申请锁,就是将线程自己的锁变量和临界资源的锁变量进行swap原子操作,即进行原子性的交换。交换后线程就持有了“1”这个锁,而临界资源中的锁变量值变为了0,就表示锁已经被申请走了,该线程也就可以进入临界区。此时其他线程来申请锁时,进行swap时只会得到“0”,表示未申请到锁,进入等待队列等待锁资源,该线程也就不可以进入临界区!

注意:所有线程在进入临界区时都会申请锁,锁也是临界资源,那么锁本身也要被保护,其实锁的申请操作----swap或exchange是一个原子操作,只有两种状态,申请成功或者申请失败,不会出现俩把锁。

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

下面来通过伪代码来再次理解lock和unlock

我们认为mutex就是这个计数器的值,mutex的值默认为1,al是一个寄存器,al里面的值就是线程的上下文数据----即我们上文所说的线程的锁变量。

  • 申请锁的过程是线程先执行move指令,把al寄存器里的值初始化为0,
  • 然后把al寄存器里的0和计数器里的1值进行交换
  • 这时判断寄存器里的值是否大于0,大于0就申请锁成功,小于0就进入等待队列,等待占有锁的线程释放锁资源,释放后就有可能唤醒挂起的进程继续申请锁,直到申请到锁资源进入临界区。
  • 一个线程在申请锁资源的时候,执行完第一步初始化,这时如果时间片发生轮转,轮到其他线程申请,其他线程也是将al初始化为0,并不会对结果造成影响
  • 若是执行完第二步交换指令,把mutex的值交换到了al寄存器,这时时间片切换到其他线程,那么在切换前,会保存上下文数据,1这个值会被线程保存起来
  • 其他进程进来,把al初始化为0,再去交换mutex的值,此时这个1已经交换到第一个线程中了,新线程拿不到1就会阻塞,原来线程拿到时间片就会恢复上下文数据,这个1也会被恢复到al寄存器,继续执行下面的指令 。
  • 从始至终,这个1只有一份,线程要不拿到1也就是申请到锁,要不拿不到1,只有申请到和没申请到两种状态,所以说申请锁的过程是原子操作。
  • 解锁的时候只有1条指令,就是把1放回mutex里,虽然al里还是1,但是申请锁的时候无论如何都会将al重新初始化为0,也就保证了1的唯一性

5. 互斥锁的特性

  1. 原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
  2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
  3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。

6. 互斥量的接口

分析

  • pthread_mutex_t 类型,其本质是一个结构体,为简化理解,应用时可忽略其实现细节,简单当成整数看待。
  • pthread_mutex_t mutex:变量mutex只有两种取值0、1;

(1)初始化互斥量(pthread_mutex_init)

A. 静态初始化

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

如果互斥锁mutex是静态分配的(定义在全局,或加了static关键字修饰),可以直接使用宏进行初始化。

B. 动态初始化

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

restrict关键字:只用于限制指针,告诉编译器,所有修改该指针指向内存中内容的操作,只能通过本指针完成。不能通过除本指针以外的其他变量或指针修改。

参数:

  • 第一个参数mutex,传出参数,表示要初始化的互斥量,调用时应传&mutex
  • 第二个参数attr,表示初始化互斥量的属性,一般设置为NULL即可,默认属性(线程间共享)

互斥锁的属性在创建锁的时候指定,在LinuxThreads实现中仅有一个锁类型属性(缺省值),不同的锁类型在试图对一个已经被锁定的互斥锁加锁时表现不同。 当前(glibc2.2.3,linuxthreads0.9)有四个值可供选择:

返回值:

  • 互斥量初始化成功返回0,失败返回错误码。
错误码 出错原因
EAGAIN 系统缺乏初始化互斥量所需的非内存资源
ENOMEM 系统缺乏初始化互斥量所需的内存资源
EPERM 调用程序没有适当的优先级

(2)销毁互斥量(pthread_mutex_destroy)

#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:

  • mutex,要初始化的互斥量

返回值:

  • 互斥量销毁成功返回0,失败返回错误码。

销毁互斥量需要注意:

  • 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

(3)互斥量加锁(pthread_mutex_lock)

功能:加锁。可理解为将 mutex减1

#include<pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

区别:

  • pthread_mutex_trylock 功能与pthread_mutex_lock一样,只是当mutex已经是锁定的时候, thread_mutex_lock()函数会将线程阻塞,直到互斥量可用为止,而pthread_mutex_trylock()通常立即返回错误码EBUSY,而不阻塞。

参数:

  • mutex,要初始化的互斥量

返回值:

  • 互斥量加锁成功返回0,失败返回错误码。

调用pthread_ lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

(4)互斥量解锁(pthread_mutex_unlock)

功能:解锁。可理解为将mtex++

#include<pthread.h>
pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:

  • mutex,要初始化的互斥量

返回值:

  • 互斥量解锁成功返回0,失败返回错误码。

lock和unlock特点

  • lock尝试加锁,如果加锁不成功,线程阻塞,阻塞到持有该互斥量的其他线程解锁为止。
  • unlock主动解锁,同时将阻塞到该锁上所有线程全部唤醒,至于哪个线程先被唤醒取决于优先级,调度。默认:先阻塞、先唤醒。
  • 例如:T1 T2 T3 T4 使用一把mutex锁,T1加锁成功,其他线程均阻塞,直至T1解锁,T1解锁后,T2 T3 T4均被唤醒,并自动再次尝试加锁

7. 互斥量具体操作(改进买票程序)

既然多个线程同时访问临界区会出现问题,那么就在进入临界区之前对临界区进行加锁操作,在有可能退出临界区的所有出口进行解锁操作


注意:

  • 加锁或者解锁一定要选在最合适的地方,加锁的区域越少越好,因为被加锁的区域线程是串行的,一个线程获得锁资源,其他线程就会阻塞,效率比较低,所以加锁的代码执行的越少效率越高
  • 在一个线程解锁时,要稍等一会,不然还没等别的线程开始抢锁,刚刚解锁的线程就又会立刻加锁,造成全部票被一个线程全部抢走的情况
  • 解锁时要在任何可能退出临界区的出口解锁,不然可能会解不了锁,造成死锁

8. 死锁及避免

(1)死锁概念

死锁是指两个或两个以上的执行流在执行过程中,都在等待只有对方执行流才能引发的时间,那么该组执行流就是死锁的(通常时因争夺资源而造成的一种互相等待的现象)

(2)死锁产生俩种情况

A. 互相竞争对方的可消耗资源

进程通信时也会引起死锁。进程间彼此相互等待对方发来的消息,结果也会使得这些进程间无法继续向前推进。例如,进程A等待进程B发的消息,进程B又在等待进程A 发的消息,可以看出进程A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁

B. 资源锁定顺序非法导致互相竞争对方锁定资源

线程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发线程 T1、T2分别锁定了资源R1、R2,而线程T1申请资源R2,线程T2申请资源R1时,两者都会因为所需资源被占用且无法释放而永久阻塞。这种情况只需要规定资源的锁定和释放顺序即可避免。

代码模拟情况B:

运行结果发现进程无法继续执行:

我们查看调用栈发现两个进程都在等待锁造成死锁

死锁gdb调试
t+线程序号,跳转到某个线程当中,p lock查看锁的所有者

(3)产生死锁的必要条件

  1. 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
  2. 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  3. 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

(4)死锁的避免

避免互斥量的死锁就是要破坏死锁产生的必要条件

  1. 加锁顺序一致
  2. 避免锁未释放的场景
  3. 资源一次性分配,即进程运行前就一次性分配资源,满足则运行,否则等待;解决请求保持问题
  4. 申请资源未成功释放已有资源;(但是破坏环路等待条件该策略实现起来复杂且代价大。因为一个资源在使用一段时间后被强行剥夺,会造成前阶段工作失效)
  5. 银行家算法,基本思想:在避免死锁方法中允许进程动态地申请资源,但系统在进行资源分配之前,应先计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待。

五、线程同步之条件变量

1. 同步的概念与竞态条件

  • 同步:在保证数据安全的前提下(加锁保护),让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:竞态条件 (race condition) 又名竞争危害 (race hazard)。旨在描述一个系统或者进程的输出展现无法预测的、对事件间相对时间的排列顺序的致命相依性。

2. 条件变量的引入

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

只使用互斥量来对临界资源进行保护时,在某些情况下效率是非常低下的;比如线程T1正在等待临界区内某个条件出现,条件出现后才能对临界资源进行各种操作,由于线程T1只有进入临界区才能判断是否满足等待条件,所以需要不断对临界区加锁和解锁(轮询),以判断临界区内线程T1所等待的条件是否出现,但是这样轮询检测的方案非常耗费时间和资源效率低。于是为了减少对锁的使用,题干效率,引入了条件变量

3. 条件变量概述

因此,为了让线程在临界区内对线程在临界资源的状态进行判断而减少锁的使用,条件变量有如下机制:

  • 当一个线程申请到了锁资源,并且满足该线程的运行条件时,如果此时该线程是持有锁的,那么就继续运行,如果该线程此时没有锁,那么就会竞争锁,得到锁后继续执行
  • 当一个线程申请到了锁资源,但是该线程没有满足运行条件,那么就释放该线程持有的锁,并将该线程挂起等待;直到其它线程告诉该线程等待条件满足,并将其唤醒,此时被唤醒的线程就会竞争锁,得到锁后继续执行

该机制主要包括俩个动作:

  • 一个线程等待某个条件成立,而将自己挂起等待
  • 另一个线程使等待条件成立,并唤醒在该条件变量下等待的线程

4. 条件变量的接口

(1)条件变量的初始化(pthread_cond_init接口)

条件变量和互斥锁一样,都有静态动态两种创建方式

A. 静态初始化

静态方式使用PTHREAD_COND_INITIALIZER常量,如下:

pthread_cond_t cond=PTHREAD_COND_INITIALIZER;

B. 动态初始化

动态方式调用pthread_cond_init()函数,定义如下:

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t   *cond_attr);

参数

  • 第一个参数:要初始化的条件变量
  • 第二个参数:尽管POSIX标准中为条件变量定义了属性,但在LinuxThreads中没有实现,因此cond_attr值通常为NULL,且被忽略。

返回值

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

(2)销毁条件变量(pthread_cond_destroy接口)

pthread_cond_destroy接口

int pthread_cond_destroy(pthread_cond_t *cond);

参数

  • cond,要销毁的条件变量

返回值

  • 条件变量销毁成功返回0,失败返回错误码。

注意:

  • 使用PTHREAD_COND_INITIALIZER 初始化的互斥量不需要销毁
  • 只有在没有线程在该条件变量上等待的时候才能销毁这个条件变量,否则返回EBUSY。

(3)等待条件变量满足(pthread_cond_wait)

A. 无条件等待(pthread_cond_wait接口)

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

函数作用:

  • 阻塞等待条件变量cond(参1)满足
  • 释放已掌握的互斥锁(解锁互斥量),相当于pthread_mutex_unlock(&mutex);
  • 当被唤醒,pthread_cond_wait函数返回时解除阻塞并重新申请获取互斥锁,相当于pthread_mutex_lock(&mutex);

注意:前俩步是原子操作

参数

  • 第一个参数cond,需要等待的条件变量
  • 第二个参数mutex,当前临界区的互斥量

返回值

  • 函数调用成功返回0,失败返回错误码

B. 计时等待(pthread_cond_timedwait接口)

int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex,const struct timespec   *abstime);

该函数的返回值以及前俩个参数和吴条件等待的意义相同

第三个参数:查看man sem_timedwait函数,查看struct timespec结构体。

struct timespec {time_t tv_sec; /* seconds */ 秒long   tv_nsec; /* nanosecondes*/ 纳秒
}

形参abstime:绝对时间。
如:time(NULL)返回的就是绝对时间。而alarm(1)是相对时间,相对当前时间定时1秒钟。

错误用法

struct timespec t = {1, 0};
pthread_cond_timedwait (&cond, &mutex, &t); 只能定时到 1970年1月1日 00:00:01秒(早已经过去)

正确用法:

time_t cur = time(NULL); // 获取当前时间。
struct timespec t; //定义timespec 结构体变量t
t.tv_sec = cur+1; //定时1秒pthread_cond_timedwait (&cond, &mutex, &t); //传参 参APUE.11.6线程同步条件变量小节

C. 伪唤醒的避免

pthread_cond_wait()函数的返回并不意味着线程的等待条件满足了,必须重新检查条件的值。

阻塞在条件变量上的线程被唤醒以后,直到pthread_cond_wait()函数返回之前等待条件都有可能发生变化。所以函数返回以后,在锁定相应的互斥锁之前,必须重新测试条件值。最好的测试方法是循环调用pthread_cond_wait()函数,并把满足条件的表达式置为循环的终止条件。如:

pthread_mutex_lock();while (condition_is_false)pthread_cond_wait();pthread_mutex_unlock();

D. 注意

无论哪种等待方式,都必须和一个互斥锁配合,以防止多个线程同时请求pthread_cond_wait()(或pthread_cond_timedwait())的竞争条件(Race Condition)。mutex互斥锁必须是普通锁或者适应锁,且在调用pthread_cond_wait()前必须由本线程加锁,而在更新条件等待队列以前,mutex保持锁定状态,并在线程挂起进入等待前解锁。在条件满足从而离开pthread_cond_wait()之前,mutex将被重新加锁,以与进入pthread_cond_wait()前的加锁动作对应。
为什么条件等待会需要传入一个锁,可参考:https://www.zhihu.com/question/24116967 中的解释。

(4)唤醒等待

A. 单个线程唤醒(pthread_cond_signal接口)

int pthread_cond_signal(pthread_cond_t *cond);

参数

  • 唤醒一个在该条件变量下等待的线程,存在多个等待线程时按入队顺序唤醒其中一个

返回值:

  • 成功返回0,失败返回错误码.

B. 多个线程唤醒(pthread_cond_broadcast接口)

int pthread_cond_broadcast (pthread_cond_t * cond);

参数

  • 唤醒一个在该条件变量下等待的所有线程

返回值:

  • 成功返回0,失败返回错误码.

必须在互斥锁的保护下使用相应的条件变量。否则对条件变量的解锁有可能发生在锁定条件变量之前,从而造成死锁。

唤醒阻塞在条件变量上的所有线程的顺序由调度策略决定,如果线程的调度策略是SCHED_OTHER类型的,系统将根据线程的优先级唤醒线程。

如果没有线程被阻塞在条件变量上,那么调用pthread_cond_signal()将没有作用。

由于pthread_cond_broadcast函数唤醒所有阻塞在某个条件变量上的线程,这些线程被唤醒后将再次竞争相应的互斥锁,所以必须小心使用pthread_cond_broadcast函数。

5. 为什么pthread_cond_wait需要互斥量(重要)

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
  • 线程要判断是否满足条件,就一定要先进入临界区,而进入临界区就一定要先获得锁资源,一旦条件不满足,就要等待,但是当前线程是占有锁资源的,如果直接进行阻塞等待,那么这个锁就永远不会释放了,就会造成死锁
  • 所以线程在调用pthread_cond_wait进行等待时,会先释放锁资源,而要释放锁,就得需要传入互斥量pthread_cond_wait会在调用时自动释放锁资源
  • 当该线程被唤醒时,程序会接着从pthread_cond_wait处继续运行,这时线程是没有锁的,也就是临界区没有被保护的,那么当线程被唤醒后,pthread_cond_wait会帮线程重新持有释放的锁资源,而重新申请锁,也需要互斥量

所以pthread_cond_wait调用时需要传入两个参数,一个条件变量和互斥量。

为什么条件等待会需要传入一个锁,可参考:https://www.zhihu.com/question/24116967 中的解释。

6. 条件变量基本使用

创建两个线程,一个线程等待唤醒,唤醒就打印字符串,一个线程唤醒,每隔一秒唤醒一次


7. 条件变量使用规范

  • 等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
  • 唤醒在条件变量下等待的线程
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

六、生产者与消费者模型

1. 生产者消费者模型概念

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

生产者与消费者其实在我们生活中很常见,比如工厂生产商品,我们购买商品,那么这里工厂就是生产者,我们就是消费者。还有一个仓库,当生产者生产出货物,将货物放入仓库,我们消费时货物从仓库发出。这三者共同组成生产者和消费者模型。

2. 生产者消费者模型特点

  • 三种关系
    生产者和生产者之间的互斥关系:向缓冲区内写数据需要存在互斥关系
    消费者和消费者之间的互斥关系:竞争缓冲区内同一数据
    生产者和消费者之间的同步关系:只有生产者生产完资源消费者才可以来消费。
  • 两个角色:生产者和消费者(通常是多个执行流)
  • 一个交易场所:指这个模型里的仓库,也就是内存里的一块缓冲区(内存空间,STL容器等)

3. 生产者与消费者模型优点

  • 解耦
    有了缓冲区之后,生产者和消费者之间不会直接通信,依赖关系将大大减少,如果其中一方出现问题,不会对另一方产生很大影响
  • 支持并发
    如果没有这个“仓库”,那么生产者和消费者之间是串行执行的,一方生产一个,一方消费一个,如果一方出现问题或者阻塞,那么整个程序都会阻塞,效率就会降低。有了这个缓冲区,生产者只管生产数据和向队列中输入数据,消费者只需拿数据处理数据尽管多生产者输入数据是互斥的,多消费者拿数据也是互斥的,但是他们的生产数据和处理数据是可以同时进行的,互不影响,效率提高。
  • 支持忙闲不均
    如果生产者生产的速度不均衡,导致时快时慢,让消费者来不及消费,那么缓冲区的作用就体现出来了,让消费者来不及处理的数据先放在缓冲区中,等消费者有时间再去处理。减少忙的时间太忙,闲的时间太闲的现象。

注意:多生产者多消费者模型的优势不在于放数据和拿数据,而是并发地获取和处理任务!!!

4. 基于阻塞队列(BlockingQueue)的生产者消费者模型

阻塞队列(BlockingQueue)

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

5. 用 C++ queue 模拟阻塞队列的生产消费模型

线程同步典型的案例即为生产者消费者模型,而借助条件变量来实现这一模型,是比较常见的一种方法。假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源,生产向其中添加产品,消费者从中消费掉产品。

多生产者多消费者

代码实现:

  • Makefile
CpTest:CpTest.ccg++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:rm -f CpTest
  • BlockQueue.hpp
#pragma once#include <iostream>
#include <queue>
#include <pthread.h>namespace ns_blockqueue
{const int default_cap = 5;template <class T>class BlockQueue{private:std::queue<T> bq_;    //我们的阻塞队列int cap_;             //队列的元素上限pthread_mutex_t mtx_; //保护临界资源的锁//防止产生饥饿问题// 1. 当生产满了的时候,就应该不要生产了(不要竞争锁了),而应该让消费者来消费// 2. 当消费空了,就不应该消费(不要竞争锁了),应该让生产者来进行生产pthread_cond_t is_full_;  // bq_满的, 消费者在改条件变量下等待,生产者唤醒消费者进行消费pthread_cond_t is_empty_; // bq_空的,生产者在改条件变量下等待,消费者唤醒生产者生产private:bool IsFull(){return bq_.size() == cap_;}bool IsEmpty(){return bq_.size() == 0;}void LockQueue(){pthread_mutex_lock(&mtx_);}void UnlockQueue(){pthread_mutex_unlock(&mtx_);}void ProducterWait()//满了之后在”空“条件变量下等待{// pthread_cond_wait// 1. 调用的时候,会首先自动释放mtx_!,然后再挂起自己// 2. 返回的时候,会让当前线程首先自动竞争锁,获取到锁之后,才能返回!//醒来的时候也是在临界区的,必须要保证有锁pthread_cond_wait(&is_empty_, &mtx_);}void ConsumerWait(){pthread_cond_wait(&is_full_, &mtx_);}void WakeupComsumer(){pthread_cond_signal(&is_full_);}void WakeupProducter(){pthread_cond_signal(&is_empty_);}public:BlockQueue(int cap = default_cap) : cap_(cap){pthread_mutex_init(&mtx_, nullptr);pthread_cond_init(&is_empty_, nullptr);pthread_cond_init(&is_full_, nullptr);}~BlockQueue(){pthread_mutex_destroy(&mtx_);pthread_cond_destroy(&is_empty_);pthread_cond_destroy(&is_full_);}public:// const &:输入//*: 输出//&: 输入输出void Push(const T &in){LockQueue();//临界区//生产者不断的将数据送到队列中,如果队列满了,如果队列中没有条件变量相关方案//就会导致该线程一直占用公共资源的锁,其它线程无法对公共资源进行操作//所以判断满的时候让该线程在条件变量下等待// if(IsFull()){ //bug?//我们需要进行条件检测的时候,这里需要使用循环方式//来保证退出循环一定是因为条件不满足导致的!//-------------------------------------------//将生产者线程挂起后,该线程如何知道自己可以进行生产呢?//生产者在挂起的时候很难知道自己何时可以再次生产了//消费者同理//答案:只有消费者知道生产者什么时候可以消费//生产者才知道消费者什么时候可以消费//为了防止一下俩种情况的出现,我们使用while检测//1.挂起失败//2.被伪唤醒:条件未满足,但是线程被唤醒了//出现1.2情况时,由于生产条件不具备,就会出错while (IsFull()){//等待的,把生产者线程挂起,生产者是持有锁的!!!//那么此时其它线程就会申请不到锁,无法消费数据,造成死锁问题//为什么cond_wait的参数是有锁?//(1)因为条件变量和互斥锁通常需要搭配使用//(2)因为条件变量在进行临界资源状态检测的时候,必须要先进入临界区ProducterWait();}//向队列中放数据,生产函数bq_.push(in);// if(bq_.size() > cap_/2 ) WakeupComsumer();UnlockQueue();WakeupComsumer();}void Pop(T *out){LockQueue();//从队列中拿数据,消费函数函数//我们需要进行条件检测的时候,这里需要使用循环方式//来保证退出循环一定是因为条件不满足导致的!while (IsEmpty()){ //无法消费ConsumerWait();}*out = bq_.front();bq_.pop();// if(bq_.size() < cap_/2 ) WakeupProducter();UnlockQueue();WakeupProducter();}};
}
  • task.hpp
#pragma once#include <iostream>
#include <pthread.h>namespace ns_task
{class Task{private:int x_;int y_;char op_; //+/*/%public:// void (*callback)();Task() {}Task(int x, int y, char op) : x_(x), y_(y), op_(op){}int Run(){int res = 0;switch (op_){case '+':res = x_ + y_;break;case '-':res = x_ - y_;break;case '*':res = x_ * y_;break;case '/':res = x_ / y_;break;case '%':res = x_ % y_;break;default:std::cout << "bug??" << std::endl;break;}std::cout << "当前任务正在被: " << pthread_self() << " 处理: " \<< x_ << op_ << y_ << "=" << res << std::endl;return res;}int operator()(){return Run();}~Task() {}};
}
  • CpTest.cc
#include "BlockQueue.hpp"
#include "task.hpp"#include <time.h>
#include <cstdlib>
#include <unistd.h>using namespace ns_blockqueue;
using namespace ns_task;void *consumer(void *args)
{BlockQueue<Task> *bq = (BlockQueue<Task>*)args;while(true){//将生产者送入阻塞队列的Task的类型的数据拿出来,进行数据的处理Task t;bq->Pop(&t); //这里完成了任务消费的第1步t();         //这里完成了任务消费的第2步}
}void *producter(void *args)
{BlockQueue<Task> *bq = (BlockQueue<Task>*)args;std::string ops = "+-*/%";while(true){//1. 制造数据,生产者的数据(task)从哪里来??int x = rand()%20+1; //[1,20]int y = rand()%10+1; //[1,10]char op = ops[rand()%5];Task t(x, y, op);std::cout << "生产者派发了一个任务: " << x << op << y << "=?" << std::endl;//2. 将数据推送到任务队列中bq->Push(t);sleep(1);}
}int main()
{srand((long long)time(nullptr));BlockQueue<Task> *bq = new BlockQueue<Task>();pthread_t c,p;pthread_t c1,c2,c3,c4;pthread_create(&c, nullptr, consumer, (void*)bq);pthread_create(&c1, nullptr, consumer, (void*)bq);pthread_create(&c2, nullptr, consumer, (void*)bq);pthread_create(&c3, nullptr, consumer, (void*)bq);pthread_create(&c4, nullptr, consumer, (void*)bq);pthread_create(&p, nullptr, producter, (void*)bq);pthread_join(c, nullptr);pthread_join(c1, nullptr);pthread_join(c2, nullptr);pthread_join(c3, nullptr);pthread_join(c4, nullptr);pthread_join(p, nullptr);return 0;
}

6. 条件变量的优点

相较于mutex而言,条件变量可以减少竞争。

如直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果阻塞队列中没有数据,消费者竞争互斥锁是无意义的,此时消费者竞争锁才是有意义的;而有了条件变量机制以后,此时消费者应当将锁释放,唤醒生产者生产数据。只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。


七、POSIX信号量

1. 信号量概念

POSIX信号量本质是一个计数器,用来描述临界区内的空闲资源数量,以便对临界资源进行更细粒度的管理

互斥锁是把临界区整个锁起来,它的粒度比较大,同一时刻只允许一个线程进入临界区,如果我们希望在多个线程间对临界区内的部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行,与直接使用单线程无异。

而POSIX信号量则是把临界区分为n个互不干扰的区域,可以允许多个线程同时访问同一个临界资源内的不同区域;线程想访问临界资源,就需要先申请信号量资源,申请到信号量就相当于预定了一部分临界资源;由于每个区域之间的数据不受影响,可以让更多的线程同时生产和消费,从而实现并发,效率更高。

2. 信号量的操作接口

(1)PV操作

申请信号量可以用P操作来表示,释放信号量可以用V操作来表示

  • P操作
    对信号量的申请操作,实质上是对计数器进行--操作,表示临界区资源数量-1,也就是这个临界区中的一个区域被申请。
  • V操作
    对信号量的释放操作,实质上是对计数器的++操作,表示临界区资源数量+1,临界区内的一个区域被释放。

由于信号量也是临界资源,需要被多个线程获取,所以线程对信号量的操作应该是原子性的。我们下面来通过伪代码来理解信号量大操作。

  • P操作伪代码
start:
lock();//保证计数器--操作是原子,先上锁
if(count<=0){//挂起unlock();goto start;
}else{count--;
}
unlock();
  • V操作伪代码
lock();
count++;
unlock();

(2)信号量的初始化(sem_init接口)

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:

  • 第一个参数sem,要初始化的信号量;使用的时候传入一个全局的sem_t变量。
  • 第二个参数pshared,传入0值表示线程间共享,传入非零值表示进程间共享
  • 第三个参数value,信号量的初始值(临界区内资源数目的初始值)

返回值:

  • 初始化信号量成功返回0,失败返回-1,错误码被设置。

(3)信号量的申请/等待(sem_wait接口)

#include <semaphore.h>
int sem_wait(sem_t *sem);

功能

  • 申请信号量,相当于P操作;申请成功向后执行,申请失败将挂起等待。

参数:

  • 需要申请的信号量

返回值:

  • 申请信号量成功返回0,失败返回-1,错误码被设置。

(4)信号量的释放/发布(sem_post接口)

#include <semaphore.h>
int sem_post(sem_t *sem);

功能

  • 释放信号量,相当于V操作

参数:

  • 需要释放的信号量

返回值:

  • 释放信号量成功返回0,失败返回-1,错误码被设置

(5)信号量的销毁(sem_destroy接口)

#include <semaphore.h>
int sem_destroy(sem_t *sem);

参数:

  • 要销毁的信号量

返回值

  • 销毁信号量成功返回0,失败返回-1,错误码被设置

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


环形队列的生产者消费者遵守的规则

  • 当队列为空的时候,此时只需要让生产者访问队列;当队列为满的时候,只需要让消费者访问队列。所以队列为满或者为空的时候,需要保证生产者和消费者的互斥和局部上的同步。
  • 当队列不为空且不为满的时候,即生产者和消费者不对同一个区域进行操作,那么生产者和消费者就可以并发执行。此时生产者不能太快,不可以超过消费者一周;消费者也不能太快,不能超过生产者。

该模型有俩种信号量,分别为blank信号量,表示队列中空位资源的数目;第二个是data信号量,表示队列中数据资源的数目

开始的时候,假设blank=10,data=0;当生产者开始生产,生产者需要申请blank资源,即P(blank),生产数据完毕后,data资源变多了,即V(data)。于是消费者检测到存在数据资源,就对数据进行消费,即P(data)消费数据完毕后,blank资源变多了,即V(blank)

(1)单生产者单消费者代码

  • ring_queue.hpp
#pragma once#include <iostream>
#include <vector>
#include <semaphore.h>namespace ns_ring_queue
{const int g_cap_default = 10;template <class T>class RingQueue{private:std::vector<T> ring_queue_;int cap_;//生产者关心空位置资源sem_t blank_sem_;// 消费者关心空位置资源sem_t data_sem_;//用来表示生产和消费的位置int c_step_;int p_step_;public:RingQueue(int cap = g_cap_default): ring_queue_(cap), cap_(cap){sem_init(&blank_sem_, 0, cap);sem_init(&data_sem_, 0, 0);c_step_ = p_step_ = 0;}~RingQueue() {sem_destroy(&blank_sem_);sem_destroy(&data_sem_);}public:// 目前高优先级的先实现单生产和单消费void Push(const T &in){   //生产接口sem_wait(&blank_sem_); //P(空位置)//可以生产了,可是往哪个位置生产呢??ring_queue_[p_step_]  = in;sem_post(&data_sem_);  //V(数据)//生产和消费位置是非常重要的//只要我们保证多个执行流访问的时临界资源的不同区域就可以保证并发//而信号量只可以保证不让多的执行流进入//而执行流访问的时临界资源的哪个位置时需要自己维护的!!!p_step_++;p_step_ %= cap_;}void Pop(T* out){//消费接口sem_wait(&data_sem_);  //P*out = ring_queue_[c_step_];sem_post(&blank_sem_);c_step_++;c_step_ %= cap_;}};
}

测试:

  • ring_cp.cc
#include "ring_queue.hpp"
#include <pthread.h>
#include <time.h>
#include <unistd.h>using namespace ns_ring_queue;void* consumer(void* args)
{RingQueue<int>* rq = (RingQueue<int>*)args;while(true){int data = 0;rq->Pop(&data);std::cout << "消费数据是: " << data << std::endl;//  sleep(1);}
}void* producter(void* args)
{RingQueue<int>* rq = (RingQueue<int>*)args;while(true){int data = rand()%20 + 1;std::cout << "生产数据是:  " << data << std::endl;rq->Push(data);sleep(1);}
}int main()
{srand((long long)time(nullptr));RingQueue<int>* rq = new RingQueue<int>();pthread_t c,p;pthread_create(&c, nullptr, consumer, (void*)rq);pthread_create(&p, nullptr, producter, (void*)rq);pthread_join(c, nullptr);pthread_join(p, nullptr);return 0;
}

使用俩个信号量完成了生产者和消费者互斥且同步的功能,如果改为多线程,那么就需要注意维护生产者和生产者之间,消费者和消费者之间的互斥关系。

(2)多生产者多消费者模型

使用信号量可以使俩个执行流,即一个生产者和一个消费者同时进入临界资源;但是任意时刻,都不能使多个生产者/多个消费者同时进入临界区,所以使用俩把锁来分别维护生产者和生产者之间的互斥和消费者和消费者之间的互斥。

  • ring_queue.hpp
#pragma once#include <iostream>
#include <vector>
#include <pthread.h>
#include <semaphore.h>namespace ns_ring_queue
{const int g_cap_default = 10;template <class T>class RingQueue{private:std::vector<T> ring_queue_;int cap_;//生产者关心空位置资源sem_t blank_sem_;// 消费者关心空位置资源sem_t data_sem_;int c_step_;int p_step_;pthread_mutex_t c_mtx_;pthread_mutex_t p_mtx_;public:RingQueue(int cap = g_cap_default) : ring_queue_(cap), cap_(cap){sem_init(&blank_sem_, 0, cap);sem_init(&data_sem_, 0, 0);c_step_ = p_step_ = 0;pthread_mutex_init(&c_mtx_, nullptr);pthread_mutex_init(&p_mtx_, nullptr);}~RingQueue(){sem_destroy(&blank_sem_);sem_destroy(&data_sem_);pthread_mutex_destroy(&c_mtx_);pthread_mutex_destroy(&p_mtx_);}public:// 目前高优先级的先实现单生产和单消费//多生产和多消费的优势不在这里,而在于并发的获取和处理任务void Push(const T &in){//生产接口//加锁的位置应该在信号量之前还是之后呢???//----------------------------------sem_wait(&blank_sem_); // P(空位置)pthread_mutex_lock(&p_mtx_);//----------------------------------//可以生产了,可是往哪个位置生产呢??ring_queue_[p_step_] = in;//它也变成了临界资源p_step_++;   p_step_ %= cap_;pthread_mutex_unlock(&p_mtx_);sem_post(&data_sem_); // V(数据)}void Pop(T *out){//消费接口sem_wait(&data_sem_); // Ppthread_mutex_lock(&c_mtx_);*out = ring_queue_[c_step_];c_step_++;c_step_ %= cap_;pthread_mutex_unlock(&c_mtx_);sem_post(&blank_sem_);}};
}
  • ring_cp.cc
#include "ring_queue.hpp"
#include <pthread.h>
#include <time.h>
#include <unistd.h>
#include "Task.hpp"using namespace ns_ring_queue;
using namespace ns_task;void* consumer(void* args)
{RingQueue<Task>* rq = (RingQueue<Task>*)args;while(true){Task t;rq->Pop(&t);//  std::cout << "消费数据是: " <<t.Show() << t() << "我是: " << pthread_self() << std::endl;t();  //比较耗时sleep(1);}
}void* producter(void* args)
{RingQueue<Task>* rq = (RingQueue<Task>*)args;const std::string ops = "+-*/%";while(true){int x = rand()%20 + 1;int y = rand()%10 + 1;char op = ops[rand()%ops.size()];Task t(x, y, op);std::cout << "生产数据是:  " << t.Show() << "我是: " << pthread_self()<< std::endl;rq->Push(t);// sleep(1);}
}int main()
{// 如果我们向改成多生产者,多消费者模型,该怎么改写srand((long long)time(nullptr));RingQueue<Task>* rq = new RingQueue<Task>();pthread_t c0,c1,c2,c3,p0,p1,p2;pthread_create(&c0, nullptr, consumer, (void*)rq);pthread_create(&c1, nullptr, consumer, (void*)rq);pthread_create(&c2, nullptr, consumer, (void*)rq);pthread_create(&c3, nullptr, consumer, (void*)rq);pthread_create(&p0, nullptr, producter, (void*)rq);pthread_create(&p1, nullptr, producter, (void*)rq);pthread_create(&p2, nullptr, producter, (void*)rq);pthread_join(c0, nullptr);pthread_join(c1, nullptr);pthread_join(c2, nullptr);pthread_join(c3, nullptr);pthread_join(p0, nullptr);pthread_join(p1, nullptr);pthread_join(p2, nullptr);return 0;
}
  • task.hpp
#pragma once#include <iostream>
#include <pthread.h>namespace ns_task
{class Task{private:int x_;int y_;char op_; //+/*/%public:// void (*callback)();Task() {}Task(int x, int y, char op) : x_(x), y_(y), op_(op){}std::string Show(){std::string message = std::to_string(x_);message += op_;message += std::to_string(y_);message += "=?";return message;}int Run(){int res = 0;switch (op_){case '+':res = x_ + y_;break;case '-':res = x_ - y_;break;case '*':res = x_ * y_;break;case '/':res = x_ / y_;break;case '%':res = x_ % y_;break;default:std::cout << "bug??" << std::endl;break;}std::cout << "当前任务正在被: " << pthread_self() << " 处理: " \<< x_ << op_ << y_ << "=" << res << std::endl;return res;}int operator()(){return Run();}~Task() {}};
}

注意!!!

以上代码存在一个比较难理解的地方,而如果理解了也可以帮助深化对信号量的理解,所以对该部分进行说明:

void Push(const T &in)
{//生产接口//加锁的位置应该在信号量之前还是之后呢???//----------------------------------sem_wait(&blank_sem_); // P(空位置)pthread_mutex_lock(&p_mtx_);//----------------------------------//可以生产了,可是往哪个位置生产呢??ring_queue_[p_step_] = in;//它也变成了临界资源p_step_++;   p_step_ %= cap_;pthread_mutex_unlock(&p_mtx_);sem_post(&data_sem_); // V(数据)
}

为了维护生产者和生产者之间的互斥关系,即保证任一时刻都只有一个生产者向队列输入数据,所以我们需要对临界资源进行加锁,而加锁的位置应该在申请信号量之前呢?还应该是在申请信号量之后呢?



八、线程池

1. 线程池的引入

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

2. 线程池的应用场景

  • 需要大量的线程来完成任务,且完成任务的时间比较短

WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。

  • 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求
  • 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用

突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误.

3. 线程池的优点

  • 降低资源消耗。通过重复利用已创建好的线程来降低线程创建和销毁时给系统带来的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即得到处理。
  • 提高线程的可管理性。我们可以对线程池里的线程进行统一的分配,调优和监控

4. 模拟实现一个线程池

(1)线程池基本框架

线程池通过一个线程安全的阻塞任务队列加n个线程实现。

  • 线程池外部存在多个生产者向线程池中的任务队列输入数据所以线程池需要对外公开一个向阻塞任务队列中输入任务的接口。
  • 线程池中的线程可以从阻塞任务队列获取任务然后进行任务处理这些线程统一在线程池的构造函数中创建。

另外还需要一把互斥锁和一个条件变量:

  • 互斥锁用来保护任务队列的数据安全;即维护内部多线程从任务队列中拿出任务时的互斥关系,以及外部多线程向任务队列中输入任务的时候保持互斥关系。(即生产者和生产者之间的互斥,消费者和消费者之间的互斥)
  • 条件变量用来维护多线程之间的同步关系;即防止任务队列无任务时,内部多线程竞争式的申请锁,查看任务队列中是否有任务;当任务队列中没有任务的时候,内部的多线程应处于挂起状态,有任务的时候才被唤醒。

(2)线程池的简单实现

  • thread_pool.hpp

PS:类型模板参数T由我们创建线程池对象时显示传入,它代表我们要处理的任务的类型。

#pragma once#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>namespace ns_threadpool
{const int g_num = 5;template <class T>class ThreadPool{private:int num_;std::queue<T> task_queue_; //该成员是一个临界资源pthread_mutex_t mtx_;pthread_cond_t cond_;public:void Lock(){pthread_mutex_lock(&mtx_);}void Unlock(){pthread_mutex_unlock(&mtx_);}void Wait(){//挂起的时候同时释放了锁pthread_cond_wait(&cond_, &mtx_);}void Wakeup(){pthread_cond_signal(&cond_);}bool IsEmpey(){return task_queue_.empty();}public:ThreadPool(int num = g_num) : num_(num){pthread_mutex_init(&mtx_, nullptr);pthread_cond_init(&cond_, nullptr);}// 在类中要让线程执行类内成员方法,是不可行的!// 必须让线程执行静态方法static void *Rountine(void *args){pthread_detach(pthread_self());ThreadPool<T> *tp = (ThreadPool<T> *)args;while (true){tp->Lock();while (tp->IsEmpey()){//任务队列为空,线程该做什么呢??//没有任务的时候,内部线程都在竞争式的申请锁//查看临界区是否有任务//解锁,然后继续申请锁,不断循环,效率比较低//为了防止内部线程做这些无用功,在检测没有任务的时候就将该线程挂起;也就是需要一个条件变量tp->Wait();}//该任务队列中一定有任务了//T t=task_queue_.front();这样写是错误的//因为静态成员函数由于没有this,不可以使用非静态成员T t;tp->PopTask(&t);tp->Unlock();//在锁之外处理任务,这样使该线程在处理任务的时候其它线程也可以拿任务和处理任务t();}}void InitThreadPool(){pthread_t tid;for (int i = 0; i < num_; i++){pthread_create(&tid, nullptr, Rountine, (void *)this /*?*/);}}void PushTask(const T &in){Lock();task_queue_.push(in);Unlock();Wakeup();//有任务之后唤醒在条件变量下等待的线程}void PopTask(T *out){*out = task_queue_.front();task_queue_.pop();}~ThreadPool(){pthread_mutex_destroy(&mtx_);pthread_cond_destroy(&cond_);}};
} // namespace ns_threadpool

内部多线程拿取任务并处理(重点)

注意:为什么要把线程执行的函数设为static ?

因为我们是在线程池对象的构造函数中去创建的线程和在析构函数中去销毁这些线程,所以最好把线程执行的函数Routine也封装到线程池这个类中可是线程执行函数只能有一个(void*) 类型的参数,如果我们把Routine函数写到了类中就会存在两个参数导致编译不通过(作为类的非静态成员函数,它的第一个位置的参数默认是这个类对象的this指针,而线程执行函数本身规定只能有一个void*类型的参数)

对此我们的解决办法是把线程执行函数设为类的静态成员函数并在创建线程时把对象的this指针传入作为执行函数的唯一参数传入,这样在Routine函数内部也可以通过this指针访问到类对象的所有成员了。

外部多线程输入任务

void PushTask(const T &in)
{Lock();task_queue_.push(in);Unlock();Wakeup();//有任务之后唤醒在条件变量下等待的线程
}

测试代码

  • Task.hpp
//与信号量的任务相同
  • main.cc
#include "thread_pool.hpp"
#include "Task.hpp"#include <ctime>
#include <cstdlib>using namespace ns_threadpool;
using namespace ns_task;int main()
{//ThreadPool<Task> *tp = new ThreadPool<Task>(3);tp->InitThreadPool();srand((long long)time(nullptr));while(true){//sleep(1);//网络Task t(rand()%20+1, rand()%10+1, "+-*/%"[rand()%5]);tp->PushTask(t);}return 0;
}

该模型其实就是基于阻塞队列的生产者和消费者模型,只不过把创建线程的工作放在类内


九、单例模式与线程池

1. 什么是单例模式

单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象

使用场景:

  • 语义上只需要一个
  • 该对象内部存在大量的空间,保存了大量的数据;如果允许该对象存在多份,或者允许发生各种拷贝,那么内存中存在冗余数据

2. 单例模式的类型

单例模式有两种类型:

  • 懒汉式:在真正需要使用对象时才去创建该单例类对象
  • 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用

如果一个对象比较大时,若代码一加载到内存中就创建该对象的话,那么就会导致进程启动慢。而延迟加载可以让进程启动速度变快

(1)饿汉实现方式

该模式在类被加载时就会实例化一个对象,具体代码如下:

template <typename T>
class Singleton
{
private:static Singleton<T> data;//饿汉模式,在加载的时候对象就已经存在了
public: static Singleton<T>* GetInstance() { return &data; }
};

说明:当我们构建这个类的时候,这个data已经在类中被创建好了,这个data属于类,而不是对象;类只要被加载到内存,data也就存在了

(2)懒汉实现方式

该模式只在你需要对象时才会生成单例对象(比如调用GetInstance方法)

template <typename T>
class Singleton
{
private:static Singleton<T>* inst; //懒汉式单例,只有在调用GetInstance时才会实例化一个单例对象
public: static Singleton<T>* GetInstance() { if (inst == NULL) { inst = new Singleton<T>(); }return inst; }
};

注意:这段代码它不是线程安全的。假设当前有多个线程都是第一次调用GetInstance(),由于当前还没有对象生成,那么多个线程就会创建多个对象

// 懒汉模式, 线程安全
template <typename T>
class Singleton
{private: static Singleton<T> *ins;
public: static T* GetInstance() { static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;if (ins == nullptr) // 双重判定空指针, 减少锁的争用, 提高性能 {//如果不使用双判定,那么每一次调用该函数都需要申请锁,效率低                 pthread_mutex_lock(&lock);  // 使用互斥锁, 保证多线程情况下也只调用一次 newif (ins == nullptr) { ins = new Singleton<T>();//ins->InitThreadPool();//std::cout << "首次加载对象" << std::endl; }pthread_mutex_unlock(&lock);}return ins;}
};

这种形式是在懒汉方式的基础上增加的,当多个线程调用GetInstance方法时,此时类中没有对象,那么多个线程就会竞争锁。必然只能有一个线程竞争锁成功,此时再次判断有没有对象被创建:如果没有就会new一个对象;如果有就会解锁并返回已有的对象

总的来说,这样的形式使得多个线程调用GetInstance方法时,无论成功与否,都会有返回值;

3. 懒汉模式下的线程池

  • thread_pool.hpp
#pragma once#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>namespace ns_threadpool
{const int g_num = 5;template <class T>class ThreadPool{private:int num_;std::queue<T> task_queue_; //该成员是一个临界资源pthread_mutex_t mtx_;pthread_cond_t cond_;static ThreadPool<T> *ins;private:// 构造函数必须得实现,但是必须的私有化ThreadPool(int num = g_num) : num_(num){pthread_mutex_init(&mtx_, nullptr);pthread_cond_init(&cond_, nullptr);}ThreadPool(const ThreadPool<T> &tp) = delete;//赋值语句ThreadPool<T> &operator=(ThreadPool<T> &tp) = delete;public:static ThreadPool<T> *GetInstance(){static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;// 当前单例对象还没有被创建if (ins == nullptr) //双判定,减少锁的争用,提高获取单例的效率!{pthread_mutex_lock(&lock);if (ins == nullptr){ins = new ThreadPool<T>();ins->InitThreadPool();std::cout << "首次加载对象" << std::endl;}pthread_mutex_unlock(&lock);}return ins;}void Lock(){pthread_mutex_lock(&mtx_);}void Unlock(){pthread_mutex_unlock(&mtx_);}void Wait(){pthread_cond_wait(&cond_, &mtx_);}void Wakeup(){pthread_cond_signal(&cond_);}bool IsEmpey(){return task_queue_.empty();}public:// 在类中要让线程执行类内成员方法,是不可行的!// 必须让线程执行静态方法static void *Rountine(void *args){pthread_detach(pthread_self());ThreadPool<T> *tp = (ThreadPool<T> *)args;while (true){tp->Lock();while (tp->IsEmpey()){//任务队列为空,线程该做什么呢??tp->Wait();}//该任务队列中一定有任务了T t;tp->PopTask(&t);tp->Unlock();t();}}void InitThreadPool(){pthread_t tid;for (int i = 0; i < num_; i++){pthread_create(&tid, nullptr, Rountine, (void *)this /*?*/);}}void PushTask(const T &in){Lock();task_queue_.push(in);Unlock();Wakeup();}void PopTask(T *out){*out = task_queue_.front();task_queue_.pop();}~ThreadPool(){pthread_mutex_destroy(&mtx_);pthread_cond_destroy(&cond_);}};template <class T>ThreadPool<T> *ThreadPool<T>::ins = nullptr;
}
  • main.cc
#include "thread_pool.hpp"
#include "Task.hpp"#include <ctime>
#include <cstdlib>using namespace ns_threadpool;
using namespace ns_task;int main()
{   srand((long long)time(nullptr));while(true){sleep(1);//网络Task t(rand()%20+1, rand()%10+1, "+-*/%"[rand()%5]);ThreadPool<Task>::GetInstance()->PushTask(t);//单例本身会在任何场景,任何环境下被调用//GetInstance():被多线程重入,进而导致线程安全的问题std::cout << ThreadPool<Task>::GetInstance() << std::endl;}return 0;
}

十、读写锁与自旋锁

1. 读写锁的引入

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

2. 读写锁概念

读写锁与互斥量类似,但是互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写)。

3. 读写锁特点

(1)读者写者的加锁特点

  • 当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞
  • 当读写锁是读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权;但是任何试图以写模式对它进行加锁的线程也都会被阻塞,直到所有的读线程都释放读锁为止。
当前锁状态 读模式请求锁 写模式请求锁
无锁 通过 通过
读锁 通过 阻塞
写锁 阻塞 阻塞

接下来我们通过伪代码的方式来理解读写锁,通过互斥锁和信号量的方式就可以完成该模型:

我们首先使用条件变量来表示正在对临界区进行读操作的读者的数量:int readers = 0;

  • 写者
//写上锁操作:pthread_rwlock();
mtx.lock();
while(read>0)
{wait();//等待读者为0
}//进入临界区,修改数据
//写解锁操作: pthread_rwunlock();
mtx.unlock();
  • 读者
//读上锁操作:pthread_rdlock()
mtx.lock();
readers++;
mtx.unlock();//读操作//读解锁操作: pthread_rdunlock()
mtx.lock()
readers--;
mtx.unlock();

解析:

(2)读者和写者的优先级

知道读写锁的加锁特性之后,我们考虑这样一种场景,几个读线程读模式占有读写锁,这时候写线程希望以写模式加锁因此阻塞但是如果一直不断有新的读线程加锁的话,一种可能就是写线程将永远得不到锁,产生饥饿问题

为了解决这个问题,当读写锁处于读模式加锁状态时,当有线程试图以写模式加锁之后读写锁通常会阻塞新的读模式加锁请求这样就可以避免读模式锁长期占用而等待的写模式锁请求一直得不到满足。

这样的俩种情况就是采用了不同的读写者优先级造成的:

  • 读者优先读者和写者同时到来的时候,让读者先进入访问临界区;但是采用这种方式时,若读者多,写者少,就会出现写饥饿问题。
  • 写者优先读者和写者同时到来的时候,比当前写者晚来的所有读者都会被阻塞,而在写者之前到来的读者会首先进行读操作等到临界区内没有读者后,让写者进入,等到写者写完后,让在写者之后而被阻塞的读者进行读操作。

4. 读写锁接口和使用

  1. 初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
  1. 销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  1. 加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t rwlock);

【例】

5. 自旋锁概念

内核当发生访问资源冲突的时候,可以有两种锁的解决方案选择:

  • 一个是原地等待
  • 一个是挂起当前进程,调度其他进程执行(睡眠)

自旋锁与互斥锁功能一样,唯一一点不同的就是互斥量阻塞后休眠让出cpu,而自旋锁阻塞后不会让出cpu,会一直忙等待,直到得到锁。

如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将原地自旋(忙等待),直到该自旋锁的保持者释放了锁。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 —— 自旋锁不应该被长时间的持有(消耗 CPU 资源)。

自旋锁在用户态使用的比较少,在内核使用的比较多!

十一、其它锁

  • 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
  • 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
  • CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
  • 自旋锁,公平锁,非公平锁

【4万字Linux线程解析】从0开始学习Linux线程相关推荐

  1. 手写一个线程池,带你学习ThreadPoolExecutor线程池实现原理

    摘要:从手写线程池开始,逐步的分析这些代码在Java的线程池中是如何实现的. 本文分享自华为云社区<手写线程池,对照学习ThreadPoolExecutor线程池实现原理!>,作者:小傅哥 ...

  2. linux运维需要掌握的,学习Linux运维需要掌握哪些技能?Linux运维

    如何学习Linux技术?每年都有许多人转行进入it互联网行业,为了高薪也好,为了理想也罢,学习it技术的人们越来越多.Linux运维学习是目前大众热衷于选择学习的方向之一.那么学习Linux技术能获的 ...

  3. 回调函数 线程_从0实现基于Linux socket聊天室-多线程服务器一个很隐晦的错误-2...

    根据 <0 基于socket和pthread实现多线程服务器模型>所述,server创建子线程的时候用的是以下代码: pconnsocke = (int *) malloc(sizeof( ...

  4. linux主设备编号从0到多少,Linux驱动开发之主设备号找驱动,次设备号找设备

    一.引言 很久前接触linux驱动就知道主设备号找驱动,次设备号找设备.这句到底怎么理解呢,如何在驱动中实现呢,在介绍该实现之前先看下内核中主次设备号的管理: 二.Linux内核主次设备号的管理 Li ...

  5. 0基础学习Linux运维的必经之路

    最近看到了一篇新闻,linux之父建议找一份基于linux和开源环境的工作,确实,这已经是未来的大趋势了. 今天就来聊一聊我的想法,本人8年linux运维一线经验,呆过很多互联网公司,从一线运维做到运 ...

  6. linux shell脚本字符串连接符,学习Linux shell脚本中连接字符串的方法

    这篇文章主要介绍了Linux shell脚本中连接字符串的方法,如果想要在变量后面添加一个字符,可以用一下方法: 代码如下: $value1=home $value2=${value1}"= ...

  7. linux网络配置命令笔记,初学者学习linux笔记与练习-第二天。一些基本命令以及初级网络配置...

    菜鸟学习linux笔记与练习-----第二天.一些基本命令以及初级网络配置 基本命令 ??uname -a -s ??hostname显示主机名 若是要永久生效,则编辑以下文件 ??#vim /etc ...

  8. python的线程组怎么写_Python学习——Python线程

    一.线程创建 1 #方法一:将要执行的方法作为参数传给Thread的构造方法 2 importthreading3 importtime4 5 defshow(arg):6 time.sleep(2) ...

  9. 在平板/手机上运行Linux(无需root),学习Linux命令行。(快速方法+详细图文+Ubuntu举例)

    本文将讲述,如何在平板/手机设备上通过强大的Termux安装Linux系统(命令行界面),进行学习Linux命令行和Linux实践.举例安装Ubuntu 22.04,CentOS等.本人使用的是Mat ...

最新文章

  1. 隐私泄露无孔不入?扫地机器人已成新型“窃听器”,小米Roborock“躺枪”
  2. Dos中重定向与文件追加
  3. MYSQL5.7.17设置初始密码
  4. TortoiseSvn
  5. 软件过程改进之百科名片
  6. 【渝粤题库】广东开放大学 文化服务营销管理 形成性考核
  7. Ural_1030. Titanic
  8. 面试官系统精讲Java源码及大厂真题 - 41 突破难点:如何看 Lambda 源码
  9. ASP.NET没有魔法——开篇-用VS创建一个ASP.NET Web程序
  10. 谷歌金山词霸更新历史
  11. 导出的CSV数据中含有身份证并在Excel正确显示方法
  12. vue使用JSzip读取压缩包文件内容进行MD5加密
  13. amazon创建sns_我们如何在36小时内重新创建Amazon Go
  14. FANUC机器人外部电缆连接示意图(一)
  15. table 表格合并
  16. SHA256加密-前端 中 HMAC-SHA256的base64加密 和 md5加密
  17. JavaWeb商城管理系统
  18. 怎么让上下两排对齐_word中如何将上下两行间字、字符、数字分别对齐
  19. .net SSO单点登录mvc
  20. SpringSecurity 源码解析 | 加JWT 实战 之 授权流程源码分析

热门文章

  1. Coinbase:2023 年 Crypto 市场展望
  2. 使用DirectPlay进行网络互联(3)
  3. UE4学习笔记:混合空间(BlendSpace)的使用
  4. 论语 季氏篇(笔记)
  5. 最新Java面试真题,备战金九银十。
  6. linux下dhcp服务器分配出去的IP地址及剩余IP地址
  7. android充电信息代码,【代码】android 关机充电
  8. 万事无忧之SEO GOOGLE优化秘诀
  9. MySQL 计算环比(同比类似)
  10. 同一个html自动跳转分页,PageMenu分页控制器(基础篇)-相同分页页面的实现