2025-03-24 18:21:03
在 Rust 异步编程中,有一种观点认为:Future 的大小显著影响性能。你是否怀疑过这个说法的真实性?如果是真的,这种性能差异的根源又是什么?今天,我翻阅了一些源码,并编写实验代码来一探究竟。
为了验证“Future 大小影响性能”这一说法是否成立,我们先从一些简单代码入手。首要任务是弄清楚一个 Future 的大小是如何确定的。毕竟,在编译器眼里,Future 只是一个 trait:
pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;}
那么,其大小取决于实现这个 trait 的具体结构体吗?我翻阅了 smol 的源码,发现在 spawn 一个 Future 时,相关代码是这样处理的:
pub unsafe fn spawn_unchecked<'a, F, Fut, S>( self, future: F, schedule: S,) -> (Runnable<M>, Task<Fut::Output, M>)where F: FnOnce(&'a M) -> Fut, Fut: Future + 'a, S: Schedule<M>, M: 'a,{ // Allocate large futures on the heap. let ptr = if mem::size_of::<Fut>() >= 2048 { let future = |meta| { let future = future(meta); Box::pin(future) }; RawTask::<_, Fut::Output, S, M>::allocate(future, schedule, self) } else { RawTask::<Fut, Fut::Output, S, M>::allocate(future, schedule, self) }; let runnable = Runnable::from_raw(ptr); let task = Task { ptr, _marker: PhantomData, }; (runnable, task)}
这里可以看到 mem::size_of::<Fut>()
是在计算这个 Future 的大小,我来写个简单的 Future 验证:
use async_executor::Executor;use futures_lite::future;use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll};pub struct LargeFuture { pub data: [u8; 10240],}impl Future for LargeFuture { type Output = usize; fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> { let value = self.data[0]; println!("First byte: {}", value); Poll::Ready(self.data.len()) }}fn main() { let ex = Executor::new(); let large_future = LargeFuture { data: [0u8; 10240] }; let res = future::block_on(ex.run(async { ex.spawn(large_future).await })); println!("Result: {}", res);}
在上面那个 async-task 的 spawn_unchecked
函数加上日志,打印出来的大小为 10256
,刚好比这个 struct 的大小大 16,顺着代码往上可以看到这里在原始的 Future 上做了一个封装,这里的意思是如果这个 Future 以后执行完,需要从 runtime 里面删掉:
let future = AsyncCallOnDrop::new(future, move || drop(state.active().try_remove(index)));
这解释了尺寸略有增加的原因。对于结构体的尺寸,我们不难理解,但对于 async 函数,其大小又是如何计算的呢?这就涉及 Rust 编译器对 async 的转换机制。
当你写下一个简单的 async fn
函数时,Rust 编译器在幕后悄然完成了一场复杂的转换:
async fn function() -> usize { let data = [0u8; 102400]; future::yield_now().await; data[0] as usize}
这段代码会被编译器转化为一个庞大的状态机,负责追踪执行进度并保存所有跨越 .await
点的变量。转换后的结构体封装了状态切换的逻辑:
enum FunctionState { // 初始状态 Initial, // yield_now 挂起后的状态,必须包含所有跨 await 点的变量 Suspended { data: [u8; 102400], // 整个大数组必须保存! }, // 完成状态 Completed,}// 2. 定义状态机结构体struct FunctionFuture { // 当前状态 state: FunctionState, // yield_now future yield_fut: Option<YieldNow>,}impl Future for FunctionFuture { // 3. 为状态机实现 Future traitimpl Future for FunctionFuture { type Output = usize; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<usize> { // 安全地获取可变引用 let this = unsafe { self.get_unchecked_mut() }; match &mut this.state { FunctionState::Initial => { // 创建大数组及其长度 let data = [0u8; 102400]; // 创建 yield future 并保存 this.yield_fut = Some(future::yield_now()); // 状态转换,保存所有需要跨越 await 的数据 this.state = FunctionState::Suspended { data }; // 立即轮询 yield match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) { Poll::Ready(_) => { // 如果立即完成,返回结果 if let FunctionState::Suspended { data } = &this.state { let result = data[0] as usize; this.state = FunctionState::Completed; Poll::Ready(result) } else { unreachable!() } } Poll::Pending => Poll::Pending, } } FunctionState::Suspended { data } => { // 继续轮询 yield match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) { Poll::Ready(_) => { // yield 完成,读取数组首元素并返回 let result = data[0] as usize; this.state = FunctionState::Completed; Poll::Ready(result) } Poll::Pending => Poll::Pending, } } FunctionState::Completed => { panic!("Future polled after completion") } } }}
可以看到,Suspended
状态中包含了那个大数组。当状态从 Initial
切换到 Suspended
时,data
会被完整保留。
由此可知,对于一个 async 函数,若临时变量需跨越 await 存活,就会被纳入状态机,导致编译时生成的 Future 大小显著增加。
明确了 Future 大小的定义后,我们接着通过代码验证其对性能的影响。在之前的 mem::size_of::<Fut>() >= 2048
条件中可以看到,如果 Future 的大小过大,Box::pin(future)
会从堆上分配内存,理论上会带来额外开销。这种设计可能基于几点考量:小型 Future 直接嵌入任务结构体中,能提升缓存命中率;而大型 Future 若嵌入,会让任务结构体过于臃肿,占用过多栈空间,反而不利于性能。
我通过实验验证,若 async 函数中包含较大的结构体,确实会导致 Future 执行变慢(即便计算逻辑相同):
RESULTS:--------Small Future (64B): 100000 iterations in 30.863125ms (avg: 308ns per iteration)Medium Future (1KB): 100000 iterations in 61.100916ms (avg: 611ns per iteration)Large Future (3KB): 100000 iterations in 105.185292ms (avg: 1.051µs per iteration)Very Large Future (10KB): 100000 iterations in 273.469167ms (avg: 2.734µs per iteration)Huge Large Future (100KB): 100000 iterations in 5.896455959s (avg: 58.964µs per iteration)PERFORMANCE RATIOS (compared to Small Future):-------------------------------------------Medium Future (1KB): 1.98x slowerLarge Future (3KB): 3.41x slowerVery Large Future (10KB): 8.88x slowerHuge Large Future (100KB): 191.44x slower
在微调这个 async 函数时,我发现了一些微妙的现象。为了让 data
跨越 await 存活,我特意在最后引用了它,以防编译器优化掉:
async fn huge_large_future() -> u64 { let data = [1u8; 102400]; // 10KB * 10 let len = data.len(); future::yield_now().await; (data[0] + data[len - 1]) as u64}
理论上,若改成下面这样,由于 len
在 await 前已计算完成,后面又没用引用到,生成的 Future 大小应该很小:
async fn huge_large_future() -> u64 { let data = [1u8; 102400]; // 10KB * 10 let len = data.len(); future::yield_now().await; 0}fn main() { let ex = Executor::new(); let task = ex.spawn(huge_large_future()); let res = future::block_on(ex.run(task)); eprintln!("Result: {}", res);}
然而,我发现 data
仍被保留在状态机中,即便 len
未被后续使用。这涉及到编译器如何判断变量是否跨越 await 存活的问题。当然,若显式限定 data
的生命周期在 await 之前,它就不会被纳入状态机:
async fn huge_large_future() -> u64 { { let data = [1u8; 102400]; // 10KB * 10 let len = data.len(); } future::yield_now().await; 0}
我查阅了 Rust 编译器的源码,发现变量是否跨越 await 存活由 locals_live_across_suspend_points 函数 决定:
/// The basic idea is as follows:/// - a local is live until we encounter a `StorageDead` statement. In/// case none exist, the local is considered to be always live./// - a local has to be stored if it is either directly used after the/// the suspend point, or if it is live and has been previously borrowed.
在我们的代码中,let len = data.len()
构成了对 data
的借用,因此 data
被保留在状态机中。或许这里仍有优化的空间?我去社区问问看。
所有实验代码均可在以下链接找到:async-executor-examples。
在 Rust 异步编程中,代码的细微调整可能引发性能的显著波动。深入理解状态机生成的内在机制,能助你打造更高效的异步代码。下次编写 async fn
时,不妨自问:这个状态机究竟有多大?
2025-03-16 16:53:04
最近一年我在做 Fiber Network 这个新的开源项目,上个月底刚好主网第一个版本发布:
这个项目的挑战还是挺大的,上主网只是一个新的开始。我在开发过程中学到了很多东西,这是我前段时间写的一篇关于 Fiber 的大致介绍。
Fiber 是基于 CKB 构建的闪电网络协议,旨在实现快速、安全且高效的链下支付解决方案。借鉴了比特币闪电网络的核心理念,Fiber 针对 CKB 的独特架构进行了深度优化,提供低延迟、高吞吐量的支付通道,适用于微支付和高频交易等场景。与传统的闪电网络不同,Fiber 拥有多项关键特性:
在这篇文章中,我们将从源码层面介绍 Fiber 的整体架构和主要模块,以及项目的后续展望和规划。
我们从最高纬度去看一个 Fiber Node,主要包含下面几个主要模块:
其中:
Channel 的管理是闪电网络中非常重要、也是异常复杂的部分。其中的复杂性主要来自于 Channel 内部数据和状态的改变来自于网络上 peer 之间的交互,事件的处理可能存在并发上的问题,一个 Channel 的双边可能同时都有 TLC 的操作。
闪电网络本质上是一个 P2P 系统,节点之间通过网络消息相互通信进而改变内部的数据状态,我们发现 Actor Model 非常适合这种场景:
Actor Model 极大地简化了代码实现的复杂度,使用 Actor model 后我们不需要使用锁来保护数据的更新,当一个 Message handle 结束的时候,我们会把 channel state 的数据更新写入 db。而像 rust lightning 如果没用使用 actor model,就可能会涉及到非常复杂的锁相关的操作。
我们的所有的重要模块都采用了 Actor Model,Network Actor负责节点内外的消息通信,比如一个节点要给另外一个节点发送 Open channel 的消息,这个消息首先会通过 Fiber node A 的 channel actor 发送到 network actor,node A 的 network actor 通过更底层的网络层 tentacle 发送到 node B 的 network actor,然后 network actor 再发给 node B 里面的所对应的 channel actor。
在一个 Fiber Node 内部,每一个新的 Channel 我们都会建立一个对应的 ChannelActor,而这个 ChannelActorState 里面包含了这个 Channel 所需要持久化的所有的数据。采用 Actor Model 的另外一个好处就是我们能够在代码实现过程中直观地把 HTLC 网络协议相关的操作映射到一个函数里,比如下图中展示了 HTLC 在多个节点之间的流转过程,对于 A 到 B 之间的 AddTlc 操作,节点 A 里的 actor 0 所应对的代码实现就是 handle_add_tlc_command,而节点 B 里的 actor 1 所对应的代码实现是 handle_add_tlc_peer_message。
Channel 之间的 TLC 操作是复杂度非常高的部分,我们在实现上延用了 rust-lightning 的方式,使用状态机来表示 TLC 的状态,根据 actor 之间的 commitment_sign 和 revoke_ack 的消息来改变状态机,总的来说 AddTlc 的操作流程和两个 Peer TLC 状态的改变过程如下:
每个 Fiber 节点都通过 Network graph 保存了自己对于整个网络的了解情况,本质上这是一个双向有向图,每一个 Fiber 节点对应于 Graph 里面的一个 vertex,每一个 Channel 对应于 Graph 里面的一个 edge,出于隐私保护的需求,Channel 的真实 balance 不会广播到网络中,所有 edge 的大小是 Channel 的 capacity。
在支付开始前,发起者会通过路径规划找到一条通往收款者的路径,如果有多条路径就需要找到各方面综合考虑最优的路径,而在信息缺失的图中找到最优路径是一个在工程上非常具有挑战性的问题,Mastering Lightning Network 对这个问题有很详细的介绍:
在 Fiber 中,支付动作由用户向 Fiber Node 通过 RPC 发起请求,节点收到请求后会创建对应的 PaymentSession 来追踪支付的生命周期。
目前我们的路径规划的算法是一个变形的 Dijkstra 算法,这个算法是通过 target 往 source 方向扩展的,搜索路径的过程中通过折算支付成功的概率、fee、TLC 的 lock time 这些因素到一个 weight 来进行排序。其中的概率估算来自于每次支付的结果记录和分析,实现在 eval_probability。路径的选择质量好坏对于整个网络的效率和支付的成功率非常重要,这部分我们今后将会继续改进,Multipart payments (MPP) 也是一个今后可能要实现的功能。
路径规划完成后下一步就是构建 Onion Packet,然后给通过 source node 发起 AddTlcCommand。后续如果 TLC 失败或者成功会通过事件通知的方式处理。
整个支付的过程可能会发生多次的重试,一个常见的场景就是我们使用 capacity 作为 Graph 里边的容量,可能路径规划出来的路线无法真实满足支付的大小,所以我们需要返回错误并更新 Graph,然后再继续自动发起下一次路径规划尝试进行支付。
Fiber 的节点之间的通过相互发送广播消息交换新的 Node 和 Channel 信息,Fiber 中的 Gossip 模块实现了 Botls 7 定义的 routing gossip。在实现过程中我们的主要技术决策在这个 PR: Refactor gossip protocol里面有描述。
当一个 Node 节点第一次启动的时候,会通过配置文件里的 bootnode_addrs来的连接第一批 peers,广播消息的类型有三类:NodeAnnouncement
、ChannelAnnouncement
、ChannelUpdate
。
Fiber 会把收到的广播的原始数据保存下来,这样方便通过 timestamp + message_id 组合的 cursor 来对广播消息进行检索,以方便来自 peer node 的 query 请求。
当一个节点启动的时候,Graph 模块会通过 load_from_store来读取所有的 messages,重新构建自己的 network graph。
我们采用基于订阅的方式在网络中传播消息。一个节点需要主动向另一个节点发送广播消息过滤器(BroadcastMessagesFilter),另一个节点收到了该消息之后会为其创建对应的 PeerFilterActor,在构造函数里创建 Gossip 消息订阅。通过基于订阅的模型这种方式,我们可以让其他节点接收在特定的 cursor 之后接收到新保存的 Gossip 消息。
处于隐私保护的需求,payment 的 TLC 在多个节点之间传播的时候,每个节点只能知道自己所需要的信息,比如当前节点接收的 TLC 的 amount、expiry、下一个传播的节点等信息,而无法获得其他不必要的信息,而且每个 hop 在发送 TLC 给下一个节点的时候也需要做相应的混淆。
类似的,如果 payment 在某个节点传播的过程中发生了错误,这个节点也可能返回一个错误信息,而这个错误信息会通过 payment 的 route 反向传递给 payment 的发起节点。这个错误信息也是需要 Onion 加密的,这样确保中间节点无法理解错误的具体内容,而只有发送者能够获得错误内容。
我们参考了 rust-lightning 在 onion packet 的实现,发现其实现方式还是不够通用 (会绑定于其项目的具体数据结构),所以我们自己从头开始实现了 fiber-sphinx,更详细的内容请参考项目的 spec。
涉及到 Onion 加解密的几个关键节点在这三个地方:
Watchtower 是闪电网络中的重要安全机制,主要用于帮助离线用户防止资金被盗。它通过实时监测链上交易,并在发现违规行为时执行惩罚交易,从而维护闪电网络的公平性和安全性。
Fiber 的 watchtower 实现在 WatchtowerActor里,这个 actor 会监听 Fiber 节点中发生的关键事件,比如一个新的 Channel 创建成功时将会收到 RemoteTxComplete
,watchtower 就在数据库里插入一条对应的记录来开始监听这个通道,Channel 双方协商成功关闭时会收到 ChannelClosed
,watchtower 从数据库中移除对应的记录。
在 Channel 中 TLC 交互时候,watchertower 将会收到 RemoteCommitmentSigned
和 RevokeAndAckReceived
,分别去更新数据库中存储的 revocation_data
和 settlement_data
,这些字段将会在后续创建 revocation transaction 和 settlement trasaction 的时候用到。
Watchtower 的惩罚机制是通过比较 commitment_number
来判断 CKB 的链上交易是否使用了老的 commitment transaction,如果发现违规则构建一个 revocation transaction 提交到链上进行惩罚,否则就构建发送一个 settlement transaction 提交到链上。
目前 Fiber 还处于前期活跃开发阶段,后续我们可能将继续做以下几个方面的改进:
Let’s scale P2P finance together! 🩵
2025-01-01 08:03:41
2024 年快结束了,在这最后的一两个小时里我写着这篇年终总结准备跨年了,顺着大致时间线来回顾一下就好了。
年初就起了个好头,众多加密货币开始上涨。总体而言,2024 年是个加密货币和区块链的大年。有那么一小段时间我每天都在关注涨跌,渐渐地我发现这个领域涨跌都是太频繁了,而过多关注除了浪费时间并没有什么大的用处。因为两年前开始在这个领域工作,所以我自然也会投资一些加密货币。刚开始我稍微接触了一下合约,但很快亏掉了几千元,算是交了学费。然后很快理智地退出了,合约本质上来说和赌博有点类似,钱来得也快亏得也快,但大概率是要亏钱的。
我听从了一些行业老鸟的建议,拿住比特币就行,其他的看着买点。我从 2023 年开始陆续买入了一些比特币,当时的价格不算高,到今年年底看来也有不少涨幅了。我抱着长期拿住的心态在买入,打算至少持有八九年以上。所以现在我基本不怎么关心价格了,如果买了就当作这钱是存在那里好了,把时间幅度拉长,我相信比特币未来会更值钱。我愿意相信这个行业是因为从技术的角度考虑是即有趣又有挑战。这两年来我工作的项目和比特币是非常类似的,就当作为信仰充值。
2024 年 5 月开始我投入到了公司的一个新项目开发上,这是个完全开源的项目叫作 nervosnetwork/fiber,简而言之就是 CKB 上的闪电网络实现。所以 2024 年的大部分时间我都专注于这个项目,因为这是个新项目所以很多功能都是从头开始实现,这对于程序员来说时段快乐时光,毕竟维护老项目很多时候都是在考虑兼容性,没有什么大量写代码的快感。
闪电网络似乎现在已经过了最火的时候,但却是古典区块链技术的代表。如何在去中心的环境中构建出信任通道,这是个非常复杂的问题,大多数时候我们都是在参考 BOLT这个规范。开发过程中一直需要考虑的是这样安全么,如果对方出错或者发出恶意的请求会怎样,channel 的基本保证是任何时候任意一方都可以退出,而不会造成资金上的损失,另外还需要兼顾的是隐私的问题,所以支付的多跳传输需要使用洋葱加密,错误的返回链路上也需要用洋葱加密。反正本质上,这些都归结为数学问题,多签、加密和解密、哈希时间锁合约,确保了交易的不可伪造性和隐私性。我不打算继续在这篇文中写更多关于闪电网络的技术细节,也许以后会写一系列的相关文章。
总体来说,2024 年又开心地写了一年代码,甚至我觉得技术越做越有意思了:
远程工作两年后,我更多采用把问题留在脑海中,时不时拿出来思考的工作方式。有几次这样的经历,我像是在睡觉的过程中还在思考某个问题,然后第二天起来还记得当时想出来的办法。
另一方面,有些遗憾的是我今年参与 Rust 等开源项目的时间比较少了,写文章也比较少。似乎在公司的项目上工作得足够有趣、找到了足够的收获感,没有多少动力和时间去做其他项目。但意想不到的是今年年底还是收到了 Rust 基金会的邮件,愿意资助我一年继续做贡献。所以明年我应该还是会把一些业余时间投入到 Rust 项目上,这也算是把爱好折腾成了责任和义务。可以说 Rust 延长了我的技术生命,让我幸运地投入到一堆 Rust 开源项目上,并且找到适合自己的公司,以远程的方式工作。
因为整天除了带娃和宅在家编程,2024 年我似乎没认识什么新的人,社交圈很小,甚至到了年底我才想起是不是该约上许久不见的朋友线下聊聊。我不知道如何解决这个问题,这有一半是远程工作带来的副作用,另一半就是人到中年在社交上的需求小了。我还在 Cambly 上练习口语,这已经变成了我强迫自己和人沟通的一个渠道,我每周三节课一共一个半小时,其中一个小时大多数都是和我的固定老师聊,他比我大 10 岁左右,我们聊过很多话题,我给他科普区块链等技术领域、做模拟演讲等。另外我喜欢找那些一直在旅游的人或者退休了的人聊,因为通常能听到一些好玩的事情,有次有个一直满世界漂流的人对我说他希望的是 die with my boots on,我一下子没听出其含义,后来通过他的解释我知道了这个俗语的意思:一个穿着靴子死去的人会一直生活和战斗到最后,他们像往常一样生活时去世,而不是因为年老和因疾病、体弱等卧床不起,对他来说他希望自己死在旅游的途中。我想这种生活态度真是太好了,而且他也在践行自己的这种生活方式。我喜欢看那些一直在路上的博主,比如 十三要和拳头 和 刘伟元的旅行,可能正是因为我已经不太可能做到像他们那样随心所欲地玩耍。
说到旅行,今年五月底公司团建我们去了大理待了一周,那里的风景和气候都还挺不错,有些地方显得商业化太重,但沿着洱海骑行和在苍山徒步都非常惬意。夏天我和家人去了一趟北方,走的是比较热门的路线,青岛、威海、大连。不过这趟很累,因为暑假期间都是家长带着孩子,所以去哪里都是人挤人,但其实孩子们也还太小,他们只是想找个地方玩沙子赶海,而对于历史遗迹之类的地方则完全不感兴趣。
11 月公司组织去了趟清迈,我们在那里举行了第一次的 CKCON,我也是第一次用英语做技术演讲。感觉清迈的基础设施还有待提升,有一次我一个人打车,司机好像是中途拐进了城中小道上歪歪扭扭的乱窜,我开始担心自己会不会被拉去割腰子。其实司机是个好人,到了终点后我才发现自己的 Grab 不能付款,他就耐心得等我去找人借现金。
我很喜欢公司组织的线下聚会,不但可以和平时合作的同事见面聊聊,也可以暂时从一直带娃的生活中抽离出来,每次出去我的感受是这样的:
所以带孩子真的很累么?确实比较累,而且得看这个孩子是几岁。我喜欢带两三岁到五岁这个年龄段的孩子,因为这时候的孩子都是天真,又比较听话。像我大女儿到了七岁八岁,开始有自主意识了就很淘气,很多时候也不怎么听话,有时候会让我焦头烂额。小学二年级的作业比较多,我女儿每天需要在家里花大概一个小时来写作业,而且现在的数学作业看起来很多应用题,像我女儿这种没接受过幼小衔接的做起来就很慢,肯定需要家长帮忙。有时候孩子做了坏事,我会想起自己小的时候也做过类似的事情,但我现在已经变成了孩子眼中那个严格的父亲了。有次父亲看我对孩子发火,就对我说对孩子还是要适当宽容一些,然后提起小时候每次打了我之后都会心里很后悔,我听了就很感慨。
今年下半年开始,我又开始经常打篮球了。刚开始主要是为了缓解久坐的疲劳,后来就变成每天不断地提升自己的投篮技术。深圳的秋冬季节很舒服,我经常中午 11 点半去小区篮球场投篮差不多一个小时,顺便晒晒太阳。每天这样练习之后投篮技术有了很大的提升,无人防守的情况下基本有 70% 左右的命中率。一个人投篮这种事情看起来很枯燥和无聊,但其实沉下心来运动的感受非常好,我把刻意练习的心态投入到了这个项目上,那一个小时内能达到类似心流的状态,时间变得清澈,仿佛只有我和篮球了。投篮最重要的是掌握出手时候的平衡度,手腕和手指用力,让篮球后旋起来,练习多了投篮动作就形成了肌肉记忆,只要动作做完就大致能知道是否命中,篮球空心入网的声音真是太悦耳了。磨练技艺真是一种最好的状态,而编程、写作、篮球都是这样的事情。
打篮球已经是我整整 20 年的爱好了,但我从未好好练习过投篮,可惜左膝盖在 2017 年伤过一次,运动激烈了容易酸疼,所以再也不怎么去和年轻人打半场了,即使偶尔玩玩总是担心自己受伤,在场上变得畏手畏脚。那些之前理所当然的事情变得奢求了,能力和自由渐渐地丧失,这真是大龄带来的切身痛苦。
有一次我傍晚还在练习投篮,有个看起来比我大七八岁的大哥过来,渐渐地我们聊了起来。我看他的篮球鞋很漂亮,他说是他儿子的,应该叫作空军一号。我们边投篮边聊天,一直聊到天完全黑掉看不到篮筐。没想到这样一个在国企工作的大哥也经常翻墙看新闻,说这几年的情形是聪明人都在蛰伏和休息。还有一次我正在投篮,刚好碰到一个幼儿园班的小朋友们经过,因为球场上就只有我一个人在锻炼,他们就围在场边观看,渐渐地我每进一个球小朋友们就开始欢呼,每次没进就惋惜叹声,这真是个有趣的经历。日子大多平淡如水,但这些小瞬间却留在了心里。
回想起来,今年生活中的一些其他变化,彻底不看朋友圈,不怎么追新闻,总体来说信息更闭塞了。但 2024 却是我生活上最朴素充实的一年,上班做感兴趣的项目下班做喜欢的运动,在我做了很多减法后,现在的生活好像就是自己理想中的状态。
祝各位新年快乐!
2024-11-07 20:03:24
CKB 相关技术文章第三篇。
CKB 的每一个交易在提交到交易池之前都会经过一个 script verification 的过程,本质上就是通过 CKB-VM 把交易里的 script 跑一遍,如果失败了则直接 reject,如果通过了才会继续后面的流程。
这里的 script 就是一种可以在链上执行的二进制可执行文件,也可以称之为 CKB 上的合约。它是图灵完备的,我们通常可以通过 C、Rust 来实现这些 script,比如 nervosnetwork/ckb-system-scripts 就是 CKB 上的一些常用的系统合约。用户在发起交易的时候就设置好相关的 script,比如 lock script 是用来作为资产才所有权的鉴定,而 type script 通常用来定义 cell 转换的条件,比如发行一个 User Define Token 就需要指定好 UDT 所对应的 type script。script 是通过 RISC-V 指令集的虚拟机上运行的,更多内容可以参考 Intro to Script | Nervos CKB。
通常一个简单的 script 在 CKB-VM 里面执行是非常快的,VM 上跑完之后会返回一个 cycle 数目,这个 cycle 数量很重要,我们用来衡量 script 校验所耗费的计算量。一个合约的 cycle 数多少,理论上来说依赖于 VM 跑的使用用了多少个指令,这由 VM 在跑的时候去计算 VM Cycle Limits。
随着业务的复杂,逐渐出现了一些大 cycles 的交易,跑这些交易可能会耗费更多的时间,但我们总不可能让 VM 一直占着 CPU,比如在处理新 block 的时候,CPU 应该在让渡出来。但之前 CKB-VM 对这块的支持不够,为了达到变相的暂停,处理大 cycles 的时候我们可以设置一个 step cycles,假设我们设置为 100 cycles,每次启动的时候就把 max_cycles 设置为 100,这样 VM 在跑完 100 cycle 的时候会退出,返回的结果是 cycle limitation exceed,然后我们就知道这个 script 其实是没跑完的,先把状态保存为 suspend,然后切换到其他业务上做完处理之后再继续来跑。回来后如何才能恢复到之前的执行状态呢,这就需要保存 VM 的 snapshot,相当于给 VM 当前状态打了一个快照:
根据这个机制,我们老的 script 校验大交易的整个流程是通过一个 FIFO 的队列保存大交易,然后通过一个后台任务不断地从这个队列中取交易跑 VM,每次都跑 1000w cycle 左右,在这个过程中就可能切换出去,没跑完的交易继续放入队列等待下一次执行:
对应到代码就是 ChunkProcess 这个单独服务来处理的。由于 ChunkProcess 是一个单独的服务,它的处理流程和其他交易的处理流程是不一样的,这样会导致代码的复杂度增加,比如:
chunk_process
里的 process_inner
和 _resumeble_process_tx
。这些问题的根本是 VM 只能通过 cycle step 的方式来暂停,有没有一种方式是我们任何时候想暂停就暂停,就是 event based 的方式。所以后来 CKB-VM 团队做了一些改进:
这个方法的本质是通过 VM 的 set_pause
接口,把一个 Arc<AtomicU8>
的 pause 共享变量设置给 VM。然后在 VM 外通过更新这个 pause 的变量让 VM 进入暂停状态或者继续执行,这样我们就不需要 dump snapshot 等操作,因为 VM 整个就还是在内存中等着:
基于这些改进我们可以重新设计和实现 CKB verify 这部分的代码,主要是为了简化这部分代码,并且提高大交易处理的效率。这是一个典型的 queue based multiple worker 方案:
主要的核心是就是这段异步执行 VM 的逻辑:chunk_run_with_signal。做的过程中发现一些其他问题:
SubmitLocalTx
和 SubmitRemoteTx
如果 verify 失败目前会立即返回 Reject
,如果改成加入队列的方式,这个结果无法实时给到,所以做了如下改动:Child VM
是执行 syscall 的时候执行 machine.run
,如果不改这块执行 child vm 的时候不可暂停Pause
传递给子,然后暂停的时候给父的 Pause
设置暂停,这样所有的子 machine 同样返回 VMError::Pause
,同时把当前的 machine 栈重新入栈,恢复的时候继续执行,这里逻辑比较重,相关代码实现:run_vms_child。整个 PR 在这里:New script verify with ckb-vm pause
2024-11-06 19:55:13
CKB 相关技术文章第二篇。
如果一个交易成功发送到交易池,但可能出现因为费用较低而一直得不到处理。之前 CKB 没有其他措施来处理这种情况。
例如 Dotbit 4 位域名注册拥堵 这个事故发生过程中,CKB
的应用方无法使用任何方式来尽快让自己的交易被打包,这就是引入 Replace-by-fee(RBF)
的原因,我们需要一个机制来提高已经在交易池里交易的费用,替换掉旧的交易,让新的交易尽快被打包。
在新的 multi_index_map
重构后,交易在 pending
阶段也会按照交易的 score
来优先处理 (通常费用高的交易 score
也会高),这会避免高费用的交易被阻塞住,所以理论上述需要手动提高费用的情况会减少,但我们还是需要 RBF 来手动提高交易的费用,应对意外的情况。
另外,RBF 可能将多个老的交易替换出去,因此也是将两个或多个支付合并为一的方法,例如下图所示,如果满足条件 tx-a
, tx-b
, tx-c
, tx-d
都会被 tx-e
这个交易替换掉:
中本聪最初的 Bitcoin 版本中就有引入一个 nSequence
的字段,如果相同交易的 nSequence
更高,就可以替换之前老的交易,这个实现的问题是没有支付额外的 fee,miner 没用动力去替换交易,另外因为没有 rate-limiting 从而导致可能被滥用,所以 Bitcoin 在 0.3.12 版本中禁止了这个功能。后来 Bitcoin 重新引入了新的 RBF 改进,主要包括需要支付额外的费用来替换老交易,另外为 RBF 指定了更多的限制条件。
在 CKB 上我们之前做过两次 RBF 的相关调研,因为之前 Pending
是一个 FIFO 的数据结构,所以处理替换不是很方便,在 RBF in CKB(draft 2023.01.05) 尝试引入一个 high priority queue
来实现 inject-replace
。交易池改造之后,整个交易池可当作一个优先队列,所以应对 RBF
会简单很多。
RBF
的流程pre-check
为 entry 加入到 tx-pool 之前必须要做的检查,之前只是做双花的检查,新增 RBF 后如果双花检查失败(这里意味着冲突),继续做 RBF 的相关检查,如果 RBF 检查成功则也返回成功,否则直接返回错误。这里默认直接做 resolve_tx 的检查,如果成功则走正常流程,目的是不给正常流程增加额外成本。所以这就是pre-check
修改后的主要逻辑 。RBF 的检查规则参考 Bitcoin 的六条,check_rbf 初步实现
实现细节:(Bitcoin Core 0.12.0)~~1. 交易需要声明为可替换交易~~ 2. 新替换交易没有包含新的、未确认的 inputs3. 新替换交易的交易费用比待替换交易费用高4. 新替换交易费用必须比节点的 min relay fee 高5. 待替换交易的子交易数量不可超过 100 条(即使用了该交易的任意 outputs,该交易替换后它们将被从内存池中移出)6. 因为 ckb 是做了两步提交,我们新增规则:被替换的交易只能是 Pending 或者 Gap 阶段的。
我们不给交易加新的字段表示是否可以被替换,而是通过节点是否配置了 min_rbf_rate
来决定是否能做替换,因此 规则 1
不做对应考虑。
修改 tx-pool
的 submit_entry
函数,传入 conflicts
,在新增 entry 之前把所有冲突的交易删除 放入 rejected
记录,另外确保所有检查完成了之后才做删除和写操作:submit_entry 逻辑。
最终实现在这个 PR 里Tx pool Replace-by-fee。
在最初的实现版本中,隐藏了一个并发的 bug 后来在测试发现了。RBF 的检查如果放在 pre-check
中,如果多个线程中的多个交易发生了冲突,input resolve 可能会出问题。Fix concurrency issue for RBF 这个 PR 修复了这个问题,把 RBF 的冲突检查移动了 submit entry 之前,因为在这个函数里面会持有 write 锁。
后来我们在做闪电网络的时候又发现 RBF 可能会引入 cycling attack 的风险,这个攻击通过构造巧妙的新交易,让支付路径上的中间节点的 commitment tx 不能按时上链,Lightning Replacement Cycling Attack Explained这篇文章有更详细的描述。
所以我们后来又做了这么一个改进:Recover possible transaction in conflicted cache when RBF 来规避这个问题。
2024-11-06 19:39:21
在 11.9 号清迈的 CKCON 会议上我会做一个关于 CKB 交易池的演讲,这是我准备的 slides Key Upgrades of the CKB Core 。所以这段时间在整理之前做项目的时候写的一些文档,顺便分享到自己的博客上。既然我们整个项目的源码都是公开的,这些文档其实也是可以分享的。
第一次听说 CKB 的读者可以参考这个文档以了解什么是 CKB 以及如何工作的:How CKB Works | Nervos CKB。
我加入 Cryptape 之后一年内做的主要工作,涉及到交易池重构、Replace-by-fee 功能、以及 new-verify。这是第一篇关于交易池重构的文章。
在 bitcoin 中交易池叫作 mempool,比如 mempool - Bitcoin Explorer 这个网站就很好地展示了其当前的状态。
交易池是 bitcoin 中的一个重要的组件,但感觉专门关于这块的资料很少,只能通过 PR 和邮件列表上的讨论看到一些文档。但交易池非常重要,因为一个交易要上链必须会通过交易池,而其中的交易打包算法涉及到如何选择合适的交易,这里面有很多因素需要考虑,所以在实现上也是比较复杂的。
当一个交易被提交到一个节点时,或者一个节点从网络中同步到交易时,这个交易首先需要被加入到交易池中,交易池里会根据一定的算法去选择下一个需要被打包的交易,另外交易池作为一个缓存,我们需要为其设置一个最大的 size。所以交易池里面最重要的两个操作就是 packaging 和 evicting。
交易池里面的交易存在父子关系,打包的时候需要从交易链的纬度去考虑,后面的 Replace by fee 这些功能也需要关注整个交易的所有子交易。
根据 RFC consensus-protocol 的设计,CKB 里的 tx-pool 采用了两段提交的方式:
相应地在交易池最初实现的时候, ckb
的代码实现中 tx-pool 同样采用了三个独立的队列,具体定义如下:
pending
交易刚加入到交易池时候的状态,我们每次只能处理不多于 MAX_BLOCK_PROPOSALS_LIMIT
个交易,交易需要先进入 gap
备选,具体代码逻辑在 update_proposals 。gap
已经被 proposed 了,但是还不能被打包,需要等一个块后才能被打包,所以这只是内部中间过渡状态。proposed
交易可以加入到 block_template.transactions
, 最终打包到 block 里,具体代码逻辑在 block_assembler。实现中 pending
和 gap
同样都是使用了 PendingQueue(LinkedHashMap)
,而 proposed
采用了 SortedTxMap(HashMap + BTreeSet)
:
pub struct TxPool { pub(crate) config: TxPoolConfig, /// The short id that has not been proposed pub(crate) pending: PendingQueue, /// The proposal gap pub(crate) gap: PendingQueue, /// Tx pool that finely for commit pub(crate) proposed: ProposedPool, .... pub(crate) expiry: u64,}
这样的实现存在以下问题:
我们不容易对所有在交易池中的 entry 做统一排序,这样会存在以下问题:
pending, gap 和 proposed 除了所采用的数据结构不同外,有很多逻辑雷同的代码,比如 entry 的新增和删除等操作,同样都维护了 deps 和 header_deps,resolve_conflict, resolve_conflict_header_dep, resolve_tx 等函数的逻辑也是类似的,但实现上有些细微差异,这导致长期来说代码不容易维护。
tx-pool
上对 entry 做迭代和查询时,需要依次针对 pending, gap, proposed 做相同的逻辑,比如 resolve_conflict_header_dep 这样的函数在 pool 中有几个类似的,甚至 get_tx_with_cycles 这样的函数,需要依次判断各个队列。基于以上解决现有问题、应对未来的潜在需求、保持代码可维护性的角度,同时参考 Bitcoin txmempool 的实现,我们提出引入 Multi_index_map 对 tx-pool 进行重构。
总体方向是把所有的 entry
放入统一的数据结构中进行管理,加入一个新的字段 status
标识目前 entry
所处的阶段,然后通过 index_map 的方式根据不同的属性进行排序和迭代:
pub enum Status { Pending, Gap, Proposed,}#[derive(MultiIndexMap, Clone)]pub struct PoolEntry { #[multi_index(hashed_unique)] pub id: ProposalShortId, #[multi_index(ordered_non_unique)] pub score: AncestorsScoreSortKey, #[multi_index(ordered_non_unique)] pub status: Status, #[multi_index(ordered_non_unique)] pub evict_key: EvictKey, // other sort key pub inner: TxEntry,}
其中根据 Rust 社区的 multi_index_map 内部实现采用的数据结构看,性能上应该没有什么大问题:
具体实现时我们是否把 inner 也放在 Slab 里面以后可以通过 benchmark 来选择,从实现的简洁性角度考虑统一放在一个数据结构里面更容易。
目前的实现版本:Tx pool rewrite with multi_index_map #3993
我们首先只是做模块内的重构 (保持对外逻辑和以前一样),当然考虑引入了新的数据结构,不管是从性能上还是内存占用上都会有一些影响。
为了做统一排序这件额外的事,本质上我们引入了额外的 Map(FxHashMap 或 BTreeMap) 来存储,所以比以前需要更多内存。另外,我们有时候需要调用 get_by_status
来筛选某个状态的 entries,这在新的实现里面需要先从 index 里面找出 slab 的 id,然后再找到对应的 entry,所以必然也会比以前慢。
从最终的性能对比结果上,除了内存会稍微有增加,性能上没有大的变化。另外我们在实现的过程中对所用到的 Rust 包 multi-index-map 做了一些贡献:Non-unique index support, capacity operations, performance improvement & more by wyjin