这块树莓派差点被我扔进抽屉。它跑着一个数据网关服务,本身不算复杂,可自从接入的设备从十几台涨到四十多台,服务就一直满负荷运行,CPU持续拉满,消息延迟和丢失也开始出现。我用Rust把核心链路整个重写了一遍,换完之后性能提升了10倍——这里的10倍不是营销话术里的那种"虚指",而是同一块板子、同一批数据、同一套依赖条件下反复测出来的真实数字。这篇文章把完整过程记录下来,包括重写的动机、瓶颈如何定位、核心代码怎么落地、实测对比数据、交叉编译部署,以及连续运行期间踩过的坑。如果你也有一台低配设备在跑Python服务,并且正在犹豫要不要动刀换成Rust,这篇应该够你做一个比较完整的参考了。
1. 这次重写的起点:一块被Python拖垮的树莓派
1.1 项目原本的样子
这个项目本质是一个传感器数据网关。树莓派放在机柜旁,通过I2C总线连接温湿度传感器,同时监听一个TCP端口,接收现场设备主动上报的JSON数据帧,解析清洗之后转发到内网的MQTT broker,最后在板子上跑一个极简HTTP状态页,方便我时不时瞄一眼运行状况。
最开始用Python写,理由和大多数人一样:快、顺手、生态成熟。smbus2读I2C,paho-mqtt做上行推送,flask起状态页,TCP服务端用socket加线程就能搞定。设备少的时候一切都很美好,十个设备、每秒几条消息,树莓派几乎没什么压力。
真正的问题出现在设备数量上来之后。现场陆陆续续接了更多采集器,上报频率也从一分钟一次改成五秒一次,消息量涨了一个数量级。我开始频繁在状态页上看到CPU占用冲到70%以上,MQTT消息的到达时间越来越不稳定,偶尔还会出现某台设备的数据几分钟不更新。最初我以为是网络问题,排查了一圈,最后用top和iotop盯了很久才确认:瓶颈就在这块板子上跑的Python进程本身。
1.2 服务没有崩,但一切都在变慢
这里要说明一下,服务并没有崩溃,进程还活着,端口也在监听。但"活着"和"健康"是两回事。Python服务在多设备并发上报的情况下,出现了几个连锁反应:
- 每来一个TCP连接就开一个线程,线程数一多,GIL竞争加剧,单个消息的处理时间被拉长。
json.loads解析每条上报帧、json.dumps格式化输出,在消息量大的时候占掉大量CPU时间。- 所有线程共享同一个
paho-mqtt客户端,客户端内部有锁保护,多个线程同时发布时互相等待。 - 状态页接口偶尔也需要查一下最新数据,Flask的处理又和数据线程抢GIL。
就这么一层一层叠下来,最后表现就是:CPU打满,消息延迟从几十毫秒涨到一秒以上,偶发丢消息。我试着优化过Python侧,比如用concurrent.futures限制线程数、把json换成orjson、把MQTT发布改成批量合并。每一项都有点效果,但整体依然是"治标不治本"。瓶颈的核心在于Python运行时在大量并发IO和解析场景下的固有开销,这不是换个库就能抹平的。
1.3 为什么选择Rust而不是继续优化Python
决定重写之前,我也认真考虑过Go、C、甚至Node.js。最终选Rust,有几个非常实际的原因:
- 树莓派的资源有限,静态编译、无运行时、无GC的Rust在这方面天然占优,编译产物直接丢到板子上就能跑,不依赖一堆动态库。
- 项目的核心链路是"网络IO + 数据解析 + 转发",正好是Rust的async生态最擅长的领域,
tokio处理并发比"线程+GIL"干净得多。 - 借着重写的机会,我想把整个消息处理流程重新梳理一遍。Rust的所有权模型会逼着你把数据流想清楚,这种约束在一个长期运行的常驻服务上,其实是好事。
选择Rust也不意味着没有代价,后面会专门讲编译和部署阶段遇到的麻烦。但从最终结果看,这笔账是划算的。
2. 先量化再动手:性能基线与三个热点
2.1 基线测试是怎么做的
重写前最重要的一件事,是把"慢"变成可测量的数字。没有基线数据就动手,后面根本说不清楚优化有没有效果。我的做法很简单:
- 同一块树莓派4B,保持系统环境不变。
- 用脚本模拟现场设备,并发建立TCP连接并向网关推送JSON数据帧,帧结构完全按线上格式构造。
- 用
top、/proc/[pid]/status记录CPU和内存。 - 在网关侧给每条消息打时间戳,统计从收到完整帧到完成MQTT发布之间的延迟。
- 连续跑30分钟取稳定值,避免瞬时波动误导判断。
这样得到的基线数据很直观:CPU稳定在62%~70%之间,内存占约108MB,p95消息处理延迟为1.1秒,稳态吞吐大概220条消息/秒。再往上加压,延迟会迅速恶化,开始出现socket backlog堆积。
拿到这组数字,我基本确定了优化目标:延迟压到100ms以内,CPU占用降到个位数,吞吐至少翻五倍。后面实测证明,这个目标定得还是保守了。
2.2 热点一:线程模型和GIL
第一个热点来自Python的线程模型。每台设备一个TCP长连接,对应一个线程,而线程里做的所有工作——读socket、解析JSON、写MQTT——都在GIL的保护下串行执行。
你可以把GIL理解成一条单行隧道:路可以修多条车道(多线程),但车最终都要挤进同一条隧道(GIL)。IO操作虽然会释放GIL,但问题在于消息处理中大部分是纯CPU计算——JSON解析、字符串拼接、字典操作,这些都不会释放GIL。设备一多,线程之间频繁竞争这把锁,等待成本直线上升。
我试过用多进程替代多线程,把解析工作分发到多个进程,确实能绕开GIL。但多进程带来了新的问题:进程间通信成本、内存翻倍、以及I2C总线等硬件资源在多进程间共享变得复杂。对于树莓派这种资源有限的设备,多进程方案并不理想。
2.3 热点二:JSON解析开销
第二个热点是JSON解析。现场的采集器都按统一格式上报,数据帧大约200字节左右,包含设备ID、时间戳、若干测量值。听上去不多,但量级上来之后非常可观。
从实测来看,Python每处理一条消息,json.loads解析加json.dumps序列化以及中间的数据清洗,大约消耗1.2毫秒的CPU时间。按220条/秒的稳态吞吐计算,仅JSON相关操作就占用了约25%的单核时间。再加上日志里的字符串格式化、f-string拼接、时间戳处理,光这些"看似不起眼"的操作,就把CPU吃掉了将近四成。
orjson确实比标准库快不少,我换上之后解析开销降了一些,但依然解决不了GIL和整体Python运行时的开销。这让我意识到,在这个项目里,优化Python库只是杯水车薪。
2.4 热点三:MQTT客户端与IO阻塞
第三个热点在MQTT上行这条链路。paho-mqtt的同步客户端在多个线程里共用时,内部的socket写入和消息确认都需要加锁;一旦网络抖动,发布线程会阻塞在等待确认上,而其他线程只能排队等待锁释放。
我当时的做法是给发布操作加了一个独立队列,由单线程统一消费,避免多线程直接抢客户端。但这个方案引入了新的延迟:消息要先入队,再由发布线程取出,一次完整的"接收—解析—入队—发布"链路变得更长。队列长度一上来,延迟就开始飙升,最后形成恶性循环。
三个热点梳理完之后,我心里基本有数了:Python的瓶颈是系统性的,不是某个库或某段代码的问题。与其继续打补丁,不如用Rust把整条链路重写一遍。
3. 核心链路重写:从GPIO读数到MQTT上行的落地过程
3.1 选型思路:rppal、tokio、rumqttc怎么配合
重写的第一步是选型。树莓派的硬件访问选了rppal,这是Rust生态里最成熟的库,同时支持GPIO、I2C、SPI和UART,底层直接操作/dev/i2c和/dev/gpiomem,几乎没有额外封装开销。
异步运行时选了tokio,处理大量TCP长连接、定时任务、MQTT事件循环都靠它。MQTT客户端用了rumqttc,它提供同步和异步两种接口,异步版的AsyncClient和EventLoop正好可以放进tokio的任务里调度。
JSON解析选了serde_json,配合serde的派生宏,可以做到直接把上报帧映射成结构体,解析速度和运行时开销都比Python标准库高一个数量级。HTTP状态页直接用axum,它是tokio生态的一部分,写起来顺手,性能也足够。
这套组合比较自然地分工:rppal管硬件,tokio管并发,rumqttc管上行,serde_json管解析,各司其职,链路清晰。
3.2 Python版主循环的样子
先看一眼Python版的核心结构,方便后面和Rust版对照:
import json, time, threading import smbus2 import paho.mqtt.client as mqtt from flask import Flask bus = smbus2.SMBus(1) mqtt_client = mqtt.Client() mqtt_client.connect("mqtt.local", 1883, keepalive=60) def publish(topic, payload): mqtt_client.publish(topic, payload) def handle_frame(raw): obj = json.loads(raw.decode("utf-8")) obj["received_at"] = time.time() return obj def tcp_worker(conn): with conn: while True: line = conn.readline() if not line: break try: obj = handle_frame(line) publish("sensors/data", json.dumps(obj)) except Exception as exc: print("parse error", exc) # 主服务 # 每个TCP连接开一个线程,Flask另开一个线程这段代码看起来很规整,但它的问题在于"责任划分模糊":读socket、解析、发布全部耦合在一起,并发全靠线程数量堆。设备一多,线程一多,GIL一争,整条链路就开始退化。
3.3 Rust版的结构与关键代码
Rust版我没有照搬Python的结构,而是把整条链路拆成了清晰的阶段:监听连接、按行读取、解析清洗、写入通道、统一上行。每个阶段由一个独立的tokio任务处理,任务之间通过channel传递数据,不再用锁保护共享状态。
核心代码大致是这个样子:
use rppal::i2c::I2c; use rumqttc::{AsyncClient, MqttOptions, QoS}; use serde::{Deserialize, Serialize}; use serde_json::{from_slice, to_string}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::mpsc; use tokio::time::{interval, Duration}; use tokio_util::codec::{FramedRead, LinesCodec}; #[derive(Deserialize, Serialize)] struct SensorFrame { device_id: String, temperature: f32, humidity: f32, #[serde(default)] extra: std::collections::HashMap<String, f32>, } async fn handle_client( stream: TcpStream, tx: mpsc::Sender<SensorFrame>, ) -> anyhow::Result<()> { let mut reader = FramedRead::new(stream, LinesCodec::new()); while let Some(line) = reader.next().await { let line = line?; let frame: SensorFrame = from_slice(line.as_bytes())?; if tx.send(frame).await.is_err() { break; } } Ok(()) } async fn mqtt_worker( mut rx: mpsc::Receiver<SensorFrame>, client: AsyncClient, ) -> anyhow::Result<()> { let mut ticker = interval(Duration::from_secs(5)); let mut batch = Vec::new(); loop { tokio::select! { Some(frame) = rx.recv() => { batch.push(frame); if batch.len() >= 32 { let payload = to_string(&batch)?; client.publish("sensors/data", QoS::AtLeastOnce, payload.as_bytes()) .await?; batch.clear(); } } _ = ticker.tick() => { if !batch.is_empty() { let payload = to_string(&batch)?; client.publish("sensors/data", QoS::AtLeastOnce, payload.as_bytes()) .await?; batch.clear(); } } } } } #[tokio::main] async fn main() -> anyhow::Result<()> { let mqtt_options = MqttOptions::new("pi-gateway", "mqtt.local", 1883); let (client, mut eventloop) = AsyncClient::new(mqtt_options, 128); tokio::spawn(async move { loop { if let Err(e) = eventloop.poll().await { eprintln!("mqtt eventloop error: {e}"); tokio::time::sleep(Duration::from_secs(1)).await; } } }); let (tx, rx) = mpsc::channel::<SensorFrame>(1024); tokio::spawn(mqtt_worker(rx, client)); let listener = TcpListener::bind("0.0.0.0:9000").await?; loop { let (stream, peer) = listener.accept().await?; let tx = tx.clone(); tokio::spawn(async move { if let Err(e) = handle_client(stream, tx).await { eprintln!("client {peer} error: {e}"); } }); } }这里有两个设计值得展开讲讲。
第一,TCP读取用LinesCodec做按行拆包。采集器的上报协议是"单条JSON + 换行符",LinesCodec天然处理粘包和半包问题。Python版用socket的readline()也能做到,但线程多的时候每行都产生大量小对象,GC压力非常大。Rust版解析完直接落进栈上的结构体,写完channel就释放,全程几乎没有堆分配。
第二,MQTT发布从"每条消息立即发"改成了批量发送。说实话这是我在重写过程中加进去的优化,原计划是保持和Python版一样的发布频率,后来发现批量发送对降低网络往返非常有效。32条一批打包成一个JSON数组发布,延迟不但没增加,反而因为减少了socket写入次数而明显下降。设备少的时候这个优化可有可无,但设备多起来之后,它直接把MQTT链路的吞吐上限拉高了一个量级。
GPIO侧的传感器读取也保留了,但单独放在一个任务里,通过interval每两秒读一次,数据同样走channel交给mqtt_worker发送,和TCP上报数据走同一条上行链路。
3.4 所有权模型倒逼出来的架构改变
说句公道话,Rust的借用检查器在初期确实让我多花了一些时间。但也正是这些约束,逼着我把架构改成了一个明显更好的形态。
Python版里所有线程共享bus和mqtt_client,靠锁保证安全。Rust版里,I2C总线只被一个任务独占持有,其他任务无法碰它,彻底消灭了硬件资源的竞争问题。MPSc channel成为唯一的数据通路,发送端拿Sender,接收端拿Receiver,所有权明确,不存在什么隐式共享。
这种"资源归特定任务所有"的模型,放在Rust里是编译器帮你强制执行的,而在Python里完全靠自觉。长期运行的服务最怕的就是"自觉"——人总有疏忽的时候,但编译器不会。
4. 同板实测:10倍差距的还原与每一毫秒的账
4.1 同条件对比方案
代码写完,最激动人心的环节来了。我没有换板子,没有换网络环境,而是用原来的树莓派4B,同一张系统镜像,用同一套模拟脚本重新跑了一遍基线测试。Python版和服务版交替部署,确保测试条件完全一致。
为了尽量公平,我保留了原有的模拟上报脚本,帧格式、连接数、频率都不改。每轮测试30分钟,取最后10分钟的稳定数据作为对比样本。
4.2 四组关键指标的对比
直接把最终数据放上来:
| 指标 | Python版 | Rust版 | 变化 |
|---|---|---|---|
| CPU占用(稳态) | 62%~70% | 6%~8% | 降低约90% |
| 内存占用(RSS) | 108MB | 9.8MB | 降低约91% |
| p95消息处理延迟 | 1.1秒 | 82ms | 降低约13倍 |
| 稳态消息吞吐 | 220条/秒 | 2480条/秒 | 提升约11倍 |
| 1000并发连接下的消息丢失率 | 12.4% | 0.2% | 显著改善 |
吞吐从220条/秒提升到2480条/秒,接近11倍;延迟从1.1秒降到82毫秒,改善超过10倍。所以标题里的"性能飙升10倍"不是从某一项指标抠出来的,而是延迟和吞吐两项都有数量级的提升,CPU和内存同时大幅下降。
4.3 这10倍究竟是从哪里省出来的
很多人看到10倍会怀疑是不是对比不公。我可以明确地说,这个提升不是某一处优化带来的,而是整条链路的系统性红利:
- 并发模型:Python的"线程+GIL"在高并发下把大量CPU时间浪费在线程切换和锁等待上。Rust的async模型让单核能够高效调度成千上万个轻量任务,这一项是延迟改善的最大来源。
- 解析成本:
serde_json直接把JSON映射到结构体,比Python标准库的解析快一个数量级,而且在Rust里几乎不产生GC压力。 - 零共享数据:Python版所有线程共享MQTT客户端,锁竞争严重;Rust版通过channel解耦,发布任务独占客户端,没有任何锁等待。
- 批量上行:合并发布减少了MQTT socket写入次数,显著提高了网络吞吐上限。
- 无运行时开销:Rust的静态编译产物没有解释器和GC,运行时资源占用天然更低。内存从108MB降到9.8MB,主要就是这个原因。
说白了,这10倍不是靠某一处"魔法优化"换来的,而是把对话语境的运行时、锁、线程、逐条发布全部换掉了。每一层省一点,最后叠加起来就是数量级的差距。
5. 开发机到树莓派:交叉编译与部署的完整链路
5.1 工具链配置:一条命令之外还要做的几件事
Rust最大的优势之一是交叉编译体验比C/C++好一个数量级,但也不是零配置。我的人生镜像装的是64位系统,所以在开发机上先添加了目标平台:
rustup target add aarch64-unknown-linux-gnu然后需要安装交叉编译的C链接器:
sudo apt install gcc-aarch64-linux-gnu接着在项目的.cargo/config.toml里指定链接器:
[target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc"到这里,一个纯Rust项目基本就能交叉编译了。但我的项目里rumqttc默认会引入一些依赖,如果走OpenSSL的路径,就需要额外配置环境变量,让pkg-config能找到交叉编译版本的OpenSSL,这一步非常容易踩坑。我的解决方案是直接启用rumqttc的rustls特性,用纯Rust实现的TLS代替OpenSSL,彻底绕开了C依赖链。
在Cargo.toml里这样配置:
[dependencies] rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"] }这样编译出来的二进制不依赖板子上的动态库,rppal和tokio也都是纯Rust加少量系统调用,整个产物非常干净。
注意:如果你的项目必须用OpenSSL(比如要连某些只支持TLS1.0的旧broker),交叉编译时记得给
pkg-config设置PKG_CONFIG_PATH=aarch64-linux-gnu的sysroot路径,否则链接阶段会报找不到库。
5.2 体积控制与systemd部署
编译产物在release模式下大约12MB,strip之后降到8.5MB,对于树莓派来说毫无压力。如果你对体积敏感,可以在Cargo.toml里开启:
[profile.release] lto = true opt-level = "s" strip = truelto做链接时优化,opt-level = "s"偏向体积优化,strip自动剥离符号表。这样编译出来的二进制会进一步减小,付出的代价是编译时间变长。
部署我用的是编写好的systemd服务,直接接管自动启停:
[Unit] Description=Rust Pi Gateway After=network-online.target [Service] User=pi WorkingDirectory=/opt/pi-gateway ExecStart=/opt/pi-gateway/pi-gateway Restart=always RestartSec=3 [Install] WantedBy=multi-user.target唯一需要留意的是,如果通过GPIO或I2C访问硬件,运行用户必须拥有对应设备节点的权限。树莓派默认把I2C设备挂在i2c组名下,需要把运行用户加进组,或者在/etc/udev/rules.d/里写一条自定义规则。rppal在权限不够的时候会返回很明确的错误,跟着提示把权限补上就好。
6. 持续运行观察:三个典型故障与最终建议
6.1 故障一:I2C总线竞争
上线运行两天后,我注意到温度传感器偶尔会读出异常值,往往是某个固定值卡住几秒再恢复。排查发现,I2C总线上除了温度传感器,还挂了一块扩展板,两者共用同一个总线段。Python时代的代码用锁保护了访问,重写后我在某个版本里把I2C读写放进了两个不同的tokio任务,总线访问没有做串行化。
Rust编译器不会帮你发现硬件层面的竞争——它只管内存安全。这类问题只能靠设计约束:后来我把I2C读写收敛到单一任务里,所有传感器请求都通过channel发送给它,由它独占访问总线,问题彻底消失。
6.2 故障二:rumqttc的EventLoop没有及时poll
刚上线的时候,MQTT发送偶尔会出现延迟陡增,持续几秒后又恢复正常。观察发现,AsyncClient和EventLoop之间的内部通道满了,发布任务被阻塞等待。
原因是我在主循环里启动EventLoop之后,没有及时调用poll()处理网络事件。rumqttc的异步客户端依赖EventLoop::poll()驱动底层socket,如果你只发布消息而不持续poll,消息确认和重传逻辑就无法及时执行。
解决办法是给EventLoop独占一个tokio任务,持续循环poll,并在出错时短暂sleep后重试。代码在前面已经写了,这里特别强调一下:rumqttc的异步模式下,poll可不是可选项,而是必须常驻的驱动循环。
6.3 故障三:TCP分包和粘包
上线一周后,有台设备上报的数据偶尔会解析失败。看了日志才知道,那台设备在发送JSON时,偶尔会分两次写入socket,两次之间间隔几十毫秒。Python版的readline()虽然也遇到这个问题,但它在底层用缓冲区的find('\n')处理粘包,非常稳健;而我在Rust版一开始用的read_until写法,逻辑上处理得不够严谨,遇到半包就把不完整的JSON交给serde_json解析,自然报错。
换成tokio_util的LinesCodec之后就再也没出现过。这类问题在嵌入式设备上报场景里非常常见,强烈建议直接用现成的编解码器,不要自己手动拼缓冲区。
6.4 连续运行一周的观察与最终建议
修复完上面三个问题之后,服务连续运行了七天。这期间我记录了系统数据:RSS稳定在9MB到12MB之间,没有发现内存泄漏;systemd的Restart计数器为空,进程没有被拉起过;MQTT消息的p95延迟稳定在90毫秒以内,CPU占用依旧是个位数。
回看这次重写,我的体会是:Rust不是万能的,但在这个场景下它确实把问题解决得很彻底。如果你的项目同时满足以下几条,我建议你认真考虑用Rust重写:
- 是一个需要长期运行的常驻服务,而不是一次性脚本。
- 有大量网络IO和解析操作,且延迟和吞吐有硬要求。
- 运行在树莓派这类资源受限的设备上。
- 业务逻辑相对稳定,不会频繁大改。
反过来,如果你的服务功能还在快速迭代、团队里没人写过Rust、或者设备数量很少根本不存在性能压力,那Rust带来的额外心智负担可能不值得。性能优化和工程投入必须匹配实际需求,这是我这半年最深的感触。最后再分享一个小技巧:重写类项目不要把新旧代码混在一起改,先在旧服务旁边并行运行新版本,抓完所有兼容性问题再切换流量,这个流程帮我省掉了大量回滚的痛苦。