ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Dragonfly Zero-Copy GET 大字符串零拷贝读取原理与实现解析

Dragonfly Zero-Copy GET 大字符串零拷贝读取原理与实现解析 Dragonfly Zero-Copy GET 大字符串零拷贝读取原理与实现解析【免费下载链接】dragonflyA modern replacement for Redis and Memcached项目地址: https://gitcode.com/GitHub_Trending/dr/dragonflyDragonfly 在其 GET 命令的读取路径上实现了对大字符串heap 分配的、超过 1024 字节的LARGE_STR_TAG值的零拷贝传输从分片shard的CompactObj存储直接写到客户端 socket中间不物化任何完整副本——分片不分配std::string回复构建器reply builder也不缓冲完整负载。本文以仓库文档 docs/zero_copy_get.md 为主线结合 src/common/borrowed_string.h、src/core/compact_object.{h,cc} 与 src/facade/reply_builder.cc 等源码系统讲解零拷贝 GET 的适用边界、线程模型、核心数据结构借出句柄 pin 注册表、并发安全设计Copy-on-Write、无锁跨线程释放以及捕获/重放capture/replay集成帮助你理解这套内存管理机制如何在共享无锁的设计中保证借出的指针在并发写压下依然安全。什么是零拷贝 GET解决的问题与覆盖范围传统的 GET 大字符串流程中分片线程需要先把存储在CompactObj中的字节物化为一个std::stringpv.ToString()回复构建器再把这个字符串整体拷贝进自己的缓冲最后writev到 socket。两次拷贝带来明显的内存分配与 memcpy 开销对于 MB 级的大 value 尤为可观。Dragonfly 的零拷贝路径消除了中间物化GET直接把用户可见字节从分片CompactObj存储写到客户端 socket中间不产生任何字符串物化——shard 不分配std::stringreply builder 也不缓冲完整 payload。文档明确划定了当前实现的能力边界docs/zero_copy_get.md已覆盖LARGE_STR_TAG即detail::LargeString值heap 分配、远大于 1024 字节的字符串RawNONE_ENC与 ASCII-packedASCII1_ENC/ASCII2_ENC两种编码。Huffman 编码字符串留待未来——若能证明其收益再行加入不在范围内EXTERNAL_TAGtiered 分层存储值需要异步磁盘取回属于另立的异步路径。命令覆盖现状目前GET是唯一走零拷贝路径的命令。变更性读取GETDEL、GETEX、GETSET与多键读取MGET仍走物化路径pv.ToString()留待后续修复。在 src/server/string_family.cc 中可以看到实际调用点BorrowStringOrReadStringResult BorrowStringOrRead(DbIndex dbid, string_view key, const PrimeValue pv, EngineShard* es) { static bool zero_copy_enabled absl::GetFlag(FLAGS_get_zero_copy); constexpr size_t kBorrowThreshold 16_KB; // only borrow if value is at least this big if (zero_copy_enabled !pv.IsExternal() pv.Size() kBorrowThreshold) { if (auto raw pv.TryBorrow()) { return StringResult{std::move(*raw)}; } } return ReadString(dbid, key, pv, es); }StringResult是std::variantstd::string, cmn::BorrowedString, TieredStorage::TResultstd::stringBorrowedString携带自己的 pin 与释放函数所有权从CompactObj::TryBorrow经 variant 流转到SendBulkStringBorrowed。值得注意的工程细节实际触发借出还有一个阈值开关——FLAGS_get_zero_copy默认开启且仅当 value 大小达到 16 KBkBorrowThreshold时才尝试借用较小的值拷贝成本低直接走ToString更划算。线程模型共享无锁架构下的借出约束Dragonfly 的共享无锁shared-nothing设计把每个分片shard固定绑定到单个 proactor 线程并使用线程本地的 mimalloc heap 管理内存。连接connection绑定到一个 proactor当GET访问一个属于其他分片的 key 时通过SingleHopT(cb)跳转到拥有该 key 的分片执行回调然后回到连接所属的 proactor 上进行回复构建。socket 写入本身是 fiber 同步的但在等待内核时会让出 fiber——这段时间内同一分片上的其他命令可以继续执行。这个模型对零拷贝提出了关键约束缓冲区分配在哪个分片释放也必须在哪个分片。虽然 mimalloc 支持跨线程 free但设计上把所有释放路由回缓冲区所属分片以便一致地追踪内存使用量。文档强调a writer that races a reader installs a fresh allocation on theCompactObjand leaves the old buffer owned by a refcount-bearing pin, which is freed on the buffers owning shard once the last reader is done with itdocs/zero_copy_get.md。核心构件从 LargeString 到 BorrowedStringdetail::LargeString16 字节的大字符串存储CompactObj中非内联 raw 字符串的 16 字节存储结构src/core/compact_object.hstruct LargeString { void* ptr; // mimalloc allocation on owning shard uint64_t sz : 56; // current length in bytes // Hint: outstanding readers may be borrowing ptr. Mutations consult // TL::pin_map and hand the buffer to its PendingRead instead of freeing. uint64_t read_pending : 1; uint64_t reserved : 7; ... };read_pending位是零拷贝机制的核心开关当一位或多位读者持有对ptr的借用视图时置 1。该位对LargeString的各个方法产生直接影响源码见 src/core/compact_object.ccSetString/Free当read_pending置位时不直接释放ptr而是先在线程本地 pin 注册表tl.pin_map中查找该指针把缓冲区移交给匹配的PendingRead条目——该条目成为缓冲区的唯一所有者直到最后一位读者 unpin。实现上统一收敛到ReleasePtrbool orphaned ls-read_pending tl.pin_map.Orphan(ls-ptr);若未找到 pinstale 位场景则直接deallocate。DefragIfNeededread_pending置位时直接返回 false不做 defrag——重新分配会使在途的借用视图失效待 pin 清除后下一轮 defrag 才会处理同一个值。AppendString原地修改会破坏已 pin 的读者因此CHECKs!IsReadPending()。其唯一调用者是rdb_load而 rdb 加载永远不会产生被 pin 过的值。cmn::BorrowedString移动独占的借用句柄定义在 src/common/borrowed_string.h。它是借用 bulk string 的**移动独占move-only**所有权句柄class BorrowedString { // BorrowedString(std::string_view encoded, size_t decoded_size, uint8_t encoding, void* pin) bool IsEncoded() const noexcept { return encoding_ ! 0; } std::string_view view() const noexcept { return encoded_; } uint8_t encoding() const noexcept { return encoding_; } ~BorrowedString() noexcept { Unpin(); } ... std::string_view encoded_; // 编码视图raw 或 packed void* pin_ nullptr; // 不透明 pin uint8_t encoding_ 0; // 0 raw非 0 packed };句柄携带编码视图、编码标签0 raw非 0 packed与不透明 pin。用户可见的解码后大小不存储在句柄中而是在需要时由BorrowedStringOps::DecodedSize计算编码 packed 视图足以推导出来。析构函数通过注册的BorrowedStringOps释放 pin。CompactObj::TryBorrow()是唯一的生产者从该点起对象在回复路径中只移动、不拷贝直到析构触发 unpin。cmn::BorrowedStringOpscommon/facade 与 core 之间的唯一接缝BorrowedStringOps是抽象接口由CompactObj::InitThreadLocal设置一次源码 src/core/compact_object.cc 中的CompactObjBorrowOps通过全局单例g_borrow_ops注册Release(BorrowedString bs)BorrowedString析构时解除 pin 并调用Release(void*)递减其 refcountDecodeChunk(...)把 ASCII-packed 源中最多max_count字节解包到目标缓冲区返回实际写入的字节数非最终 chunk 可能因对齐而小于max_count。注释与实现揭示了 ASCII1/2 的细节7 个源字节 → 8 个解码字节packed 前缀部分末尾decoded_size % 8字节逐字 1:1 存储非最终 chunk 必须是 8 的倍数以保证下一轮从 packed group 边界开始DecodeResult{src_consumed, dec_written}双游标设计因为源尺寸与解码尺寸一般不同例如 ASCII 7 源字节 → 8 解码字节。这是common/或facade/与 core 内部之间唯一的接缝具体实现把 pin 强转为PendingRead*并调用detail::ascii_unpackcore/之外的调用者永远看不到这两种类型。CompactObj::TryBorrow唯一的生产者声明于 src/core/compact_object.h实现于 src/core/compact_object.ccstd::optionalcmn::BorrowedString CompactObj::TryBorrow() const { if (taglen_ ! LARGE_STR_TAG) return std::nullopt; if (encoding_ ! NONE_ENC encoding_ ! ASCII1_ENC encoding_ ! ASCII2_ENC) return std::nullopt; string_view view u_.large_str.AsView(); size_t decoded_size Size(); // Register the pin and stamp read_pending. The bit is bookkeeping; safe to // mutate via const_cast. PendingRead* pin tl.pin_map.RegisterPin(view.data()); const_castdetail::LargeString(u_.large_str).read_pending 1; return cmn::BorrowedString{view, decoded_size, static_castuint8_t(encoding_), pin}; }当且仅当CompactObj持有大 raw 或 packed 字符串时返回cmn::BorrowedString否则返回std::nullopt调用方回退到GetSlice/ToString。成功时通过受控的const_cast在LargeString上盖章read_pending该位是簿记信息不属于逻辑值的一部分并在线程本地 pin 映射中注册PendingRead。返回的BorrowedString拥有该 pin调用方只需让它离开作用域或显式调用Unpin()即可。Pin 注册表PendingRead/PinnedMappin 注册表位于 src/core/compact_object.cc 的线程本地作用域与local_mr、Huffman 表并存。内部结构struct PendingRead { const void* ptr nullptr; std::atomicuint32_t refcnt{0}; bool orphaned false; // Any-thread: decrement refcnt. After this returns, the caller must not // touch the pin again — the owning thread may reap it on the next drain. void UnpinRead() { refcnt.fetch_sub(1, std::memory_order_release); } };映射是单线程的——只有拥有线程可以修改它。其他线程只通过UnpinRead经由BorrowedStringOps::Release间接调用触碰单个PendingRead::refcnt。没有队列UnpinRead只是单次 release-storefetch_sub拥有线程通过遍历映射回收refcnt0的条目。为什么不用跨线程队列文档解释了设计取舍早期设计在 refcnt 归零时把 pin 推入 MPSC free list但如果新读者在 unpin-to-zero 与 drain 之间 pin 了同一缓冲区同一个 pin 可能被推送两次。映射遍历完全消除了这种竞争代价是每次 drain 为 O(N)——N 是当前线程在途读的数量实际很小每个活跃 GET 回复对应一个 pin。公开接口只有两个CompactObj::TryBorrow()——拥有线程调用入口点盖章read_pending、注册 pin、返回持有 pin 的BorrowedStringCompactObj::DrainPendingReads()——拥有线程、static遍历映射回收 refcnt 归零的条目释放 orphaned 缓冲区、擦除槽位、删除条目。由EngineShard::Heartbeat周期调用src/server/engine_shard.cc。orphan 转换是LargeString::SetString与LargeString::Free的私有细节它们在tl.pin_map中查找 ptr、置entry-orphaned true、并从映射中擦除即PinnedMap::Orphan。PinnedMap析构函数还会兜底线程关闭时释放 Heartbeat 来不及 drain 的条目。回复构建器集成WriteRef / WriteDecodedAscii 与 FlushSinkReplyBuilder通过两种方式累积 iovecsrc/facade/reply_builder.ccWritePieces(...)拷贝进 scratch 缓冲写前缀、长度、CRLF 等小块时用WriteRef(std::string_view)仅指针入 iovecvecs_.push_back(iovec{const_castchar*(str.data()), str.size()})Send()执行同步writev。借出的字节必须比这次 write 活得更久。RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString bs)src/facade/reply_builder.cc是新增的借用字符串序列化方法void RedisReplyBuilder::SendBulkStringBorrowed(const cmn::BorrowedString bs) { const size_t total ...; // DecodedSizeraw 时即 view 大小 WritePieces(kLengthPrefix, total, kCRLF); if (bs.IsEncoded()) { WriteDecodedAscii(bs); // 迭代解码 chunk 写入 sink } else { WriteRef(bs.view()); // raw直接引用零拷贝 } WritePieces(kCRLF); ... Flush(); // 让所有引用到达内核后才返回 }raw 编码WriteRef(bs.view())直接以指针形式引用存储缓冲区——这就是零拷贝的落点packed 编码WriteDecodedAscii迭代解码。其内部先从DecodeChunk解出 chunk 写入 scratchbuffer_.AppendBuffer()再用WriteRef引用刚写入的 scratch 段关键细节是每次CommitWrite推进缓冲游标避免下一轮AppendBuffer().data()返回同一地址覆盖已排队的 chunk容量不足时先Flush(kMaxBufferSize)排空既有 iovec防止 reallocate-grow 让前缀 iovec 悬垂。最后调用Flush()让所有引用在函数返回前到达内核——此时参数上的~BorrowedString通过BorrowedStringOps::Release释放 pin。无需任何延迟释放机制pin 的生命周期就是普通的 C 作用域。捕获/重放Capture / replay集成payload::Payload直接新增一个cmn::BorrowedString备选sizeof(Payload)保持不变——BorrowedString恰好能放进 variant 既有空间。src/facade/reply_capture.cc 中CapturingReplyBuilder覆写的SendBulkStringBorrowed把借用移动进 payload——捕获的 payload 在捕获存续期内持有 pin。重放时CaptureVisitor把它移回真实 sink 的SendBulkStringBorrowed由同样的 Flush-then-release 流程接管src/facade/reply_capture.cc。因此借用的源在整个 squashing /MULTI-EXEC流水线期间保持有效。端到端路径与并发图景完整 GET 流程拥有该 key 的分片上的 GET 回调调用pv.TryBorrow()成功后返回的BorrowedString通过SingleHopT的返回值移动回连接所属的 proactorsrc/server/string_family.ccrb()-SendBulkStringBorrowed(std::move(getcmn::BorrowedString(res)))连接线程调用rb-SendBulkStringBorrowed(std::move(bs))发出 iovecraw →WriteRefpacked →WriteDecodedAscii尾部Flush()通过writev排空所有 iovec随后~BorrowedString释放 pinEngineShard::Heartbeat周期调用CompactObj::DrainPendingReads()回收refcnt 0的条目若 orphaned 则释放缓冲区。并发写-读竞态的时间线文档原图 docs/zero_copy_get.md参与者事件Shard A拥有线程GET 回调bs pv.TryBorrow()盖章 read_pending、在tl.pin_map注册 pinShard A → 连接 XBorrowedString{view, pin}经SingleHopT返回值移动连接 XproactorSendBulkStringBorrowedraw →WriteRefpacked →WriteDecodedAsciiFlush()经 writev 排空 iovecShard A并发 SETLargeString::SetString发现read_pending1→ 在tl.pin_map查旧 ptr、标记 orphaned、擦除 → 分配新缓冲、清除 read_pending连接 X函数退出时~BorrowedString→Release→UnpinReadrelease-storefetch_sub之后不再触碰Shard AHeartbeatDrainPendingReads()refcnt0且 orphaned → 释放旧缓冲若 borrow 与 unpin 之间没有发生写操作drain 发现条目orphanedfalse只需移除映射槽位CompactObj继续通过正常生命周期拥有该缓冲区。并发安全四类竞态的处理Drain 与 re-pin若UnpinRead把条目的refcnt减到 0而另一位读者在下一轮 drain 之前用TryBorrow把它加回 1drain 在 acquire 序下观察到refcnt 0便跳过该条目。映射槽继续指向同一条目新读者最终 unpin 把它带回 0下一轮 drain 回收。无双重释放。Drain 之后的变更stale read_pending 位LargeString::read_pending位可以比对应的映射条目活得更久——最后一位读者 unpin 且 drain 已移除非 orphaned 条目后该位仍置在CompactObj的LargeString上。后续的写操作在tl.pin_map中查不到ptr便回退到正常释放。这是安全的因为查不到只发生在 unpin-to-zero 与写操作之间没有读者 pin 同一地址的情况下此时所有既往 pin 的 refcount 均为零且无新 pin 执行——没人正在读ptr释放安全。位陈旧无害。Defragdefrag 任务会在原地重新分配LargeString缓冲区并使在途借用视图失效。DefragIfNeeded在read_pending1时返回 falsepin 清除后下一轮 defrag 再处理同一值。跨线程释放在 Shard A 分配、被不同 IO 线程 unpin 的 pin在 A 的下一次 drain 中从 A 的tl.pin_map回收。IO 线程对该 pin 的唯一访问是UnpinRead内的fetch_sub该调用返回后IO 线程不再引用 pin 指针A 可在 drain 观察到refcnt 0时随时删除条目。缓冲区在 A 的线程上通过其 mimalloc heap 释放——与分配位置一致保证内存统计一致。范围之外明确不做的事MGETCollectKeys目前为每个分片分配存储缓冲并打包值。零拷贝 MGET 需要改为逐结果携带借用视图。Huffman 编码大字符串TryBorrow对HUFFMAN_ENC返回nullopt。Huffman 码变长不物化的解码需要一个有状态的流式解码器。EXTERNAL_TAGtiered值异步磁盘取回与物化不属于内存内零拷贝叙事。SMALL_TAG与内联值拷贝已经很便宜。标志位与计数器速查符号位置含义LARGE_STR_TAGCompactObj::TagEnumHeap 分配的 raw 大字符串read_pendingdetail::LargeString一位或多位读者可能正在借用ptrFLAGS_get_zero_copystring_family.cc零拷贝路径总开关默认开启kBorrowThreshold16 KBstring_family.cc值大小达到该阈值才尝试借用文件地图关注点文件BorrowedString、BorrowedStringOps接口src/common/borrowed_string.hread_pending位、TryBorrow、pin 注册表、DrainPendingReads、CompactObjBorrowOpssrc/core/compact_object.h、src/core/compact_object.ccSendBulkStringBorrowed、WriteDecodedAscii、WriteRefsrc/facade/reply_builder.h、src/facade/reply_builder.cc捕获/重放覆写、Payloadvariant 备选src/facade/reply_capture.{h,cc}、src/facade/reply_payload.hGET 调用点与 16 KB 阈值、StringResultvariantsrc/server/string_family.ccHeartbeat 周期 drain 调用src/server/engine_shard.ccHTTP-API visitor 物化DecodeToString场景src/server/http_api.cc、src/common/borrowed_string.h测试src/core/compact_object_test.cc小结零拷贝 GET 是 Dragonfly 在共享无锁架构下做内存所有权精细管理的一个范例以 1 个read_pending位 线程本地 pin 映射为代价换取了读路径上对大字符串的零中间拷贝用 Copy-on-Write写者新分配、旧缓冲交由 pin 托管化解了读者与写者的竞态用所有权线程独占映射 任意线程仅做原子 fetch_sub的模型在不引入跨线程队列的前提下保证了无锁且无双重释放的回收。要深入验证这些行为可以从 src/core/compact_object_test.cc 的测试用例入手结合 docs/zero_copy_get.md 中的时序图逐帧对照。【免费下载链接】dragonflyA modern replacement for Redis and Memcached项目地址: https://gitcode.com/GitHub_Trending/dr/dragonfly创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表