100 Exercises To Learn Rust练习笔记

101 阅读18分钟

100 Exercises To Learn Rust

一、基础

整数

  • 无自动类型转换,不同类型变量之间赋值会报错
  • 2个整数相除得到的还是整数,比如5/2得到的是2

变量

  • 函数的参数必须显示声明类型

if语句

  • if 表达式中的条件必须是 bool 类型,即布尔值
  • if/else是一个表达式,在rust中表达式可以返回一个值,但要求各个分支返回的类型必须一致
let number = 3;
let message = if number < 5 {
    "smaller than 5"
} else {
    "greater than or equal to 5"
};

溢出和下溢

比如两个u8类型数据相加值为256,当发生溢出时,rust会有两种处理方式

  • 拒绝执行
  • wrap around 选择环绕,两个u8相加的值,溢出的值重新从0开始到255
  • overflow-checks可以在配置文件中设置这两种方法的选择
  • 如果需要根据上下文执行不同的运算效果,可以使用下面的方法
    • wrapping_methods
    • saturating_methods
let x = 255u8;
let y = 1u8;
let sum = x.wrapping_add(y);
assert_eq!(sum, 0);

let x = 255u8;
let y = 1u8;
let sum = x.saturating_add(y);
assert_eq!(sum, 255);

as类型转换

详细文档

需要注意的这个截断的概念

模块可见性

  • pub:使实体公开 ,即可以从定义它的模块外部访问,可能从其他 crate 访问。
  • pub(crate): 使实体在同一个 crate 中公开,但不在外部。
  • pub(super): 在父模块中公开实体。
  • pub(in path::to::module): 在指定模块中公开实体。

类型布局

这里包括数据对齐,初次了解这个概念,通过deepseek查找了一下答案。

在计算机中数据对齐的作用是什么?

提升内存访问速度(性能)

现代计算机的CPU通过数据总线从内存中读取数据。这个总线是有宽度的(例如32位、64位)。内存系统也通常被设计为在对齐的地址上访问时效率最高。

  • 未对齐访问的代价:如果一个4字节的整数存储在地址0x0003(即没有在4字节边界上对齐),CPU需要执行两次内存访问:

    • 第一次从地址0x0000读取4个字节(获取0x0000-0x0003,包含该整数的第一个字节)。
    • 第二次从地址0x0004读取4个字节(获取0x0004-0x0007,包含该整数的后三个字节)。
    • 然后CPU需要将这两次读取结果中的相关部分拼接起来,才能得到这个整数的值。
  • 对齐访问的优势:如果同一个4字节整数存储在地址0x0004(在4字节边界上对齐),CPU只需一次内存访问即可读取整个整数。

显然,一次操作比两次操作外加拼接要快得多。对于高性能计算,这种差异是至关重要的。

//在rust中String类型是由指针,长度,空间三个部分组成的
//每一个字段都是表示usize,所以在64位系统中,usize占用8个字节
//那么String占用内容的大小就是 3*8 = 24
fn string_size() {
        assert_eq!(size_of::<String>(), 24);
}

更详细内容查看,类型布局

引用

这里容易误解的一个点是,并不是所有的指针都指向堆

Rust 中的大多数引用,在内存中表示为指向内存位置的指针。因此,它们的大小与指针的大小相同,即 usize

assert_eq!(std::mem::size_of::<&String>(), 8);
assert_eq!(std::mem::size_of::<&mut String>(), 8);

什么是胖指针? 就是带有附加元数据的指针 比如Stirng类型它存储的结构是,有指针,长度,容量。比如&str,它的内存模式是一个指针和一个字符长度

二、Trait

标准库中常用trait

先附上一个关于trait的文档链接tour-of-rusts-standard-library-traits

在标准库中定义的一些关键trait

  • Operator traits (e.g. AddSubPartialEq, etc.)
OperatorTrait
+Add
-Sub
*Mul
/Div
%Rem
== and !=PartialEq
<><=, and >=PartialOrd

算术运算符在std::ops模块,比较运算符在std::cmp模块

  • From and Into, for infallible conversions
  • Clone and Copy, for copying values
  • Deref and deref coercion
    • 通过为类型 T 实现 Deref<Target = U>,您可以告诉编译器 &T 和 &U 在某种程度上是可以互换的。
  • Sized, to mark types with a known size
  • Sized, to mark types with a known size

trait的作用

  • 解锁内置行为,比如上面的运算符trait
  • 扩展trait,向现有的类型添加行为
  • 通用编程,比如泛型
pub struct Ticket {
    title: String,
    description: String,
    status: String,
}
// 存在一个疑问这里为什么不是&self.title.trim()
impl Ticket {
    pub fn title(&self) -> &str {
        self.title.trim()
    }

    pub fn description(&self) -> &str {
        self.description.trim()
    }
}

Sized

动态大小类型

要点

  • str也是动态大小类型
  • Rust 有一个特定的 trait 来确定一个类型的大小是否在编译时可知,即Sized
  • 对于动态大小类型,必须放在指针后面来使用
  • 每次具有泛型类型参数时,编译器都会隐式假定它是 Sized
  • 当函数绑定动态大小类型时,可以使用?Sized,表示可能是或可能不是Sized
// 注意t的类型是&T因为其类型可能不是 `Sized` 的,所以需要将其置于某种指针之后,所以使用了引用
fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

Into和From

  • From和Into是双重Trait,实现了From的任何类型,都实现了Into
  • into()转换成什么类型,需要显示类型标注
pub struct WrappingU32 {
    value: u32,
}
impl From<u32> for WrappingU32{
    fn from(value:u32)->Self{
        WrappingU32{value}
    }
}

fn example() {
    let wrapping: WrappingU32 = 42.into();
    let wrapping = WrappingU32::from(42);
}

泛型和关联类型

关联类型使得只有一个实现,在这里定义trait时,type Output是在实现trait时需要确定的类型,Exponent=Self默认值是Self,在使用时可以更改为需要实现的类型,使得与self关联一起

pub trait Power<Exponent = Self> {
    type Output;

    fn power(&self, n: Exponent) -> Self::Output;
}

impl Power<u16> for u32 {
    type Output = u32;

    fn power(&self, n: u16) -> Self::Output {
        self.pow(n.into())
    }
}

impl Power<&u32> for u32 {
    type Output = u32;

    fn power(&self, n: &u32) -> Self::Output {
        self.power(*n)
    }
}

impl Power<u32> for u32 {
    type Output = u32;

    fn power(&self, n: u32) -> Self::Output {
        self.pow(n)
    }
}

Clone

它的方法 clone 采用对 self 的引用,并返回一个相同类型的新拥有的实例。

pub trait Clone {
    fn clone(&self) -> Self;
}

Copy

pub trait Copy: Clone { }

如果类型实现了 Copy,则无需调用 .clone() 来创建该类型的新实例:Rust 会隐式地为您完成此作。

  • 无论 T 是什么,&mut T 从不实现 Copy

类型必须满足一些要求才能被允许实现 Copy

  • 除了它在内存中占用的std::mem::size_of字节之外,该类型不管理任何额外的资源(例如堆内存、文件句柄等)。
  • 该类型不是可变引用 (&mut T)。

在实现Copy时,可以派生它,但必须也派生Clone,因为Copy是Clone的子trait,所以这个类型也必须的视线Clone功能,下面示例说明:

#[derive(Copy, Clone)]
struct MyStruct {
    field: u32,
}

Drop

pub trait Drop {
    fn drop(&mut self);
}

Drop与Copy

如果一个类型管理超出其在内存中占用的 std::mem::size_of 字节之外的其他资源,则它无法实现 Copy

编译器如何知道类型是否管理其他资源? 没错: Droptrait实现!

如果您的类型具有显式的 Drop 实现,则编译器将假定您的类型附加了其他资源,并且不允许您实现 Copy

下面的实现将报错


impl Drop for MyType {
    fn drop(&mut self) {
        todo();
    }
}

Error

pub trait Error: Debug + Display {}

TryFrom和TryInto

TryFrom 和 TryInto 都在 std::convert 模块中定义,就像 From 和 Into 一样。

如果为某个类型实现 TryFrom,则可以自动获得 TryInto

pub trait TryFrom<T>: Sized {
    type Error;
    fn try_from(value: T) -> Result<Self, Self::Error>;
}

pub trait TryInto<T>: Sized {
    type Error;
    fn try_into(self) -> Result<T, Self::Error>;
}

在这里还是有点困惑的,没有写出来,真的动手了,发现这个章节好几个写不出来,这里的思路挺好的,先实现&str转换到Status的,然后再实现String转换到Status的

#[derive(Debug, PartialEq, Clone)]
enum Status {
    ToDo,
    InProgress,
    Done,
}
#[derive(Debug,thiserror::Error)]
#[error("{invalid_status} is not a valid status")]
struct ParseStatusError{
    invalid_status:String
}
impl TryFrom<String> for Status{
    type Error=ParseStatusError;

    fn try_from(value: String) -> Result<Self, Self::Error>{
       value.as_str().try_into()
    }
}
impl TryFrom<&str> for Status {
    type Error = ParseStatusError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        match value.to_lowercase().as_str() {
            "todo" => {
                Ok(Status::ToDo)
            },
            "inprogress" => Ok(Status::InProgress),
            "done" => Ok(Status::Done),
            _ => Err(ParseStatusError {
                invalid_status: value.to_string(),
            }),
        }
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryFrom;

    #[test]
    fn test_try_from_string() {
        let status = Status::try_from("ToDO".to_string()).unwrap();
        assert_eq!(status, Status::ToDo);

        let status = Status::try_from("inproGress".to_string()).unwrap();
        assert_eq!(status, Status::InProgress);

        let status = Status::try_from("Done".to_string()).unwrap();
        assert_eq!(status, Status::Done);
    }

    #[test]
    fn test_try_from_str() {
        let status = Status::try_from("todo").unwrap();
        assert_eq!(status, Status::ToDo);

        let status = Status::try_from("inprogress").unwrap();
        assert_eq!(status, Status::InProgress);

        let status = Status::try_from("done").unwrap();
        assert_eq!(status, Status::Done);
    }
}

三、复杂类型

向量

对于vec!向量存储,可以通过Vec::with_capacity设置容量大小,如果超出内容,它将要求分配器提供新的(更大的)堆内存块,复制元素,并释放旧内存。此作可能成本高昂,因为它涉及新的内存分配和复制所有现有元素。因此对于你能预料的内存大小可以使用Vec::with_capacity进行设置,避免自动扩容,导致性能问题。

let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
numbers.push(3); // Max capacity reached
numbers.push(4); // What happens here?

Iterator trait

iterator trait的实现

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

for循环的本质

let mut iter = IntoIterator::into_iter(v);
loop {
    match iter.next() {
        Some(n) => {
            println!("{}", n);
        }
        None => break,
    }
}

IntoIterator trait

并非所有类型都实现 Iterator,但许多类型都可以转换为实现 Iterator 的类型, 这就是 IntoIterator 特征的用武之地:

trait IntoIterator {
    type Item;
    type IntoIter: Iterator<Item = Self::Item>;
    fn into_iter(self) -> Self::IntoIter;
}

组合器

  • map 将函数应用于迭代器的每个元素。
  • filter 仅保留满足条件的元素。
  • filter_map 将filtermap组合在一个步骤中。
  • cloned 将引用的迭代器转换为值的迭代器,克隆每个元素。
  • enumerate 返回一个新的迭代器,该迭代器产生 (index, value) 对。
  • skip 跳过迭代器的前 n 个元素。
  • take 在 n 个元素之后停止迭代器。
  • chain将两个迭代器合二为一。

collect

collect 使用迭代器并将其元素收集到您选择的集合中。

collect是泛型的,需要提供类型提示来帮助推断出正确的类型,比如下面,在squares_of_evens标注类型,或者使用turbofish语法来指定类型

let numbers = vec![1, 2, 3, 4, 5];
let squares_of_evens: Vec<u32> = numbers.iter()
    .filter(|&n| n % 2 == 0)
    .map(|&n| n * n)
    .collect();
    
let squares_of_evens = numbers.iter()
    .filter(|&n| n % 2 == 0)
    .map(|&n| n * n)
    // Turbofish syntax: `<method_name>::<type>()`
    // It's called turbofish because `::<>` looks like a fish
    .collect::<Vec<u32>>();

Slice

一个示例

let numbers = vec![1, 2, 3];
//需要注意:`iter` 不是 `Vec` 的方法!
//它是 `&[T]` 的方法,但你可以对 `Vec` 调用它
//多亏了解引用强制转换。
let sum: i32 = numbers.iter().sum();

Vec<T> 和 &[T] 的关系

  • Vec<T> 是一个可增长的堆分配数组
  • &[T] 是一个切片引用(胖指针,包含数据指针和长度)
  • Vec<T> 可以自动转换为 &[T]

方法查找过程

当你调用 numbers.iter() 时,Rust 编译器会:

let numbers = vec![1, 2, 3];
let sum: i32 = numbers.iter().sum();
  1. numbers 是 Vec<i32> 类型
  2. Vec<i32> 没有 iter() 方法
  3. 但 Vec<i32> 实现了 Deref<Target = [i32]>
  4. 编译器自动将 &Vec<i32> 转换为 &[i32]
  5. &[i32] 有 iter() 方法

要点:当您需要将对 Vec 的不可变引用传递给函数时,首选 &[T] 而不是 &Vec<T>

let array = [1, 2, 3];
let slice: &[i32] = &array;

数组切片和 Vec 切片是同一类型:它们是指向连续元素序列的胖指针。在数组的情况下,指针指向堆栈而不是堆,但在使用切片时这并不重要

可变切片

let mut numbers = vec![1, 2, 3];
let slice: &mut [i32] = &mut numbers;
slice[0] = 42; //可以通过切片修改元素

可变切片,Rust 不允许您在切片中添加或删除元素。您将只能修改/替换已经存在的元素。

let mut numbers = Vec::with_capacity(2);
let mut slice: &mut [i32] = &mut numbers;
slice.push(1);

Index trait

真的没想到,index也是trait 详细的查看文档

IndexMut

只有当类型已经实现了 Index 时,才能实现 IndexMut,因为它解锁了额外的功能

// Slightly simplified
pub trait IndexMut<Idx>: Index<Idx>
{
    // Required method
    fn index_mut(&mut self, index: Idx) -> &mut Self::Output;
}

BTreeMap类型

BTreeMap 保证条目按其键排序。

// `K` and `V` stand for the key and value types, respectively,
// just like in `HashMap`.
impl<K, V> BTreeMap<K, V> {
    pub fn insert(&mut self, key: K, value: V) -> Option<V>
    where
        K: Ord,
    {
        // implementation
    }
}

四、线程

在此示例中,第一个生成的线程将反过来生成一个子线程,该子线程每秒打印一条消息。然后第一个线程将完成并退出。当这种情况发生时, 只要整个进程正在运行,它的子线程就会继续运行 。在 Rust 的行话中,我们说子线程的寿命超过了它的父线程。

use std::thread;

fn f() {
    thread::spawn(|| {
        thread::spawn(|| {
            loop {
                thread::sleep(std::time::Duration::from_secs(1));
                println!("Hello from the detached thread!");
            }
        });
    });
}

关于move

在sum函数里,线程使用了v1和v2并且into_iter函数是需要获取所有权的,但为什么没有写move也能使用,这是因为下面来自reference的文档内容,也就是说就是不写,编译器会根据情况自动捕获变量的所有权等,如果直接写了move 那么所有情况都是移动数据,获取所有权。

编译器更喜欢通过不可变借用捕获封闭变量
然后是唯一的不可变借用
通过可变借用,最后是*move *
use std::thread;
pub fn sum(v: Vec<i32>) -> i32 {
    let mid = v.len() / 2;
    let (v1,v2) = v.split_at(mid);
    let v1 = v1.to_vec();
    let v2 = v2.to_vec();

    let handle1 = thread::spawn( || v1.into_iter().sum::<i32>());
    let handle2 = thread::spawn( || v2.into_iter().sum::<i32>());
    handle1.join().unwrap() + handle2.join().unwrap()
}

fn five() {
    assert_eq!(sum(vec![1, 2, 3, 4, 5]), 15);
}
fn main(){
    five();
}
    

spawn

传递给std::thread::spawn的闭包必须具有 静态生命周期

pub fn spawn<F, T>(f: F) -> JoinHandle<T> 
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static
{
    // [..]
}

leak

下面代码为什么使用leak函数

  • Vec::leak 的作用:把一个 Vec<T> 的堆分配“泄漏”出去,返回一个 &'static mut [T],也就是把所有权变成一个拥有 'static 生命周期的切片引用;Vec 自身不再负责释放内存(因此内存永远不会被释放——故意产生内存泄漏,但在语义上是安全的)。
  • 为什么要这样做:thread::spawn 要求传入的闭包所捕获的数据满足 'static(线程可能在当前作用域之外继续运行)。通过 leak 得到 &'static 切片后,可以把该切片分成两个具有 'static 生命周期的子切片,然后把它们按值移动到两个线程中,线程里可以安全地读取这些切片而不用克隆数据或担心生命周期问题。
  • 代价与替代方案:代价是内存不会被释放(泄漏)。如果不想泄漏,可以把原数据复制/拆分成两个独立的 Vec 并把它们移动到线程,那样不会泄漏但会有复制开销。
use std::thread;
pub fn sum(v: Vec<i32>) -> i32 {
    let v = v.leak();
    let mid = v.len() / 2;
    let (slice1, slice2) = v.split_at(mid);
    let handle1 = thread::spawn(move || slice1.iter().sum::<i32>());
    let handle2 = thread::spawn(move || slice2.iter().sum::<i32>());
    handle1.join().unwrap() + handle2.join().unwrap()
}

 fn five() {
        assert_eq!(sum(vec![1, 2, 3, 4, 5]), 15);
    }
fn main(){
    five();
}

那么leak数据的内存,什么时候被释放?

泄漏的内存(由 Vec::leak 产生的那块堆内存)不会被 Rust 在运行时释放——它会一直存在到进程结束,由操作系统回收。也就是说在程序运行期间那块内存是永久泄漏的。

Vec::leak 将 Vec 的堆块转成 &'static mut [T],丢失了释放所需的元信息(如 capacity),因此无法安全地自动释放。

std::thread::scope

针对生命周期问题都有一个共同的来源:生成的线程可以比其父线程更长。 因此可以通过scope来解决。

在 Rust 中,std::thread::scope 是一个用于创建作用域线程的函数,它的主要作用是允许生成能够在当前作用域内借用的线程,确保所有线程在作用域结束前完成执行。

安全的数据借用

作用域线程可以安全地借用栈上的数据,而无需 'static 生命周期要求:

use std::thread;

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    
    thread::scope(|s| {
        // 可以安全地借用 `numbers`,因为作用域确保线程在 numbers 被丢弃前结束
        s.spawn(|| {
            println!("Length: {}", numbers.len());
        });
        
        s.spawn(|| {
            println!("Sum: {}", numbers.iter().sum::<i32>());
        });
    }); // 所有线程在这里保证已经结束
    
    // numbers 仍然可用
    println!("Numbers: {:?}", numbers);
}
自动线程连接

作用域内的所有线程会在作用域结束时自动 join,无需手动管理:

use std::thread;

fn main() {
    let mut results = Vec::new();
    
    thread::scope(|s| {
        for i in 0..5 {
            results.push(s.spawn(move || {
                i * 2
            }));
        }
        // 不需要手动 join,自动等待所有线程完成
    });
    
    for handle in results {
        println!("Result: {}", handle.join().unwrap());
    }
}

channel

通道有2中风格,一种有界,一种无界。

use std::sync::mpsc::channel;

let (sender, receiver) = channel();
// sender可以被clone,允许有多个
// receiver不可以,只能有一个
// 当通道有效关闭时,sender,或 receiver会返回错误

双通道模式

大概实现逻辑就是,在客户端创建通道A,在服务端创建通道B,客户端拿到服务端的sender,发送数据,在发送的数据中,带有A通道的sender,这样服务端在接受到自己线程的reveiver后再用A的sender发送数据。

unsafeCell

在练习示例中,我们通过sender发送数据,但是在receiver时可以进行修改,这是因为sender数据是可内部修改的,比如Rc、RefCell都是通过UnsafeCell来实现的内部可变性。

有界通道

在无界通道中,你可以发送任意数量消息,这将导致通道的内存不断地扩大,如果生产者以比消费者处理消息的速度更快的速度排队消息,则通道将继续增长,可能会消耗所有可用内存。

建议是在生产中,不要使用无界通道


use std::sync::mpsc::sync_channel;

let (sender, receiver) = sync_channel(10);
// receiver与无界通道std::sync::mpsc::channel创建的一致,而sender是SyncSender<T>的实例
sender

sender有两种发送方式:

  • send:如果通道中有空间,它将消息排队并返回 Ok(())。如果通道已满,它将阻塞并等待,直到有可用空间。
  • try_send:如果通道中有空间,它将消息排队并返回 Ok(())。如果通道已满,它将返回 Err(TrySendError::Full(value)) ,其中value是无法发送的消息。

Mutex和RwLock

Mutex\<T>允许一个线程访问数据,无论是读还是写。通过Mutex::lock or Mutex::try_lock来获取数据,在获取数据之前,线程是阻塞的

RwLock<T> 代表读写锁 。它允许多个读取器同时访问数据,但一次只能访问一个写入器。RwLock<T> 有两种获取锁的方法: 读取写入 

RwLock<T>的缺点:

锁`RwLock<T>` 比锁 `Mutex<T>` 更昂贵。
这是因为 `RwLock<T>` 必须跟踪活动读取器和写入器的数量,
而 `Mutex<T>` 只需要跟踪锁是否被持有。如果读取器多于写入器,
则此性能开销不是问题,但如果工作负载是写入密集型 `Mutex<T>` 可能是更好的选择。

如果始终有读取器等待获取锁,则写入器可能永远没有机会运行。

五、异步

主要就是tokio库的使用了

tokio::spawn的使用

// 生命周期必须是'static的
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{ /* */ }

使用示例

use tokio;

#[tokio::main]
async fn main() {
    // 启动一个新的异步任务
    let handle = tokio::spawn(async {
        // 这是一个模拟的耗时操作
        println!("任务开始执行!");
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
        println!("耗时任务完成!");
        "任务结果"
    });

    // 上面的任务在后台并发执行的同时,主任务可以继续做其他事情
    println!("主任务继续执行,不会被阻塞...");
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    println!("主任务做了些别的事情...");

    // 等待后台任务完成并获取其结果
    let result = handle.await.expect("任务执行失败");
    println!("接收到生成任务的结果: {}", result);
}

在使用异步时,需要注意的一点是在  .await 点上保留的任何值都必须是 Send具体查看文档

多线程运行时

use tokio::net::TcpListener;
pub async fn echoes(first: TcpListener, second: TcpListener) -> Result<(), anyhow::Error> {
    let handle1 = tokio::spawn(echo(first));
    let handle2 = tokio::spawn(echo(second));
    let (outcome1, outcome2) = tokio::join!(handle1, handle2);
    outcome1??;
    outcome2??;
    Ok(())
}

async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> {
    loop {
        let (mut socket, _) = listener.accept().await?;
        tokio::spawn(async move {
            let (mut reader, mut writer) = socket.split();
            tokio::io::copy(&mut reader, &mut writer).await.unwrap();
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::SocketAddr;
    use std::panic;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::task::JoinSet;

    async fn bind_random() -> (TcpListener, SocketAddr) {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        (listener, addr)
    }

    #[tokio::test]
    async fn test_echo() {
        let (first_listener, first_addr) = bind_random().await;
        let (second_listener, second_addr) = bind_random().await;
        tokio::spawn(echoes(first_listener, second_listener));

        let requests = vec!["hello", "world", "foo", "bar"];
        let mut join_set = JoinSet::new();

        for request in requests.clone() {
            for addr in [first_addr, second_addr] {
                join_set.spawn(async move {
                    let mut socket = tokio::net::TcpStream::connect(addr).await.unwrap();
                    let (mut reader, mut writer) = socket.split();

                    // Send the request
                    writer.write_all(request.as_bytes()).await.unwrap();
                    // Close the write side of the socket
                    writer.shutdown().await.unwrap();

                    // Read the response
                    let mut buf = Vec::with_capacity(request.len());
                    reader.read_to_end(&mut buf).await.unwrap();
                    assert_eq!(&buf, request.as_bytes());
                });
            }
        }

        while let Some(outcome) = join_set.join_next().await {
            if let Err(e) = outcome {
                if let Ok(reason) = e.try_into_panic() {
                    panic::resume_unwind(reason);
                }
            }
        }
    }
}

task::spawn_blocking

用于在专门的阻塞线程池中执行阻塞操作,避免阻塞异步运行时。 附带一个更详细的链接

tokio::sync::Mutex

...