用 Rust 实现高性能并发下载器:从原理到实战

1、引言

Rust 近年来在系统编程与服务端领域迅速被采用。它将"高性能"与"内存安全"结合,并通过所有权/借用系统在编译期避免大量常见错误。本文希望通过一个实战案例,带你体验 Rust 在性能与安全之间取得的完美平衡。

2、为什么选择 Rust?

Rust 是一种兼具性能与安全性的系统编程语言,它在没有垃圾回收(GC)的前提下,通过"所有权系统"和"借用检查器",在编译期保障了内存安全线程安全

Rust 的三大核心优势:

特性 Rust 优势说明
高性能 零成本抽象、无运行时开销、接近 C/C++ 的执行效率
内存安全 编译期防止悬垂指针、空指针等内存问题
并发可靠 所有权系统确保多线程资源访问安全

在实际工程中,这意味着更少的崩溃、更高的吞吐、更强的稳定性

3、项目目标

目标:实现一个在 Windows 上可运行的高性能并发下载器,支持多线程分块下载、可视化进度条、平均速度显示、简单的断点续传提示(保存进度信息以便后续手工/自动恢复)、以及优雅中断(Ctrl-C)处理。

4、高层设计与流程图

设计要点:避免在运行时出现数据竞争(使用 Arc + Mutex 或原子变量);通过 Range 请求实现断点下载;在 Windows 下注意文件句柄与路径的差异。

5、项目创建

本项目使用 VScode 运行代码。

5.1 前置准备

环境搭建可以参考这位大佬的从零开始的vscode配置及安装rust教程_vscode rust-CSDN博客

5.2 项目创建

5.2.1 通过 VS Code 终端创建
  1. 打开 VS Code,新建终端(点击顶部菜单栏「终端 → 新建终端」,或按 `Ctrl+``)。
  1. 在终端中切换到你想存放项目的目录(例如存放在「D:\code\Rust\projects」文件夹):
rust 复制代码
cd D:\code\Rust\projects
  1. 执行 <font style="color:rgb(0, 0, 0);">cargo new 项目名</font> 命令创建项目(项目名只能包含字母、数字、下划线,且不能以数字开头):
rust 复制代码
cargo new downloaded_file
  1. 执行成功后,终端会输出提示:

Creating binary (application) downloaded_file package

此时项目目录结构已自动生成,如下:

6、完整代码

说明:代码已尽量兼容 Windows。我们在代码中实现:

  • 多线程分块下载(THREADS 可配置)
  • 进度条与下载速度显示(indicatif
  • Ctrl-C 优雅中断(保存当前位置到一个小的 .progress 文件)
  • 简单的限速(通过在循环中 sleep 控制)
rust 复制代码
// 定义全局错误类型
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;

use std::{
    fs::OpenOptions,
    io::{Seek, SeekFrom, Write},
    path::Path,
    sync::{
        atomic::{AtomicU64, Ordering},
        Arc,
    },
    time::{Duration, Instant},
};

use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::signal;
use tokio::sync::Mutex;

const THREADS: usize = 4; // 可根据需要调整
const PROGRESS_FILE: &str = "downloaded_file.progress.json";

#[derive(Serialize, Deserialize, Debug, Default)]
struct ProgressInfo {
    // 已下载的总字节数
    downloaded: u64,
    total: u64,
}

#[tokio::main]
async fn main() -> Result<()> {
    // ====== 配置(可改) ======
    let url = "https://registry.npmmirror.com/-/binary/git-for-windows/v2.25.0.windows.2/mingw-w64-git-2.25.0.windows.2-1.src.tar.gz"
        .to_string();
    let filename = "downloaded_file.bin".to_string();
    let max_speed_bytes_per_sec: Option<u64> = None; // Some(2_000_000) 限速示例
    // ==========================

    let client = Client::new();

    // 1) HEAD 获取文件大小
    let resp = client.head(&url).send().await?;
    let total_size = resp
        .headers()
        .get("content-length")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<u64>().ok())
        .unwrap_or(0);

    println!("📦 文件总大小: {} bytes", total_size);

    // 2) 尝试读取进度文件(如果存在)
    let mut resume_downloaded: u64 = 0;
    if Path::new(PROGRESS_FILE).exists() {
        if let Ok(text) = std::fs::read_to_string(PROGRESS_FILE) {
            if let Ok(pi) = serde_json::from_str::<ProgressInfo>(&text) {
                if pi.total == total_size {
                    resume_downloaded = pi.downloaded;
                    println!(
                        "🔁 发现进度文件,已记录已下载 {} bytes,可尝试继续下载",
                        resume_downloaded
                    );
                } else {
                    println!("⚠️ 进度文件与当前资源大小不一致,忽略进度文件。");
                }
            }
        }
    }

    // 3) 准备目标文件
    let mut file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .open(&filename)?;
    let current_len = file.metadata()?.len();
    if current_len < total_size {
        file.set_len(total_size)?; // 预分配文件大小
    }

    let file = Arc::new(Mutex::new(file));

    // 4) 进度条
    let pb = ProgressBar::new(total_size);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {bytes_per_sec}")
            .unwrap(),
    );
    pb.set_message("下载中");
    pb.set_position(resume_downloaded);

    // 5) 原子计数器(跨线程累计已下载)
    let downloaded = Arc::new(AtomicU64::new(resume_downloaded));
    // 6) Ctrl-C 处理器:优雅保存进度并退出
    let downloaded_for_signal = downloaded.clone();
    let total_size_clone = total_size; // 克隆用于信号处理线程(避免生命周期问题)
    tokio::spawn(async move {
        signal::ctrl_c().await.expect("failed to listen for ctrl-c");
        let v = downloaded_for_signal.load(Ordering::Relaxed);
        let pi = ProgressInfo {
            downloaded: v,
            total: total_size_clone,
        };
        let _ = std::fs::write(
            PROGRESS_FILE,
            serde_json::to_string_pretty(&pi).unwrap(),
        );
        println!(
            "\n🛑 已捕获 Ctrl-C,进度已保存到 {} ({} bytes)",
            PROGRESS_FILE, v
        );
        std::process::exit(0);
    });

    let start_time = Instant::now();
    let mut handles = vec![];

    for i in 0..THREADS {
        let client = client.clone();
        let file = file.clone();
        let pb = pb.clone();
        let downloaded = downloaded.clone();
        let url = url.clone();
        let total_size = total_size;
        let max_speed = max_speed_bytes_per_sec;

        // 每个线程的字节范围
        let start = (total_size / THREADS as u64) * i as u64;
        let end = if i == THREADS - 1 {
            total_size - 1
        } else {
            (total_size / THREADS as u64) * (i as u64 + 1) - 1
        };

        let handle: tokio::task::JoinHandle<Result<()>> = tokio::spawn(async move {
            let range_header = format!("bytes={}-{}", start, end);
            let mut resp = client
                .get(&url)
                .header("Range", range_header)
                .send()
                .await
                .map_err(|e| format!("reqwest error: {}", e))?;
            let mut stream = resp.bytes_stream();
            let mut pos = start;

            // 简单限速实现:统计本线程已读字节,然后 sleep
            let mut last_instant = Instant::now();
            let mut bytes_in_period: u64 = 0;
            let period = Duration::from_millis(200);

            while let Some(item) = stream.next().await {
                let chunk = item.map_err(|e| format!("stream error: {}", e))?;
                {
                    let mut file = file.lock().await;
                    file.seek(SeekFrom::Start(pos))?;
                    file.write_all(&chunk)?;
                }
                let len = chunk.len() as u64;
                pos += len;
                downloaded.fetch_add(len, Ordering::Relaxed);
                pb.inc(len);

                // 限速(粗略):如果设置了 max_speed,则在短周期内控制速率
                if let Some(max_bps) = max_speed {
                    bytes_in_period += len;
                    let elapsed = last_instant.elapsed();
                    if elapsed >= period {
                        let cur_bps = (bytes_in_period as f64) / elapsed.as_secs_f64();
                        if cur_bps > (max_bps as f64) {
                            // 计算需要 sleep 的时间
                            let need_sleep = Duration::from_secs_f64(
                                (bytes_in_period as f64 / max_bps as f64) - elapsed.as_secs_f64(),
                            );
                            tokio::time::sleep(need_sleep).await;
                        }
                        bytes_in_period = 0;
                        last_instant = Instant::now();
                    }
                }
            }

            Ok(())
        });
        handles.push(handle);
    }

    // 等待所有线程完成
    for h in handles {
        h.await??;
    }

    pb.finish_with_message("✅ 下载完成");

    // 删除进度文件(下载完全)
    if Path::new(PROGRESS_FILE).exists() {
        let _ = std::fs::remove_file(PROGRESS_FILE);
    }

    let elapsed = start_time.elapsed();
    let total_downloaded = downloaded.load(Ordering::Relaxed);
    let speed = (total_downloaded as f64 / elapsed.as_secs_f64()) / 1024.0 / 1024.0;

    println!(
        "\n📥 下载完成: {}\n⏱️ 总耗时: {:.2}s\n🚀 平均速度: {:.2} MB/s",
        filename,
        elapsed.as_secs_f64(),
        speed
    );

    Ok(())
}

6.1 添加依赖

代码用到了 6 个外部库,执行以下命令一键添加(确保终端在项目根目录 <font style="color:rgba(0, 0, 0, 0.85);">downloaded_file/</font> 下):

rust 复制代码
cargo add tokio@1.0 --features full  # 异步运行时(核心,支持 async/await)
cargo add reqwest@0.12 --features stream  # HTTP 客户端(用于下载文件)
cargo add futures-util@0.3  # 异步流处理(StreamExt  trait 所在)
cargo add indicatif@0.17  # 进度条显示(ProgressBar/ProgressStyle)
cargo add serde@1.0 --features derive  # 序列化/反序列化(Deserialize/Serialize)
cargo add serde_json@1.0  # JSON 解析(from_str/to_string_pretty)
命令说明:
  • <font style="color:rgb(0, 0, 0);">cargo add 库名@版本</font>:自动将依赖添加到 <font style="color:rgb(0, 0, 0);">Cargo.toml</font>,并下载对应版本(版本号用 <font style="color:rgb(0, 0, 0);">@x.y</font> 指定,确保兼容性)。
  • <font style="color:rgb(0, 0, 0);">--features</font>:启用库的必要功能(比如 <font style="color:rgb(0, 0, 0);">tokio</font> 需要 <font style="color:rgb(0, 0, 0);">full</font> 特性支持信号处理、线程池;<font style="color:rgb(0, 0, 0);">reqwest</font> 需要 <font style="color:rgb(0, 0, 0);">stream</font> 支持流式下载)。
  • <font style="color:rgb(0, 0, 0);">serde</font><font style="color:rgb(0, 0, 0);">derive</font> 特性:允许使用 <font style="color:rgb(0, 0, 0);">#[derive(Serialize, Deserialize)]</font> 自动生成序列化代码。

7、逐段代码讲解

7.1 HEAD 请求与文件大小

通过 client.head(url).send().await 获取 content-length。如果服务器不支持 content-length(例如 Transfer-Encoding: chunked),本示例会把 total_size 置为 0------这时分块下载就不适用,需要采用流式下载。

7.2 进度持久化(简单策略)

使用了 serde 用于保存一个非常简单的进度文件(存储已下载总字节数和资源总大小),这主要用于:当用户按 Ctrl-C 手动中断时,程序能把当前总体进度保存到磁盘(.progress.json),下次运行时程序会检查该文件并打印提示(自动精确恢复每个分块需要更复杂的策略,本示例采取了较简单且通用的提示/记录策略)。

我们保存了一个 JSON 文件,结构:

{

"downloaded": 123456,

"total": 104857600

}

这不是精确的分块恢复信息,但在用户中断时可以作为恢复提示 。如果要完全自动化的断点续传 ,需在磁盘上记录每个分块是否完成(或使用临时分块文件)。那会稍微复杂一些,但原理相同。

7.3 文件预分配

file.set_len(total_size) 在 Windows 上会创建一个具有指定大小的稀疏文件(如果文件系统支持),避免运行时频繁扩容带来的性能波动。

7.4 并发与写入

我们采用 Arc<Mutex<File>> 简单地让多协程串行化文件写入。这个策略在 I/O 较快时可能成为瓶颈。如果你想进一步优化:可以为每个分块打开独立的文件句柄(Windows 下也支持),或者将写入任务放到单独的写线程,通过通道接收已下载块再写磁盘。

7.5 限速实现说明

限速使用了一个非常粗糙的周期统计(200ms),根据短周期内的数据量和预期速率决定是否 sleep。它不是非常精确,但对于大多数用途已经足够。如果你需要更精确的速率控制,建议使用令牌桶(token bucket)算法。

7.6 Ctrl-C 捕获

使用 tokio::signal::ctrl_c() 监听中断信号,保存当前已下载字节数到 .progress.json 并优雅退出。Windows 下该方法同样可用。

8、结果展示

方式一:先编译再运行 **<font style="color:rgb(0, 0, 0);">cargo build</font>**+ 执行产物

先编译生成可执行文件,再手动运行(适合需要重复运行、分发程序给他人,或测试编译产物时)。

  1. 编译代码

D:\code\Rust\projects\downloaded_file> cargo build --release

  1. 运行时进度条终端截图
  1. 若中断并恢复。
  1. 下载完成后,显示文件属性的截图

方式二:****开发调试(最常用)------ **<font style="color:rgb(0, 0, 0);">cargo run</font>**

直接编译 + 运行,自动处理依赖和编译,适合开发时快速测试代码(支持热修改后重新运行)。

D:\code\Rust\projects\downloaded_file> cargo run --release

9、实验与性能对比(Rust vs Python 简单测试)

我们可以设计一个和 Rust 下载器功能类似的 Python 并发下载脚本 ,用于性能对比测试。

为了公平对比,我们采用:

9.1 Python 对照实验代码

⚠️ 说明:此代码能在 Windows 上直接运行(需安装 requests 库)。

Python 版本建议:3.8+

python 复制代码
import os
import threading
import time
import requests

URL = "https://registry.npmmirror.com/-/binary/git-for-windows/v2.25.0.windows.2/mingw-w64-git-2.25.0.windows.2-1.src.tar.gz"
FILENAME = "download_py.bin"
THREADS = 4


def get_file_size(url):
    try:
        r = requests.head(url, allow_redirects=True)
        if "content-length" in r.headers:
            return int(r.headers["content-length"])
        else:
            print("⚠️ HEAD 请求未返回 content-length,尝试 GET...")
            r = requests.get(url, stream=True)
            return int(r.headers.get("content-length", 0))
    except Exception as e:
        print("⚠️ 无法通过 HEAD 获取文件大小:", e)
        return 0


def download_range(start, end, index):
    headers = {'Range': f'bytes={start}-{end}'}
    response = requests.get(URL, headers=headers, stream=True)
    with open(FILENAME, "r+b") as f:
        f.seek(start)
        f.write(response.content)
    print(f"线程 {index} 下载完成")


def main():
    total_size = get_file_size(URL)
    print(f"📦 文件总大小: {total_size} bytes")

    if total_size < 1024 * 1024:
        print("⚠️ 可能未正确获取文件大小,请检查 URL 或代理设置。")
        return

    with open(FILENAME, "wb") as f:
        f.truncate(total_size)

    part_size = total_size // THREADS
    threads = []

    start_time = time.time()

    for i in range(THREADS):
        start = part_size * i
        end = total_size - 1 if i == THREADS - 1 else (start + part_size - 1)
        t = threading.Thread(target=download_range, args=(start, end, i))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

    elapsed = time.time() - start_time
    speed = total_size / (1024 * 1024) / elapsed
    print(f"\n📥 下载完成: {FILENAME}")
    print(f"⏱️ 总耗时: {elapsed:.2f}s")
    print(f"🚀 平均速度: {speed:.2f} MB/s")


if __name__ == "__main__":
    main()

9.2 结果展示

这里给出一个非常简化的对比思路,真实对比请在相同机器、相同网络环境下多次取平均。

  • Python 测试用例:requests + threading,由于 GIL,CPU 密集型或大量小请求并发性能差。对于 I/O 密集型任务,Python 仍然能完成任务,但并发吞吐通常低于 Rust。
  • Rust:并发真实利用多核(runtime + OS 线程),无 GC 干扰,网络与写入延迟在高负载时表现更稳定。
指标 Rust(本示例) Python (requests+threading)
平均下载速度(示例) ~15--40 MB/s(依网络) ~5--12 MB/s
稳定性 高(编译期保证) 依赖运行时与库
复杂性 代码稍复杂但可维护 更简单但风险在运行时

9.3 总结

Rust 的性能优势主要来自:

  • 原生异步 I/O + 无 GIL 限制;
  • 零成本抽象 + 无解释器;
  • 多线程真正并行(Python 的 GIL 会限制 CPU 并发)。

10、结语: Rust,让系统编程再次充满魅力

Rust 的出现,并不仅仅是多了一门"语法新潮"的语言,而是重新定义了 系统级开发的安全与效率平衡点

通过本文的实践案例,我们从多个角度验证了 Rust 的核心价值:

  • 🧠 内存安全:借助所有权与生命周期机制,Rust 在无 GC 的前提下彻底消除了悬垂指针与内存泄漏问题。
  • ⚙️ 高性能:编译为原生代码、零成本抽象,使其在 CPU 密集或网络密集型任务中接近 C/C++ 的性能。
  • 🔒 并发可靠:语言层面对数据竞争进行静态检测,让多线程不再是"悬崖边起舞"。
  • 🌐 生态日益完善:Cargo、Crates.io 以及 Tokio、Reqwest 等优秀库,使现代系统开发的门槛大幅降低。

相比之下,Python、Go、C++ 各有优势,但在综合安全性与性能的权衡中,Rust 的设计理念显得更具未来感。

尤其在高并发、云原生、区块链、嵌入式等领域,Rust 正逐步成为"安全高性能"的代名词。

相关推荐
避避风港2 小时前
Java 抽象类
java·开发语言·python
cookies_s_s2 小时前
C++20 协程
linux·开发语言·c++
石油人单挑所有2 小时前
C语言知识体系梳理-第一篇
c语言·开发语言
把csdn当日记本的菜鸡2 小时前
js查缺补漏
开发语言·javascript·ecmascript
lkbhua莱克瓦242 小时前
Java练习——数组练习
java·开发语言·笔记·github·学习方法
武子康3 小时前
Java-168 Neo4j CQL 实战:WHERE、DELETE/DETACH、SET、排序与分页
java·开发语言·数据库·python·sql·nosql·neo4j
Filotimo_3 小时前
SpringBoot3入门
java·spring boot·后端
通往曙光的路上3 小时前
SpringIOC-注解
java·开发语言
闲人编程3 小时前
Python与大数据:使用PySpark处理海量数据
大数据·开发语言·分布式·python·spark·codecapsule·大规模