文章目录
- 1.POSIX信号量
- 1.1介绍
- 1.2接口
- 2.基于环形队列的PC模型
- 2.1环形队列常用计算
- 2.2如何设计?
- 2.3如何实现?
- 3.细节处理
- 3.1空间资源和数据资源
- 3.2push/pop
- 3.3理解信号量的出现
- 1.回顾基于阻塞队列的PC模型中条件变量的使用
- 2.如何理解信号量的投入使用?
- 3.4多生产多消费的意义在哪里?
- 4.参考代码
- 4.1sem.hpp
- 4.2RingQueue.hpp
- 4.3pcModel.cc
1.POSIX信号量
1.1介绍
- 进程通信讲的System V信号量 消息队列+信号量
- POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
1.2接口
初始化信号量
#include <semaphore.h>
int sem_init(sem t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非0表示进程间共享
value:信号量初始值
销毁信号量
int sem_destroy(sem t*sem);
等待信号量
功能: 等待信号量,会将信号量的值减1
int sem_wait(sem t*sem);
发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem t*sem);
总结sem_init/sem_destroy/sem_post/sem_wait
在Linux下,sem_init、sem_destroy、sem_post和sem_wait是用于操作POSIX信号量的函数,它们为进程或线程间的同步提供了一种机制。
功能:
sem_init
功能:初始化一个未命名的信号量。
底层原理:在内存中为信号量分配空间,并设置其初始值。这个初始值通常表示可用资源的数量或允许进入临界区的线程数。
sem_destroy
功能:销毁一个先前初始化的信号量。
底层原理:释放信号量所占用的内存空间,并取消其关联的任何系统资源。
sem_post
功能:增加(或“发布”)信号量的值。
底层原理:对信号量的值进行原子性增加操作。这通常意味着释放了一个资源或允许更多的线程进入临界区。这个操作可能会唤醒等待该信号量的一个或多个线程。
sem_wait
功能:减少(或“等待”)信号量的值。
底层原理:尝试对信号量的值进行原子性减少操作。如果信号量的值大于0,则将其减1并立即返回;如果信号量的值为0,则调用线程或进程将被阻塞,直到信号量的值变为正数。这个阻塞是通过系统调用和内核的调度机制实现的。
底层原理: 主要涉及到操作系统的内核支持和原子操作。
原子操作:对信号量的增加和减少操作必须是原子的,即不可中断的。这确保了多线程或多进程环境中对信号量操作的正确性和一致性。这些原子操作通常是通过特殊的硬件指令或内核提供的系统调用来实现的。
内核支持:当线程或进程调用sem_wait并因信号量值为0而被阻塞时,操作系统内核会负责将该线程或进程放入等待队列中,并在信号量值变为正数时将其唤醒。同样,当sem_post被调用以增加信号量的值时,内核会检查是否有线程或进程在等待该信号量,如果有,则将其唤醒。
内存管理:sem_init和sem_destroy涉及到信号量在内存中的分配和释放。这通常是通过调用操作系统的内存管理函数来完成的,以确保对信号量对象的正确访问和生命周期管理。
通过这些函数和底层的支持机制,POSIX信号量提供了一种强大而灵活的同步原语,用于解决多线程或多进程环境中的并发访问和同步问题。
如何理解Linux下的posix信号量,他与system v信号量有何异同?
Linux下的POSIX信号量是一种实现进程/线程间通信的机制,主要用于保护共享资源,确保资源在某一时刻只被一个进程(线程)使用。它分为有名信号量和无名信号量两种类型。有名信号量的值保存在文件中,因此它可以用于线程间和进程间的同步;而无名信号量的值保存在内存中,因此它主要用于线程间的同步,如果需要用于进程间同步,则信号量需要放在共享内存中。POSIX信号量包含一个非负整型变量,并带有两个原子操作:wait(也被称为down、P或lock)和signal(也被称为up、V、unlock或post)。
与POSIX信号量相比,System V信号量也是一种在操作系统中提供的进程间通信(IPC)机制,用于实现进程之间的同步和互斥。它通过对计数器进行操作来控制资源的访问。每个System V信号量由一个整型的标识符(semaphore identifier)来标识,每个标识符对应着一个信号量集合(semaphore set),而信号量集合中可以包含多个单独的信号量。
POSIX信号量和System V信号量在功能上有相似之处,但也有一些关键的差异:
平台支持:POSIX信号量在某些平台(如SUSE8)上可能并未被实现,而System V信号量在大多数Linux/UNIX系统中都已经实现。
创建与初始化:POSIX信号量通过sem_open单一的调用就完成了信号量的创建、初始化和权限的设置,而System V信号量需要两步来实现。
多线程/多进程安全性:POSIX信号量被设计为多线程和多进程安全的,而System V信号量在这方面可能存在问题。
标识符类型:System V信号量通过一个int类型的值来标识自己,类似于调用open()返回的fd;而sem_open函数返回sem_t类型(长整形)作为POSIX信号量的标识值。
总的来说,POSIX信号量和System V信号量都是用于进程间或线程间同步和互斥的重要机制,但它们在实现方式、平台支持、安全性以及使用方式上存在一些差异。在选择使用哪种信号量时,需要根据具体的应用场景和需求进行考虑。
为什么wait也被称为P和signal也被称为V?
在操作系统和并发编程中,wait 和 signal 是用于同步原语的两个基本操作。在某些上下文中,特别是与信号量(semaphores)相关的上下文中,wait 和 signal 操作有时也被称为 P 和 V。这两个名称来源于 Dijkstra 的信号量实现,它用这两个字母来代表两个关键操作。
P 操作(Proberen,荷兰语中的“测试”):对应于 wait 或 down 操作。当一个进程或线程执行 P 操作时,它试图减少信号量的值。如果信号量的值大于 0,则将其减 1 并允许该进程或线程继续执行。如果信号量的值为 0,则执行 P 操作的进程或线程将被阻塞,直到信号量的值变得大于 0 为止。这个操作确保了对共享资源的互斥访问。
V 操作(Verhogen,荷兰语中的“增加”):对应于 signal 或 up 操作。当一个进程或线程执行 V 操作时,它会增加信号量的值。这通常表示释放了对某个共享资源的访问权。如果有其他进程或线程因为信号量的值为 0 而被阻塞在 P 操作上,执行 V 操作的进程或线程可能会唤醒其中一个(或多个,取决于调度策略)被阻塞的进程或线程。
使用 P 和 V 作为 wait 和 signal 的别名有助于简洁地描述信号量的操作,特别是在描述算法或进行理论分析时。这种命名方式也强调了这两个操作在信号量机制中的核心作用:P 操作用于请求资源(可能需要等待),而 V 操作用于释放资源(可能唤醒等待的进程或线程)。
需要注意的是,不是所有的系统或文档都使用 P 和 V 作为 wait 和 signal 的别名。在不同的上下文中,这些操作可能有不同的名称或表示方式。但在讨论 Dijkstra 信号量或相关同步机制时,使用 P 和 V 作为术语可以帮助明确指代这两个基本操作。
2.基于环形队列的PC模型
上一节生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写PC模型
- 环形队列采用数组模拟,用模运算来模拟环状特性
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,通过加计数器或者标记位来判断满或者空。也可以预留一个空的位置,作为满的状态
- 但是信号量这个计数器可以简单的进行多线程间的同步过程
环形队列在计算机中可以用一维数组来维护,这体现了怎样的设计思想?
环形队列在计算机中通过一维数组的物理结构来维护其逻辑上的循环队列结构,这进一步体现了空间复用和抽象化的设计思想。
首先,从物理结构的角度来看,环形队列使用一维数组作为其底层数据结构。一维数组是计算机内存中的连续存储空间,其元素可以通过索引进行快速访问和修改。环形队列利用数组的连续存储特性,通过维护头尾指针来模拟队列的循环操作。头尾指针在数组中的移动遵循循环规则,当指针到达数组的末尾时,会回到数组的起始位置,形成逻辑上的环形结构。
其次,从逻辑结构的角度来看,环形队列表现为一个循环队列,遵循FIFO(先进先出)的原则。在逻辑上,环形队列的头部和尾部是相连的,形成一个闭合的环。新元素从尾部入队,旧元素从头部出队,当尾部指针追上头部指针时,表示队列已满。这种逻辑结构使得环形队列能够高效地处理元素的入队和出队操作,同时避免了传统队列在元素移动时可能产生的空间浪费。
空间复用体现在环形队列通过循环使用数组空间,使得数组中的每个位置都能被充分利用,减少了空间浪费。而抽象化则体现在环形队列将复杂的循环操作封装在底层实现中,对外只提供简单的入队和出队接口,使得使用者无需关心底层实现细节,只需关注队列的基本操作即可。
综上所述,环形队列通过一维数组的物理结构来维护其逻辑上的循环队列结构,不仅提高了空间的使用效率,也简化了队列的操作和使用,再次体现了空间复用和抽象化的设计思想。
与此类似的例子:
磁盘由一个个同心圆组成,我们把当抽象成数据结构来维护。 ==> 一个结构他在内存中的存储样式(存储结构/物理结构)仅仅是一块空间,这块空间能实现什么样的功能取决于设计者在软件层给这块空间维护了怎样的设计。
Linux下,基于阻塞队列/环形队列的生产者消费者模型,各自的原理以及二者的异同
在Linux下,基于阻塞队列和环形队列的生产者消费者模型是并发编程中常见的模式,用于解决多线程间的数据共享和同步问题。下面我将分别讲解它们的原理,并对比它们的异同。
阻塞队列原理
阻塞队列是一种特殊的队列,它支持两个附加操作:在队列为空时,获取元素的线程将会阻塞,直到有元素可获取;当队列已满时,尝试添加元素的线程也将阻塞,直到队列有空余空间。
在生产者消费者模型中,生产者线程负责向队列中添加数据(生产),消费者线程从队列中移除数据(消费)。使用阻塞队列可以确保当队列满时生产者线程阻塞,当队列空时消费者线程阻塞,从而避免数据的丢失或重复处理。
环形队列原理
环形队列(也称为循环队列)是一种使用固定大小数组实现的队列。当队列的尾部到达数组的末尾时,它会循环回到数组的开头。这种设计可以高效地利用数组空间,避免了传统队列在插入和删除元素时可能需要的数组移动操作。
在生产者消费者模型中,环形队列同样用于存储生产者产生的数据,供消费者线程消费。由于环形队列的空间是固定的,因此当队列满时,生产者线程需要等待消费者线程消费数据以释放空间;同样,当队列空时,消费者线程需要等待生产者线程生产数据。
异同点
相同点:
同步机制:无论是阻塞队列还是环形队列,都需要使用某种同步机制(如互斥锁、条件变量等)来确保生产者和消费者之间的正确同步。
数据共享:两者都用于在多个线程之间共享数据,生产者将数据放入队列,消费者从队列中取出数据。
不同点:
空间管理:阻塞队列通常可以动态地扩展和收缩,以适应不同数量的数据。而环形队列使用固定大小的数组,空间使用更加受限,但操作更加高效。
阻塞行为:阻塞队列在队列满或空时会阻塞相应的线程,直到条件满足。这种特性简化了生产者消费者的同步逻辑,但可能增加线程的上下文切换成本。而环形队列通常需要通过额外的同步机制(如条件变量)来实现阻塞行为。
适用场景:阻塞队列更适合于那些数据量变化较大,或者对内存使用不是非常敏感的场景。而环形队列由于其空间固定且操作高效的特点,更适合于那些对性能要求较高,且数据量相对稳定的场景。
总的来说,阻塞队列和环形队列都是实现生产者消费者模型的有效工具,选择哪种取决于具体的应用场景和需求。
2.1环形队列常用计算
- 设置一个计数器 记录当前队列中元素个数
- 设定队列中一个特定的空间用来存放元素个数
- 使用模运算
1、队空:q.front == q.rear
2、队满:(q.rear + 1)% N = q.front
3、队长:(q.rear - q.front + N) % N
4、循环计数:
q.front = (q.front + 1) % N
q.rear=(q.rear + 1) % N
2.2如何设计?
2.3如何实现?
- 假定我们这么设计:生产者指向他上次生产的下一个位置即生产者每生产一次就++;消费者指向他未来要消费的资源,消费一次就++。
- 如果生产者和消费者指向了环形结构的同一个位置,那么此时队列的状态要么为空要么为满:初始时队列为空,二者指向同一个位置。生产者不断生产,他只能生产到消费者的前一个位置,生产完最后一个后,生产者++,此时二者相遇;消费者不断获取,他只能获取完最后一个元素,获取完后,消费者++,此时二者相遇。
- 生产者和消费者存在互斥或同步问题。一般情况下,生产者和消费者指向的是不同的位置。当生产者和消费者指向同一个位置时,进行互斥与同步的控制。当生产者和消费者指向不同的位置,让他们并发执行即可。
- 生产者不能超过消费者;消费者不能超过生产者。
- 为空:让生产者先运行
- 为满:让消费者先运行
3.细节处理
3.1空间资源和数据资源
3.2push/pop
先加锁还是先申请信号量
这里说的申请信号量成功/失败的意思是:对信号量的值进行对应的pv操作
- 先加锁在获取信号量
多个线程要push/pop时,先申请锁,申请锁成功后去申请对应的信号量,申请信号量不成功则阻塞等待,此时锁不能被其他线程使用;
多个线程要push/pop时,先申请锁,申请锁失败后会阻塞等待锁。
-
先申请信号量
多个线程要push/pop时,先申请信号量,申请信号量成功再去申请锁;
多个线程要push/pop时,先申请信号量,申请信号量失败则阻塞等待; -
先加锁还是先申请信号量?
很明显,我们要先申请信号量,为什么?我们在不加锁的前提下先申请信号量时,此时是可以有多个线程调用push/pop函数的,信号量的值是大于1的,即可以有多个线程成功申请信号量,申请信号量成功的就去申请锁,申请信号量失败的就阻塞等待;而如果我们先申请锁,如果申请锁失败则阻塞,其他的线程可以申请锁;就算申请锁成功,此时已经进入了临界区,只能由成功申请锁的该线程去申请信号量,如果申请信号量成功也还好,这个线程可以继续执行,如果申请信号量失败,不仅这个申请到锁的线程在阻塞等待信号量,其他未申请到锁的线程也在当地。与先申请信号量相比:线程们都可以申请信号量,之后由某一个申请到信号量又申请到锁的去执行临近区代码;而先加锁,一旦加锁后只能有一个线程执行临界区,其余的线程只能等他执行完才能得到调度。总结:先申请信号量可以先进行信号量的申请,一旦得到调度就可以申请锁继续后续动作。
3.3理解信号量的出现
已经有条件变量了,为什么设计者又搞出一个信号量?显然,是为了进一步的提高效率。俗话来讲,就是人们的思想层次在不断地提高,基于以往知识的理解,设计出更好用的东西。
1.回顾基于阻塞队列的PC模型中条件变量的使用
条件变量是如何诞生的,或者说是如何投入使用的?
多线程背景下,要保证单线程访问临界资源,就得有锁,申请锁后访问临界资源前,存在资源是否就绪的问题,如果资源不就绪,就要释放锁,那么就出现了这么一种情况:某些/某个线程在不断地申请/访问资源状态/释放锁,这使得丧失了设计多线程的目的,于是有了条件变量,条件变量的出现使得在资源不就绪的情况下,可以让线程在临界区等待,一旦被唤醒/得到通知,就可以继续执行,避免了不断地申请/访问资源状态/释放锁这一无意义的情况。
2.如何理解信号量的投入使用?
- 用条件变量配合互斥锁实现互斥与同步:申请锁 -> 判断与访问资源 -> 释放锁 ===》使用条件变量这样做的原因是我们不清楚临界资源的情况/状态
- 信号量提前预订了资源,通过计算机软硬件给我们提供的原子性的epv接口,我们可以在非临界区知晓临界资源的情况,即,我们不再考虑资源是否就绪了!即,条件变量解决的因资源就绪而循环申请/释放锁的情况,信号量解决的是压根就不用再考虑资源是否就绪!
- 信号量本质是一把计数器,使得线程可以不用进入临界区就可以得知资源情况==》减少临界区内部的判断!
3.4多生产多消费的意义在哪里?
不要狭隘的认为,任务或数据在交易场所的存放和获取,就是PC模型的意义。==》将数据或任务生产前和拿到之后的处理,才是最耗费时间的
生产的本质:私有的生产的任务/数据放在公共空间中供许多消费者获取
消费的本质:公共空间中的任务/数据获取后私有的进行处理
Linux下的信号量和条件变量哪个更好用?
如果你需要限制对共享资源的并发访问数量,或者需要在多线程或多进程环境中进行同步,那么信号量可能是一个更好的选择。
如果你需要等待某个条件成立,并在条件变化时执行相应的操作,那么条件变量可能更适合你的需求。
为什么要设计多线程?设计多线程的目的是什么?
设计多线程的目的主要是为了充分利用计算机的多核处理器资源,提高程序的执行效率和响应速度,以及实现复杂的并发任务处理。以下是设计多线程的主要目的:
提高系统吞吐量:多线程可以使得多个任务并发执行,从而提高系统的整体吞吐量。如果系统只使用单线程,那么在一个任务执行时,其他任务只能等待,这大大降低了系统的处理速度。通过多线程,系统可以同时处理多个任务,使得资源得到更充分的利用。
提高响应速度:对于需要实时响应的系统(如服务器、游戏等),多线程可以确保在等待一个任务完成时,其他任务仍然可以继续执行。这样可以提高系统的响应速度,使得用户可以更快地得到反馈。
利用多核处理器:现代计算机普遍采用多核处理器,每个核心可以独立地执行线程。设计多线程可以使得程序能够充分利用这些处理器核心,从而提高程序的执行效率。
简化编程模型:多线程使得并发编程更为简单和直观。通过将任务分解为多个线程,程序员可以更容易地管理和控制这些任务的执行。
实现复杂的并发任务处理:有些任务需要并发执行才能满足需求,例如网络编程中的服务器需要同时处理多个客户端的请求。通过多线程,可以轻松地实现这种并发处理。
然而,需要注意的是,多线程编程也带来了一些挑战,如线程同步、数据共享和死锁等问题。因此,在设计多线程程序时,需要仔细考虑这些问题,并采取相应的措施来避免潜在的问题。
4.参考代码
4.1sem.hpp
#ifndef _SEM_HPP_
#define _SEM_HPP_
#include <iostream>
#include <semaphore.h>
class Sem
{
public:
Sem(int value)
{
sem_init(&_sem, 0, value);
}
//value=0 wait; value>0 value--
void p()
{
sem_wait(&_sem);
}
//发布信号量 唤醒等待中的线程 资源使用完毕 可以归还资源 将信号量值加1
void v()
{
sem_post(&_sem);
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
};
#endif
4.2RingQueue.hpp
#ifndef _Ring_QUEUE_HPP_
#define _Ring_QUEUE_HPP_
#include <iostream>
#include <vector>
#include <pthread.h>
#include "sem.hpp"
const int g_DefaultNum = 5;
template <class T>
class RingQueue
{
public:
RingQueue(int defaultNum = g_DefaultNum, int num = g_DefaultNum,
int pstep = 0, int cstep = 0,
int spaceSem = g_DefaultNum, int dataSem = 0)
: _rqueue(defaultNum),
_num(num),
_pstep(pstep),
_cstep(cstep),
_spaceSem(spaceSem),
_dataSem(dataSem)
{
pthread_mutex_init(&cLock, nullptr);
pthread_mutex_init(&pLock, nullptr);
}
~RingQueue()
{
pthread_mutex_destroy(&cLock);
pthread_mutex_destroy(&pLock);
}
// 生产者需要空间资源 生产者们的临界资源是 下标
void push(const T &in)
{
/*index只在该函数内可见 外部无法获取 不使用
static int index = 0;
_rqueue[index] = in;
*/
_spaceSem.p(); // 阻塞等待减少生产者空间资源Sem值 生产者发送资源的前提是队列中有空间
pthread_mutex_lock(&pLock); // 保证多线程生产者环境下 只有一个生产者线程进入
// 生产者发送资源到队列
_rqueue[_pstep++] = in;
_pstep %= _num;
pthread_mutex_unlock(&pLock);
// 现已确定队列中肯定有资源
_dataSem.v(); // 增加消费者数据资源Sem值 使得消费者等待信号量时知道队列中有数据可取
}
// 消费者需要数据资源 消费者们的临界资源是下标
void pop(T *out)
{
_dataSem.p(); // 阻塞等待减少消费者数据资源Sem值 消费者获取资源的前提是队列中有资源可取
pthread_mutex_lock(&cLock); // 保证多线程消费者环境下 只有一个消费者线程进入
// 消费者从队列中获取资源
*out = _rqueue[_cstep++];
_cstep %= _num;
pthread_mutex_unlock(&cLock);
// 现已确定队列中肯定有空间
_spaceSem.v(); // 增加生产者空间资源Sem值 使得生产者等待信号量时知道队列中有空间可放
}
private:
std::vector<T> _rqueue;
int _num;
int _pstep; // 生产者当前的位置
int _cstep; // 消费者当前的位置
Sem _spaceSem;
Sem _dataSem;
pthread_mutex_t cLock;
pthread_mutex_t pLock;
};
#endif
4.3pcModel.cc
#include "RingQueue.hpp"
#include <cstdlib>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
void *productor(void *args)
{
RingQueue<int> *rq = (RingQueue<int> *)args;
while (true)
{
// sleep(1); // 生产者慢
// 生产数据或构建任务(外部获取/自己创造) -- 有时间消耗
std::cout << "Producer is producing data... ";
int data = rand() % 100 + 1;
// pthread_t tid是一个地址 整形值很大 我们将他%10000是为了验证看到是不同的生产者线程
std::cout << " 生产: " << data << " [" << pthread_self() % 10000 << "]" << std::endl;
// 发送到交易场所 -- 环形队列
rq->push(data);
}
}
void *consumer(void *args)
{
RingQueue<int> *rq = (RingQueue<int> *)args;
while (true)
{
sleep(1); // 消费者慢
int data;
// 从环形队列中获取任务或数据
rq->pop(&data);
// 进行数据的处理 -- 有时间消耗
std::cout << "Consumer is processing data... ";
std::cout << " 消费: " << data << " [" << pthread_self() % 10000 << "]" << std::endl;
}
}
#define prodctrNum 3
#define consmrNum 2
int main()
{
srand((uint64_t)time(nullptr) ^ getpid());
RingQueue<int> *rq = new RingQueue<int>();
// rq->debug(); for debug
pthread_t consmr[3], prodctr[2];
for (int i = 0; i < prodctrNum; i++)
pthread_create(prodctr + i, nullptr, productor, (void *)rq);
for (int i = 0; i < consmrNum; i++)
pthread_create(consmr + i, nullptr, consumer, (void *)rq);
for (int i = 0; i < prodctrNum; i++)
pthread_join(prodctr[i], nullptr);
for (int i = 0; i < consmrNum; i++)
pthread_join(consmr[i], nullptr);
return 0;
}