最后的项目: 构建多线程 web server - 将单线程 server 变为多线程 server

优质
小牛编辑
128浏览
2023-12-01

目前 server 会依次处理每一个请求,意味着它在完成第一个连接的处理之前不会处理第二个连接。如果 server 正接收越来越多的请求,这类串行操作会使性能越来越差。如果一个请求花费很长时间来处理,随后而来的请求则不得不等待这个长请求结束,即便这些新请求可以很快就处理完。我们需要修复这种情况,不过首先让我们实际尝试一下这个问题。

让我们看看一个慢请求如何影响当前 server 实现中的其他请求。示例 20-10 通过模拟慢响应实现了 /sleep 请求处理,它会使 server 在响应之前休眠五秒。

文件名: src/main.rs

示例 20-10: 通过识别 /sleep 并休眠五秒来模拟慢请求

这段代码有些凌乱,不过对于模拟的目的来说已经足够!这里创建了第二个请求 sleep,我们会识别其数据。在 if 块之后增加了一个 else if 来检查 /sleep 请求,当接收到这个请求时,在渲染成功 HTML 页面之前会先休眠五秒。

现在就可以真切的看出我们的 server 有多么的原始;真实的库将会以更简洁的方式处理多请求识别问题。

使用 cargo run 启动 server,并接着打开两个浏览器窗口:一个请求 http://localhost:7878/ 而另一个请求 http://localhost:7878/sleep。如果像之前一样多次请求 /,会发现响应的比较快速。不过如果请求/sleep 之后在请求 /,就会看到 / 会等待直到 sleep 休眠完五秒之后才出现。

这里有多种办法来改变我们的 web server 使其避免所有请求都排在慢请求之后;我们将要实现的一个便是线程池。

使用线程池改善吞吐量

线程池thread pool)是一组预先分配的等待或准备处理任务的线程。当程序收到一个新任务,线程池中的一个线程会被分配任务,这个线程会离开并处理任务。其余的线程则可用于处理在第一个线程处理任务的同时处理其他接收到的任务。当第一个线程处理完任务时,它会返回空闲线程池中等待处理新任务。线程池允许我们并发处理连接,增加 server 的吞吐量。

我们会将池中线程限制为较少的数量,以防拒绝服务(Denial of Service, DoS)攻击;如果程序为每一个接收的请求都新建一个线程,某人向 server 发起千万级的请求请求时会耗尽服务器的资源并导致所有请求的处理都被终止。

不同于分配无限的线程,线程池中将有固定数量的等待线程。当新进请求时,将请求发送到线程池中做处理。线程池会维护一个接收请求的队列。每一个线程会从队列中取出一个请求,处理请求,接着向对队列索取另一个请求。通过这种设计,则可以并发处理 N 个请求,其中 N 为线程数。如果每一个线程都在响应慢请求,之后的请求仍然会阻塞队列,不过相比之前增加了能处理的慢请求的数量。

这个设计仅仅是多种改善 web server 吞吐量的方法之一。其他可供探索的方法有 fork/join 模型和单线程异步 I/O 模型。如果你对这个主题感兴趣,则可以阅读更多关于其他解决方案的内容并尝试用 Rust 实现他们;对于一个像 Rust 这样的底层语言,所有这些方法都是可能的。

在开始之前,让我们讨论一下线程池应用看起来怎样。当尝试设计代码时,首先编写客户端接口确实有助于指导代码设计。以期望的调用方式来构建 API 代码的结构,接着在这个结构之内实现功能,而不是先实现功能再设计公有 API。

类似于第十二章项目中使用的测试驱动开发。这里将要使用编译器驱动开发(Compiler Driven Development)。我们将编写调用所期望的函数的代码,接着观察编译器错误告诉我们接下来需要修改什么使得代码可以工作。

为每一个请求分配线程的代码结构

首先,让我们探索一下为每一个连接都创建一个线程的代码看起来如何。这并不是最终方案,因为正如之前讲到的它会潜在的分配无限的线程,不过这是一个开始。示例 20-11 展示了 main 的改变,它在 for 循环中为每一个流分配了一个新线程进行处理:

文件名: src/main.rs

  1. # use std::thread;
  2. # use std::io::prelude::*;
  3. # use std::net::TcpListener;
  4. # use std::net::TcpStream;
  5. #
  6. fn main() {
  7. let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
  8. for stream in listener.incoming() {
  9. let stream = stream.unwrap();
  10. thread::spawn(|| {
  11. handle_connection(stream);
  12. });
  13. }
  14. }
  15. # fn handle_connection(mut stream: TcpStream) {}

示例 20-11: 为每一个流新建一个线程

正如第十六章讲到的,thread::spawn 会创建一个新线程并在其中运行闭包中的代码。如果运行这段代码并在在浏览器中加载 /sleep,接着在另两个浏览器标签页中加载 /,确实会发现 / 请求不必等待 /sleep 结束。不过正如之前提到的,这最终会使系统崩溃因为我们无限制的创建新线程。

为有限数量的线程创建一个类似的接口

我们期望线程池以类似且熟悉的方式工作,以便从线程切换到线程池并不会对使用该 API 的代码做出较大的修改。示例 20-12 展示我们希望用来替换 thread::spawnThreadPool 结构体的假想接口:

文件名: src/main.rs

  1. # use std::thread;
  2. # use std::io::prelude::*;
  3. # use std::net::TcpListener;
  4. # use std::net::TcpStream;
  5. # struct ThreadPool;
  6. # impl ThreadPool {
  7. # fn new(size: u32) -> ThreadPool { ThreadPool }
  8. # fn execute<F>(&self, f: F)
  9. # where F: FnOnce() + Send + 'static {}
  10. # }
  11. #
  12. fn main() {
  13. let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
  14. let pool = ThreadPool::new(4);
  15. for stream in listener.incoming() {
  16. let stream = stream.unwrap();
  17. pool.execute(|| {
  18. handle_connection(stream);
  19. });
  20. }
  21. }
  22. # fn handle_connection(mut stream: TcpStream) {}

示例 20-12: 假想的 ThreadPool 接口

这里使用 ThreadPool::new 来创建一个新的线程池,它有一个可配置的线程数的参数,在这里是四。这样在 for 循环中,pool.execute 有着类似 thread::spawn 的接口,它获取一个线程池运行于每一个流的闭包。pool.execute 需要实现为获取闭包并传递给池中的线程运行。这段代码还不能编译,不过通过尝试编译器会指导我们如何修复它。

采用编译器驱动构建 ThreadPool 结构体

继续并对示例 20-12 中的 src/main.rs 做出修改,并利用来自 cargo check 的编译器错误来驱动开发。下面是我们得到的第一个错误:

  1. $ cargo check
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. error[E0433]: failed to resolve. Use of undeclared type or module `ThreadPool`
  4. --> src\main.rs:10:16
  5. |
  6. 10 | let pool = ThreadPool::new(4);
  7. | ^^^^^^^^^^^^^^^ Use of undeclared type or module
  8. `ThreadPool`
  9. error: aborting due to previous error

好的,这告诉我们需要一个 ThreadPool 类型或模块,所以我们将构建一个。ThreadPool 的实现会与 web server 的特定工作相独立,所以让我们从 hello crate 切换到存放 ThreadPool 实现的新库 crate。这也意味着可以在任何工作中使用这个单独的线程池库,而不仅仅是处理网络请求。

创建 src/lib.rs 文件,它包含了目前可用的最简单的 ThreadPool 定义:

文件名: src/lib.rs

  1. pub struct ThreadPool;

接着创建一个新目录,src/bin,并将二进制 crate 根文件从 src/main.rs 移动到 src/bin/main.rs。这使得库 crate 成为 hello 目录的主要 crate;不过仍然可以使用 cargo run 运行 src/bin/main.rs 二进制文件。移动了 main.rs 文件之后,修改 src/bin/main.rs 文件开头加入如下代码来引入库 crate 并将 ThreadPool 引入作用域:

文件名: src/bin/main.rs

  1. extern crate hello;
  2. use hello::ThreadPool;

这仍然不能工作,再次尝试运行来得到下一个需要解决的错误:

  1. $ cargo check
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. error[E0599]: no function or associated item named `new` found for type
  4. `hello::ThreadPool` in the current scope
  5. --> src/bin/main.rs:13:16
  6. |
  7. 13 | let pool = ThreadPool::new(4);
  8. | ^^^^^^^^^^^^^^^ function or associated item not found in
  9. `hello::ThreadPool`

好的,这告诉我们下一步是为 ThreadPool 创建一个叫做 new 的关联函数。我们还知道 new 需要有一个参数可以接受 4,而且 new 应该返回 ThreadPool 实例。让我们实现拥有此特征的最小化 new 函数:

文件夹: src/lib.rs

  1. pub struct ThreadPool;
  2. impl ThreadPool {
  3. pub fn new(size: usize) -> ThreadPool {
  4. ThreadPool
  5. }
  6. }

这里选择 usize 作为 size 参数的类型,因为我们知道为负的线程数没有意义。我们还知道将使用 4 作为线程集合的元素数量,这也就是使用 usize 类型的原因,如第三章 “整数类型” 部分所讲。

再次编译检查这段代码:

  1. $ cargo check
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. warning: unused variable: `size`
  4. --> src/lib.rs:4:16
  5. |
  6. 4 | pub fn new(size: usize) -> ThreadPool {
  7. | ^^^^
  8. |
  9. = note: #[warn(unused_variables)] on by default
  10. = note: to avoid this warning, consider using `_size` instead
  11. error[E0599]: no method named `execute` found for type `hello::ThreadPool` in the current scope
  12. --> src/bin/main.rs:18:14
  13. |
  14. 18 | pool.execute(|| {
  15. | ^^^^^^^

现在有了一个警告和一个错误。暂时先忽略警告,发生错误是因为并没有 ThreadPool 上的 execute 方法。回忆 “为有限数量的线程创建一个类似的接口” 部分我们决定线程池应该有与 thread::spawn 类似的接口,同时我们将实现 execute 函数来获取传递的闭包并将其传递给池中的空闲线程执行。

我们会在 ThreadPool 上定义 execute 函数来获取一个闭包参数。回忆第十三章的 “使用带有泛型和 Fn trait 的闭包” 部分,闭包作为参数时可以使用三个不同的 trait:FnFnMutFnOnce。我们需要决定这里应该使用哪种闭包。最终需要实现的类似于标准库的 thread::spawn,所以我们可以观察 thread::spawn 的签名在其参数中使用了何种 bound。查看文档会发现:

F 是这里我们关心的参数;T 与返回值有关所以我们并不关心。考虑到 spawn 使用 FnOnce 作为 F 的 trait bound,这可能也是我们需要的,因为最终会将传递给 execute 的参数传给 spawn。因为处理请求的线程只会执行闭包一次,这也进一步确认了 FnOnce 是我们需要的 trait,这里符合 FnOnceOnce 的意思。

F 还有 trait bound Send 和生命周期绑定 'static,这对我们的情况也是有意义的:需要 Send 来将闭包从一个线程转移到另一个线程,而 'static 是因为并不知道线程会执行多久。让我们编写一个使用带有这些 bound 的泛型参数 FThreadPoolexecute 方法:

文件名: src/lib.rs

  1. # pub struct ThreadPool;
  2. impl ThreadPool {
  3. // --snip--
  4. pub fn execute<F>(&self, f: F)
  5. where
  6. F: FnOnce() + Send + 'static
  7. {
  8. }
  9. }

这里再一次增加了 execute 方法的最小化实现,它没有做任何工作。再次进行检查:

  1. $ cargo check
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. warning: unused variable: `size`
  4. --> src/lib.rs:4:16
  5. |
  6. 4 | pub fn new(size: usize) -> ThreadPool {
  7. | ^^^^
  8. |
  9. = note: #[warn(unused_variables)] on by default
  10. = note: to avoid this warning, consider using `_size` instead
  11. warning: unused variable: `f`
  12. --> src/lib.rs:8:30
  13. |
  14. 8 | pub fn execute<F>(&self, f: F)
  15. | ^
  16. |
  17. = note: to avoid this warning, consider using `_f` instead

现在就只有警告了!这意味着能够编译了!注意如果尝试 cargo run 运行程序并在浏览器中发起请求,仍会在浏览器中出现在本章开始时那样的错误。这个库实际上还没有调用传递给 execute 的闭包!

一个你可能听说过的关于像 Haskell 和 Rust 这样有严格编译器的语言的说法是 “如果代码能够编译,它就能工作”。这是一个提醒大家的好时机,实际上这并不是普适的。我们的项目可以编译,不过它完全没有做任何工作!如果构建一个真实且功能完整的项目,则需花费大量的时间来开始编写单元测试来检查代码能否编译 并且 拥有期望的行为。

new 中验证池中线程数量

这里仍然存在警告是因为其并没有对 newexecute 的参数做任何操作。让我们用期望的行为来实现这些函数。以考虑 new 作为开始。

之前选择使用无符号类型作为 size 参数的类型,因为线程数为负的线程池没有意义。然而,线程数为零的线程池同样没有意义,不过零是一个完全有效的 u32 值。让我们增加在返回 ThreadPool 实例之前检查 size 是否大于零的代码,并使用 assert! 宏在得到零时 panic,如示例 20-13 所示:

在返回 ThreadPool 之前检查 size 是否大于零,并使用 assert! 宏在得到零时 panic,如列表 20-13 所示:

文件名: src/lib.rs

  1. # pub struct ThreadPool;
  2. impl ThreadPool {
  3. /// Create a new ThreadPool.
  4. ///
  5. /// The size is the number of threads in the pool.
  6. ///
  7. /// # Panics
  8. ///
  9. /// The `new` function will panic if the size is zero.
  10. pub fn new(size: usize) -> ThreadPool {
  11. assert!(size > 0);
  12. ThreadPool
  13. }
  14. // --snip--
  15. }

示例 20-13: 实现 ThreadPool::newsize 为零时 panic

趁着这个机会我们用文档注释为 ThreadPool 增加了一些文档。注意这里遵循了良好的文档实践并增加了一个部分来提示函数会 panic 的情况,正如第十四章所讨论的。尝试运行 cargo doc --open 并点击 ThreadPool 结构体来查看生成的 new 的文档看起来如何!

相比像这里使用 assert! 宏,也可以让 new 像之前 I/O 项目中示例 12-9 中 Config::new 那样返回一个 Result,不过在这里我们选择创建一个没有任何线程的线程池应该是不可恢复的错误。如果你想做的更好,尝试编写一个采用如下签名的 new 版本来感受一下两者的区别:

  1. fn new(size: usize) -> Result<ThreadPool, PoolCreationError> {

分配空间以储存线程

现在有了一个有效的线程池线程数,就可以实际创建这些线程并在返回之前将他们储存在 ThreadPool 结构体中。

这引出了另一个问题:如何 “储存” 一个线程?让我们再看看 thread::spawn 的签名:

  1. pub fn spawn<F, T>(f: F) -> JoinHandle<T>
  2. where
  3. F: FnOnce() -> T + Send + 'static,
  4. T: Send + 'static

spawn 返回 JoinHandle<T>,其中 T 是闭包返回的类型。尝试使用 JoinHandle 来看看会发生什么。在我们的情况中,传递给线程池的闭包会处理连接并不返回任何值,所以 T 将会是单元类型 。

示例 20-14 中的代码可以编译,不过实际上还并没有创建任何线程。我们改变了 ThreadPool 的定义来存放一个 thread::JoinHandle<()> 的 vector 实例,使用 size 容量来初始化,并设置一个 for 循环了来运行创建线程的代码,并返回包含这些线程的 ThreadPool 实例:

文件名: src/lib.rs

  1. use std::thread;
  2. pub struct ThreadPool {
  3. threads: Vec<thread::JoinHandle<()>>,
  4. }
  5. impl ThreadPool {
  6. // --snip--
  7. pub fn new(size: usize) -> ThreadPool {
  8. let mut threads = Vec::with_capacity(size);
  9. for _ in 0..size {
  10. // create some threads and store them in the vector
  11. }
  12. ThreadPool {
  13. threads
  14. }
  15. }
  16. // --snip--
  17. }

示例 20-14: 为 ThreadPool 创建一个 vector 来存放线程

这里将 std::thread 引入库 crate 的作用域,因为使用了 thread::JoinHandle 作为 ThreadPool 中 vector 元素的类型。

在得到了有效的数量之后,ThreadPool 新建一个存放 size 个元素的 vector。本书还未使用过 with_capacity,它与 Vec::new 做了同样的工作,不过有一个重要的区别:它为 vector 预先分配空间。因为已经知道了 vector 中需要 size 个元素,预先进行分配比仅仅 Vec::new 要稍微有效率一些,因为 Vec::new 随着插入元素而重新改变大小。

如果再次运行 cargo check,会看到一些警告,不过应该可以编译成功。

Worker 结构体负责从 ThreadPool 中将代码传递给线程

示例 20-14 的 for 循环中留下了一个关于创建线程的注释。如何实际创建线程呢?这是一个难题。标准库提供的创建线程的方法,thread::spawn,它期望获取一些一旦创建线程就应该执行的代码。然而,我们希望开始线程并使其等待稍后传递的代码。标准库的线程实现并没有包含这么做的方法;我们必须自己实现。

我们将要实现的行为是创建线程并稍后发送代码,这会在 ThreadPool 和线程间引入一个新数据类型来管理这种新行为。这个数据结构称为 Worker:这是一个池实现中的常见概念。想象一下在餐馆厨房工作的员工:员工等待来自客户的订单,他们负责接受这些订单并完成它们。

不同于在线程池中储存一个 JoinHandle<()> 实例的 vector,我们会储存 Worker 结构体的实例。每一个 Worker 会储存一个单独的 JoinHandle<()> 实例。接着会在
Worker 上实现一个方法,它会获取需要允许代码的闭包并将其发送给已经运行的线程执行。我们还会赋予每一个 worker id,这样就可以在日志和调试中区别线程池中的不同 worker。

首先,让我们做出如此创建 ThreadPool 时所需的修改。在通过如下方式设置完 Worker 之后,我们会实现向线程发送闭包的代码:

  1. 定义 Worker 结构体存放 idJoinHandle<()>
  2. 修改 ThreadPool 存放一个 Worker 实例的 vector
  3. 定义 Worker::new 函数,它获取一个 id 数字并返回一个带有 id 和用空闭包分配的线程的 Worker 实例
  4. ThreadPool::new 中,使用 for 循环计数生成 id,使用这个 id 新建 Worker,并储存进 vector 中

如果你渴望挑战,在查示例 20-15 中的代码之前尝试自己实现这些修改。

准备好了吗?示例 20-15 就是一个做出了这些修改的例子:

文件名: src/lib.rs

  1. use std::thread;
  2. pub struct ThreadPool {
  3. workers: Vec<Worker>,
  4. }
  5. impl ThreadPool {
  6. // --snip--
  7. pub fn new(size: usize) -> ThreadPool {
  8. assert!(size > 0);
  9. let mut workers = Vec::with_capacity(size);
  10. for id in 0..size {
  11. workers.push(Worker::new(id));
  12. }
  13. ThreadPool {
  14. workers
  15. }
  16. }
  17. // --snip--
  18. }
  19. struct Worker {
  20. id: usize,
  21. thread: thread::JoinHandle<()>,
  22. }
  23. impl Worker {
  24. fn new(id: usize) -> Worker {
  25. let thread = thread::spawn(|| {});
  26. Worker {
  27. id,
  28. thread,
  29. }
  30. }
  31. }

示例 20-15: 修改 ThreadPool 存放 Worker 实例而不是直接存放线程

这里将 ThreadPool 中字段名从 threads 改为 workers,因为它现在储存 Worker 而不是 JoinHandle<()>。使用 for 循环中的计数作为 Worker::new 的参数,并将每一个新建的 Worker 储存在叫做 workers 的 vector 中。

Worker 结构体和其 new 函数是私有的,因为外部代码(比如 src/bin/main.rs 中的 server)并不需要知道关于 ThreadPool 中使用 Worker 结构体的实现细节。Worker::new 函数使用 id 参数并储存了使用一个空闭包创建的 JoinHandle<()>

这段代码能够编译并用指定给 ThreadPool::new 的参数创建储存了一系列的 Worker 实例,不过 仍然 没有处理 execute 中得到的闭包。让我们聊聊接下来怎么做。

使用通道向线程发送请求

下一个需要解决的问题是传递给 thread::spawn 的闭包完全没有做任何工作。目前,我们在 execute 方法中获得期望执行的闭包,不过在创建 ThreadPool 的过程中创建每一个 Worker 时需要向 thread::spawn 传递一个闭包。

我们希望刚创建的 Worker 结构体能够从 ThreadPool 的队列中获取需要执行的代码,并发送到线程中执行他们。

在第十六章,我们学习了 通道 —— 一个沟通两个线程的简单手段 —— 对于这个例子来说则是绝佳的。这里通道将充当任务队列的作用,execute 将通过 ThreadPool 向其中线程正在寻找工作的 Worker 实例发送任务。如下是这个计划:

  1. ThreadPool 会创建一个通道并充当发送端。
  2. 每个 Worker 将会充当通道的接收端。
  3. 新建一个 Job 结构体来存放用于向通道中发送的闭包。
  4. execute 方法会在通道发送端发出期望执行的任务。
  5. 在线程中,Worker 会遍历通道的接收端并执行任何接收到的任务。

让我们以在 ThreadPool::new 中创建通道并让 ThreadPool 实例充当发送端开始,如示例 20-16 所示。Job 是将在通道中发出的类型;目前它是一个没有任何内容的结构体:

文件名: src/lib.rs

  1. # use std::thread;
  2. // --snip--
  3. use std::sync::mpsc;
  4. pub struct ThreadPool {
  5. workers: Vec<Worker>,
  6. sender: mpsc::Sender<Job>,
  7. }
  8. struct Job;
  9. impl ThreadPool {
  10. // --snip--
  11. pub fn new(size: usize) -> ThreadPool {
  12. assert!(size > 0);
  13. let (sender, receiver) = mpsc::channel();
  14. let mut workers = Vec::with_capacity(size);
  15. for id in 0..size {
  16. workers.push(Worker::new(id));
  17. }
  18. ThreadPool {
  19. workers,
  20. sender,
  21. }
  22. }
  23. // --snip--
  24. }
  25. #
  26. # struct Worker {
  27. # id: usize,
  28. # thread: thread::JoinHandle<()>,
  29. # }
  30. #
  31. # impl Worker {
  32. # fn new(id: usize) -> Worker {
  33. # let thread = thread::spawn(|| {});
  34. #
  35. # Worker {
  36. # id,
  37. # thread,
  38. # }
  39. # }
  40. # }

示例 20-16: 修改 ThreadPool 来储存一个发送 Job 实例的通道发送端

ThreadPool::new 中,新建了一个通道,并接着让线程池在接收端等待。这段代码能够编译,不过仍有警告。

让我们尝试在线程池创建每个 worker 时将通道的接收端传递给他们。须知我们希望在 worker 所分配的线程中使用通道的接收端,所以将在闭包中引用 receiver 参数。示例 20-17 中展示的代码还不能编译:

示例 20-17: 将通道的接收端传递给 worker

这是一些小而直观的修改:将通道的接收端传递进了 Worker::new,并接着在闭包中使用它。

如果尝试 check 代码,会得到这个错误:

  1. $ cargo check
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. error[E0382]: use of moved value: `receiver`
  4. --> src/lib.rs:27:42
  5. |
  6. 27 | workers.push(Worker::new(id, receiver));
  7. | ^^^^^^^^ value moved here in
  8. previous iteration of loop
  9. |
  10. = note: move occurs because `receiver` has type
  11. `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait

这段代码尝试将 receiver 传递给多个 Worker 实例。这是不行的,回忆第十六章:Rust 所提供的通道实现是多 生产者,单 消费者 的。这意味着不能简单的克隆通道的消费端来解决问题。即便可以,那也不是我们希望使用的技术;我们希望通过在所有的 worker 中共享单一 receiver,在线程间分发任务。

另外,从通道队列中取出任务涉及到修改 receiver,所以这些线程需要一个能安全的共享和修改 receiver 的方式,否则可能导致竞争状态(参考第十六章)。

回忆一下第十六章讨论的线程安全智能指针,为了在多个线程间共享所有权并允许线程修改其值,需要使用 Arc<Mutex<T>>Arc 使得多个 worker 拥有接收端,而 Mutex 则确保一次只有一个 worker 能从接收端得到任务。示例 20-18 展示了所需的修改:

文件名: src/lib.rs

  1. # use std::thread;
  2. # use std::sync::mpsc;
  3. use std::sync::Arc;
  4. use std::sync::Mutex;
  5. // --snip--
  6. # pub struct ThreadPool {
  7. # workers: Vec<Worker>,
  8. # sender: mpsc::Sender<Job>,
  9. # }
  10. # struct Job;
  11. #
  12. impl ThreadPool {
  13. // --snip--
  14. pub fn new(size: usize) -> ThreadPool {
  15. assert!(size > 0);
  16. let (sender, receiver) = mpsc::channel();
  17. let receiver = Arc::new(Mutex::new(receiver));
  18. let mut workers = Vec::with_capacity(size);
  19. for id in 0..size {
  20. workers.push(Worker::new(id, Arc::clone(&receiver)));
  21. }
  22. ThreadPool {
  23. workers,
  24. sender,
  25. }
  26. }
  27. // --snip--
  28. }
  29. # struct Worker {
  30. # id: usize,
  31. # thread: thread::JoinHandle<()>,
  32. # }
  33. #
  34. impl Worker {
  35. fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
  36. // --snip--
  37. # let thread = thread::spawn(|| {
  38. # receiver;
  39. # });
  40. #
  41. # Worker {
  42. # id,
  43. # thread,
  44. # }
  45. }
  46. }

示例 20-18: 使用 ArcMutex 在 worker 间共享通道的接收端

ThreadPool::new 中,将通道的接收端放入一个 Arc 和一个 Mutex 中。对于每一个新 worker,克隆 Arc 来增加引用计数,如此这些 worker 就可以共享接收端的所有权了。

通过这些修改,代码可以编译了!我们做到了!

实现 execute 方法

最后让我们实现 ThreadPool 上的 execute 方法。同时也要修改 Job 结构体:它将不再是结构体,Job 将是一个有着 execute 接收到的闭包类型的 trait 对象的类型别名。第十九章 “类型别名用来创建类型同义词” 部分提到过,类型别名允许将长的类型变短。观察示例 20-19:

文件名: src/lib.rs

  1. // --snip--
  2. # pub struct ThreadPool {
  3. # workers: Vec<Worker>,
  4. # sender: mpsc::Sender<Job>,
  5. # }
  6. # use std::sync::mpsc;
  7. # struct Worker {}
  8. type Job = Box<FnOnce() + Send + 'static>;
  9. impl ThreadPool {
  10. // --snip--
  11. pub fn execute<F>(&self, f: F)
  12. where
  13. F: FnOnce() + Send + 'static
  14. {
  15. let job = Box::new(f);
  16. self.sender.send(job).unwrap();
  17. }
  18. }
  19. // --snip--

示例 20-19: 为存放每一个闭包的 Box 创建一个 Job 类型别名,接着在通道中发出任务

在使用 execute 得到的闭包新建 Job 实例之后,将这些任务从通道的发送端发出。这里调用 send 上的 unwrap,因为发送可能会失败,这可能发生于例如停止了所有线程执行的情况,这意味着接收端停止接收新消息了。不过目前我们无法停止线程执行;只要线程池存在他们就会一直执行。使用 unwrap 是因为我们知道失败不可能发生,即便编译器不这么认为。

不过到此事情还没有结束!在 worker 中,传递给 thread::spawn 的闭包仍然还只是 引用 了通道的接收端。相反我们需要闭包一直循环,向通道的接收端请求任务,并在得到任务时执行他们。如示例 20-20 对 Worker::new 做出修改:

文件名: src/lib.rs

  1. // --snip--
  2. impl Worker {
  3. fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
  4. let thread = thread::spawn(move || {
  5. loop {
  6. let job = receiver.lock().unwrap().recv().unwrap();
  7. println!("Worker {} got a job; executing.", id);
  8. (*job)();
  9. }
  10. });
  11. Worker {
  12. id,
  13. thread,
  14. }
  15. }
  16. }

示例 20-20: 在 worker 线程中接收并执行任务

这里,首先在 receiver 上调用了 lock 来获取互斥器,接着 unwrap 在出现任何错误时 panic。如果互斥器处于一种叫做 被污染poisoned)的状态时获取锁可能会失败,这可能发生于其他线程在持有锁时 panic 了且没有释放锁。在这种情况下,调用 unwrap 使其 panic 是正确的行为。请随意将 unwrap 改为包含有意义错误信息的 expect

如果锁定了互斥器,接着调用 recv 从通道中接收 Job。最后的 unwrap 也绕过了一些错误,这可能发生于持有通道发送端的线程停止的情况,类似于如果接收端关闭时 send 方法如何返回 Err 一样。

调用 recv阻塞 当前线程,所以如果还没有任务,其会等待直到有可用的任务。Mutex<T> 确保一次只有一个 Worker 线程尝试请求任务。

理论上这段代码应该能够编译。不幸的是,Rust 编译器仍不够完美,会给出如下错误:

  1. error[E0161]: cannot move a value of type std::ops::FnOnce() +
  2. std::marker::Send: the size of std::ops::FnOnce() + std::marker::Send cannot be
  3. statically determined
  4. --> src/lib.rs:63:17
  5. |
  6. 63 | (*job)();
  7. | ^^^^^^

这个错误非常的神秘,因为这个问题本身就很神秘。为了调用储存在 Box<T> (这正是 Job 别名的类型)中的 FnOnce 闭包,该闭包需要能将自己移动 Box<T>,因为当调用这个闭包时,它获取 self 的所有权。通常来说,将值移动出 Box<T> 是不被允许的,因为 Rust 不知道 Box<T> 中的值将会有多大;回忆第十五章能够正常使用 Box<T> 是因为我们将未知大小的值储存进 Box<T> 从而得到已知大小的值。

第十七章曾见过,示例 17-15 中有使用了 self: Box<Self> 语法的方法,它允许方法获取储存在 Box<T> 中的 Self 值的所有权。这正是我们希望做的,然而不幸的是 Rust 不允许我们这么做:Rust 当闭包被调用时行为的那部分并没有使用 self: Box<Self> 实现。所以这里 Rust 也不知道它可以使用 self: Box<Self> 来获取闭包的所有权并将闭包移动出 Box<T>

Rust 仍在努力改进提升编译器的过程中,不过将来示例 20-20 中的代码应该能够正常工作。有很多像你一样的人正在修复这个以及其他问题!当你结束了本书的阅读,我们希望看到你也成为他们中的一员。

不过目前让我们通过一个小技巧来绕过这个问题。可以显式的告诉 Rust 在这里我们可以使用 self: Box<Self> 来获取 Box<T> 中值的所有权,而一旦获取了闭包的所有权就可以调用它了。这涉及到定义一个新 trait,它带有一个在签名中使用 self: Box<Self> 的方法 call_box,为任何实现了 FnOnce() 的类型定义这个 trait,修改类型别名来使用这个新 trait,并修改 Worker 使用 call_box 方法。这些修改如示例 20-21 所示:

文件名: src/lib.rs

  1. trait FnBox {
  2. fn call_box(self: Box<Self>);
  3. }
  4. impl<F: FnOnce()> FnBox for F {
  5. fn call_box(self: Box<F>) {
  6. (*self)()
  7. }
  8. }
  9. type Job = Box<FnBox + Send + 'static>;
  10. // --snip--
  11. impl Worker {
  12. fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
  13. let thread = thread::spawn(move || {
  14. loop {
  15. let job = receiver.lock().unwrap().recv().unwrap();
  16. println!("Worker {} got a job; executing.", id);
  17. job.call_box();
  18. }
  19. });
  20. Worker {
  21. id,
  22. thread,
  23. }
  24. }
  25. }

示例 20-21: 新增一个 trait FnBox 来绕过当前 Box<FnOnce()> 的限制

首先,新建了一个叫做 FnBox 的 trait。这个 trait 有一个方法 call_box,它类似于其他 Fn* trait 中的 call 方法,除了它获取 self: Box<Self> 以便获取 self 的所有权并将值从 Box<T> 中移动出来。

接下来,为任何实现了 FnOnce() trait 的类型 F 实现 FnBox trait。这实际上意味着任何 FnOnce() 闭包都可以使用 call_box 方法。call_box 的实现使用 (*self)() 将闭包移动出 Box<T> 并调用此闭包。

现在我们需要 Job 类型别名是任何实现了新 trait FnBoxBox。这允许我们在得到 Job 值时使用 Worker 中的 call_box。为任何 FnOnce() 闭包都实现了 FnBox trait 意味着无需对实际在通道中发出的值做任何修改。

最后,对于 Worker::new 的线程中所运行的闭包,调用 call_box 而不是直接执行闭包。现在 Rust 就能够理解我们的行为是正确的了。

这是非常狡猾且复杂的手段。无需过分担心他们并不是非常有道理;总有一天,这一切将是毫无必要的。

通过这个技巧,线程池处于可以运行的状态了!执行 cargo run 并发起一些请求:

  1. $ cargo run
  2. Compiling hello v0.1.0 (file:///projects/hello)
  3. warning: field is never used: `workers`
  4. --> src/lib.rs:7:5
  5. |
  6. 7 | workers: Vec<Worker>,
  7. | ^^^^^^^^^^^^^^^^^^^^
  8. |
  9. = note: #[warn(dead_code)] on by default
  10. warning: field is never used: `id`
  11. --> src/lib.rs:61:5
  12. |
  13. 61 | id: usize,
  14. | ^^^^^^^^^
  15. |
  16. = note: #[warn(dead_code)] on by default
  17. warning: field is never used: `thread`
  18. --> src/lib.rs:62:5
  19. |
  20. 62 | thread: thread::JoinHandle<()>,
  21. | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  22. |
  23. = note: #[warn(dead_code)] on by default
  24. Finished dev [unoptimized + debuginfo] target(s) in 0.99 secs
  25. Running `target/debug/hello`
  26. Worker 0 got a job; executing.
  27. Worker 2 got a job; executing.
  28. Worker 1 got a job; executing.
  29. Worker 3 got a job; executing.
  30. Worker 0 got a job; executing.
  31. Worker 2 got a job; executing.
  32. Worker 1 got a job; executing.
  33. Worker 3 got a job; executing.
  34. Worker 0 got a job; executing.
  35. Worker 2 got a job; executing.

成功了!现在我们有了一个可以异步执行连接的线程池!它绝不会创建超过四个线程,所以当 server 收到大量请求时系统也不会负担过重。如果请求 /sleep,server 也能够通过另外一个线程处理其他请求。

在学习了第十八章的 while let 循环之后,你可能会好奇为何不能如此编写 worker 线程:

文件名: src/lib.rs

  1. // --snip--
  2. impl Worker {
  3. fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
  4. let thread = thread::spawn(move || {
  5. while let Ok(job) = receiver.lock().unwrap().recv() {
  6. println!("Worker {} got a job; executing.", id);
  7. job.call_box();
  8. }
  9. });
  10. Worker {
  11. id,
  12. thread,
  13. }
  14. }

示例 20-22: 一个使用 while letWorker::new 替代实现

这段代码可以编译和运行,但是并不会产生所期望的线程行为:一个慢请求仍然会导致其他请求等待执行。如此的原因有些微妙:Mutex 结构体没有公有 unlock 方法,因为锁的所有权依赖 lock 方法返回的 LockResult<MutexGuard<T>>MutexGuard<T> 的生命周期。这允许借用检查器在编译时确保绝不会在没有持有锁的情况下访问由 Mutex 守护的资源,不过如果没有认真的思考 MutexGuard<T> 的生命周期的话,也可能会导致比预期更久的持有锁。因为 while 表达式中的值在整个块一直处于作用域中, 调用的过程中其仍然持有锁,这意味着其他 worker 不能接收任务。