惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

NISL@THU
NISL@THU
Latest news
Latest news
Scott Helme
Scott Helme
T
Tenable Blog
Simon Willison's Weblog
Simon Willison's Weblog
T
The Exploit Database - CXSecurity.com
C
CERT Recently Published Vulnerability Notes
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 司徒正美
K
Kaspersky official blog
The Hacker News
The Hacker News
Jina AI
Jina AI
C
CXSECURITY Database RSS Feed - CXSecurity.com
C
Cisco Blogs
S
Secure Thoughts
雷峰网
雷峰网
Project Zero
Project Zero
T
Troy Hunt's Blog
IT之家
IT之家
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - Franky
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Know Your Adversary
Know Your Adversary
爱范儿
爱范儿
博客园 - 聂微东
N
News and Events Feed by Topic
The Cloudflare Blog
博客园_首页
阮一峰的网络日志
阮一峰的网络日志
D
Darknet – Hacking Tools, Hacker News & Cyber Security
AI
AI
Schneier on Security
Schneier on Security
Recent Announcements
Recent Announcements
博客园 - 三生石上(FineUI控件)
大猫的无限游戏
大猫的无限游戏
The Last Watchdog
The Last Watchdog
L
LINUX DO - 热门话题
Vercel News
Vercel News
C
Check Point Blog
Cisco Talos Blog
Cisco Talos Blog
Apple Machine Learning Research
Apple Machine Learning Research
量子位
C
Cyber Attacks, Cyber Crime and Cyber Security
TaoSecurity Blog
TaoSecurity Blog
B
Blog RSS Feed
MongoDB | Blog
MongoDB | Blog
P
Proofpoint News Feed
P
Palo Alto Networks Blog
云风的 BLOG
云风的 BLOG

I'm OWenT

国产大模型(GLM 5.1、Kimi K2.6)真实场景效果和 Coding Plan 额度测试 新版本libatapp的连接管理——从etcd服务发现到拓扑驱动的自动重连 新版本libatbus的设计变更——从树形路由到拓扑驱动 Protobuf又一坑 - C++标准和ABI兼容性 AI真好用-给Blog主题统一加mermaid,chart.js,excalidraw,draw.io的多种引入方式支持 给内网部署Squid-通用HTTP下载缓存 UE使用CodeChecker和clang-tidy生成静态分析报告 找出UE的循环依赖 C++小协程栈和临时变量及作用域的栈溢出问题分析 游戏服务的可观测性能力建设(C++生态) 指标上报的多线程优化和多拉取源点优化 协程(libcopp)的Channel功能和CPU命中率优化 通用RPC代码生成器 实现strong_rc_ptr(比shared_ptr更快的引用计数智能指针) 手夯一个STL allocator和对象内存分析组件 std::condition_variable 的信号丢失问题 踩坑一处(GCC)STL std::async 实现BUG导致的crash问题 GCC 14的一个warning to error BUG 给xresloader(Excel导表工具)增强UE读表支持(包含蓝图,Blueprint) Opentelemetry社区在gRPC的几个链接问题(静态库和动态库混用,musl工具链,符号裁剪) Excel转表工具(xresloader)的新验证器(验证外部Excel和文本数据,唯一性和自定义规则) protobuf v22和gRPC v1.55版本升级的依赖变化和upb适配 关于protobuf近期版本(v20/v3.20+)和 gRPC v1.54版本在某些编译环境下的一些链接和编译问题 xresloader-Excel导表工具链的近期变更汇总 打通游戏服务端框架的C++20协程改造的最后一环 Opentelemetry-cpp的Logs模块标准更新(涉及近期版本:1.8-1.9的BREAK CHANGES) 给cmake-toolset和工具链(curl等)加HTTP/2和HTTP/3支持 又开新坑之 coredns 插件: nftables和filter 关于opentelemetry-cpp社区对于C++ Head Only组件单例和符号可见性的讨论小记 填个转表工具 xresloader 去年的坑(数组尾部裁剪) 集成 upb 和 lua binding 的踩坑小记 libcopp对C++20协程的接入和接口设计 再度优化GCC、LLVM、Clang、libc++、libc++abi等套件的构建脚本 游戏服务的分布式事务优化(二)- 事务管理 游戏服务的分布式事务优化(一)- Write Ahead Log(WAL) 模块 记录一些bazel适配用编译选项 测试现代化硬件C++浮点数性能和一致性 适配Boringssl和OpenSSL 3.0 近期cmake-toolset的一些适配问题 C++20 Text Formatting/fmtlib 适配问题小记 再次重构LLVM+Clang+libcxx+libc++abi+其他相关工具的构建流程 重构基于CMake的构建工具链 新版GCC和LLVM+Clang终于Release啦 折腾一下nftables下的双拨 [C++20] Module partitions和符号交叉引用(声明和实现分离) [Rust] 实现一个线程安全且迭代器可以保存的链表 基于protobuf的代码生成 Amazon Aurora DB存储引擎论文阅读小记 近期对libatapp的一些优化调整(增加服务发现和连接管理,支持yaml等) xresloader转表工具链增加了一些新功能(map,oneof支持,输出矩阵,基于模板引擎的加载代码生成等) 在游戏服务器中使用分布式事务 libcopp接入C++20 Coroutine和一些过渡期的设计 libatbus 的大幅优化 nftables初体验 容器配置开发环境小计 PALM Tree - 适合多核并发架构的B+树 - 论文阅读小记 跨平台协程库 - libcopp 简介 C++20 Coroutine 性能测试 (附带和libcopp/libco/libgo/goroutine/linux ucontext对比) 尝鲜Github Action 一些xresloader(转表工具)的改进 protobuf、flatbuffer、msgpack 针对小数据包的简单对比 协程框架(libcopp) 小幅优化 Excel转表工具(xresloader) 增加protobuf插件功能和集成 UnrealEngine 支持 Anna(支持任意扩展和超高性能的KV数据库系统)阅读笔记 C++20 Coroutine libcopp merge boost.context 1.69.0 Google去中心化分布式系统论文三件套(Percolator、Spanner、F1)读后感 Rust玩具-企业微信机器人通用服务 使用ELK辅助监控开发测试环境服务质量和问题定位 2018年的新通用伪随机数算法(xoshiro / xoroshiro)的C++(head only)实现 Webpack+vue+boostrap+ejs构建Web版GM工具 Rust的第二次接触-写个小服务器程序 理解和适配AEAD加密套件 atsf4g-co的进化:协程框架v2、对象路由系统和一些其他细节优化 协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比 可执行文件压缩 初识Rust 使用restructedtext编写xresloader文档 atframework的etcd模块化重构 C++的backtrace ECDH椭圆双曲线(比DH快10倍的密钥交换)算法简介和封装 protobuf-net的动态Message实现 pbc的proto3接入 atgateway内置协议流程优化-加密、算法协商和ECDH 整理一波软件源镜像同步工具+DevOps工具 Blog切换到Hugo libcopp v2的第一波优化完成 libcopp(v2) vs goroutine性能测试 libcopp的线程安全、栈池和merge boost.context 1.64.0 GCC 7和LLVM+Clang+libc++abi 4.0的构建脚本 libatbus的几个藏得很深的bug 用cmake交叉编译到iOS和Android 开源项目得一些小维护 atapp的c binding和c#适配 对象路由系统设计 2016年总结 近期的一个协程流程BUG 重写了llvm+clang+libc++和libc++abi的构建脚本 atsf4g完整游戏工程示例|I'm OWenT atframework基本框架已经完成|I'm OWenT
几个使用protobuf中C++接口的Arena的坑
owent · 2020-11-11 · via I'm OWenT

blog-website

前言

protobuf 从3.0版本开始对C++增加了Arena接口,可以用于使用连续的内存块分配内部对象,并且可以更容易精确地控制对象地生命周期,最终达到减少内存碎片地目的。最近我给我们项目的部分接口流程进行相关地改造,在大多数使用 protobuf 的地方都增加了对Arena的支持,但是在接入过程中也碰到了一些问题和坑。

Arena的原理十分简单,就是预先分配一个内存块。创建Message和内部对象的时候全部在分配好的内存块上 placement new 出来,所有的Message对象也会内部记录所属的Arena以便创建字对象和某些情况下需要检查Arena时使用。如果创建的对象不支持Arena的,在 placement new 完成后要在Arena上设置一个析构回调,以便在释放的时候调用析构流程。如果Arena内部的内存块剩余内存不足则会自动创建下一个(可能是更大的)内存块。

Arena可以在创建Arena的时候通过指定自定义的ArenaOptions来设置一些系数,包括最大内存块大小(如果超出了会直接用)、初始内存块大小、分配/回收内存块的实现、事件接口等。

每次Arena内存块剩余内存不足时,会尝试分配 最后一个内存块size*2ArenaOptions设置里的最大内存块 中的最小值(即: min(2 * last_block.size, ArenaOptions.max_block_size) )。如果要分配的内存大小本身就是大于 ArenaOptions设置里的最大内存块 的,则会直接分配需要的内存块的大小+Header的大小(当前版本Header的大小是三个指针长度对齐到8,64位系统下就是24字节)。

长期存在对象的生命周期

Arena有一个特点是它维护的 所有对象都是在Arena析构的时候统一释放 的。这中间它内部维护的内存块只会不断地append,并不会删除。所以这也决定了由Arena维护的对象要么只能是临时对象,要么是不可变的。否则它的内存会无限增长下去。比如,我们是有状态服务器,如果我们把一个用户的数据块长期缓存在内存里,然后Arena和用户对象的生命绑定。那么中间很多操作会不断地变更内部的对象结构,这就会导致用户下线前Arena无限增长。

所以,我们主要对Arena的集成最终集中在各个Task的入口处,然后一个Task里的子Task和RPC请求中需要创建的局部变量数据都复用这个Arena。当一个Task及其子Task全部结束以后,Arena就释放了。而除非少量的一些对全服数据操作的Task以外,大多数Task生命周期也就几秒钟,内存的回收时间就相对可控。

初始化分配的大小和最大分配的大小

ArenaOptions设置 里,默认的初始分配大小是 256B ,最大分配大小是 8KB 。前面也提到,我们的集成主要在各个Task的入口处,在Task里光是链路跟踪和RPC header相关的数据就占了100-200字节,而实际使用中一个Task的请求包、应答包就2倍曹处256B了。所以我们把初始值提升到了512B。同时我们项目中战斗记录的包都偏大,然后一些玩家数据拉取的包体也比较大,所以最大值也提高到了64KB。当然这些值后面有待观察,我们后面出了更详细的统计之后可能也再会调整。

直接迁移 set_allocated_XXX/release_XXX 可能导致内存泄漏

protobuf 里,经常会碰上一些类似消息转发或者复用某些Message的操作,如果这些Message比较大,Copy的话显然是比较浪费的。所以有些地方会使用 set_allocated_XXXrelease_XXX 接口来复用某些Message。 比如在我们的项目里,保存数据到DB的时候经常会有这种操作:

// 参数传入 user_basic_profile;
table_message_type container;
container.set_id(user_id);
container.set_allocated_basic_profile(&user_basic_profile); // 直接复用已有的数据结构,用于后续打包
// ... 其他类似赋值代码
int result = pack_and_send(TcaplusService::TCAPLUS_API_UPDATE_REQ, container); // 打包和RPC
container.release_basic_profile(&user_basic_profile);       // 释放生命周期管理
// ... 其他类似释放代码

但是加入了Arena之后就不一样了。我们 不能 简单地把代码改成这样 :

// 参数传入 arena, user_basic_profile;
table_message_type* container = ::google::protobuf::Arena::CreateMessage<table_message_type>(arena);
container->set_id(user_id);
container->set_allocated_basic_profile(&user_basic_profile); // 直接复用已有的数据结构,用于后续打包
// ... 其他类似赋值代码
int result = pack_and_send(TcaplusService::TCAPLUS_API_UPDATE_REQ, container); // 打包和RPC
container->release_basic_profile(&user_basic_profile);       // 释放生命周期管理
// ... 其他类似释放代码
// ...

为什么呢?我们来看看生成的 set_allocated_basic_profilerelease_basic_profile

// ============ set_allocated_basic_profile ============
void table_message_type::set_allocated_basic_profile(user_basic_profile_t* basic_profile) {
  ::PROTOBUF_NAMESPACE_ID::Arena* message_arena = GetArena();
  if (message_arena == nullptr) {
    delete reinterpret_cast< ::PROTOBUF_NAMESPACE_ID::MessageLite*>(basic_profile_);
  }
  if (basic_profile) {
    ::PROTOBUF_NAMESPACE_ID::Arena* submessage_arena =
      reinterpret_cast<::PROTOBUF_NAMESPACE_ID::MessageLite*>(basic_profile)->GetArena();
    if (message_arena != submessage_arena) {
      basic_profile = ::PROTOBUF_NAMESPACE_ID::internal::GetOwnedMessage(
          message_arena, basic_profile, submessage_arena);
    }

  } else {

  }
  basic_profile_ = basic_profile;
  // @@protoc_insertion_point(field_set_allocated:tdr2pb.TABLE_USER_DEF.basic_profile)
}

// ------------ set_allocated_basic_profile 相关接口实现: GetOwnedMessage  ------------
template <typename T>
T* GetOwnedMessage(Arena* message_arena, T* submessage,
                   Arena* submessage_arena) {
  // The casts must be reinterpret_cast<> because T might be a forward-declared
  // type that the compiler doesn't know is related to MessageLite.
  return reinterpret_cast<T*>(GetOwnedMessageInternal(
      message_arena, reinterpret_cast<MessageLite*>(submessage),
      submessage_arena));
}

// ------------ set_allocated_basic_profile 相关接口实现: GetOwnedMessage  ------------
MessageLite* GetOwnedMessageInternal(Arena* message_arena,
                                     MessageLite* submessage,
                                     Arena* submessage_arena) {
  GOOGLE_DCHECK(submessage->GetArena() == submessage_arena);
  GOOGLE_DCHECK(message_arena != submessage_arena);
  if (message_arena != NULL && submessage_arena == NULL) {
    message_arena->Own(submessage);                    // 堆上的message直接转移进arena
    return submessage;
  } else {
    MessageLite* ret = submessage->New(message_arena); // 如果message_arena非空,则是复制了一个对象并放在message_arena上,否则堆上复制了一个对象。并不影响原message的生命周期
    ret->CheckTypeAndMergeFrom(*submessage);
    return ret;
  }
}

// ============ release_basic_profile ============
inline user_basic_profile_t* table_message_type::release_basic_profile() {
  user_basic_profile_t* temp = basic_profile_;
  basic_profile_ = nullptr;
  if (GetArena() != nullptr) {
    temp = ::PROTOBUF_NAMESPACE_ID::internal::DuplicateIfNonNull(temp);
  }
  return temp;
}

重要的注解都在上面标注了。回到我们之前的例子,对于底层接口而言,我们不能假设传入的 user_basic_profile 是在哪里分配的。实际上对于上面例子里的数据库保存操作,大多数情况下 user_basic_profile 来自于user对象的内存缓存。前面也提及了,这部分数据是在堆上的,那么对于这种情况,前面改Arena的例子里实际的流程就会变成这样:

// 参数传入 arena, user_basic_profile;
table_message_type* container = ::google::protobuf::Arena::CreateMessage<table_message_type>(arena);
container->set_id(user_id);
container->set_allocated_basic_profile(&user_basic_profile); // 只要user_basic_profile不在arena上,那么这里就复制了一份数据
// ... 其他类似赋值代码
int result = pack_and_send(TcaplusService::TCAPLUS_API_UPDATE_REQ, container); // 打包和RPC
container->release_basic_profile(&user_basic_profile);       // 这里则是复制了一份user_basic_profile,因为返回值被忽略了,这里就内存泄露了。
// ... 其他类似释放代码
// ...

开启Arena之后,实际上增加了两个函数 unsafe_arena_set_allocated_XXXunsafe_arena_release_XXX 。我们能不能直接用这个代替掉 set_allocated_XXXrelease_XXX 呢?我们继续来看它生成的代码:

// ============ unsafe_arena_set_allocated_basic_profile ============
inline void TABLE_USER_DEF::unsafe_arena_set_allocated_basic_profile(
    ::mvp::table_user_basic_profile* basic_profile) {
  if (GetArena() == nullptr) {
    delete reinterpret_cast<::PROTOBUF_NAMESPACE_ID::MessageLite*>(basic_profile_); // 注意这里父Message如果Arena是空直接调用了delete子成员,这里没有判断子成员是否是在某个Arena里的。
  }
  basic_profile_ = basic_profile;
  if (basic_profile) {

  } else {

  }
  // @@protoc_insertion_point(field_unsafe_arena_set_allocated:tdr2pb.TABLE_USER_DEF.basic_profile)
}

// ============ unsafe_arena_release_basic_profile ============
inline user_basic_profile_t* TABLE_USER_DEF::unsafe_arena_release_basic_profile() {
  // @@protoc_insertion_point(field_release:tdr2pb.TABLE_USER_DEF.basic_profile)
  
  user_basic_profile_t* temp = basic_profile_;
  basic_profile_ = nullptr;
  return temp;
}

使用的时候 如果父级Message如果是Arena分配的,只要使用者能保证调用 unsafe_arena_set_allocated_XXX 时成员为空,那么这里是可以直接代替的。但是在实际调用流程复杂了以后仍然怕这部分不小心误用,一旦误用带来的后果也很严重并且很难排查。所以我们项目中是 仅仅代码生成器会使用这个接口,人工调用是禁止的 。最终的变更形式如下:

// 参数传入 arena, user_basic_profile;
table_message_type* container = ::google::protobuf::Arena::CreateMessage<table_message_type>(arena); // 这里能保证刚创建出来的一定为空
container->set_id(user_id);
if (container->GetArena() == user_basic_profile.GetArena()) {
    if (nullptr == user_basic_profile.GetArena()) {
        container->set_allocated_basic_profile(&user_basic_profile);
    } else {
        container->unsafe_arena_set_allocated_basic_profile(&user_basic_profile);
    }
} else {
    protobuf_copy_message(*container->mutable_basic_profile(), user_basic_profile); // 退化到复制message,下面会贴protobuf_copy_message的实现
}
// ... 其他类似赋值代码
int result = pack_and_send(TcaplusService::TCAPLUS_API_UPDATE_REQ, container); // 打包和RPC
if (container->GetArena() == user_basic_profile.GetArena()) {
    if (nullptr == user_basic_profile.GetArena()) {
        container->release_basic_profile();
    } else {
        container->unsafe_arena_release_basic_profile();
    }
}
// ... 其他类似释放代码
// ...

Swap退化成Copy

最后一个问题是和 Swap 接口有关。有些接口流程里,我们会用Swap来减少不必要的复制。常见的地方比如在dispatcher层(有些框架叫 executor)来解包后处理一些前置信息,完了之后透传部分数据到业务层。这时候就经常用 Swap 接口来减少不必要的复制。但是开启了Arena之后,生成的代码就变成了如下形式:

// .pb.h
inline void Swap(ConstSettingsType* other) {
  if (other == this) return;
  if (GetArena() == other->GetArena()) {
    InternalSwap(other);
  } else {
    ::PROTOBUF_NAMESPACE_ID::internal::GenericSwap(this, other);
  }
}
// 增加了一个 UnsafeArenaSwap 函数
void UnsafeArenaSwap(ConstSettingsType* other) {
  if (other == this) return;
  GOOGLE_DCHECK(GetArena() == other->GetArena());
  InternalSwap(other);
}

// .pb.cpp
void ConstSettingsType::InternalSwap(ConstSettingsType* other) {
  using std::swap;
  _internal_metadata_.Swap<::PROTOBUF_NAMESPACE_ID::UnknownFieldSet>(&other->_internal_metadata_);                        // 内部数据swap
  rpc_version_.Swap(&other->rpc_version_, &::PROTOBUF_NAMESPACE_ID::internal::GetEmptyStringAlreadyInited(), GetArena()); // 字段的swap
}

// reflection_ops.cc , MessageLite的版本和这个差不多,就不列举了
void GenericSwap(Message* m1, Message* m2) {
  Arena* m2_arena = m2->GetArena();
  GOOGLE_DCHECK(m1->GetArena() != m2_arena);

  // Copy semantics in this case. We try to improve efficiency by placing the
  // temporary on |m2|'s arena so that messages are copied twice rather than
  // three times.
  Message* tmp = m2->New(m2_arena);
  std::unique_ptr<Message> tmp_deleter(m2_arena == nullptr ? tmp : nullptr);
  tmp->CheckTypeAndMergeFrom(*m1);
  m1->Clear();
  m1->CheckTypeAndMergeFrom(*m2);
  m2->GetReflection()->Swap(tmp, m2);
}

可以看到,在所属的Arena不同的时候 Swap 函数实际执行了两次Copy和一次创建Message,这种时候还不如直接Copy 。 基于此,我们原来为了编译期检查一下Copy的protobuf message的类型提供了 protobuf_copy_message 函数来代替直接 CopyFrom , 现在又额外提供了 protobuf_move_message 来处理这种转移数据的 Swap 调用。

// protobuf_copy_message
template <class TMsg>
inline void protobuf_copy_message(TMsg &dst, const TMsg &src) {
    dst.CopyFrom(src);
}

template <class TField>
inline void protobuf_copy_message(::google::protobuf::RepeatedField<TField> &dst, const ::google::protobuf::RepeatedField<TField> &src) {
    dst.Reserve(src.size());
    dst.CopyFrom(src);
}

template <class TField>
inline void protobuf_copy_message(::google::protobuf::RepeatedPtrField<TField> &dst, const ::google::protobuf::RepeatedPtrField<TField> &src) {
    dst.Reserve(src.size());
    dst.CopyFrom(src);
}

// protobuf_move_message
template <class TMsg>
inline void protobuf_move_message(TMsg &dst, TMsg &&src) {
    if (dst.GetArena() == src.GetArena()) {
        dst.Swap(&src);
    } else {
        protobuf_copy_message(dst, src);
    }

    src.Clear();
}

template <class TField>
inline void protobuf_move_message(::google::protobuf::RepeatedField<TField> &dst, ::google::protobuf::RepeatedField<TField> &&src) {
    if (dst.GetArena() == src.GetArena()) {
        dst.Swap(&src);
    } else {
        protobuf_copy_message(dst, src);
    }

    src.Clear();
}

template <class TField>
inline void protobuf_move_message(::google::protobuf::RepeatedPtrField<TField> &dst, ::google::protobuf::RepeatedPtrField<TField> &&src) {
    if (dst.GetArena() == src.GetArena()) {
        dst.Swap(&src);
    } else {
        protobuf_copy_message(dst, src);
    }

    src.Clear();
}

写在最后

目前的碰到的问题基本就这么多了,近期的 protobuf 大版本更新对Arena还有一些改进,其中包含对 std::string 类型的特殊处理和在Arena上分配Map时的一处 use-after-destroy bug ,避开使用就好了。

以上代码使用 protobuf 3.13.0 版本。在 protobuf 3.14.0 版本之前,要开启C++ Arena接口要在proto的文件级选项里加上 option cc_enable_arenas = true;