在上篇文章SessionManager会话管理模块:架构设计与实现的结尾,我们展示了接口实现的单元测试结果,如下图所示:

      我们会发现测试是通过的,并没有代码运行的问题,但是我们看到图中在获取所有会话ID列表的打印结果中,虽然只有两个会话ID,但是却凭空多出了一些空行,在上篇文章当时我并没有觉得有什么太大的问题,觉得就是打印的时候一个小 bug 没在意。
      但是后来我拿着会话ID列表数组去访问每个会话的历史消息时程序就崩溃了,而产生上面图中的结果已经程序崩溃的原因就是因为 getSessionLists 接口设计的有问题。

问题现象复盘

      从日志表面看,程序可以正常跑完 GTest,最终测试标记 PASS,没有直接崩溃。 但容器内部已经出现脏数据:
      我们预期temp容器里面只有 2 条会话数据,而实际容器内一共存在 8 个元素,也就是说那些空行就是一个一个vector中的元素。
      在打印输出的时候,遍历整个temp,空 pair 的第一个字段是空字符串,打印出来就表现为肉眼看到的空行。

      最危险的地方:这个错误不会触发编译报错,短时间也不一定触发崩溃,属于典型的静默逻辑 bug。
      当后续业务逻辑去读取、访问这些空会话元素、尝试去拿到Session对象的成员时,就会访问非法内存,直接触发程序崩溃。

getSessionLists 原始错误写法

// 获取所有会话ID列表:返回所有当前存在的会话ID
std::vector<std::string> SessionManager::getSessionLists() const
{
    // 加锁保护,确保在多线程环境下对获取会话列表是线程安全的,并且在函数退出时自动释放锁
    std::lock_guard<std::mutex> lock(_mutex);

    // 根据更新时间戳对会话ID进行降序排序,保证最新的会话ID在列表的最前面
    std::vector<std::pair<std::string, std::time_t>> temp(_sessions.size());
    for (auto pair : _sessions)
    {
        temp.emplace_back(pair.first, pair.second->_updated_at);
    }
    std::sort(temp.begin(), temp.end(), [](const auto &a, const auto &b)
              { return a.second > b.second; });

    // 从排序后的会话ID-时间戳对中提取排完序的会话ID,构建会话ID列表
    std::vector<std::string> sessionIds(temp.size());
    for (const auto &pair : temp)
    {
        sessionIds.push_back(pair.first);
    }
    return sessionIds;
}
问题产生原因
  1. vector<T>(count) 这个带参构造,会直接创建 count 个默认元素
    • temp.size() 初始就等于 _sessions.size(),里面每一个 pair 都是默认构造:空字符串、time_t=0
    • 容量 capacity 和 size 相等
  2. 循环里继续调用 emplace_back:是追加到容器尾部,不是覆盖原有元素
    • 最终 temp 的元素数量 = _sessions.size() + _sessions.size(),翻倍
    • 前半部分全部是默认空值,后半部分才是我们真正塞进去的会话 + 时间,排序的时候空数据也参与排序,逻辑完全错乱,这就是致命问题。
  3. 这种初始化方式适合:用下标temp[i] = xxx直接覆盖赋值的场景,不适合搭配 emplace_back/push_back。

      一句话总结:只要打算循环用 emplace_back 往里面填数据,就不能用 vector(N) 这种方式初始化。

      这也是为什么上面我们的结果展示中会显示那么多空行的直接原因。

正确写法:reserve 预分配方案

// 获取所有会话ID列表:返回所有当前存在的会话ID
std::vector<std::string> SessionManager::getSessionLists() const
{
    // 加锁保护,确保在多线程环境下对获取会话列表是线程安全的,并且在函数退出时自动释放锁
    std::lock_guard<std::mutex> lock(_mutex);

    // 根据更新时间戳对会话ID进行降序排序,保证最新的会话ID在列表的最前面
    std::vector<std::pair<std::string, std::time_t>> temp;
    temp.reserve(_sessions.size()); // 预留足够的空间,避免多次内存分配
    // 注意:不能直接使用带参数的构造函数,因为会立即构造对象,导致temp已经包含了_sessions实际的元素个数
    // 如果再使用插入接口,会导致temp的大小再次增加,与_sessions的大小不一致!并且前面的元素都是默认的空值元素
    for (auto pair : _sessions)
    {
        temp.emplace_back(pair.first, pair.second->_updated_at);
    }
    std::sort(temp.begin(), temp.end(), [](const auto &a, const auto &b)
              { return a.second > b.second; });

    // 从排序后的会话ID-时间戳对中提取排完序的会话ID,构建会话ID列表
    std::vector<std::string> sessionIds;
    sessionIds.reserve(temp.size());
    for (const auto &pair : temp)
    {
        sessionIds.push_back(pair.first);
    }
    return sessionIds;
}
原理说明
  1. reserve(n):仅仅申请底层连续内存,不会调用元素的构造函数
    • 执行完 reserve 之后:temp.size() = 0,temp.capacity() = _sessions.size()
    • 容器里没有任何有效元素,内存只是提前准备好了,避免循环 emplace_back 时多次扩容、内存拷贝
  2. emplace_back 会从容器起始位置依次放入元素,最终 size 刚好等于_sessions.size(),没有多余空元素
  3. 性能优势:一次性分配内存,规避 vector 动态扩容带来的多次内存重分配,在会话数量很多的时候收益明显。

两种 vector 初始化方式对比

方式size 初始值capacity是否构造元素适配接口
vector<T>(N)NN是,全部默认构造temp[i] = xxx 下标覆盖赋值
reserve(N)0N否push_back / emplace_back 尾部追加

正确运行结果展示:

      我们会发现空行也就没有了,那么后续这个vector数据内部也就不存在无效的元素,通过会话ID访问会话消息也就不会产生程序崩溃的情况了。

小结

      这个坑是 C++ STL 容器开发中非常容易忽略的点:很多人误以为vector(n)只是单纯预留内存,实际上它会直接构造元素。
      记住一条规范:如果你的填充方式是循环 push_back /emplace_back,就使用 reserve 预分配,不要使用 vector (n) 初始化。
      这类隐性 bug,不会立刻崩溃,只会在后续访问脏元素的时候随机触发宕机,排查成本很高,在 SDK 开发中需要重点规避。

Logo

更多推荐