相信不少小伙伴在面试过程中都被问到过共享内存。通过阅读本文您可以学习到一套比较完整的共享内存使用流程,以及共享内存在使用过程中常见问题的处理方法。
本文介绍一个基于共享内存SHM实现的消息队列,通过该队列能够实现多进程数据通信。在工程项目中,也具有一定的参考价值。
具体内容如下:
- SHM循环队列需求说明
- SHM循环队列整体设计
- SHM Manager设计
- SHM Mutex设计
- Circular Queue结构设计
- Circular Queue入队设计
- Circular Queue出队设计
- SHM Queue设计
- 总结
00 SHM循环队列需求说明
00.1 功能性需求
- 需要基于共享内存,实现多个进程(非父子关系)之间相互通信的消息队列。
- 每个进程都可以写入消息和读出消息。
- 共享内存的消息队列使用循环队列。
00.2 稳定性需求
其中某个进程崩溃时,要尽可能不影响其他进程访问共享内存。
01 SHM循环队列整体设计
为了实现上述的SHM消息队列,需要完成如下一些模块的设计:
- 一个管理SHM对象的模块,SHM Manager,用于创建/销毁或打开/关闭SHM对象。
- 一个维护循环队列的模块,Circular Queue。
- 一个保证多个进程安全访问共享内存的同步模块,SHM Mutex。
- 一个用户调用接口模块,SHM Queue。
为了简化设计,我们将进程分为两类:
- 一个manager进程,用于创建和销毁SHM资源,同时也能读写消息。
- 多个Normal进程,不需要创建和销毁SHM资源,只需要打开和关闭由manager进程创建的SHM资源,并通过循环队列读写消息。
程序运行流程如下:
在Manager进程启动,并创建SHM资源后,SHM进入可用状态。在此状态中,Normal进程才能启动并读写SHM循环消息队列。
02 SHM Manager设计
这里我们使用POSIX SHM接口(使用其他SHM方式也可以)。
02.1 ShmManager接口定义
ShmManager接口定义如下:
class ShmManager {
public:
ShmManager();
~ShmManager();
// 初始化SHM资源对象,即创建/打开
bool Init(uint64_t shm_size, bool is_manager);
// 释放SHM资源,即销毁/关闭
void Release();
void* GetMemory(uint64_t& mem_size);
private:
bool is_manager_{false};
int shm_fd_{-1};
void* start_ptr_{nullptr};
uint64_t shm_size_{0};
static const std::string shm_name_;
};
02.2 创建/打开SHM
创建/打开SHM资源的代码如下:
const std::string ShmManager::shm_name_("/shm_queue");
bool ShmManager::Init(uint64_t shm_size, bool is_manager) {
shm_size_ = shm_size;
is_manager_ = is_manager;
int oflag = O_RDWR;
if (is_manager_) {
// manager进程需要O_CREAT标记创建SHM资源
oflag |= O_CREAT;
// manager进程需要删除历史残留文件,normal进程不需要
shm_unlink(shm_name_.c_str());
}
// 1. 创建共享内存对象
shm_fd_ = shm_open(shm_name_.c_str(), oflag, 0666);
if (shm_fd_ == -1) {
std::cerr << "shm_open failed:" << strerror(errno)
<< std::endl;
return false;
}
if (is_manager_) {
// 2. 设置共享内存大小,normal进程不需要
if (ftruncate(shm_fd_, shm_size_) == -1) {
std::cerr << "ftruncate failed:" << strerror(errno)
<< std::endl;
return false;
}
}
// 3. 将共享内存映射到当前进程地址空间
start_ptr_ = mmap(0, shm_size_, PROT_WRITE|PROT_READ,
MAP_SHARED, shm_fd_, 0);
if (start_ptr_ == MAP_FAILED) {
std::cerr << "mmap failed:" << strerror(errno)
<< std::endl;
start_ptr_ = nullptr;
return false;
}
return true;
}
上面的代码产生了一个共享内存的区域,共享内存的首地址为start_ptr_,大小为shm_size_。
对于manager进程来说,调用shm_open时,需要使用O_RDWR | O_CREAT参数,以确保能新建SHM对象。对于normal进程,需要使用O_RDWR参数,以确保打开已有的SHM对象。
normal进程不需要调用ftruncate函数,来修改共享内存大小。
另外,manager进程需要在调用shm_open前,调用shm_unlink函数,以防止之前由于进程崩溃等原因,残留的shm文件能够被清理掉。以增加程序稳定性。
02.3 销毁/关闭SHM
销毁/关闭SHM资源的代码如下:
void ShmManager::Release() {
// 1. 解除映射
if (start_ptr_ != nullptr) {
munmap(start_ptr_, shm_size_);
start_ptr_ = nullptr;
}
// 2. 关闭共享内存对象
if (shm_fd_ != -1) {
close(shm_fd_);
shm_fd_ = -1;
}
// 3. 删除位于/dev/shm下的shm文件,normal进程不需要
if (is_manager_) {
shm_unlink(shm_name_.c_str());
}
shm_size_ = 0;
}
对于normal进程,在退出过程中不需要删除shm文件。
对于SHM文件,如果在调用shm_unlink函数之前,其它进程已经打开了该文件的文件描述,那么此文件描述一直有效,即使某些进程通过shm_unlink函数删除了该文件。
02.4 其它接口
GetMemroy接口实现如下:
void* ShmManager::GetMemory(uint64_t& mem_size) {
mem_size = shm_size_;
return start_ptr_;
}
这个接口用来将SHM内存区传递给Circular Queue。
03 SHM Mutex设计
进程间的同步方式由很多种,其中不依赖SHM的简单方式有文件锁和信号量。
由于我们这里是对共享内存中的循环队列访问进行同步,使用同步对象是SHM对象已经被创建出来了,因此,这里可以采用需要依赖SHM的同步方式。
这里使用linux的互斥锁。
03.1 SHM Mutex接口定义
SHM Mutex接口定义如下:
class SHMMutex {
public:
SHMMutex();
~SHMMutex();
// ptr是一个位于SHM内存区的指针
void Init(pthread_mutex_t* ptr);
void Release();
void lock();
void unlock();
private:
pthread_mutex_t* ptr_{nullptr};
};
03.2 Init接口实现
初始化接口Init的实现如下:
void SHMMutex::Init(pthread_mutex_t* ptr) {
ptr_ = ptr;
if (!ptr_) {
std::cerr << "ptr is nullptr" << std::endl;
return;
}
// 初始化健壮互斥锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
// 加锁后崩溃,可以自动恢复解锁.
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);
pthread_mutex_init(ptr_, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);
}
将pthread_mutex_t对象放在SHM内存区,并且设置PTHREAD_PROCESS_SHARED属性,就可以使得pthread_mutex_t对象能够完成进程之间的同步。
另外,为了防止单一进程在获取到锁之后,还没来得及解锁就出现进程崩溃,从而导致其它进程无法获取到锁而卡死(stalled)。这里为pthread_mutex_t对象设置了PTHREAD_MUTEX_ROBUST属性。
设置PTHREAD_MUTEX_ROBUST属性后,如果某个进程崩溃了,其它进程调用pthread_mutex_lock函数时,会有一个进程获得锁并返回EOWNERDEAD,这时需要该进程调用pthread_mutex_consistent函数以修复该pthread_mutex_t对象。
03.3 lock接口实现
加锁接口lock的实现如下:
void SHMMutex::lock() {
if (!ptr_) {
std::cerr << "ptr is nullptr" << std::endl;
return;
}
// 尝试获取锁
int lock_status = pthread_mutex_lock(ptr_);
if (lock_status == EOWNERDEAD) {
// 锁的拥有者崩溃了,需要修复锁
std::cout << "Previous process holding the lock has \
crashed. Recovering the lock..." << std::endl;
pthread_mutex_consistent(ptr_);
}
}
03.4 unlock接口实现
解锁接口unlock的实现如下:
void SHMMutex::unlock() {
if (!ptr_) {
std::cerr << "ptr is nullptr" << std::endl;
return;
}
pthread_mutex_unlock(ptr_);
}
03.5 Release接口实现
释放接口Release的实现如下:
void SHMMutex::Release() {
// 由于mutex在共享内存上,这里不能销毁mutex,
// 否则会导致其他进程操作一个已经被销毁的mutex
// if (ptr_) {
// pthread_mutex_destroy(ptr_);
// }
ptr_ = nullptr;
}
SHMMutex释放时,不应该调用pthread_mutex_destory。
04 Circular Queue结构设计
基于SHM Manager产生的共享内存的首地址start_ptr和大小shm_size,我们需要设计一个循环队列,队列的多进程同步使用前面的SHM Mutex。
04.1 数据结构
队列的数据结构如下:
// 队列的控制结构,
struct QueueHeader {
//同步锁,由SHMMutex封装具体操作
pthread_mutex_t mutex;
// 队头指针,单位字节,取值范围[0,shm_size-sizeof(SHMHeader)-1]
std::atomic_int64_t head{0};
// 队尾指针,单位字节,取值范围[0,shm_size-sizeof(SHMHeader)-1]
std::atomic_int64_t tail{0};
};
// 队列中的消息结构
struct Message{
// 消息长度
uint32_t length{0};
// 消息数据首地址
uint8_t data[0];
};
04.2 内存布局
共享内存区和循环队列的内存布局如下:
单个队列消息的内存布局如下:
由于这里的Message的data长度可变,为了使得共享内存空间得到充分的利用,循环队列的对头指针head和队尾指针tail以字节为单位记录队列数据状态。
04.3 接口设计
Circular Queue接口设计如下:
class CircularQueue {
public:
CircularQueue();
~CircularQueue();
// 用共享内存的首地址start_ptr和大小shm_size作为参数.
bool Init(void* ptr, uint64_t size, bool is_manager);
void Release();
bool Push(const std::string& data);
bool Pop(std::string& data);
private:
QueueHeader* header_{nullptr};
uint8_t* buffer_ptr_{nullptr};
uint64_t buffer_size_{0};
SHMMutex shm_mutex_;
uint64_t GetOffset(uint64_t offset);
};
05 Circular Queue入队设计
入队操作的细节比较多,稍微有点复杂。
为了衔接前面的代码,在介绍入队操作之前,先介绍一下Init接口。
05.1 Init接口实现
Init接口实现如下:
bool CircularQueue::Init(void* ptr, uint64_t size, bool is_manager) {
if (is_manager) {
header_ = new (ptr) QueueHeader();
} else {
header_ = (QueueHeader*) ptr;
}
shm_mutex_.Init(&header_->mutex);
buffer_ptr_ = (uint8_t*) (header_+1);
buffer_size_ = size - sizeof(QueueHeader);
return true;
}
由上层的SHM Queue模块调用,将SHM Manager模块产生的内存区域传递给Circular Queue模块。调用代码如下:
if (!shm_manager_.Init(shm_size, is_manager)) {
return false;
}
uint64_t mem_size = 0;
void* shm_ptr = shm_manager_.GetMemory(mem_size);
if (shm_ptr == nullptr) {
return false;
}
if (!circular_queue_.Init(shm_ptr, mem_size, is_manager)) {
return false;
}
return true;
这样使得SHM Manager和Circular Queue没有直接模块依赖关系。
05.2 入队完整流程
队列写入可以分为如下几个步骤:
- 数据校验:校验数据有效性,数据不能过大或为空。
- 同步加锁:使用SHMMutex加锁,保证操作过程时互斥的。
- 队列溢出检查:判断当前数据写入是否会导致超过队列缓存大小。
- 数据拷贝:在之前队尾指针指向的内存位置,向后分配一块足够大的内存空间,并将数据拷贝到该内存空间中。
- 修改队尾指针:将新的队尾指针写入tail。
- 解锁:解锁后其它进程才能加锁操作队列。
Push接口实现代码如下:
bool CircularQueue::Push(const std::string& data) {
// 1. 数据校验
if (data.size() == 0) {
return false;
}
if (!header_ || !buffer_ptr_) {
std::cerr << "header_ is nullptr" << std::endl;
return false;
}
if (buffer_size_ < data.size() + sizeof(Message)) {
std::cerr << "buffer_size_ < data.size() + \
sizeof(Message)" << std::endl;
return false;
}
// 2. 同步加锁
std::lock_guard<SHMMutex> lg(shm_mutex_);
// 3. 队列溢出检查
uint64_t head = header_->head.load();
uint64_t old_tail = header_->tail.load();
uint64_t new_tail = old_tail + data.size() + sizeof(Message);
uint64_t new_tail_offset = GetOffset(new_tail);
if ((old_tail < head && head <= new_tail) ||
(old_tail > head && head <= new_tail_offset &&
new_tail_offset < new_tail)) {
//这里的<=,使得队列满时最大容量是buffer_size_-1
//std::cerr << "shm_queue is full." << std::endl;
return false; // 队列满了
}
// 4. 数据拷贝
if (new_tail <= buffer_size_) {
Message* msg = (Message*) (buffer_ptr_ + old_tail);
memcpy(msg->data, data.data(), data.size());
msg->length = data.size();
} else { // 跨越了buffer的结尾
Message* msg = (Message*) (buffer_ptr_ + old_tail);
uint64_t first_len = buffer_size_ - old_tail;
if (first_len >= sizeof(Message)) {
// data跨越了buffer尾部
first_len = first_len - sizeof(Message);
memcpy(msg->data, data.data(), first_len);
uint64_t second_len = data.size() - first_len;
memcpy(buffer_ptr_, data.data() + first_len,
second_len);
msg->length = data.size();
} else { // Message跨越了buffer尾部
Message msg_tmp;
msg_tmp.length = data.size();
memcpy((char*)msg, (char*)&msg_tmp, first_len);
uint64_t second_len = sizeof(Message) - first_len;
memcpy(buffer_ptr_, ((char*)&msg_tmp) + first_len,
second_len);
memcpy(buffer_ptr_+second_len, data.data(),
data.size());
}
}
// 5. 修改队尾指针
header_->tail.store(new_tail_offset);
// 6. 解锁,lg析构自动解锁
return true;
}
在上述代码中,最复杂的是队列溢出检查和数据拷贝。
05.3 队列溢出检查
队列溢出检查种有两种情况出现队列溢出:
-
第一种情况:写入数据之前,队列头head和队列尾old_tail之间的数据跨越了buffer的末尾,新数据Message在old_tail基础上向后移动到new_tail,当head<=new_tail时,队列溢出。
-
第二种情况:写入数据之前,队列头head和队列尾old_tail之间的数据没有跨越buffer的尾部,新数据Message在old_tail基础上向后移动到buffer的尾部后,长度不够,需要将剩下的数据从buffer的开始位置继续向后写,新的队列尾为new_tail_offset,如果head<=new_tail_offset,则队列溢出。
05.4 数据拷贝
数据拷贝分为三种情况:
-
一般情况的数据拷贝:源数据直接拷贝到tail指定的内存区域。
-
队列缓存区尾部内存太小,不足以存放源数据data。
-
队列缓存区尾部内存太小,不足以存放没有data的Message结构体(length字段)。
05.5 稳定性
如果在加锁后解锁前崩溃,不会对其它进程访问队列产生负面影响。原因如下:
- 在加锁后到解锁前这期间,进行了数据拷贝和修改队尾指针两个操作。
- 如果在数据拷贝过程中进程崩溃了,由于没有修改队尾指针,即使拷贝数据只进行了一部分,其它进程完全不会感觉到曾经有进程进行过该操作。
- 如果在修改队尾指针指针的过程中崩溃了,原子变量操作保证该操作的原子性。
- 在SHM Mutex中,使用PTHREAD_MUTEX_ROBUST属性,并配合pthread_mutex_consistent函数,修复了由于崩溃导致的锁状态混乱问题。
06 Circular Queue出队设计
队列的出队操作,是写入操作的逆过程,基本流程如下:
- 同步加锁
- 检查队列是否为空
- 数据读出拷贝
- 修改队头指针:将新的队尾指针写入head。
- 解锁:解锁后其它进程才能加锁操作队列。
队列读取的代码如下:
bool CircularQueue::Pop(std::string& data) {
data.clear();
if (!header_ || !buffer_ptr_) {
std::cerr << "header_ is nullptr" << std::endl;
return false;
}
// 1. 同步加锁
std::lock_guard<SHMMutex> lg(shm_mutex_);
// 2. 检查队列是否为空
uint64_t head = header_->head.load();
if (head == header_->tail.load()) {
//std::cerr << "shm_queue is empty." << std::endl;
return false; // 队列空了
}
// 3.数据读出拷贝
Message* msg = (Message*) (buffer_ptr_ + head);
uint64_t new_head = 0;
if (buffer_size_ - head >= sizeof(Message)) {
// 在Message头部是完整的情况下
uint64_t msg_len = sizeof(Message) + msg->length;
if (msg->length >= buffer_size_ ||
msg_len >= buffer_size_) {
std::cerr << "invalid data." << std::endl;
return false;
}
new_head = GetOffset(head + msg_len);
if (head + msg_len <= buffer_size_) {
data.assign((char*)msg->data, msg->length);
} else {
uint64_t first_len = buffer_size_ - head
- sizeof(Message);
uint64_t second_len = msg->length - first_len;
data.reserve(msg->length);
data.assign((char*)msg->data, first_len);
data.append((char*)buffer_ptr_, second_len);
}
} else { // Message头部被拆分时
uint64_t first_len = buffer_size_ - head;
Message msg_tmp;
memcpy(&msg_tmp, msg, first_len);
uint64_t second_len = sizeof(Message) - first_len;
memcpy(((char*)&msg_tmp)+first_len, buffer_ptr_,
second_len);
if (msg_tmp.length >= buffer_size_ ||
msg_tmp.length + sizeof(Message) >= buffer_size_) {
std::cerr << "invalid data." << std::endl;
return false;
}
data.append((char*)buffer_ptr_+second_len,
msg_tmp.length);
new_head = second_len + msg_tmp.length;
}
// 4. 修改队头指针
header_->head.store(new_head);
// 5. 解锁,lg析构自动解锁
return true;
}
这里将head==tail作为队列为空的条件。
在稳定性方面,Pop和前面的Push一样,具有较好的稳定性。在完成修改队头指针之前,如果有进程崩溃,并不会对其他进程有任何影响。如果在完成修改队头指针之后,进程崩溃,其它进程会认为被读走了一个Message,即已出队列。
上面的流程中,数据读出拷贝是最复杂的。和数据写入时的拷贝相对应,也需要对Push中三种情况分别进行处理。这里就不专门分开分析了。
07 SHM Queue设计
SHM Queue直接调用SHM Manager和SHM Mutex的接口。
接口定义如下:
class ShmQueue {
public:
ShmQueue();
~ShmQueue();
// 初始化
bool Init(uint64_t shm_size, bool is_manager);
// 入队
bool Push(const std::string& data);
// 出队
bool Pop(std::string& data);
private:
ShmManager shm_manager_;
CircularQueue circular_queue_;
};
接口实现如下:
ShmQueue::ShmQueue() {
}
ShmQueue::~ShmQueue() {
circular_queue_.Release();
shm_manager_.Release();
}
bool ShmQueue::Init(uint64_t shm_size, bool is_manager) {
if (!shm_manager_.Init(shm_size, is_manager)) {
return false;
}
uint64_t mem_size = 0;
void* shm_ptr = shm_manager_.GetMemory(mem_size);
if (shm_ptr == nullptr) {
return false;
}
// 将SHM Manager模块产生的内存区域传递给Circular Queue模块
if (!circular_queue_.Init(shm_ptr, mem_size, is_manager)) {
return false;
}
return true;
}
bool ShmQueue::Push(const std::string& data) {
return circular_queue_.Push(data);
}
bool ShmQueue::Pop(std::string& data) {
return circular_queue_.Pop(data);
}
SHM Queue逻辑比较简单。
08 总结
本文设计的SHM循环队列,主要分成三个功能子模块SHM Manager、SHM Mutex、Circular Queue,和一个用户调用接口模块SHM Queue:
- SHM Manager管理SHM资源的创建和回收。
- SHM Mutex封装pthread_mutex_t对象,实现跨进程同步,并具备一定的鲁棒性。
- Circular Queue是基于SHM内存块的循环队列,逻辑比较复杂。
- SHM Queue提供用户接口。
SHM在使用过程中,一般需要重点关注如下一些问题:
- 创建和销毁SHM对象,本文通过单个Manager进程完成该项工作,避免了多进程同时创建和销毁的产生的多进程竞争问题。在某些工程实践场景下,需要处理多进程竞争问题。甚至需要考虑SHM是残留资源,还是正常使用的资源。要处理得很好还是很有挑战的。
- 共享内存通信的同步,本文通过封装pthread_mutex_t实现。当然还有很多其它方式,如:文件锁、信号量、读写锁、原子变量、自旋锁、条件变量,甚至socket等。
- 管理SHM内存块,本文通过循环队列以字节为单位管理内存区域。这部分很灵活,由很多设计思路,需要根据项目场景来设计。比较常见的方法是将大内存区域划分为多个小的内存块,然后按块分配内存。
- 对于通信资源是否可用的判断,通常有两种思路,轮询和通知。轮询的方式,在需要读数据或写数据时,通过不断尝试来检查通信资源是否可读或可写;通知的方式,通过注册回调接口对象或函数,当数据可写或可读时,由底层通知用户。一般情况下,通知的方式会比轮询的方式更好,可以有效避免忙等。但如果时对实时性要求较高的场景,轮询则更合适。也并不是绝对的,需要根据实际情况来判断。对于本文暴露的接口来说,只能用轮询的方式,轮询调用Pop函数或者Push函数。
- 稳定性问题,多进程的稳定性问题比单进程要复杂得多。一般考虑如何让一部分进程崩溃后,不影响另外一部分进程的运行。本文的互斥锁封装和循环队列的设计都在尽力考虑这个问题。
关注微信公众号“程序员小阳”,相互交流更多软件开发技术。