V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
istomyang
V2EX  ›  Rust

请教关于 Rust 异步编程软件设计思路

  •  
  •   istomyang · 4 天前 · 445 次点击

    我请教了 GPT ,发出来看看有什么补充的?大家是怎么看的,可以学习一下。

    问:以 Rust 专家的角色:我在做 Rust 异步编程,考虑到所有权的限制,软件设计的时候我提出了两个方案,第一个是全部非阻塞设计,在 main 函数一个大 loop 中执行,第二个是把系统拆分多个独立的模块,每个模块在各自的线程中运行,模块之间使用 channel 通信。第一个方案感觉像单线程,第二个方案就是模块之前用 channel 通信,感觉需要编写通信代码。我在网上听说用锁,考虑到所有权问题,我觉得这个方法很不优雅,你需要靠锁在不同线程转移所有权,你肯定要在运行时借用和转移。你怎么看?社区针对这种系统一般是怎么设计的?
    

    ChatGPT

    答:你的思考很有道理,特别是对锁的担忧。Rust 的所有权模型确实让多线程编程变得复杂,特别是当你试图在不同线程间安全地共享数据时。使用锁的确可以解决并发问题,但正如你提到的,这会带来一些运行时的开销,并且锁竞争会降低性能,导致不可预测的延迟。
    
    针对你提出的两个方案,社区中常见的设计模式有以下几个方向:
    
    1. **异步编程与单线程执行**(像你的第一个方案)
    在 Rust 的异步生态(比如 `tokio` 或 `async-std`)中,单线程执行是常见的方案。因为 Rust 的 `Future` 是基于状态机的,运行时可以通过事件驱动的方式在单个线程上运行多个任务,不需要涉及多线程与锁的问题。这种模式的优势在于避免了多线程编程的复杂性和锁的开销。缺点是,如果你有 CPU 密集型任务,单线程会成为瓶颈,需要手动将某些任务分发到线程池执行。
    
    适用场景:
    - I/O 密集型应用(如网络服务)。
    - 不需要进行复杂的 CPU 密集型计算。
    
    2. **多线程模块化设计**(你的第二个方案)
    通过 `channel` 实现模块间通信是一个较为常见的设计。Rust 提供了 `std::sync::mpsc` 和 `crossbeam` 等库,这些库允许在线程间传递消息,而不需要直接使用锁。模块之间通过消息传递来共享数据,能够有效避免使用锁,从而简化所有权和生命周期管理。此方案的编程模型可以理解为"消息传递并发"。
    
    **优点**:
    - 消息传递模式符合 Rust 的所有权模型,不会有所有权争议和数据竞争问题。
    - 每个模块在各自的线程上运行,充分利用多核 CPU 。
    
    **缺点**:
    - 需要设计模块间的通信协议。
    - 如果模块间通信频繁,可能增加系统复杂度和延迟。
    
    3. **使用锁的多线程并发**
    虽然锁(如 `Mutex`、`RwLock`)在 Rust 是安全的,但从社区经验来看,锁在 Rust 代码中一般是最后的选择。Rust 的锁因为保证了线程安全,往往比其他语言的锁更"沉重",并且会引入死锁的潜在风险。
    
    对于你提到的所有权转移和运行时借用的复杂性,确实不够优雅,也不推荐。如果使用锁的场景比较复杂,社区一般建议:
    - 尽量减少锁的粒度和使用时长。
    - 尽量将锁与其它数据结构解耦,以便更好地控制并发行为。
    
    4. **基于 `Arc` 的共享所有权**
    在需要多线程并发时,Rust 通常使用 `Arc`(原子引用计数)来实现数据的共享所有权。通过 `Arc<Mutex<T>>` 或 `Arc<RwLock<T>>` 可以在线程间共享数据,但这种设计模式也会引入锁的开销。
    
    总结
    对于你的设计,我建议:
    - **I/O 密集型应用**优先使用第一种方案,即非阻塞的异步模型,这符合 Rust 生态的主流做法。
    - **模块化多线程设计**在需要清晰模块边界并且有较多并发需求时很有优势,可以通过 `channel` 模式来实现模块间的通信,避免锁的使用。
    
    如果要考虑性能和所有权管理的简洁性,避免锁是比较优雅的方式。在 Rust 社区,消息传递、异步编程都是热门设计模式,相比直接使用锁更加简洁、安全。
    

    Codeium Chat

    作为 Rust 专家,我可以给出一些关于 Rust 异步编程设计的建议。
    
    首先,关于你的第一个方案,使用一个大循环来执行非阻塞设计是常见的做法。在这个循环中,你可以使用异步任务来处理各种任务。Rust 提供了多种异步任务管理器,如 Tokio 、async-std 等,可以帮助你管理异步任务的执行和调度。
    
    关于你的第二个方案,将系统拆分为独立的模块,每个模块在自己的线程中运行,并使用通道进行通信,这也是一个常见的设计模式。Rust 提供了强大的通道 API ,可以方便地进行异步通信。使用通道可以避免直接访问共享的状态,从而减少竞争条件。
    
    关于使用锁来解决所有权问题,你是正确的。在 Rust 中,使用锁来保护共享资源是可行的,但这确实会引入额外的复杂性和潜在的性能开销。为了避免使用锁,你可以考虑使用 Rust 的消息传递模式,例如使用通道来进行异步通信,而不是直接访问共享状态。
    
    在 Rust 社区中,关于异步编程设计的最佳实践是使用 Tokio 作为异步运行时,并使用 async/await 关键字来编写异步函数。Tokio 提供了丰富的异步 API 和任务管理器,可以帮助你管理异步任务的执行和调度。同时,使用通道进行异步通信也是一个常见的做法。
    
    总结起来,Rust 异步编程设计通常涉及到使用异步任务管理器来管理任务的执行和调度,以及使用通道来进行异步通信。使用 Tokio 和 async/await 关键字可以简化异步编程的开发过程。对于需要共享状态的场景,可以考虑使用 Rust 的消息传递模式,而不是直接访问共享状态。
    
    
    8 条回复    2024-10-18 00:39:30 +08:00
    nagisaushio
        1
    nagisaushio  
       4 天前 via Android
    第一个方法就是手搓的低配 async runtime ,多数时候不如直接用成熟的 async 库,第二个有点 go 的意思。

    一般的应用直接上 async 应该就差不多了,虽然 rust 的 async 有好多问题
    my3157
        2
    my3157  
       4 天前
    @nagisaushio +1 手搓一个简单的 runtime, 比如这种 https://github.com/hangj/toy-runtime , 我自己以前手搓过一个, 也就一千多行代码, 其实大多数 runtime 大量的代码在利用操作系统提供的机制如 poll/select/epoll/kqueue/IOCP/io_uring 将同步 io 转为异步 io, 不考虑这部分, 剩下的其实不复杂
    my3157
        3
    my3157  
       4 天前
    也有现成的封装好的库, 比如 https://github.com/smol-rs/polling 这种, 其实无栈协程原理不同语言大差不差, 比如 python 的 asyncio , 有栈协程实现起来反而很复杂, 从各方面来说, golang 应该是独一份
    nebkad
        4
    nebkad  
       2 天前
    不是很明确你的需求具体是什么,按照我自己的理解参与一下讨论:

    Rust 异步编程,要么就**不要**隐藏 async/await 的语言机制,这种适合作为其他 Rust 编写的代码提供 crate 或者 lib 的方式来使用;

    如果是作为跨语言的基础类库,提供给譬如 C/C++ C# 等等的情形,我个人的实践经验是,参考操作系统 API 设计(即 epoll kqueue iocp 等等)提供 C 接口来描述异步的数据交换,主要思路是把不同语言的异步机制用 C API 隔离开来,由两边的语言各自封装异步交互机制;
    istomyang
        5
    istomyang  
    OP
       2 天前
    @nagisaushio
    @my3157
    @nebkad 感谢回复!

    我主要写 Go ,一般都是先写抽象系统,然后写实现代码,到 Rust 这边就磕磕碰碰。
    比如,一个父级大模块有个 async run 方法,里面包含多个独立小模块,各自包含 async run 方法,然后在父模块做组合编排。这个操作在 Rust 的 async 系统就很糟心,而且很不优雅,情感上特别难受。

    就下面这个还报错,还得让我改成 Future 形式,编译器过于保守了。所以我研究好几种方式,比如不用 async ,用 nonblocking_run 。

    你们是怎么做的?赐教!感谢🙏

    ```rust
    trait A: Send {
    - async fn run(&mut self);
    + fn run(&mut self) -> impl std::future::Future<Output = ()> + Send
    }

    struct B<T>
    where
    T: A + 'static,
    {
    core: Option<T>,
    }

    impl<T> B<T>
    where
    T: A + 'static,
    {
    async fn run(&mut self) -> Result<()> {
    let mut core = self.core.take().unwrap();
    tokio::spawn(async move { core.run().await });
    Ok(())
    }
    }
    ```
    nagisaushio
        6
    nagisaushio  
       2 天前
    @istomyang #5 目前没有办法对 async fn 的返回值加 bound ,这是 rust 当前一个缺陷,但是回退到 -> impl Future 的语法感觉问题也不是很大。比如你的例子中,只有 trait 定义处需要用 impl Future 语法,实现处还是可以用 async 语法的

    https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=17f767259a13f2b0c4bc0572fa99af99
    istomyang
        7
    istomyang  
    OP
       2 天前
    @nagisaushio 非常感谢!算是权宜之计了。

    主要我不太明白,为什么我告诉编译器这个 Trait 是 Send 还是不放心?哪怕我脱钩,T: Async_Trait + Send; 都不行,语义上这就不是 Trait 的问题了。
    nebkad
        8
    nebkad  
       1 天前
    @istomyang #7
    Future 是一个 trait
    Async_Trait 也是一个 trait ,
    Async_Trait 是 Send 的跟其定义的 fn fun 的返回值(某个实现 Future 的) 类型是不是 Send 的,两者没有必然关系。

    如果你直接写成一个 struct 或者 enum 恐怕就没那么容易遇到 rust 目前的类型系统表达缺陷。
    直接从 trait 开始写但又没想得那么细,肯定会写得不顺手啊
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2463 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 15:32 · PVG 23:32 · LAX 08:32 · JFK 11:32
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.