Iteration 7 是春节以来第一个完整的工作周期,用一个词语来概括这个周期就是兵荒马乱。
性能翻车
这周 opendal
正式开源了,它是一个旨在对接各种存储服务的数据访问层:Open Data Access Layer。目前支持了 read
,write
,metadata
和 delete
等操作。接口看起来大概是这样:
use anyhow::Result;
use futures::AsyncReadExt;
use opendal::services::fs;
use opendal::Operator;
#[tokio::main]
async fn main() -> Result<()> {
let op = Operator::new(fs::Backend::build().root("/tmp").finish().await?);
let o = op.object("test_file");
// Write data info file;
let w = o.writer();
let n = w
.write_bytes("Hello, World!".to_string().into_bytes())
.await?;
assert_eq!(n, 13);
// Read data from file;
let mut r = o.reader();
let mut buf = vec![];
let n = r.read_to_end(&mut buf).await?;
assert_eq!(n, 13);
assert_eq!(String::from_utf8_lossy(&buf), "Hello, World!");
// Get file's Metadata
let meta = o.metadata().await?;
assert_eq!(meta.content_length(), 13);
// Delete file.
o.delete().await?;
Ok(())
}
在这个周期中 opendal
替代了原本 databend 中的 common/dal
,成为了 databend 项目的存储底座。随着 opendal 开始进入真实的场景,很多问题被一下子暴露了出来:性能不好,API 难用,报错信息过于简陋。其中最难堪的莫过于性能问题了:根据反馈,opendal 上线后,databend 的吞吐下降到了旧版本的 1%。好在大家都比较 nice,讨论问题对事不对人,不然遭遇职场滑铁卢的我要羞愧辞职了。(我好菜啊- -)
背后的根本原因是我对异步 Rust 的理解还是不到位。有些概念自己学过了,理解过了,但是到写时候,在一个真正的复杂环境里,往往会忽略了它与其他因素的交互。在这次的大型性能回退(以及后续的修复)中,我陆陆续续犯了很多错误,这里挑一些记忆比较深刻的简单聊几句。
先来看看第一个版本的错误实现:
match &mut self.state {
ReadState::Idle => {
let acc = self.acc.clone();
let pos = self.pos;
let size = min(buf.len(), self.remaining);
let op = OpRead {
path: self.path.to_string(),
offset: Some(pos),
size: Some(size),
};
let future = async move { acc.read(&op).await };
self.state = ReadState::Sending(Box::pin(future));
self.poll_read(cx, buf)
}
ReadState::Sending(future) => match ready!(Pin::new(future).poll(cx)) {
Ok(r) => {
self.state = ReadState::Reading(r);
self.poll_read(cx, buf)
}
Err(e) => Poll::Ready(Err(io::Error::from(e))),
},
ReadState::Reading(r) => match ready!(Pin::new(r).poll_read(cx, buf)) {
Ok(n) => {
self.pos += n as u64;
self.state = ReadState::Idle;
Poll::Ready(Ok(n))
}
Err(e) => Poll::Ready(Err(e)),
},
}
}
逻辑很简单,Idle
状态时构造一个 future,Sending
时尝试 resolve 它,Reading
时则读取它并重置。看起来挺对的,但是会慢到爆炸。因为每次 IO 只有 8k 不到,底层的 Reader 并不会保证每次都把 buf
填满。
接下来我尝试每次都把 buf 填满再返回:
match &mut self.state {
ReadState::Idle => {
let acc = self.acc.clone();
let pos = self.pos;
let size = min(buf.len(), self.remaining);
let op = OpRead {
path: self.path.to_string(),
offset: Some(pos),
size: Some(size),
};
let future = async move { acc.read(&op).await };
self.state = ReadState::Sending(Box::pin(future));
self.poll_read(cx, buf)
}
ReadState::Sending(future) => match ready!(Pin::new(future).poll(cx)) {
Ok(r) => {
self.state = ReadState::Reading(r);
self.poll_read(cx, buf)
}
Err(e) => Poll::Ready(Err(io::Error::from(e))),
},
ReadState::Reading(r) => match ready!(Pin::new(r).read_exact(buf).poll(cx)) {
Ok(n) => {
self.pos += n as u64;
self.state = ReadState::Idle;
Poll::Ready(Ok(n))
}
Err(e) => Poll::Ready(Err(e)),
},
}
}
这就是一个典型错误了,r.read_exact(buf)
会构造了一个新的 future,这导致每次 runtime poll 进来的时候都在尝试 resolve 一个新的 future 直至报错。如果尝试自己 loop 这个 future 就更错了:rust 采用协作式调度,在实现 future 的时候要尽快的返回 Poll::Pending
。如果在函数中执行阻塞操作,就会阻塞住整个 runtime,导致没有线程进行调度。
现在需要跳出来想一想用户是如何使用 Reader
:
- 构造一个 reader
- 读取一些数据
- drop 这个 reader
所以真正要做的事情是在整个 Reader
的生命周期内,只发起一次 read
请求,让用户始终在 poll_read
同一个底层的 reader。所以在发起请求的时候,只需要指定正确的 offset,不需要限制 size,把 buffer 之类的活儿交给用户来决定。
match &mut self.state {
ReadState::Idle => {
let acc = self.acc.clone();
let pos = self.pos;
let op = OpRead {
path: self.path.to_string(),
offset: Some(pos),
size: None,
};
let future = async move { acc.read(&op).await };
self.state = ReadState::Sending(Box::pin(future));
self.poll_read(cx, buf)
}
ReadState::Sending(future) => match ready!(Pin::new(future).poll(cx)) {
Ok(r) => {
self.state = ReadState::Reading(r);
self.poll_read(cx, buf)
}
Err(e) => Poll::Ready(Err(io::Error::from(e))),
},
ReadState::Reading(r) => match ready!(Pin::new(r).poll_read(cx, buf)) {
Ok(n) => {
self.pos += n as u64;
Poll::Ready(Ok(n))
}
Err(e) => Poll::Ready(Err(e)),
},
}
用户的每次 poll_read
返回 Poll::Ready
后,这一次的 future 就已经处理完毕了,但是 Reader
并没有被 drop,同一个 Reader 的下一次 poll_read
请求还是会进入到 Reading
状态。我之所以犯了上面的种种错误,就是因为没有搞清楚在 Future 的状态变化中,哪些是在变化的,而哪些是不变的。
经历了一些波折之后,opendal
终于达到了符合预期的性能,我不用担心被解雇了(
聊聊 RFC
在变成独立项目后,我开始维护起 opendal 的 RFC 流程,大型的改动全都先写了 RFC:
OpenDAL
的 RFC 模板 来自 Rust 项目,相比于其他项目,Rust 的 RFC 拆分出了 Guide-level explanation
和 Reference-level explanation
。在 Guide-level explanation
中需要阐述假如 RFC 已经实现了,用户的使用体验会发生什么变化,有点像一个 User Story;而 Reference-level explanation
中则需要描述详细的设计细节。这样的拆分能帮助 Reviewer 更快的理解这个 RFC 想做什么以及怎么做。
写 RFC 的好处自然不用多说,短期价值是能够考虑问题更加全面,在 review 的时候更容易正确的陈述自己的想法;长期价值在于帮助未来的维护者明白此时此地为什么会做出这样的决定,理解历史路径。当然了,优秀的 RFC 还拥有超越项目本身的价值,成为整个开源世界的共同财富。
不过在实践中也会遇到各种问题。首当其冲是项目的成熟度。我们项目正处在高速迭代的过程中,有没有必要采用 RFC 流程?采用 RFC 之后是不是会导致迭代速度变慢?
这其实是很多人的错误认识。把自己的想法写下来,形成 RFC 的过程本质上是完善自己思路的过程。人是生物而非机械,大脑中的想法往往都是零碎的,发散的。如果没有纸面的记录,想法会没有一个坚实基础以供依附。
- 特别是在实现一个大型特性的时候,如果没有记录下来,那很有可能在一两天之后就已经忘记了某个细节,结果需要在用到的时候重新推导或者踩坑;
- 特别是在项目本身在高速迭代的时候,如果没有记录下来,每一个大型重构都在依赖当时的项目状态,时过境迁,当没有人还记得当时项目出现了什么问题,我们的项目中就出现了一块死去的代码。更糟糕的是,我们会一次又一次踏进同一条河流,踩进同一个坑;
- 特别是在开源项目中,贡献者来来往往,如果没有记录下来,维护者将会在不同贡献者一次又一次的发问中 burnout;
所以,无论项目成熟度如何,只要生命周期不终止于今天,都值得维护一份 RFC。
其次是 RFC 的形式。
很多人不愿意写 RFC 是因为把 RFC 看得过于正式,需要使用英文书写,需要有正式的提交、审核、合并流程,需要写得非常严谨。这一切只能说明我们陷入了形式主义的泥沼之中。RFC 的全拼是 Request for comment,把自己的想法写下来,跟开源共同体的成员一起交流。这就是全部。在实践中我们尤其需要注意 community over code
的原则,围绕着 RFC 展开的社区沟通和交流才是最核心的价值,基于它产生的种种形式和流程都是附加的,是根据项目的实际情况决定的。
很多问题正是因为这样的错误认识而诞生的:
- 比如我们项目现在没有 RFC 流程,可以提 RFC 吗?
当然可以。RFC 流程不是一日建成的,或者说 RFC 流程本身就需要通过很多 RFC 来不断完善。我们需要在社区中不断地去实践,写下自己的想法,邀请维护者参与 review,如此往复。直到这个流程在整个开源共同体中都成为了一个共识,我们再通过 RFC 的形式将它固化下来。
- 比如我英文不太好,可以提中文 RFC 吗?
当然可以。在翻译软件已经如此发达的今天,我们不应当被这些形式所束缚。将自己的想法写下爱来,然后跟开源共同体一起交流才是最重要的。当然有的社区会要求必须使用英文写 RFC,这也很容易,写下中文的 RFC 然后通过 DeepL 翻译即可。
- 比如我的 RFC 写的不规范,可以提交吗?
当然可以,事实上很多社区都提供了 pre-RFC 的环节让贡献者可以提前跟维护者沟通和交流自己的想法。
总之,开源项目并不是只有代码,围绕着代码形成的开源共同体才是其核心价值。我们不应当拘泥于形式或者流程,我们需要主动交流,广泛交流,甚至过度交流。大家相隔天涯海角,只有把自己的想法陈述出来,别人才会知道你的想法,才能够加入进来。
啊,扯远了。
总之这个周期非常忙碌,但是成果颇丰,希望下个周期也能如此高产。欢迎给 opendal 点个赞或者聊聊你的想法~