目录
- 1.POSIX信号量
- 1.基本概念
- 2.为什么要有信号量? --> 提高效率
- 3.信号量的PV操作
- 4.PV操作必须是原子操作
- 5.申请信号量失败被挂起等待
- 6.理解信号量大致结构
- 2.信号量函数
- 1.初始化
- 2.销毁
- 3.等待信号量 -- 申请信号量 --> P()
- 4.发布信号量 -- 释放信号量 --> V()
- 3.基于环形队列的生产者消费者模型
- 1.基本概念
- 2.信号量如何保护环形队列?
- 3.空间资源和数据资源
- 4.blank_sem和data_sem的初始值设置
- 5.生产者和消费者申请和释放资源
- 6.需要遵守的两个原则
- 7.思考问题
- 4.线程池
- 1.概念
- 2.线程池的优点
- 3.线程池的应用场景
- 4.线程池实现
1.POSIX信号量
1.基本概念
- 信号量本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理
- 每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特定的临界资源的权限,当操作完毕后就应该释放信号量
- 信号量存在的价值:
- 进行同步与互斥
- 更细粒度的临界资源的管理
- 注意:
- POSIX信号量和System V信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源的目的
- 但POSIX信号量可以用于线程间同步
2.为什么要有信号量? --> 提高效率
- 当仅用一个互斥锁对临界资源进行保护时,相当于将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问
- 如果将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,这些执行流访问的是临界资源的不同区域
- 那么可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题
3.信号量的PV操作
- P操作:
- 将**申请信号量(count–)**称为P操作
- 申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一
- 因此P操作的本质就是让计数器减一
- V操作:
- 将**释放信号量(count++)**称为V操作
- 释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一
- 因此V操作的本质就是让计数器加一
4.PV操作必须是原子操作
- 多个执行流为了访问临界资源会竞争式的申请信号量
- 因此信号量是会被多个执行流同时访问的
- 也就是说信号量本质也是临界资源
- 但信号量本质就是用于保护临界资源的,不可能再用信号量去保护信号量,所以信号量的PV操作必须是原子操作
- 内存中变量的++、–操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行++、–操作
5.申请信号量失败被挂起等待
- 当执行流在申请信号量时,可能此时信号量的值为0,也就是说信号量描述的临界资源已经全部被申请了
- 此时该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒
- 信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列
6.理解信号量大致结构
2.信号量函数
1.初始化
- *原型:int sem_init(sem_t sem, int pshared, unsigned int value);
- 参数:
- sem**:**需要初始化的信号量
- pshared**:**传入0值表示线程间共享,传入非0值表示进程间共享
- value**:**信号量的初始值 --> 计数器的初始值
- **返回值:**成功返回0,失败返回-1
2.销毁
- *原型:int sem_destroy(sem_t sem);
- **参数:sem:**需要销毁的信号量
- **返回值:**成功返回0,失败返回-1
3.等待信号量 – 申请信号量 --> P()
- *原型:int sem_wait(sem_t sem);
- **参数:sem:**需要等待的信号量
- 返回值:
- 成功返回0,信号量的值减一
- 失败返回-1,信号量的值保持不变
4.发布信号量 – 释放信号量 --> V()
- *原型:int sem_post(sem_t sem);
- **参数:sem:**需要发布的信号量
- 返回值:
- 成功返回0,信号量的值加一
- 失败返回-1,信号量的值保持不变
3.基于环形队列的生产者消费者模型
- 基于阻塞队列的生产者与消费者模型存在一个很大的问题就是他们其实是在串行运行的,并没有并行运行,这就导致他们的效率不是很高,而使用环形队列则可以解决这个问题
1.基本概念
- 环形队列采用数组模拟,用模运算来模拟环状特性
2.信号量如何保护环形队列?
- 在blank_sem和data_sem两个信号量的保护后,环形队列中不可能会出现数据不一致的问题
- 只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
- 环形队列为空时
- 环形队列为满时
- 但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问
- 当环形队列为空的时,消费者一定不能进行消费,因为此时数据资源为0
- 当环形队列为满的时,生产者一定不能进行生产,因为此时空间资源为0
- 只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
- 当环形队列为空和满时,已经通过信号量保证了生产者和消费者的串行化过程
- 而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列当中不可能会出现数据不一致的问题
- 并且大部分情况下生产者和消费者指向并不是同一个位置,因此大部分情况下该环形队列可以让生产者和消费者并发的执行
3.空间资源和数据资源
- 生产者关注的是空间资源,消费者关注的是数据资源
- 只要环形队列中有空间,生产者就可以进行生产
- 只要环形队列中有数据,消费者就可以进行消费
4.blank_sem和data_sem的初始值设置
- blank_sem的初始值应该设置为环形队列的容量,因为刚开始时环形队列当中全是空间
- data_sem的初始值应该设置为0,因为刚开始时环形队列当中没有数据
5.生产者和消费者申请和释放资源
- 生产者申请空间资源,释放数据资源
- 对于生产者来说,生产者每次生产数据前都需要先申请blank_sem:
- 如果blank_sem的值不为0,则信号量申请成功,此时生产者可以进行生产操作。
- 如果blank_sem的值为0,则信号量申请失败,此时生产者需要在blank_sem的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒
- 当生产者生产完数据后,应该释放data_sem
- 虽然生产者在进行生产前是对blank_sem进行的P操作,但是当生产者生产完数据,应该对data_sem进行V操作而不是blank_sem
- 生产者在生产数据前申请到的是blank位置,当生产者生产完数据后,该位置当中存储的是生产者生产的数据,在该数据被消费者消费之前,该位置不再是blank位置,而应该是data位置
- 当生产者生产完数据后,意味着环形队列当中多了一个data位置,因此应该对data_sem进行V操作
- 对于生产者来说,生产者每次生产数据前都需要先申请blank_sem:
- 消费者申请数据资源,释放空间资源
- 对于消费者来说,消费者每次消费数据前都需要先申请data_sem
- 如果data_sem的值不为0,则信号量申请成功,此时消费者可以进行消费操作
- 如果data_sem的值为0,则信号量申请失败,此时消费者需要在data_sem的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒
- 当消费者消费完数据后,应该释放blank_sem
- 虽然消费者在进行消费前是对data_sem进行的P操作,但是当消费者消费完数据,应该对blank_sem进行V操作而不是data_sem
- 消费者在消费数据前申请到的是data位置,当消费者消费完数据后,该位置当中的数据已经被消费过了,再次被消费就没有意义了,为了让生产者后续可以在该位置生产新的数据,我们应该将该位置算作blank位置,而不是data位置
- 当消费者消费完数据后,意味着环形队列当中多了一个blank位置,因此应该对blank_sem进行V操作
- 对于消费者来说,消费者每次消费数据前都需要先申请data_sem
6.需要遵守的两个原则
- 生产者和消费者不能对同一个位置进行访问(互斥)
- 如果生产者和消费者访问的是环形队列当中的同一个位置,那么此时生产者和消费者就相当于同时对这一块临界资源进行了访问,这当然是不允许的
- 而如果生产者和消费者访问的是环形队列当中的不同位置,那么此时生产者和消费者是可以同时进行生产和消费的,此时不会出现数据不一致等问题
- 无论是生产者还是消费者,都不应该将对方套一个圈以上(格子的数量有限)
- 生产者从消费者的位置开始一直按顺时针方向进行生产,如果生产者生产的速度比消费者消费的速度快,那么当生产者绕着消费者生产了一圈数据后再次遇到消费者,此时生产者就不应该再继续生产了,因为再生产就会覆盖还未被消费者消费的数据
- 同理,消费者从生产者的位置开始一直按顺时针方向进行消费,如果消费者消费的速度比生产者生产的速度快,那么当消费者绕着生产者消费了一圈数据后再次遇到生产者,此时消费者就不应该再继续消费了,因为再消费就会消费到缓冲区中保存的废弃数据
7.思考问题
- 对于多生产者和多消费者来说,要保证他们各自之间要满足互斥,就必须加锁,那么这把锁是在信号量之前加还是之后加呢?
- 首先明确一点,信号量也是原子性的
- 在获取信号量之前进行加锁
- 在这种情况下,也就意味着只有一个执行流能够竞争到锁,然后申请信号量
- 那么对这个临界资源进行划分的意义何在呢,和之前的单生产者单消费者没有太大的区别,显然没有太大的价值
- 在获取信号量之后进行加锁
- 在这种情况下,当多个执行流访问临界资源的时候,他们都要去申请信号量,但是只会有一个执行流竞争锁成功
- 等到这个执行流执行完毕后,下一个执行流就不需要再去申请信号量然后竞争锁,因为它是拿着信号量被挂起的
- 总结:
- 在获取信号量之后进行加锁,确保了每个执行流都能预定到相应的部分临界资源,相比第一种做法效率高一些
- 多生产多消费的意义在哪里?
- 不要狭隘的认为,把任务或者数据放在交易场所,就是生产和消费
- 将数据或者任务生产前和拿到之后处理,才是最耗费时间的
- 生产的本质:私有的任务 --> 公共空间中
- 消费的本质:公共空间中的任务 --> 私有的
- 信号量本质是一把计数器,计数器的意义是什么?
- 在使用互斥量时
- 申请锁 --> 判断与访问 --> 释放锁 --> 本质是并不清楚临界资源的情况
- 信号量要提前预设资源的情况,而且在PV变化过程中,可以在外部就能知晓临界资源的情况
- 可以不用进入临界区,就可以得知资源情况
- 甚至可以减少临界区内部的判断
- 在使用互斥量时
4.线程池
1.概念
- 线程池是一种线程使用模式
- 线程过多会带来调度开销,进而影响缓存局部和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务
- **注意:**线程池中可用线程的数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量
2.线程池的优点
- 线程池避免了在处理短时间任务时创建与销毁线程的代价
- 线程池能够去掉创建线程的时间
- 线程池不仅能够保证内核充分利用,还能防止过分调度
- 防止服务器线程过多导致的系统过载问题
- 如果任务来的时候才创建线程意味着,当大量任务同时到来,就需要创建大量线程,这时服务器就扛不住了挂掉了
- 但是使用多线程的方式保证线程的个数是稳定的,就决定了服务器不会因为受到大量请求到来时而造成的冲击进而导致服务器宕机
- 相对于进程池,线程池资源占用较少,但是健壮性很差
3.线程池的应用场景
- 需要大量的线程来完成任务,且完成任务的时间比较短
- 像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的
- 因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数
- 对于长时间的任务,比如Telnet连接请求,线程池的优点就不明显了
- 因为Telnet会话时间比线程的创建时间大多了
- 像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的
- 对性能要求苛刻的应用
- 比如要求服务器迅速响应客户请求
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用
- 突发性大量客户请求,在没有线程池的情况下,将产生大量线程
- 虽然理论上大部分操作系统线程数目最大值不是问题,但短时间内产生大量线程可能使内存到达极限,出现错误
4.线程池实现
- 交互接口
- 线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理
- 线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中
- 线程池中需要用到互斥锁和条件变量
- 线程池中的任务队列是会被多个执行流同时访问的临界资源
- 因此需要引入互斥锁对任务队列进行保护
- 线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务
- 因此线程池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务
- 若此时任务队列为空,那么该线程应该进行等待,直到任务队列中有任务时再将其唤醒
- 因此需要引入条件变量
- 当外部线程向任务队列中Push一个任务后,此时可能有线程正处于等待状态,因此在新增任务后需要唤醒在条件变量下等待的线程
- 线程池中的任务队列是会被多个执行流同时访问的临界资源
- 注意:
- 当某线程被唤醒时,其可能是被异常或是伪唤醒,或者是一些广播类的唤醒线程操作而导致所有线程被唤醒,使得在被唤醒的若干线程中,只有个别线程能拿到任务
- 此时应该让被唤醒的线程再次判断是否满足被唤醒条件,所以在判断任务队列是否为空时,应该使用while进行判断,而不是if
- 如果执行任务特别耗费时间,任务已经从任务队列里面拿出来了,线程自己私有就不在临界资源里了
- 执行任务处理的代码不应该在临界区里面执行,可以让一个线程把任务拿出来之后先把锁释放掉,自己再处理消化任务
- 好处是,它正在处理任务的同时其他线程正在拿任务,可以并行处理
- 当某线程被唤醒时,其可能是被异常或是伪唤醒,或者是一些广播类的唤醒线程操作而导致所有线程被唤醒,使得在被唤醒的若干线程中,只有个别线程能拿到任务
- 唤醒在条件变量下等待的线程用signal,而不是broadcast
- 什么是惊群效应:
- 惊群效应是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态)
- 如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程)
- 但是最终却只能有一个进程(线程)获得这个时间的"控制权",对该事件进行处理
- 而其他进程(线程)获取"控制权"失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应
- 惊群效应消耗了什么:
- Linux****内核对用户进程(线程)频繁地做无效的调度、上下文切换等使系统性能大打折扣
- 上下文切换(context switch)过高会导致CPU像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。
- 直接的消耗包括CPU寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行
- 间接的消耗在于多核cache之间的共享数据
- 为了确保只有一个进程(线程)得到资源,需要对资源操作进行加锁保护,加大了系统的开销
- 什么是惊群效应:
- 为什么线程池中的线程执行历程需要设置为静态方法?
- 使用pthread_create函数创建线程时,需要为创建的线程传入一个Routine(执行例程),该Routine只有一个为void的参数,以及返回类型为void的返回值。
- 而此时Routine作为类的成员函数,该函数的第一个参数是隐藏的this指针
- 因此这里的Routine函数,虽然看起来只有一个参数,而实际上它有两个参数
- 此时直接将该Routine函数作为创建线程时的执行例程是不行的,无法通过编译。
- 静态成员函数属于类,而不属于某个对象,没有隐藏的this指针的
- 因此需要将Routine设置为静态方法,此时Routine函数才真正只有一个参数类型为void*的参数。
- 静态成员函数内部无法调用非静态成员函数,但可能需要在Routine函数当中调用该类的某些非静态成员函数
- 因此需要在创建线程时,向Routine函数传入当前对象的this指针
- 此时就能够通过该this指针在Routine函数内部调用非静态成员函数了