多线程编程和异步编程


多线程编程和异步编程都是提高程序性能和响应能力的重要手段,在具体应用时需要结合任务特点和系统架构进行选择(在某些情况下两者也可以结合使用,发挥各自的优势)。

二者定义:

  • 多线程编程指使用多个线程并行执行任务的编程方式。线程是操作系统分配CPU时间片的基本单位。
  • 异步编程指程序不需要等待某个操作完成就可以继续执行其他操作的编程方式。

执行机制:

  • 多线程编程依赖于操作系统在多个线程之间切换执行,利用多核CPU提高并行计算能力。
  • 异步编程通过事件驱动、回调函数、Promises或async/await等机制实现,避免程序因某个操作阻塞而停滞不前。

应用场景:

  • 多线程编程适用于CPU密集型任务,如数学计算、图像处理等。
  • 异步编程适用于I/O密集型任务,如网络请求、文件读写等,可以提高资源利用率和响应速度。

难度:

  • 多线程编程涉及线程同步、死锁等复杂问题,编程难度较高。
  • 异步编程虽然编程复杂度较低,但需要掌握事件驱动、回调等编程模式。


thread::spawn与Rust的多线程编程


下面是Rust实现一段简单代码:

执行fn1(耗时3s),
执行fn2(耗时6s),
执行fn3(耗时4s)。

这三个func由先到后依次串行执行,最后打印出总的耗时

use std::time::{Duration, Instant};
use std::thread;

fn fn1() {
    thread::sleep(Duration::from_secs(3));
}

fn fn2() {
    thread::sleep(Duration::from_secs(6));
}

fn fn3() {
    thread::sleep(Duration::from_secs(4));
}

fn main() {
    let start = Instant::now();

    println!("开始执行 fn1");
    fn1();
    println!("fn1 执行完毕");

    println!("-------");

    println!("开始执行 fn2");
    fn2();
    println!("fn2 执行完毕");

    println!("-------");

    println!("开始执行 fn3");
    fn3();
    println!("fn3 执行完毕");

    println!("-------");

    let duration = start.elapsed();
    println!("总耗时: {:?}", duration);
}


输出:

开始执行 fn1
fn1 执行完毕
-------
开始执行 fn2
fn2 执行完毕
-------
开始执行 fn3
fn3 执行完毕
-------
总耗时: 13.011184791s


很多情况下,几个func之间并没有依赖关系,完全可以同时执行,即 将串行改为并行,这样耗时就不再是三者耗时之和,而是其中耗时最大的那个func执行所需的时间。

use std::time::{Duration, Instant};
use std::thread;

fn fn1() {
    thread::sleep(Duration::from_secs(3));
}

fn fn2() {
    thread::sleep(Duration::from_secs(6));
}

fn fn3() {
    thread::sleep(Duration::from_secs(4));
}

fn main() {
    let start = Instant::now();

    let handle1 = thread::spawn(|| {
        println!("开始执行 fn1");
        fn1();
        println!("fn1 执行完毕");
    });

    let handle2 = thread::spawn(|| {
        println!("开始执行 fn2");
        fn2();
        println!("fn2 执行完毕");
    });

    let handle3 = thread::spawn(|| {
        println!("开始执行 fn3");
        fn3();
        println!("fn3 执行完毕");
    });

    // 等待所有线程完成
    handle1.join().unwrap();
    handle2.join().unwrap();
    handle3.join().unwrap();

    let duration = start.elapsed();
    println!("总耗时: {:?}", duration);
}

输出:

开始执行 fn1
开始执行 fn2
开始执行 fn3
fn1 执行完毕
fn3 执行完毕
fn2 执行完毕
总耗时: 6.00545525s

使用thread::spawn为每个func创建一个新线程。 每个thread::spawn返回一个JoinHandle,可以用它来等待线程完成。

在主线程中,我们使用join()方法等待所有子线程完成。

由于这三个func是并行执行的,总耗时将接近最长的单个函数耗时(在此例中是fn2的6秒),而不是三个func耗时的总和。


不难用Go语言实现以上同样的操作

package main

import (
    "fmt"
    "sync"
    "time"
)

func fn1() {
    time.Sleep(3 * time.Second)
}

func fn2() {
    time.Sleep(6 * time.Second)
}

func fn3() {
    time.Sleep(4 * time.Second)
}

func main() {
    start := time.Now()

    var wg sync.WaitGroup
    wg.Add(3)

    go func() {
        defer wg.Done()
        fmt.Println("开始执行 fn1")
        fn1()
        fmt.Println("fn1 执行完毕")
    }()

    go func() {
        defer wg.Done()
        fmt.Println("开始执行 fn2")
        fn2()
        fmt.Println("fn2 执行完毕")
    }()

    go func() {
        defer wg.Done()
        fmt.Println("开始执行 fn3")
        fn3()
        fmt.Println("fn3 执行完毕")
    }()

    wg.Wait()

    duration := time.Since(start)
    fmt.Printf("总耗时: %v\n", duration)
}

输出:

开始执行 fn1
开始执行 fn3
开始执行 fn2
fn1 执行完毕
fn3 执行完毕
fn2 执行完毕
总耗时: 6s


Rust中的thread::spawn类似Go中的go关键字,创建多个线程(或协程),返回的JoinHandle则类似Go中的sync.WaitGroup或channel,用来优雅等待 子线程(或协程)执行结束。


我们知道,和线程本身的内存资源占用及线程调度所需的开销相比,goroutine的最大优势是轻量级,可以开成千上万个。

在Go中很常见在for循环中使用go关键字,启动若干的协程,同时调用某个子方法或闭包函数。

例如,有这样需求: 我想请求Alexa排名前5万的网站,拿到每个网站的http状态码。并维护一个map,以网站域名为key,http状态码为value。

func main() {
    // 读取Alexa排名前5万的网站
    websites, err := readWebsites("top-50000.txt")
    if err != nil {
        fmt.Println("Error reading websites:", err)
        return
    }

    results := make(map[string]int)
    var mutex sync.Mutex
    var wg sync.WaitGroup

    for _, site := range websites {
        wg.Add(1)
        go func(site string) {
            defer wg.Done()

            status := getStatus(site)

            mutex.Lock()
            results[site] = status
            mutex.Unlock()
        }(site)
    }

    wg.Wait()

    // 打印结果
    for site, status := range results {
        fmt.Printf("%s: %d\n", site, status)
    }
}

func getStatus(site string) int {
  time.Sleep(3e9)
  return 200
}

以上代码没有进行协程数量的控制,而getStatus中有3s的休眠期,程序运行后的3s内,将产生几万的goroutine。

简化以上场景,实际编码运行一下,不请求前5万的网站了,而模拟打印从1-5万的数字,并在每次打印前sleep 3s

package main

import (
    "fmt"
    "sync"
    "time"
)

func printWithDelay(num int, wg *sync.WaitGroup) {
    defer wg.Done()
    time.Sleep(3 * time.Second)
    fmt.Println(num)
}

func main() {
    start := time.Now()

    var wg sync.WaitGroup

    for i := 1; i <= 50000; i++ {
        wg.Add(1)
        go printWithDelay(i, &wg)
    }

    wg.Wait()

    duration := time.Since(start)
    fmt.Printf("Total time: %v\n", duration)
}

输出:

17
219
3
...
49122
49385
48988
48615
49392
48596
Total time: 3.975494792s

如果将50000改为50,则输出为:

37
50
39
...
22
3
41
47
43
Total time: 3.015294042s


<font size=1>

以上代码没有进行最大并发数的限制,会短时间产生大量协程,对系统资源有较多消耗。往往线上场景,会通过 make(chan struct{}, 100) 来 限制最大并发数,并在for循环中,go func1()之前获取信号量, 在func1执行结束释放信号量

这样会限制同时运行的 goroutine 数量,从而减少资源的压力(内存使用和CPU负载),但总运行时间会增加。

往往需要根据系统资源情况调整最大并发数。

</font>


依凭直觉,在Rust中,恐怕不能用thread::spawn产生大量线程 来做上述操作

use std::thread;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex};

fn print_with_delay(num: u32) {
    thread::sleep(Duration::from_secs(3));
    println!("{}", num);
}

fn main() {
    let start = Instant::now();

    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 1..=50000 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            print_with_delay(i);
            let mut count = counter.lock().unwrap();
            *count += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
    println!("Total numbers printed: {}", *counter.lock().unwrap());
}

输出:

thread 'main' panicked at /rustc/9b00956e56009bab2aa15d7bff10916599e3d6d6/library/std/src/thread/mod.rs:683:29:
failed to spawn thread: Os { code: 35, kind: WouldBlock, message: "Resource temporarily unavailable" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

*(如果将50000改为50,则如上代码则可正常输出,Total time为3.006541334s
)*

以上报错是因为操作系统已经无法创建更多的线程。

一般可以限制并发线程的数量,并使用线程池。可以用 rayon 库来优化之前的代码:

use std::time::{Duration, Instant};
use rayon::prelude::*;

fn print_with_delay(num: u32) {
    std::thread::sleep(Duration::from_secs(2));
    println!("{}", num);
}

fn main() {
    let start = Instant::now();

    // 配置线程池,这里使用系统的逻辑核心数量作为线程数
    rayon::ThreadPoolBuilder::new()
        .num_threads(num_cpus::get())
        .build_global()
        .unwrap();

    (1..=50000).into_par_iter().for_each(|i| {
        print_with_delay(i);
    });

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
}

会有类似下面的乱序输出,并且最终耗时感人...

...
37549
18799
49
10986
25049
6299
...


难道Rust中没有更好的方式了吗...

当然不是。。


async/await,Future与Rust的异步编程


use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use futures::executor::block_on;
use futures::future::{join_all, BoxFuture};

struct Sleep {
    duration: Duration,
    start: Option<Instant>,
}

impl Future for Sleep {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if let Some(start) = self.start {
            if start.elapsed() >= self.duration {
                Poll::Ready(())
            } else {
                cx.waker().wake_by_ref();
                Poll::Pending
            }
        } else {
            self.start = Some(Instant::now());
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

fn sleep(duration: Duration) -> Sleep {
    Sleep {
        duration,
        start: None,
    }
}

fn print_with_delay(num: u32) -> BoxFuture<'static, ()> {
    Box::pin(async move {
        sleep(Duration::from_secs(2)).await;
        println!("{}", num);
    })
}

fn main() {
    let start = Instant::now();

    let futures: Vec<_> = (1..=50000)
        .map(|i| print_with_delay(i))
        .collect();

    block_on(async {
        join_all(futures).await;
    });

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
}

输出:

1
2
...
49998
49999
50000
Total time: 3.795615084s
(如果把50000改为50,Total time变为 3.00024825s)


以下是上面代码的详细解释:

    1. 创建了一个自定义的Sleep结构体来模拟异步睡眠。这个结构体实现了Future trait。
    1. sleep函数返回一个Sleep实例,用于异步等待。
    1. print_with_delay函数现在返回一个BoxFuture,包含了睡眠和打印操作。
    1. main函数中,创建了一个future的向量,每个future对应一个从1到50000的数字。
    1. 使用block_on函数来在同步上下文中运行异步代码。
    1. 使用join_all来并发执行所有的future。

要运行该程序,需在Cargo.toml中添加:

[dependencies]
futures = "0.3"

程序会并发地打印1到50000的数字,每个数字打印之前都会等待3秒。由于使用了异步编程,这个过程会比顺序执行快得多。

还有以下几点需注意:

    1. 这个实现在主线程上模拟了异步操作,实际上并没有利用多核处理器的优势。在真实的异步I/O场景中,这种方法会更有效。
    1. 由于并发执行的特性,输出的数字顺序可能不是严格按照1到50000的顺序。
    1. 这个程序可能会消耗大量的内存,因为它一次性创建了50000个future。在实际应用中,需要考虑分批处理或限制并发度。
    1. 这个自定义的Sleep实现并不是真正的非阻塞睡眠,只是在轮询时检查是否经过了足够的时间。在实际应用中,可能需要更复杂的实现或使用专门的异步运行时。


futures不在Rust标准库,但是由Rust官方来维护


futures 库是 Rust 中用于处理异步编程的核心库。

以下是futures库和另外一些竞品的比较:

  1. futures 库

futures 是 Rust 异步编程的基础库,它定义了核心的 Future trait 和其他相关的抽象。

优点:

  • 是 Rust 异步编程的标准库,与语言紧密集成
  • 提供了基本的异步抽象,如 FutureStream
  • 轻量级,不包含运行时
  • 与其他异步库兼容性好

缺点:

  • 相对底层,直接使用可能比较复杂
  • 不提供运行时,需要配合其他库使用
  1. tokio

tokio 是一个异步运行时和工具集,建立在 futures 库之上。

优点:

  • 提供了完整的异步运行时
  • 包含了网络、文件 I/O、定时器等异步原语
  • 性能优秀,广泛使用
  • 文档完善,社区活跃

缺点:

  • 相对较重,可能对小型项目来说过于复杂
  • 学习曲线可能比较陡峭
  1. async-std

async-std 是另一个异步运行时和标准库的异步版本。

优点:

  • API 设计接近标准库,易于学习
  • 提供了完整的异步运行时
  • 包含文件 I/O、网络等异步操作

缺点:

  • 相比 tokio,生态系统相对较小
  • 在某些性能基准测试中可能不如 tokio
  1. smol

smol 是一个小型、简单的异步运行时。

优点:

  • 非常轻量级,适合小型项目
  • API 简单直观
  • 启动速度快

缺点:

  • 功能相对较少,可能需要额外的库来补充
  • 对于大型、复杂的项目可能不够强大
  1. actix

虽然是一个 web 框架,但actix 也提供了自己的异步运行时。

优点:

  • 性能出色,特别是在 web 应用场景
  • 提供了 actor 模型,适合某些并发场景

缺点:

  • 主要面向 web 开发,对于通用异步编程可能不是最佳选择
  • 学习曲线可能较陡

如果需要底层控制且不需要完整运行时,可以使用 futures

对于大多数项目,特别是需要网络功能的项目,tokio 是一个很好的选择。

如果你喜欢与标准库相似的 API,可以考虑 async-std

对于小型项目或者嵌入式系统,smol 可能是个不错的选择。

如果主要做 web 开发,actix 值得考虑。


对初学者来说,tokio 可能是最好的选择,tokio有广泛的社区支持和丰富的生态系统。

上面用futures的实现,相比于使用tokio更轻量级,但需要开发者自己处理较多底层细节。

而使用tokio:

use std::time::Duration;
use tokio::time::sleep;
use futures::future::join_all;

async fn print_with_delay(num: u32) {
    sleep(Duration::from_secs(3)).await; // `tokio`运行时提供了异步的sleep功能。
    println!("{}", num);
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();

    let tasks: Vec<_> = (1..=50000)
        .map(|i| tokio::spawn(async move {
            print_with_delay(i).await;
        }))
        .collect();

    join_all(tasks).await;

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
}

Cargo.toml 中添加以下依赖:

[dependencies]
tokio = { version = "1.0", features = ["full"] }
futures = "0.3"

输出:

...
45130
44578
44571
44568
Total time: 3.901633292s (似乎比使用futures慢一些)
    1. 定义了一个异步函数 print_with_delay,等待3秒,然后打印一个数字。
    1. main 函数中,使用 #[tokio::main] 属性来设置异步运行时。
    1. 创建了一个任务向量,每个任务对应一个从1到50000的数字。
    1. 使用 tokio::spawn 来为每个任务创建一个新的异步任务。
    1. 使用 join_all 来等待所有任务完成。
    1. 最后打印总运行时间。

如上程序会创建大量的并发任务,会消耗大量系统资源。在实际应用中,可能需要考虑限制并发度来避免过度消耗系统资源。

要限制并发数量,可以使用 Tokio 提供的 semaphore 或者 futures 库的 StreamExt trait

使用 Tokio 的 Semaphore:

use std::time::Duration;
use tokio::time::sleep;
use tokio::sync::Semaphore;
use std::sync::Arc;

const CONCURRENT_LIMIT: usize = 10000; // 设置并发限制为1w

async fn print_with_delay(num: u32) {
    sleep(Duration::from_secs(3)).await;
    println!("{}", num);
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();
    let semaphore = Arc::new(Semaphore::new(CONCURRENT_LIMIT));

    let mut handles = vec![];
    for i in 1..=50000 {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            let _permit = permit; // 持有信号量直到任务完成
            print_with_delay(i).await;
        }));
    }

    for handle in handles {
        handle.await.unwrap();
    }

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
}

输出为:

...
49703
47315
46945
Total time: 16.015408292s


使用 futures::stream::StreamExt:

use std::time::Duration;
use tokio::time::sleep;
use futures::stream::{self, StreamExt};

const CONCURRENT_LIMIT: usize = 10000; // 设置并发限制为1w

async fn print_with_delay(num: u32) {
    sleep(Duration::from_secs(3)).await;
    println!("{}", num);
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();

    stream::iter(1..=50000)
        .map(|i| async move { print_with_delay(i).await })
        .buffer_unordered(CONCURRENT_LIMIT)
        .collect::<Vec<()>>()
        .await;

    let duration = start.elapsed();
    println!("Total time: {:?}", duration);
}

输出为:

...
49478
47577
47581
Total time: 16.564279667s


这两种方式的主要区别:

  1. Semaphore:

    • 使用 Tokio 的 Semaphore 来限制并发。
    • 更灵活,可以在更复杂的场景中使用。
    • 代码稍微复杂一些。
  2. StreamExt:

    • 使用 futures 库的 StreamExt trait。
    • 代码更简洁。
    • 特别适合处理大量相似任务的情况。

使用这两种方式之一,都可以避免一次性创建太多任务而耗尽系统资源,同时仍然能够并发地处理任务。程序会持续运行,直到所有 50000 个任务都完成。由于每个任务需要 3 秒,总运行时间大约会是 (50000 / 10000) * 3 秒,也就是大约 15 秒 左右。

选择哪种方法主要取决于具体需求和个人偏好。如果需要更细粒度的控制,Semaphore 方法更合适;如果想要更简洁的代码,StreamExt 方法更好。



好文收藏
38 声望6 粉丝

好文收集


引用和评论

0 条评论