下载一个大对象、迁移一整个文件夹,Nebula 会尽可能快地拉数据——这在你不干别的时候很好,但如果你正在开会、看视频,后台传输把上行/下行吃干,体验立刻崩。所以这次加的是全局带宽限速:在设置里填一个 KiB/秒 的上限(0 = 不限速),所有传输合起来不超过它。
看着简单,但"限速"这件事有几个容易做错的地方。
令牌桶:按时间匀速发放配额
限速的经典做法是令牌桶:桶按固定速率(字节/秒)注入令牌,每传 N 字节要先从桶里取走 N 个令牌;桶空了就得等。桶还有个容量上限,允许短时突发——攒下的令牌可以一次用掉,但不会无限攒。
pub struct RateLimiter {
rate: AtomicU64, // 字节/秒;0 = 不限速,可热更新
bucket: Mutex<Bucket>,
}
struct Bucket { tokens: f64, last: Instant }
桶容量取 1 秒的额度(rate 字节),即最多允许 1 秒的突发。够平滑,又不会因为攒太多令牌导致一阵猛冲。
坑一:块比桶还大怎么办?——用"欠账"
朴素实现是"令牌不够就循环等,直到 tokens >= n"。但传输的块大小不受你控制:限速设 100 KiB/s(桶容量 100 KiB),而下载分块可能是 8 MiB。这时 tokens 永远追不上 n,死循环。
我们改用欠账模型:一次直接把 n 扣掉,tokens 允许变负;然后按速率把这笔负债"等"平:
b.tokens = (b.tokens + elapsed * rate).min(cap); // 按流逝时间补桶
b.tokens -= n as f64; // 直接扣,可为负
if b.tokens < 0.0 {
let wait = -b.tokens / rate as f64; // 欠多少,等多少
tokio::time::sleep(Duration::from_secs_f64(wait)).await;
}
不管块多大都能正确限速,不会死循环——超大块就多等一会儿,平均速率始终收敛到设定值。
坑二:改了限速要立刻生效
限速是用户随时会调的。如果把速率写死在结构体里,改一次就得把进行中的传输全断了重建,很粗暴。所以 rate 用 AtomicU64 存,set_rate 直接原子写入——正在跑的传输下一块就按新速率走,无需重启任务。设置里把限速从 1 MiB/s 调到不限速,当场松绑。
坑三:在哪儿节流?——读取侧,一个全局桶
限速要"全局",不能每个任务各限各的(那样 5 个任务就是 5 倍带宽)。所以整个 App 共享同一个令牌桶:App 持有一个 TransferLimits,它内部是 Arc<RateLimiter>,App 的每个克隆(包括每个 Tauri command)都指向同一个桶,天然汇成一个全局上限。
节流点选在读取侧——数据从云上下来的地方:
- 下载 / 文件夹下载:写盘循环里,每写一块先
throttle(chunk.len())。 - 跨账号迁移:把源的读取流用限速包一层,每块产出前扣配额,再喂给目标的流式上传:
let (len, stream) = src.read_stream(src_path).await?;
let stream = self.limits.throttled(stream); // 套上全局限速
dst.write_stream(dst_path, len, stream, None, progress).await?;
因为节流只作用在统一的字节流 / 分块循环上,不碰任何厂商签名或协议细节,所以对每家云一视同仁,以后新接入的云自动受同一个全局限速约束。
小结
一个共享令牌桶,三个要点:欠账模型让任意块大小都不死循环;原子速率让调整即时生效;单一全局桶 + 读取侧节流让"全局限速"名副其实。填个数,后台传输就不会再和你抢带宽了。