在日常 Rust 开发中,我经常遇到一个两难的场景:需要在线程间传递大量数据,但
tokio::sync::mpsc功能单一,而引入Redis或Kafka又显得过重,特别是在tauri这类开发桌面端程序中,更不允许使用Redis和Kafka。这篇文章记录了我如何从零开始,用 Rust 的特性构建一个内存消息队列,以及在过程中对所有权、生命周期和Trait的深刻体会
缘起:为什么要造这个轮子?
作为一名 Rust 开发者,我最初的想法很简单:"不就是在一个线程里 push,另一个线程里 pop 吗?" 但很快,现实就给了我一巴掌。
rust
// 最初天真的想法
static mut QUEUE: Vec<String> = Vec::new();
// 生产者
unsafe { QUEUE.push("data".to_string()); }
// 消费者
unsafe { let data = QUEUE.pop(); }
这段代码连编译都过不了。Rust 的借用检查器无情地告诉我:多线程 + 可变全局变量 = 数据竞争。
现有工具的局限性
那我们试试 Rust 自带的工具?
1. std::sync::mpsc - 同步阻塞
rust
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
// ❌ 问题1:只能有一个接收者
let rx2 = rx.clone(); // 编译错误!
// ❌ 问题2:同步阻塞,在异步环境中会阻塞整个线程
let data = rx.recv().unwrap(); // 阻塞!
2. tokio::sync::mpsc - 单消费者限制
rust
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(100);
// ❌ 问题:仍然只能有一个接收者
// 不支持真正的广播模式
// ❌ 缺乏主题概念,所有消息混在一个通道
tx.send("topic1:data".to_string()).await?;
tx.send("topic2:data".to_string()).await?;
3. tokio::sync::broadcast - 丢失消息
rust
use tokio::sync::broadcast;
let (tx, _rx1) = broadcast::channel(100);
let rx2 = tx.subscribe();
// ❌ 问题:消费者处理慢时,消息会丢失!
tokio::spawn(async move {
while let Some(msg) = rx2.recv().await {
tokio::time::sleep(Duration::from_secs(1)).await; // 模拟慢处理
}
});
// 快速发送会导致后面的消息被丢弃
for i in 0..1000 {
tx.send(i).await?; // 可能丢失!
}
这些工具都有明显的局限性:
std::mpsc:同步阻塞,不适合异步应用tokio::mpsc:不支持多订阅者广播tokio::broadcast:可能丢失消息,且新订阅者无法获取历史消息
这迫使我开始思考:如何在 Rust 的安全框架内,实现一个高性能的异步消息队列?
第一章:Rust 的所有权与我的挣扎
1.1 Arc 救不了所有人
我的第一个想法是使用 Arc<Mutex<Vec<T>>>:
rust
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
struct SimpleQueue<T> {
inner: Arc<AsyncMutex<Vec<T>>>,
}
impl<T> SimpleQueue<T> {
pub fn push(&self, item: T) {
let mut guard = self.inner.lock().await;
guard.push(item);
}
}
这个实现能跑,但性能并不理想。每次操作都需要获取锁,在高并发场景下,锁竞争会成为瓶颈。更重要的是,我遇到了一个 Rust 的经典问题:如何在不移动所有权的情况下,让多个消费者访问同一份数据?
1.2 拥抱 Arc:零拷贝的曙光
答案就是 Arc(Atomically Reference Counted)。在 tokio-memq 中,我大量使用了 Arc 来共享数据:
rust
// src/mq/message.rs
#[derive(Clone)]
pub enum MessagePayload {
// 1. 多个订阅者共享同一份字节数据
Bytes(Arc<Vec<u8>>),
// 2. 零拷贝的终极形态:直接共享 Rust 对象
Native(Arc<dyn Any + Send + Sync>),
}
这里体现了 Rust 开发的第一个感悟:所有权不是敌人,而是朋友 。当你理解了如何用 Arc 共享所有权,而不是试图用原始指针或 unsafe 绕过它时,你就能写出既安全又高效的代码。
比如,传递一个 100MB 的视频帧:
rust
#[derive(Clone)]
struct VideoFrame {
data: Vec<u8>, // 100MB
timestamp: u64,
}
// 发布者
let frame = VideoFrame { ... };
// 使用 Native 模式,将整个结构体装入 Arc
let payload = MessagePayload::Native(Arc::new(frame));
// 订阅者
if let MessagePayload::Native(any_data) = &msg.payload {
// 通过 downcast_ref 安全地获取原始类型
let frame: &VideoFrame = any_data.downcast_ref().unwrap();
// 这里没有复制!frame 指向同一块内存
}
这种零拷贝的设计,是 Rust 独有的优势。在 Java 或 Go 中,你通常需要序列化和反序列化,或者依赖垃圾回收器来管理内存。
第二章:Trait 驱动的设计哲学
在 tokio-memq 的开发中,Trait 定义了我的整个设计思路。这不仅仅是接口定义,更是一种思考方式的转变。
2.1 先定义 Trait,再谈实现
我的 src/mq/traits.rs 文件是整个项目的灵魂:
rust
// 定义异步发布者的行为
#[async_trait::async_trait]
pub trait AsyncMessagePublisher: Send + Sync {
fn topic(&self) -> &str;
// 核心发布方法 - 注意这个泛型约束
async fn publish<T: serde::Serialize + Send + Sync + 'static>(&self, data: T) -> anyhow::Result<()>;
// 批量发布 - 展示了 Rust 的集合类型
async fn publish_batch<T: serde::Serialize + Send + Sync + 'static>(&self, data_list: Vec<T>) -> anyhow::Result<()>;
}
为什么用 Trait? 因为我想解耦。今天我可以用 VecDeque 作为底层存储,明天换成更高效的数据结构,用户代码不需要修改。这就是 Rust Trait 的威力:定义契约,而非实现。
2.2 async_trait 的必要性
你可能注意到 #[async_trait::async_trait] 这个属性。这是一个真实的 Rust 开发痛点:
rust
// 这样的代码在 Rust 中目前无法直接工作
pub trait MyAsyncTrait {
async fn do_something(&self);
}
Rust 的 async 函数返回一个 Future,而 Trait 方法不支持泛型关联类型(GATs)在 async 上的完整支持。所以社区提供了 async-trait 这个宏来变通:
rust
// 宏展开后,实际上是这样的
pub trait MyAsyncTrait {
fn do_something<'life0, 'async_trait>(
&'life0 self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + 'async_trait + 'life0>>
where
'life0: 'async_trait;
}
这反映了 Rust 异步生态的演进过程:语言特性还在发展中,但社区总能找到务实的解决方案。
2.3 关联类型的力量
在 QueueManager Trait 中,我使用了关联类型:
rust
#[async_trait::async_trait]
pub trait QueueManager: Send + Sync {
type Publisher: AsyncMessagePublisher;
type Subscriber: MessageSubscriber;
async fn create_publisher(&self, topic: String) -> Self::Publisher;
async fn create_subscriber(&self, topic: String) -> anyhow::Result<Self::Subscriber>;
}
这比泛型更优雅。它让每个实现者定义自己的发布者和订阅者类型,但遵循相同的接口。当我实现 MessageQueue 时:
rust
// src/mq/broker.rs
#[async_trait::async_trait]
impl QueueManager for TopicManager {
type Publisher = Publisher; // 具体类型
type Subscriber = Subscriber; // 具体类型
async fn create_publisher(&self, topic: String) -> Self::Publisher {
Publisher::new(self.clone(), topic)
}
// ...
}
这种设计让我可以在不改变接口的情况下,替换内部实现。Rust 的类型系统在编译时确保一切安全。
第三章:Native 模式:零拷贝的极致与序列化的取舍
在设计 tokio-memq 时,我面临一个关键抉择:是专注于内存队列的本质,还是追求通用性?最终,我选择了Native 模式优先的设计哲学。
3.1 Native 模式:Rust 的零拷贝魔法
对于进程内的消息队列,最理想的情况是:完全避免序列化 。tokio-memq 通过 Arc<dyn Any + Send + Sync> 实现了这一目标:
rust
// src/mq/message.rs
#[derive(Clone)]
pub enum MessagePayload {
// 零拷贝的终极形态:直接共享 Rust 对象
Native(Arc<dyn Any + Send + Sync>),
// 序列化备选:为了兼容性和调试
Bytes(Arc<Vec<u8>>),
}
这种设计的精妙之处在于:
rust
// 发布者 - 直接将结构体装入 Arc,零开销
let frame = VideoFrame { data: large_vec, timestamp: now };
let payload = MessagePayload::Native(Arc::new(frame));
// 订阅者 - 通过 downcast_ref 安全地获取原始类型
if let MessagePayload::Native(any_data) = &msg.payload {
// 运行时类型检查 + 零拷贝访问
let frame: &VideoFrame = any_data.downcast_ref().unwrap();
// frame 指向同一块内存,没有复制!
}
这就是 Native 模式的威力:在 Rust 的类型安全保证下,实现真正的零拷贝 。对于一个 100MB 的视频帧,在 Redis 中需要序列化/反序列化两次,而在 tokio-memq 中只需要传递一个指针。
3.2 序列化:不情愿但必要的妥协
既然 Native 模式如此完美,为什么还要保留序列化接口?这是一个实用主义的权衡。
3.2.1 序列化的现实用途
-
调试的便利性:
rust// Native 模式无法直接打印 println!("{:?}", native_payload); // 只能打印 Any // 序列化后可以清晰查看内容 let json = serde_json::to_string(&data)?; println!("Message: {}", json); // 可读的调试信息 -
日志和监控:系统需要记录消息内容用于问题排查,JSON 是最通用的日志格式。
3.2.2 设计上的让步
我采用了双轨制设计:默认 Native 模式,提供序列化作为备选:
rust
impl<T: serde::Serialize> AsyncMessagePublisher for Publisher {
async fn publish(&self, data: T) -> anyhow::Result<()> {
// 默认使用 Native 模式
let message = TopicMessage::new_shared_topic(
self.topic.clone(),
data,
SerializationFormat::Native // 关键:默认零拷贝
)?;
self.topic_manager.publish(message).await
}
}
但同时,我保留了 publish_serialized 方法:
rust
// 当用户确实需要序列化时
publisher.publish_serialized(json_string).await?;
publisher.publish_bytes(raw_bytes, SerializationFormat::Json).await?;
3.2.3 复杂性的代价
为了支持序列化,我不得不引入 erased_serde 和 dyn Serializer,这确实增加了复杂度:
rust
// 为了动态类型擦除而引入的复杂性
pub trait Serializer: Send + Sync {
fn serialize(&self, data: &dyn erased_serde::Serialize) -> Result<Vec<u8>, SerializationError>;
fn format(&self) -> SerializationFormat;
}
但这个复杂性被隔离在了序列化模块中,99% 的用户只需要使用默认的 Native 模式,完全不需要接触这些复杂的概念。
第四章:异步通知的权衡选择
在设计消息通知机制时,我面临几个选项:
- 为每个订阅者创建一个 MPMC 通道:简单,但内存开销 O(N)。
- 使用一个全局 MPMC 通道:复杂,容易出错。
- 使用
watch::Sender:轻量,但需要订阅者主动检查。
我选择了 watch,这是 tokio 提供的一个被低估的强大工具:
rust
// 在 TopicChannel 中
pub struct TopicChannel {
// ...
notify: Arc<watch::Sender<usize>>, // 只发送最新的 offset
}
// 发布时
self.notify.send(next_offset).ok();
// 订阅者端(在 Subscriber 中)
pub struct Subscriber {
channel_notify: watch::Receiver<usize>,
// ...
}
impl MessageSubscriber for Subscriber {
async fn recv(&self) -> anyhow::Result<TopicMessage> {
loop {
// 注册通知监听
let mut rx = self.channel_notify.clone();
// 检查缓冲区
if let Some(msg) = self.fetch_from_buffer(target_offset).await {
return Ok(msg.message);
}
// 如果没有消息,等待通知
rx.changed().await.unwrap();
}
}
}
这种设计的精妙之处在于:通知的数据量极小(只是一个 usize),但唤醒能力是广播的。当有新消息时,所有订阅者都会被唤醒,然后各自去检查自己的偏移量。
这就是 Rust 异步编程的真实体验:你总是在权衡。CPU 开销 vs 内存开销 vs 实现复杂度。没有银弹,只有适合场景的取舍。
第五章:从 Trait 到实现的痛苦之路
定义 Trait 是快乐的,实现它却充满了细节的折磨。
5.1 生命周期地狱
在实现 Subscriber 的 stream() 方法时,我遇到了生命周期问题:
rust
impl Subscriber {
pub fn stream(&self) -> impl Stream<Item = anyhow::Result<TopicMessage>> + '_ {
// '_ 这个生命周期注解是必须的
try_stream! {
loop {
let msg = self.recv().await?;
yield msg;
}
}
}
}
'_ 表示这个 Stream 的生命周期不能超过 self。Rust 的生命周期系统迫使你思考:这个对象能活多久?谁拥有它?
5.2 异步锁的艺术
在 TopicChannel::add_to_buffer 中,我需要小心翼翼地处理锁:
rust
pub async fn add_to_buffer(&self, message: TopicMessage) -> anyhow::Result<()> {
// 1. 原子操作:无锁,快速获取 offset
let current_offset = self.next_offset.fetch_add(1, Ordering::SeqCst);
// 2. 获取写锁:临界区尽可能小
let mut buffer = self.message_buffer.write().await;
// 3. 清理逻辑
let mut dropped = 0;
while let Some(ttl) = self.options.message_ttl {
if let Some(msg) = buffer.front() {
if msg.is_expired(ttl) {
buffer.pop_front();
dropped += 1;
} else {
break;
}
} else {
break;
}
}
// 4. 添加新消息
buffer.push_back(TimestampedMessage::new(message, current_offset));
drop(buffer); // 尽早释放锁
// 5. 通知订阅者
self.notify.send(current_offset + 1).ok();
Ok(())
}
这里体现了几个 Rust 开发的最佳实践:
- 使用原子操作减少锁竞争
- 尽早释放锁(
drop(buffer)) - 将通知移出临界区
5.3 错误处理的哲学
我选择了 anyhow 作为错误处理库,而不是自定义错误类型。这是因为:
rust
// anyhow 的简洁
async fn publish<T>(&self, data: T) -> anyhow::Result<()> {
let message = TopicMessage::new(...)?; // ? 自动转换
self.topic_manager.publish(message).await?;
Ok(())
}
// 自定义错误的繁琐
async fn publish<T>(&self, data: T) -> Result<(), MyError> {
let message = TopicMessage::new(...).map_err(MyError::Serialization)?;
self.topic_manager.publish(message).await.map_err(MyError::Broker)?;
Ok(())
}
在实际项目中,我发现 anyhow 的 Context 特性足够用于调试,而代码简洁性的提升是巨大的。当然,这在公共库中可能有争议,但在内部项目中,我倾向于开发效率。
第六章:性能优化的真实体会
性能优化不是魔法,而是权衡。
6.1 批量处理的威力
基准测试告诉我,对于小消息,批量处理能提升 3 倍性能:
rust
// 单条发布
for i in 0..1000 {
publisher.publish(i).await?;
}
// 批量发布
let batch: Vec<i32> = (0..1000).collect();
publisher.publish_batch(batch).await?;
为什么快?
- 减少锁竞争:获取一次锁 vs 获取一千次锁。
- 内存连续性:批量分配 vs 频繁小分配。
- 通知效率:一次唤醒 vs 一千次唤醒。
6.2 分区的代价
分区确实提升了吞吐量,但也带来了复杂性:
rust
// 分区路由选择
pub async fn select_partition(&self, message: &TopicMessage) -> usize {
let routing = self.routing_strategy.read().await; // 又一个锁!
match &*routing {
PartitionRouting::RoundRobin => {
// 原子操作,无锁
self.round_robin_counter.fetch_add(1, Ordering::SeqCst) % self.partition_count
},
PartitionRouting::Hash(key) => {
// 哈希计算,CPU 密集
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
(hasher.finish() as usize) % self.partition_count
}
}
}
每个分区都需要一个独立的 RwLock,这意味着内存使用和锁管理的复杂度增加。这是 Rust 开发中的现实:没有免费的午餐。每个优化都有代价,你需要根据场景选择。
结论:Rust 开发的苦与乐
经过这个项目,我对 Rust 有了更深的理解:
我爱 Rust 的地方
- 编译器的严格:它迫使我思考所有的边界情况。虽然过程痛苦,但结果是可靠的代码。
- Trait 系统:它让我的架构更清晰,更容易测试和扩展。
- 零成本抽象 :当我写出
Arc<Vec<u8>>时,我知道它在运行时几乎没有额外开销。 - 异步生态 :
tokio、async-trait、async-stream等工具链的成熟度令人印象深刻。
我挣扎的地方
- 学习曲线陡峭:生命周期、借用检查器、Pin、Future... 每个概念都需要深度理解。
- 编译时间:大项目的编译时间确实是个问题。
- 生态选择:太多选择反而增加了决策成本。比如序列化库,就有十几种。
给 Rust 新手的建议
如果你也想用 Rust 造轮子:
- 先定义 Trait:这会强迫你理清思路,而不是一头扎进实现细节。
- 拥抱所有权 :不要试图用
unsafe绕过它。理解Arc、Rc、Box的使用场景。 - 善用
anyhow:在原型和内部项目中,优先考虑开发效率。 - 阅读源码 :
tokio、serde的源码是最好的学习材料。
给开源社区的邀请
tokio-memq 不是完美的,但它是一个真诚的尝试:试图在 Rust 的安全约束下,实现一个高性能的消息队列。在这个过程中,我学到的不仅是技术,更是一种工程思维的转变------从追求最快的实现,转向寻求平衡的方案。
如果你对这个项目感兴趣,欢迎试用和贡献:
- GitHub 仓库 : github.com/weiwangfds/...
- Crates.io 页面 : crates.io/crates/toki...
如何开始使用
在你的 Cargo.toml 中添加:
toml
[dependencies]
tokio-memq = "1.0.0"
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
然后运行最简单的示例:
rust
use tokio_memq::mq::MessageQueue;
use tokio_memq::{MessageSubscriber, AsyncMessagePublisher};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mq = MessageQueue::new();
let topic = "demo";
// 创建订阅者
let sub = mq.subscriber(topic.to_string()).await?;
let publisher = mq.publisher(topic.to_string());
// 发布消息
tokio::spawn(async move {
publisher.publish("Hello from tokio-memq!").await.unwrap();
});
// 接收消息
let msg = sub.recv().await?;
let payload: String = msg.deserialize()?;
println!("Received: {}", payload);
Ok(())
}
欢迎贡献
这个项目还在早期阶段,任何形式的贡献都被欢迎:
- 🐛 Bug 报告:如果你发现了问题,请在 GitHub 上提交 issue。
- 💡 功能建议:有什么想法?开个 issue 我们讨论。
- 🔧 代码贡献:无论是新功能、性能优化还是文档改进,PR 都很受欢迎。
- 📖 文档完善:好的文档比代码更重要,如果你觉得哪里不清楚,请告诉我。
让我们一起把 Rust 的消息队列生态做得更好!
或许,这就是 Rust 开发的真谛:在约束中寻找最优解,在社区中共同成长。