面试可以直接用的IPC小项目--SHM实现消息队列

相信不少小伙伴在面试过程中都被问到过共享内存。通过阅读本文您可以学习到一套比较完整的共享内存使用流程,以及共享内存在使用过程中常见问题的处理方法。

本文介绍一个基于共享内存SHM实现的消息队列,通过该队列能够实现多进程数据通信。在工程项目中,也具有一定的参考价值。

具体内容如下:

  1. SHM循环队列需求说明
  2. SHM循环队列整体设计
  3. SHM Manager设计
  4. SHM Mutex设计
  5. Circular Queue结构设计
  6. Circular Queue入队设计
  7. Circular Queue出队设计
  8. SHM Queue设计
  9. 总结

00 SHM循环队列需求说明

00.1 功能性需求

  • 需要基于共享内存,实现多个进程(非父子关系)之间相互通信的消息队列。
  • 每个进程都可以写入消息和读出消息。
  • 共享内存的消息队列使用循环队列。

00.2 稳定性需求

其中某个进程崩溃时,要尽可能不影响其他进程访问共享内存。
在这里插入图片描述

01 SHM循环队列整体设计

为了实现上述的SHM消息队列,需要完成如下一些模块的设计:

  1. 一个管理SHM对象的模块,SHM Manager,用于创建/销毁或打开/关闭SHM对象。
  2. 一个维护循环队列的模块,Circular Queue。
  3. 一个保证多个进程安全访问共享内存的同步模块,SHM Mutex。
  4. 一个用户调用接口模块,SHM Queue。
    在这里插入图片描述

为了简化设计,我们将进程分为两类:

  1. 一个manager进程,用于创建和销毁SHM资源,同时也能读写消息。
  2. 多个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 入队完整流程

队列写入可以分为如下几个步骤:

  1. 数据校验:校验数据有效性,数据不能过大或为空。
  2. 同步加锁:使用SHMMutex加锁,保证操作过程时互斥的。
  3. 队列溢出检查:判断当前数据写入是否会导致超过队列缓存大小。
  4. 数据拷贝:在之前队尾指针指向的内存位置,向后分配一块足够大的内存空间,并将数据拷贝到该内存空间中。
  5. 修改队尾指针:将新的队尾指针写入tail。
  6. 解锁:解锁后其它进程才能加锁操作队列。
    在这里插入图片描述

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 队列溢出检查

队列溢出检查种有两种情况出现队列溢出:

  1. 第一种情况:写入数据之前,队列头head和队列尾old_tail之间的数据跨越了buffer的末尾,新数据Message在old_tail基础上向后移动到new_tail,当head<=new_tail时,队列溢出。
    在这里插入图片描述

  2. 第二种情况:写入数据之前,队列头head和队列尾old_tail之间的数据没有跨越buffer的尾部,新数据Message在old_tail基础上向后移动到buffer的尾部后,长度不够,需要将剩下的数据从buffer的开始位置继续向后写,新的队列尾为new_tail_offset,如果head<=new_tail_offset,则队列溢出。
    在这里插入图片描述

05.4 数据拷贝

数据拷贝分为三种情况:

  1. 一般情况的数据拷贝:源数据直接拷贝到tail指定的内存区域。
    在这里插入图片描述

  2. 队列缓存区尾部内存太小,不足以存放源数据data。
    在这里插入图片描述

  3. 队列缓存区尾部内存太小,不足以存放没有data的Message结构体(length字段)。
    在这里插入图片描述

05.5 稳定性

如果在加锁后解锁前崩溃,不会对其它进程访问队列产生负面影响。原因如下:

  1. 在加锁后到解锁前这期间,进行了数据拷贝和修改队尾指针两个操作。
  2. 如果在数据拷贝过程中进程崩溃了,由于没有修改队尾指针,即使拷贝数据只进行了一部分,其它进程完全不会感觉到曾经有进程进行过该操作。
  3. 如果在修改队尾指针指针的过程中崩溃了,原子变量操作保证该操作的原子性。
  4. 在SHM Mutex中,使用PTHREAD_MUTEX_ROBUST属性,并配合pthread_mutex_consistent函数,修复了由于崩溃导致的锁状态混乱问题。

06 Circular Queue出队设计

队列的出队操作,是写入操作的逆过程,基本流程如下:

  1. 同步加锁
  2. 检查队列是否为空
  3. 数据读出拷贝
  4. 修改队头指针:将新的队尾指针写入head。
  5. 解锁:解锁后其它进程才能加锁操作队列。
    在这里插入图片描述

队列读取的代码如下:

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:

  1. SHM Manager管理SHM资源的创建和回收。
  2. SHM Mutex封装pthread_mutex_t对象,实现跨进程同步,并具备一定的鲁棒性。
  3. Circular Queue是基于SHM内存块的循环队列,逻辑比较复杂。
  4. SHM Queue提供用户接口。

SHM在使用过程中,一般需要重点关注如下一些问题:

  1. 创建和销毁SHM对象,本文通过单个Manager进程完成该项工作,避免了多进程同时创建和销毁的产生的多进程竞争问题。在某些工程实践场景下,需要处理多进程竞争问题。甚至需要考虑SHM是残留资源,还是正常使用的资源。要处理得很好还是很有挑战的。
  2. 共享内存通信的同步,本文通过封装pthread_mutex_t实现。当然还有很多其它方式,如:文件锁、信号量、读写锁、原子变量、自旋锁、条件变量,甚至socket等。
  3. 管理SHM内存块,本文通过循环队列以字节为单位管理内存区域。这部分很灵活,由很多设计思路,需要根据项目场景来设计。比较常见的方法是将大内存区域划分为多个小的内存块,然后按块分配内存。
  4. 对于通信资源是否可用的判断,通常有两种思路,轮询和通知。轮询的方式,在需要读数据或写数据时,通过不断尝试来检查通信资源是否可读或可写;通知的方式,通过注册回调接口对象或函数,当数据可写或可读时,由底层通知用户。一般情况下,通知的方式会比轮询的方式更好,可以有效避免忙等。但如果时对实时性要求较高的场景,轮询则更合适。也并不是绝对的,需要根据实际情况来判断。对于本文暴露的接口来说,只能用轮询的方式,轮询调用Pop函数或者Push函数。
  5. 稳定性问题,多进程的稳定性问题比单进程要复杂得多。一般考虑如何让一部分进程崩溃后,不影响另外一部分进程的运行。本文的互斥锁封装和循环队列的设计都在尽力考虑这个问题。

关注微信公众号“程序员小阳”,相互交流更多软件开发技术。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值