From b9b907d78a9e0312bad0b7b9d94d799407bee92e Mon Sep 17 00:00:00 2001 From: KaiserY Date: Mon, 30 Jan 2023 17:39:46 +0800 Subject: [PATCH] update ch20 --- src/ch20-00-final-project-a-web-server.md | 8 +- src/ch20-01-single-threaded.md | 72 +++++++++-------- src/ch20-02-multithreaded.md | 85 +++++++++++--------- src/ch20-03-graceful-shutdown-and-cleanup.md | 68 ++++++---------- 4 files changed, 113 insertions(+), 120 deletions(-) diff --git a/src/ch20-00-final-project-a-web-server.md b/src/ch20-00-final-project-a-web-server.md index 24322d9..e73e846 100644 --- a/src/ch20-00-final-project-a-web-server.md +++ b/src/ch20-00-final-project-a-web-server.md @@ -2,7 +2,7 @@ > [ch20-00-final-project-a-web-server.md](https://github.com/rust-lang/book/blob/main/src/ch20-00-final-project-a-web-server.md) >
-> commit c084bdd9ee328e7e774df19882ccc139532e53d8 +> commit 5df6909c57b3ba55f156a4122a42b805436de90c 这是一次漫长的旅途,不过我们到达了本书的结束。在本章中,我们将一同构建另一个项目,来展示最后几章所学,同时复习更早的章节。 @@ -12,7 +12,7 @@ 图例 20-1: 我们最后将一起分享的项目 -如下是我们将怎样构建此 web server 的计划: +如下是构建 web server 的计划: 1. 学习一些 TCP 与 HTTP 知识 2. 在套接字(socket)上监听 TCP 请求 @@ -20,6 +20,4 @@ 4. 创建一个合适的 HTTP 响应 5. 通过线程池改善 server 的吞吐量 -不过在开始之前,需要提到一点细节:这里使用的方法并不是使用 Rust 构建 web server 最好的方法。[crates.io](https://crates.io/) 上有很多可用于生产环境的 crate,它们提供了比我们所要编写的更为完整的 web server 和线程池实现。 - -然而,本章的目的在于学习,而不是走捷径。因为 Rust 是一个系统编程语言,我们能够选择处理什么层次的抽象,并能够选择比其他语言可能或可用的层次更低的层次。因此我们将自己编写一个基础的 HTTP server 和线程池,以便学习将来可能用到的 crate 背后的通用理念和技术。 +不过在开始之前,需要提到一点细节:这里使用的方法并不是使用 Rust 构建 web server 最好的方法。[crates.io](https://crates.io/) 上有很多可用于生产环境的 crate,它们提供了比我们所要编写的更为完整的 web server 和线程池实现。然而,本章的目的在于学习,而不是走捷径。因为 Rust 是一个系统编程语言,我们能够选择处理什么层次的抽象,并能够选择比其他语言可能或可用的层次更低的层次。因此我们将自己编写一个基础的 HTTP server 和线程池,以便学习将来可能用到的 crate 背后的通用理念和技术。 diff --git a/src/ch20-01-single-threaded.md b/src/ch20-01-single-threaded.md index ec3a2e0..2f924fc 100644 --- a/src/ch20-01-single-threaded.md +++ b/src/ch20-01-single-threaded.md @@ -2,7 +2,7 @@ > [ch20-01-single-threaded.md](https://github.com/rust-lang/book/blob/main/src/ch20-01-single-threaded.md) >
-> commit 9c0fa2714859738ff73cbbb829592e4c037d7e46 +> commit 5df6909c57b3ba55f156a4122a42b805436de90c 首先让我们创建一个可运行的单线程 web server,不过在开始之前,我们将快速了解一下构建 web server 所涉及到的协议。这些协议的细节超出了本书的范畴,不过一个简单的概括会提供我们所需的信息。 @@ -30,13 +30,13 @@ $ cd hello 示例 20-1: 监听传入的流并在接收到流时打印信息 -`TcpListener` 用于监听 TCP 连接。我们选择监听地址 `127.0.0.1:7878`。将这个地址拆开,冒号之前的部分是一个代表本机的 IP 地址(这个地址在每台计算机上都相同,并不特指作者的计算机),而 `7878` 是端口。选择这个端口出于两个原因:通常 HTTP 接受这个端口而且 7878 在电话上打出来就是 "rust"(译者注:九宫格键盘上的英文)。 +`TcpListener` 用于监听 TCP 连接。我们选择监听本地地址 `127.0.0.1:7878`。将这个地址拆开,冒号之前的部分是一个代表本机的 IP 地址(这个地址在每台计算机上都相同,并不特指作者的计算机),而 `7878` 是端口。选择这个端口出于两个原因:通常 HTTP 不接受这个端口的请求所以它不太与你机器上运行的其它 web server 的端口冲突,而且 7878 在电话上打出来就是 "rust"(译者注:九宫格键盘上的英文)。 在这个场景中 `bind` 函数类似于 `new` 函数,在这里它返回一个新的 `TcpListener` 实例。这个函数叫做 `bind` 是因为,在网络领域,连接到监听端口被称为 “绑定到一个端口”(“binding to a port”) -`bind` 函数返回 `Result`,这表明绑定可能会失败,例如,连接 80 端口需要管理员权限(非管理员用户只能监听大于 1024 的端口),所以如果不是管理员尝试连接 80 端口,则会绑定失败。另一个例子是如果运行两个此程序的实例这样会有两个程序监听相同的端口,绑定会失败。因为我们是出于学习目的来编写一个基础的 server,将不用关心处理这类错误,使用 `unwrap` 在出现这些情况时直接停止程序。 +`bind` 函数返回 `Result`,这表明绑定可能会失败,例如,连接 80 端口需要管理员权限(非管理员用户只能监听大于 1023 的端口),所以如果不是管理员尝试连接 80 端口,则会绑定失败。例如如果运行两个此程序的实例这样会有两个程序监听相同的端口,绑定会失败。因为我们是出于学习目的来编写一个基础的 server,将不用关心处理这类错误,使用 `unwrap` 在出现这些情况时直接停止程序。 -`TcpListener` 的 `incoming` 方法返回一个迭代器,它提供了一系列的流(更准确的说是 `TcpStream` 类型的流)。**流**(*stream*)代表一个客户端和服务端之间打开的连接。**连接**(*connection*)代表客户端连接服务端、服务端生成响应以及服务端关闭连接的全部请求 / 响应过程。为此,`TcpStream` 允许我们读取它来查看客户端发送了什么,并可以编写响应。总体来说,这个 `for` 循环会依次处理每个连接并产生一系列的流供我们处理。 +`TcpListener` 的 `incoming` 方法返回一个迭代器,它提供了一系列的流(更准确的说是 `TcpStream` 类型的流)。**流**(*stream*)代表一个客户端和服务端之间打开的连接。**连接**(*connection*)代表客户端连接服务端、服务端生成响应以及服务端关闭连接的全部请求 / 响应过程。为此,我们会从 `TcpStream` 读取客户端发送了什么并接着向流发送响应以向客户端发回数据。总体来说,这个 `for` 循环会依次处理每个连接并产生一系列的流供我们处理。 目前为止,处理流的过程包含 `unwrap` 调用,如果出现任何错误会终止程序,如果没有任何错误,则打印出信息。下一个示例我们将为成功的情况增加更多功能。当客户端连接到服务端时 `incoming` 方法返回错误是可能的,因为我们实际上没有遍历连接,而是遍历 **连接尝试**(*connection attempts*)。连接可能会因为很多原因不能成功,大部分是操作系统相关的。例如,很多系统限制同时打开的连接数;新连接尝试产生错误,直到一些打开的连接关闭为止。 @@ -53,7 +53,7 @@ Connection established! 这也可能是因为浏览器尝试多次连接 server,因为 server 没有响应任何数据。当 `stream` 在循环的结尾离开作用域并被丢弃,其连接将被关闭,作为 `drop` 实现的一部分。浏览器有时通过重连来处理关闭的连接,因为这些问题可能是暂时的。现在重要的是我们成功的处理了 TCP 连接! -记得当运行完特定版本的代码后使用 ctrl-C 来停止程序。并在做出最新的代码修改之后执行 `cargo run` 重启服务。 +记得当运行完特定版本的代码后使用 ctrl-C 来停止程序。并通过执行 `cargo run` 命令在做出最新的代码修改之后重启服务。 ### 读取请求 @@ -67,13 +67,15 @@ Connection established! 示例 20-2: 读取 `TcpStream` 并打印数据 -这里将 `std::io::prelude` 引入作用域来获取读写流所需的特定 trait。在 `main` 函数的 `for` 循环中,相比获取到连接时打印信息,现在调用新的 `handle_connection` 函数并向其传递 `stream`。 +这里将 `std::io::prelude` 和 `std::io::BufReader` 引入作用域来获取读写流所需的特定 trait。在 `main` 函数的 `for` 循环中,相比获取到连接时打印信息,现在调用新的 `handle_connection` 函数并向其传递 `stream`。 -在 `handle_connection` 中,`stream` 参数是可变的。这是因为 `TcpStream` 实例在内部记录了所返回的数据。它可能读取了多于我们请求的数据并保存它们以备下一次请求数据。因此它需要是 `mut` 的因为其内部状态可能会改变;通常我们认为 “读取” 不需要可变性,不过在这个例子中则需要 `mut` 关键字。 +在 `handle_connection` 中,我们新建了一个 `BufReader` 实例来封装一个 `stream` 的可变引用。`BufReader` 增加了缓存来替我们管理 `std::io::Read` trait 方法的调用。 -接下来,需要实际读取流。这里分两步进行:首先,在栈上声明一个 `buffer` 来存放读取到的数据。这里创建了一个 1024 字节的缓冲区,它足以存放基本请求的数据并满足本章的目的需要。如果希望处理任意大小的请求,缓冲区管理将更为复杂,不过现在一切从简。接着将缓冲区传递给 `stream.read` ,它会从 `TcpStream` 中读取字节并放入缓冲区中。 +我们创建了一个 `http_request` 变量来收集浏览器发送给服务端的请求行。这里增加了 `Vec<_>` 类型注解表明希望将这些行收集到一个 vector 中。 -接下来将缓冲区中的字节转换为字符串并打印出来。`String::from_utf8_lossy` 函数获取一个 `&[u8]` 并产生一个 `String`。函数名的 “lossy” 部分来源于当其遇到无效的 UTF-8 序列时的行为:它使用 `�`,`U+FFFD REPLACEMENT CHARACTER`,来代替无效序列。你可能会在缓冲区的剩余部分看到这些替代字符,因为他们没有被请求数据填满。 +`BufReader` 实现了 `std::io::BufRead` trait,它提供了 `lines` 方法。`lines` 方法通过遇到换行符(newline)字节就切分数据流的方式返回一个 `Result` 的迭代器。为了获取每一个 `String`,通过 map 并 `unwrap` 每一个 `Result`。如果数据不是有效的 UTF-8 编码或者读取流遇到问题时,`Result` 可能是一个错误。一如既往生产环境的程序应该更优雅地处理这些错误,不过出于简单的目的我们选择在错误情况下停止程序。 + +浏览器通过连续发送两个换行符来代表一个 HTTP 请求的结束,所以为了从流中获取一个请求,我们获取行直到它们不为空。一旦将这些行收集进 vector,就可以使用友好的 debug 格式化打印它们,以便看看 web 浏览器发送给服务端的指令。 让我们试一试!启动程序并再次在浏览器中发起请求。注意浏览器中仍然会出现错误页面,不过终端中程序的输出现在看起来像这样: @@ -82,21 +84,27 @@ $ cargo run Compiling hello v0.1.0 (file:///projects/hello) Finished dev [unoptimized + debuginfo] target(s) in 0.42s Running `target/debug/hello` -Request: GET / HTTP/1.1 -Host: 127.0.0.1:7878 -User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 -Firefox/52.0 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 -Accept-Language: en-US,en;q=0.5 -Accept-Encoding: gzip, deflate -Connection: keep-alive -Upgrade-Insecure-Requests: 1 -������������������������������������ +Request: [ + "GET / HTTP/1.1", + "Host: 127.0.0.1:7878", + "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0", + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language: en-US,en;q=0.5", + "Accept-Encoding: gzip, deflate, br", + "DNT: 1", + "Connection: keep-alive", + "Upgrade-Insecure-Requests: 1", + "Sec-Fetch-Dest: document", + "Sec-Fetch-Mode: navigate", + "Sec-Fetch-Site: none", + "Sec-Fetch-User: ?1", + "Cache-Control: max-age=0", +] ``` -根据使用的浏览器不同可能会出现稍微不同的数据。现在我们打印出了请求数据,可以通过观察 `Request: GET` 之后的路径来解释为何会从浏览器得到多个连接。如果重复的连接都是请求 */*,就知道了浏览器尝试重复获取 */* 因为它没有从程序得到响应。 +根据使用的浏览器不同可能会出现稍微不同的数据。现在我们打印出了请求数据,可以通过观察第一行 `GET` 之后的路径来解释为何会从浏览器得到多个连接。如果重复的连接都是请求 */*,就知道了浏览器尝试重复获取 */* 因为它没有从程序得到响应。 -拆开请求数据来理解浏览器向程序请求了什么。 +让我们拆开请求数据来理解浏览器向程序请求了什么。 #### 仔细观察 HTTP 请求 @@ -108,7 +116,7 @@ headers CRLF message-body ``` -第一行叫做 **请求行**(*request line*),它存放了客户端请求了什么的信息。请求行的第一部分是所使用的 *method*,比如 `GET` 或 `POST`,这描述了客户端如何进行请求。这里客户端使用了 `GET` 请求。 +第一行叫做 **请求行**(*request line*),它存放了客户端请求了什么的信息。请求行的第一部分是所使用的 *method*,比如 `GET` 或 `POST`,这描述了客户端如何进行请求。这里客户端使用了 `GET` 请求,表明它在请求信息。 请求行接下来的部分是 */*,它代表客户端请求的 **统一资源标识符**(*Uniform Resource Identifier*,*URI*) —— URI 大体上类似,但也不完全类似于 URL(**统一资源定位符**,*Uniform Resource Locators*)。URI 和 URL 之间的区别对于本章的目的来说并不重要,不过 HTTP 规范使用术语 URI,所以这里可以简单的将 URL 理解为 URI。 @@ -150,11 +158,9 @@ HTTP/1.1 200 OK\r\n\r\n 示例 20-3: 将一个微型成功 HTTP 响应写入流 -新代码中的第一行定义了变量 `response` 来存放将要返回的成功响应的数据。接着,在 `response` 上调用 `as_bytes`,因为 `stream` 的 `write` 方法获取一个 `&[u8]` 并直接将这些字节发送给连接。 - -因为 `write` 操作可能会失败,所以像之前那样对任何错误结果使用 `unwrap`。同理,在真实世界的应用中这里需要添加错误处理。最后,`flush` 会等待并阻塞程序执行直到所有字节都被写入连接中;`TcpStream` 包含一个内部缓冲区来最小化对底层操作系统的调用。 +新代码中的第一行定义了变量 `response` 来存放将要返回的成功响应的数据。接着,在 `response` 上调用 `as_bytes`,因为 `stream` 的 `write_all` 方法获取一个 `&[u8]` 并直接将这些字节发送给连接。因为 `write_all` 操作可能会失败,所以像之前那样对任何错误结果使用 `unwrap`。同理,在真实世界的应用中这里需要添加错误处理。 -有了这些修改,运行我们的代码并进行请求!我们不再向终端打印任何数据,所以不会再看到除了 Cargo 以外的任何输出。不过当在浏览器中加载 *127.0.0.1:7878* 时,会得到一个空页面而不是错误。太棒了!我们刚刚手写了一个 HTTP 请求与响应。 +有了这些修改,运行我们的代码并进行请求!我们不再向终端打印任何数据,所以不会再看到除了 Cargo 以外的任何输出。不过当在浏览器中加载 *127.0.0.1:7878* 时,会得到一个空页面而不是错误。太棒了!我们刚刚手写收发了一个 HTTP 请求与响应。 ### 返回真正的 HTML @@ -163,7 +169,7 @@ HTTP/1.1 200 OK\r\n\r\n 文件名:hello.html ```html -{{#include ../listings/ch20-web-server/listing-20-04/hello.html}} +{{#include ../listings/ch20-web-server/listing-20-05/hello.html}} ``` 示例 20-4: 一个简单的 HTML 文件用来作为响应 @@ -178,13 +184,13 @@ HTTP/1.1 200 OK\r\n\r\n 示例 20-5: 将 *hello.html* 的内容作为响应 body 发送 -在开头增加了一行来将标准库中的 `File` 引入作用域。打开和读取文件的代码应该看起来很熟悉,因为第十二章 I/O 项目的示例 12-4 中读取文件内容时出现过类似的代码。 +我们在开头 `use` 语句将标准库的文件系统模块 `fs` 引入作用域。打开和读取文件的代码应该看起来很熟悉,因为第十二章 I/O 项目的示例 12-4 中读取文件内容时出现过类似的代码。 接下来,使用 `format!` 将文件内容加入到将要写入流的成功响应的 body 中。 使用 `cargo run` 运行程序,在浏览器加载 *127.0.0.1:7878*,你应该会看到渲染出来的 HTML 文件! -目前忽略了 `buffer` 中的请求数据并无条件的发送了 HTML 文件的内容。这意味着如果尝试在浏览器中请求 *127.0.0.1:7878/something-else* 也会得到同样的 HTML 响应。如此其作用是非常有限的,也不是大部分 server 所做的;让我们检查请求并只对格式良好(well-formed)的请求 `/` 发送 HTML 文件。 +目前忽略了 `http_request` 中的请求数据并无条件的发送了 HTML 文件的内容。这意味着如果尝试在浏览器中请求 *127.0.0.1:7878/something-else* 也会得到同样的 HTML 响应。目前我们的 server 的作用是非常有限的,也不是大部分 server 所做的;让我们检查请求并只对格式良好(well-formed)的请求 `/` 发送 HTML 文件。 ### 验证请求并有选择的进行响应 @@ -196,11 +202,13 @@ HTTP/1.1 200 OK\r\n\r\n {{#rustdoc_include ../listings/ch20-web-server/listing-20-06/src/main.rs:here}} ``` -示例 20-6: 匹配请求并区别处理 */* 请求与其他请求 +示例 20-6: 以不同于其它请求的方式处理 */* 请求 + +我们只看 HTTP 请求的第一行,所以不同于将整个请求读取进 vector 中,这里调用 `next` 从迭代器中获取第一项。第一个 `unwrap` 负责处理 `Option` 并在迭代器没有项时停止程序。第二个 `unwrap` 处理 `Result` 并与示例 20-2 中增加的 `map` 中的 `unwrap` 有着相同的效果。 -首先,将与 */* 请求相关的数据硬编码进变量 `get`。因为我们将原始字节读取进了缓冲区,所以在 `get` 的数据开头增加 `b""` 字节字符串语法将其转换为字节字符串。接着检查 `buffer` 是否以 `get` 中的字节开头。如果是,这就是一个格式良好的 */* 请求,也就是 `if` 块中期望处理的成功情况,并会返回 HTML 文件内容的代码。 +接下来检查 `request_line` 是否等于一个 */* 路径的 GET 请求。如果是,`if` 代码块返回 HTML 文件的内容。 -如果 `buffer` **不** 以 `get` 中的字节开头,就说明接收的是其他请求。之后会在 `else` 块中增加代码来响应所有其他请求。 +如果 `request_line` **不** 等于一个 */* 路径的 GET 请求,就说明接收的是其他请求。我们之后会在 `else` 块中增加代码来响应所有其他请求。 现在如果运行代码并请求 *127.0.0.1:7878*,就会得到 *hello.html* 中的 HTML。如果进行任何其他请求,比如 *127.0.0.1:7878/something-else*,则会得到像运行示例 20-1 和 20-2 中代码那样的连接错误。 diff --git a/src/ch20-02-multithreaded.md b/src/ch20-02-multithreaded.md index cc7dd5b..58208fb 100644 --- a/src/ch20-02-multithreaded.md +++ b/src/ch20-02-multithreaded.md @@ -2,7 +2,7 @@ > [ch20-02-multithreaded.md](https://github.com/rust-lang/book/blob/main/src/ch20-02-multithreaded.md) >
-> commit 95b5e7c86d33e98eec6f73b268d106621f3d24a1 +> commit 98c6225e5fb8255349ec0dc235433530ed3fb534 目前 server 会依次处理每一个请求,意味着它在完成第一个连接的处理之前不会处理第二个连接。如果 server 正接收越来越多的请求,这类串行操作会使性能越来越差。如果一个请求花费很长时间来处理,随后而来的请求则不得不等待这个长请求结束,即便这些新请求可以很快就处理完。我们需要修复这种情况,不过首先让我们实际尝试一下这个问题。 @@ -16,15 +16,18 @@ {{#rustdoc_include ../listings/ch20-web-server/listing-20-10/src/main.rs:here}} ``` -示例 20-10: 通过识别 */sleep* 并休眠五秒来模拟慢请求 +示例 20-10: 通过休眠五秒来模拟慢请求 -这段代码有些凌乱,不过对于模拟的目的来说已经足够。这里创建了第二个请求 `sleep`,我们会识别其数据。在 `if` 块之后增加了一个 `else if` 来检查 */sleep* 请求,当接收到这个请求时,在渲染成功 HTML 页面之前会先休眠五秒。 +从 `if` 切换到 `match` 后现在有三个分支了。我们需要显式匹配一个 slice 的 `request_line` 以匹配字符串字面值的模式。`match` 不会像相等方法那样自动引用和解引用。 + +第一个分支与示例 20-9 中的 `if` 代码块相同。第二个分支匹配一个 */sleep* 请求。当接收到这个请求时,server 在渲染成功 HTML 页面之前会先休眠五秒。第三个分支与示例 20-9 中的 `else` 代码块相同。 现在就可以真切的看出我们的 server 有多么的原始:真实的库将会以更简洁的方式处理多请求识别问题! 使用 `cargo run` 启动 server,并接着打开两个浏览器窗口:一个请求 *http://127.0.0.1:7878/* 而另一个请求 *http://127.0.0.1:7878/sleep* 。如果像之前一样多次请求 */*,会发现响应的比较快速。不过如果请求 */sleep* 之后在请求 */*,就会看到 */* 会等待直到 `sleep` 休眠完五秒之后才出现。 -这里有多种办法来改变我们的 web server 使其避免所有请求都排在慢请求之后;我们将要实现的一个便是线程池。 + +有多种技术可以用来避免所有请求都排在慢请求之后;我们将要实现的一个便是线程池。 ### 使用线程池改善吞吐量 @@ -34,15 +37,15 @@ 不同于分配无限的线程,线程池中将有固定数量的等待线程。当新进请求时,将请求发送到线程池中做处理。线程池会维护一个接收请求的队列。每一个线程会从队列中取出一个请求,处理请求,接着向队列索取另一个请求。通过这种设计,则可以并发处理 `N` 个请求,其中 `N` 为线程数。如果每一个线程都在响应慢请求,之后的请求仍然会阻塞队列,不过相比之前增加了能处理的慢请求的数量。 -这个设计仅仅是多种改善 web server 吞吐量的方法之一。其他可供探索的方法有 fork/join 模型和单线程异步 I/O 模型。如果你对这个主题感兴趣,则可以阅读更多关于其他解决方案的内容并尝试用 Rust 实现他们;对于一个像 Rust 这样的底层语言,所有这些方法都是可能的。 +这个设计仅仅是多种改善 web server 吞吐量的方法之一。其他可供探索的方法有 **fork/join 模型**(*fork/join model*)、**单线程异步 I/O 模型**(*single-threaded async I/O model*)或者**多线程异步 I/O 模型**(*multi-threaded async I/O model*)。如果你对这个主题感兴趣,则可以阅读更多关于其他解决方案的内容并尝试实现他们;对于一个像 Rust 这样的底层语言,所有这些方法都是可能的。 在开始之前,让我们讨论一下线程池应用看起来怎样。当尝试设计代码时,首先编写客户端接口确实有助于指导代码设计。以期望的调用方式来构建 API 代码的结构,接着在这个结构之内实现功能,而不是先实现功能再设计公有 API。 -类似于第十二章项目中使用的测试驱动开发。这里将要使用编译器驱动开发(compiler-driven development)。我们将编写调用所期望的函数的代码,接着观察编译器错误告诉我们接下来需要修改什么使得代码可以工作。 +类似于第十二章项目中使用的测试驱动开发。这里将要使用编译器驱动开发(compiler-driven development)。我们将编写调用所期望的函数的代码,接着观察编译器错误告诉我们接下来需要修改什么使得代码可以工作。不过在开始之前,我们将探索不会作为起点的技术。 -#### 为每一个请求分配线程的代码结构 +#### 为每一个请求分配线程 -首先,让我们探索一下为每一个连接都创建一个线程的代码看起来如何。这并不是最终方案,因为正如之前讲到的它会潜在的分配无限的线程,不过这是一个开始。示例 20-11 展示了 `main` 的改变,它在 `for` 循环中为每一个流分配了一个新线程进行处理: +首先,让我们探索一下为每一个连接都创建一个线程的代码看起来如何。这并不是最终方案,因为正如之前讲到的它会潜在的分配无限的线程,不过这是一个可用的多线程 server 的起点。接着我们会增加线程池作为改进,这样比较两个方案将会更容易。示例 20-11 展示了 `main` 的改变,它在 `for` 循环中为每一个流分配了一个新线程进行处理: 文件名:src/main.rs @@ -54,7 +57,7 @@ 正如第十六章讲到的,`thread::spawn` 会创建一个新线程并在其中运行闭包中的代码。如果运行这段代码并在在浏览器中加载 */sleep*,接着在另两个浏览器标签页中加载 */*,确实会发现 */* 请求不必等待 */sleep* 结束。不过正如之前提到的,这最终会使系统崩溃因为我们无限制的创建新线程。 -#### 为有限数量的线程创建一个类似的接口 +#### 创建有限数量的线程 我们期望线程池以类似且熟悉的方式工作,以便从线程切换到线程池并不会对使用该 API 的代码做出较大的修改。示例 20-12 展示我们希望用来替换 `thread::spawn` 的 `ThreadPool` 结构体的假想接口: @@ -68,7 +71,7 @@ 这里使用 `ThreadPool::new` 来创建一个新的线程池,它有一个可配置的线程数的参数,在这里是四。这样在 `for` 循环中,`pool.execute` 有着类似 `thread::spawn` 的接口,它获取一个线程池运行于每一个流的闭包。`pool.execute` 需要实现为获取闭包并传递给池中的线程运行。这段代码还不能编译,不过通过尝试,编译器会指导我们如何修复它。 -#### 采用编译器驱动构建 `ThreadPool` 结构体 +#### 采用编译器驱动构建 `ThreadPool` 继续并对示例 20-12 中的 *src/main.rs* 做出修改,并利用来自 `cargo check` 的编译器错误来驱动开发。下面是我们得到的第一个错误: @@ -86,12 +89,12 @@ {{#rustdoc_include ../listings/ch20-web-server/no-listing-01-define-threadpool-struct/src/lib.rs}} ``` -接着创建一个新目录,*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` 引入作用域: +接着编辑 *main.rs* 文件通过在 *src/main.rs* 的开头增加如下代码将 `ThreadPool` 从库 crate 引入作用域: 文件名:src/bin/main.rs ```rust,ignore -{{#rustdoc_include ../listings/ch20-web-server/no-listing-01-define-threadpool-struct/src/bin/main.rs:here}} +{{#rustdoc_include ../listings/ch20-web-server/no-listing-01-define-threadpool-struct/src/main.rs:here}} ``` 这仍然不能工作,再次尝试运行来得到下一个需要解决的错误: @@ -116,9 +119,9 @@ {{#include ../listings/ch20-web-server/no-listing-02-impl-threadpool-new/output.txt}} ``` -现在有了一个警告和一个错误。暂时先忽略警告,发生错误是因为并没有 `ThreadPool` 上的 `execute` 方法。回忆 [“为有限数量的线程创建一个类似的接口”](#creating-a-similar-interface-for-a-finite-number-of-threads) 部分我们决定线程池应该有与 `thread::spawn` 类似的接口,同时我们将实现 `execute` 函数来获取传递的闭包并将其传递给池中的空闲线程执行。 +现在有了一个警告和一个错误。暂时先忽略警告,发生错误是因为并没有 `ThreadPool` 上的 `execute` 方法。回忆 [“创建有限数量的线程”](#creating-a-finite-number-of-threads) 部分我们决定线程池应该有与 `thread::spawn` 类似的接口,同时我们将实现 `execute` 函数来获取传递的闭包并将其传递给池中的空闲线程执行。 -我们会在 `ThreadPool` 上定义 `execute` 函数来获取一个闭包参数。回忆第十三章的 [“使用带有泛型和 `Fn` trait 的闭包”][storing-closures-using-generic-parameters-and-the-fn-traits] 部分,闭包作为参数时可以使用三个不同的 trait:`Fn`、`FnMut` 和 `FnOnce`。我们需要决定这里应该使用哪种闭包。最终需要实现的类似于标准库的 `thread::spawn`,所以我们可以观察 `thread::spawn` 的签名在其参数中使用了何种 bound。查看文档会发现: +我们会在 `ThreadPool` 上定义 `execute` 函数来获取一个闭包参数。回忆第十三章的 [“将被捕获的值移出闭包和 `Fn` trait”][fn-traits] 部分,闭包作为参数时可以使用三个不同的 trait:`Fn`、`FnMut` 和 `FnOnce`。我们需要决定这里应该使用哪种闭包。最终需要实现的类似于标准库的 `thread::spawn`,所以我们可以观察 `thread::spawn` 的签名在其参数中使用了何种 bound。查看文档会发现: ```rust,ignore pub fn spawn(f: F) -> JoinHandle @@ -162,17 +165,17 @@ pub fn spawn(f: F) -> JoinHandle 示例 20-13: 实现 `ThreadPool::new` 在 `size` 为零时 panic -这里用文档注释为 `ThreadPool` 增加了一些文档。注意这里遵循了良好的文档实践并增加了一个部分来提示函数会 panic 的情况,正如第十四章所讨论的。尝试运行 `cargo doc --open` 并点击 `ThreadPool` 结构体来查看生成的 `new` 的文档看起来如何! +这里也用文档注释为 `ThreadPool` 增加了一些文档。注意这里遵循了良好的文档实践并增加了一个部分来提示函数会 panic 的情况,正如第十四章所讨论的。尝试运行 `cargo doc --open` 并点击 `ThreadPool` 结构体来查看生成的 `new` 的文档看起来如何! -相比像这里使用 `assert!` 宏,也可以让 `new` 像之前 I/O 项目中示例 12-9 中 `Config::new` 那样返回一个 `Result`,不过在这里我们选择创建一个没有任何线程的线程池应该是不可恢复的错误。如果你想做的更好,尝试编写一个采用如下签名的 `new` 版本来感受一下两者的区别: +相比像这里使用 `assert!` 宏,也可以让 `new` 像之前 I/O 项目中示例 12-9 中 `Config::build` 那样将 `new` 更改为 `build` 并返回一个 `Result`,不过在这里我们选择创建一个没有任何线程的线程池应该是不可恢复的错误。如果你想做的更好,尝试编写一个采用如下签名的名为 `build` 的函数来对比一下 `new` 函数: ```rust,ignore -pub fn new(size: usize) -> Result { +pub fn build(size: usize) -> Result { ``` #### 分配空间以储存线程 -现在有了一个有效的线程池线程数,就可以实际创建这些线程并在返回之前将他们储存在 `ThreadPool` 结构体中。不过如何 “储存” 一个线程?让我们再看看 `thread::spawn` 的签名: +现在有了一个有效的线程池线程数,就可以实际创建这些线程并在返回结构体之前将他们储存在 `ThreadPool` 结构体中。不过如何 “储存” 一个线程?让我们再看看 `thread::spawn` 的签名: ```rust,ignore pub fn spawn(f: F) -> JoinHandle @@ -196,19 +199,19 @@ pub fn spawn(f: F) -> JoinHandle 这里将 `std::thread` 引入库 crate 的作用域,因为使用了 `thread::JoinHandle` 作为 `ThreadPool` 中 vector 元素的类型。 -在得到了有效的数量之后,`ThreadPool` 新建一个存放 `size` 个元素的 vector。本书还未使用过 `with_capacity`,它与 `Vec::new` 做了同样的工作,不过有一个重要的区别:它为 vector 预先分配空间。因为已经知道了 vector 中需要 `size` 个元素,预先进行分配比仅仅 `Vec::new` 要稍微有效率一些,因为 `Vec::new` 随着插入元素而重新改变大小。 +在得到了有效的数量之后,`ThreadPool` 新建一个存放 `size` 个元素的 vector。`with_capacity` 函数与 `Vec::new` 做了同样的工作,不过有一个重要的区别:它为 vector 预先分配空间。因为已经知道了 vector 中需要 `size` 个元素,预先进行分配比仅仅 `Vec::new` 要稍微有效率一些,因为 `Vec::new` 随着插入元素而重新改变大小。 -如果再次运行 `cargo check`,会看到一些警告,不过应该可以编译成功。 +如果再次运行 `cargo check`,它应该会成功。 #### `Worker` 结构体负责从 `ThreadPool` 中将代码传递给线程 示例 20-14 的 `for` 循环中留下了一个关于创建线程的注释。如何实际创建线程呢?这是一个难题。标准库提供的创建线程的方法,`thread::spawn`,它期望获取一些一旦创建线程就应该执行的代码。然而,我们希望开始线程并使其等待稍后传递的代码。标准库的线程实现并没有包含这么做的方法;我们必须自己实现。 -我们将要实现的行为是创建线程并稍后发送代码,这会在 `ThreadPool` 和线程间引入一个新数据类型来管理这种新行为。这个数据结构称为 `Worker`:这是一个池实现中的常见概念。想象一下在餐馆厨房工作的员工:员工等待来自客户的订单,他们负责接受这些订单并完成它们。 +我们将要实现的行为是创建线程并稍后发送代码,这会在 `ThreadPool` 和线程间引入一个新数据类型来管理这种新行为。这个数据结构称为 *Worker*,这是一个池实现中的常见概念。想象一下在餐馆厨房工作的员工:员工等待来自客户的订单,他们负责接受这些订单并完成它们。 不同于在线程池中储存一个 `JoinHandle<()>` 实例的 vector,我们会储存 `Worker` 结构体的实例。每一个 `Worker` 会储存一个单独的 `JoinHandle<()>` 实例。接着会在 `Worker` 上实现一个方法,它会获取需要允许代码的闭包并将其发送给已经运行的线程执行。我们还会赋予每一个 worker `id`,这样就可以在日志和调试中区别线程池中的不同 worker。 -首先,让我们做出如此创建 `ThreadPool` 时所需的修改。在通过如下方式设置完 `Worker` 之后,我们会实现向线程发送闭包的代码: +如下是创建 `ThreadPool` 时会发生的新过程。在通过如下方式设置完 `Worker` 之后,我们会实现向线程发送闭包的代码: 1. 定义 `Worker` 结构体存放 `id` 和 `JoinHandle<()>` 2. 修改 `ThreadPool` 存放一个 `Worker` 实例的 vector @@ -229,7 +232,9 @@ pub fn spawn(f: F) -> JoinHandle 这里将 `ThreadPool` 中字段名从 `threads` 改为 `workers`,因为它现在储存 `Worker` 而不是 `JoinHandle<()>`。使用 `for` 循环中的计数作为 `Worker::new` 的参数,并将每一个新建的 `Worker` 储存在叫做 `workers` 的 vector 中。 -`Worker` 结构体和其 `new` 函数是私有的,因为外部代码(比如 *src/bin/main.rs* 中的 server)并不需要知道关于 `ThreadPool` 中使用 `Worker` 结构体的实现细节。`Worker::new` 函数使用 `id` 参数并储存了使用一个空闭包创建的 `JoinHandle<()>`。 +`Worker` 结构体和其 `new` 函数是私有的,因为外部代码(比如 *src/main.rs* 中的 server)并不需要知道关于 `ThreadPool` 中使用 `Worker` 结构体的实现细节。`Worker::new` 函数使用 `id` 参数并储存了使用一个空闭包创建的 `JoinHandle<()>`。 + +> 注意:如果操作系统因为没有足够的系统资源而无法创建线程时,`thread::spawn` 会 panic。这会导致整个 server panic,即使一些线程可能创建成功了。出于简单的考虑,这个行为是可行的,不过在一个生产级别的线程池实现中,你可能会希望使用 [`std::thread::Builder`][builder] 和其 [`spawn`][builder-spawn] 方法来返回一个 `Result`。 这段代码能够编译并用指定给 `ThreadPool::new` 的参数创建储存了一系列的 `Worker` 实例,不过 **仍然** 没有处理 `execute` 中得到的闭包。让我们聊聊接下来怎么做。 @@ -241,13 +246,13 @@ pub fn spawn(f: F) -> JoinHandle 在第十六章,我们学习了 **信道** —— 一个沟通两个线程的简单手段 —— 对于这个例子来说则是绝佳的。这里信道将充当任务队列的作用,`execute` 将通过 `ThreadPool` 向其中线程正在寻找工作的 `Worker` 实例发送任务。如下是这个计划: -1. `ThreadPool` 会创建一个信道并充当发送端。 -2. 每个 `Worker` 将会充当信道的接收端。 +1. `ThreadPool` 会创建一个信道并充当发送者。 +2. 每个 `Worker` 将会充当接收者。 3. 新建一个 `Job` 结构体来存放用于向信道中发送的闭包。 -4. `execute` 方法会在信道发送端发出期望执行的任务。 -5. 在线程中,`Worker` 会遍历信道的接收端并执行任何接收到的任务。 +4. `execute` 方法会在发送者发出期望执行的任务。 +5. 在线程中,`Worker` 会遍历接收者并执行任何接收到的任务。 -让我们以在 `ThreadPool::new` 中创建信道并让 `ThreadPool` 实例充当发送端开始,如示例 20-16 所示。`Job` 是将在信道中发出的类型,目前它是一个没有任何内容的结构体: +让我们以在 `ThreadPool::new` 中创建信道并让 `ThreadPool` 实例充当发送者开始,如示例 20-16 所示。`Job` 是将在信道中发出的类型,目前它是一个没有任何内容的结构体: 文件名:src/lib.rs @@ -255,11 +260,11 @@ pub fn spawn(f: F) -> JoinHandle {{#rustdoc_include ../listings/ch20-web-server/listing-20-16/src/lib.rs:here}} ``` -示例 20-16: 修改 `ThreadPool` 来储存一个发送 `Job` 实例的信道发送端 +示例 20-16: 修改 `ThreadPool` 来储存一个传输 `Job` 实例的发送者 -在 `ThreadPool::new` 中,新建了一个信道,并接着让线程池在接收端等待。这段代码能够编译,不过仍有警告。 +在 `ThreadPool::new` 中,新建了一个信道,并接着让线程池在接收端等待。这段代码能够成功编译。 -让我们尝试在线程池创建每个 worker 时将信道的接收端传递给他们。须知我们希望在 worker 所分配的线程中使用信道的接收端,所以将在闭包中引用 `receiver` 参数。示例 20-17 中展示的代码还不能编译: +让我们尝试在线程池创建每个 worker 时将接收者传递给他们。须知我们希望在 worker 所分配的线程中使用接收者,所以将在闭包中引用 `receiver` 参数。示例 20-17 中展示的代码还不能编译: 文件名:src/lib.rs @@ -269,7 +274,7 @@ pub fn spawn(f: F) -> JoinHandle 示例 20-17: 将信道的接收端传递给 worker -这是一些小而直观的修改:将信道的接收端传递进了 `Worker::new`,并接着在闭包中使用它。 +这是一些小而直观的修改:将接收者传递进了 `Worker::new`,并接着在闭包中使用它。 如果尝试 check 代码,会得到这个错误: @@ -277,7 +282,7 @@ pub fn spawn(f: F) -> JoinHandle {{#include ../listings/ch20-web-server/listing-20-17/output.txt}} ``` -这段代码尝试将 `receiver` 传递给多个 `Worker` 实例。这是不行的,回忆第十六章:Rust 所提供的信道实现是多 **生产者**,单 **消费者** 的。这意味着不能简单的克隆信道的消费端来解决问题。即便可以,那也不是我们希望使用的技术;我们希望通过在所有的 worker 中共享单一 `receiver`,在线程间分发任务。 +这段代码尝试将 `receiver` 传递给多个 `Worker` 实例。这是不行的,回忆第十六章:Rust 所提供的信道实现是多 **生产者**,单 **消费者** 的。这意味着不能简单的克隆信道的消费端来解决问题。我们也不希望将一个消息向多个消费者发送多次;我们希望有一个消息列表和多个 worker 这样每个消息就只会处理一次。 另外,从信道队列中取出任务涉及到修改 `receiver`,所以这些线程需要一个能安全的共享和修改 `receiver` 的方式,否则可能导致竞争状态(参考第十六章)。 @@ -289,9 +294,9 @@ pub fn spawn(f: F) -> JoinHandle {{#rustdoc_include ../listings/ch20-web-server/listing-20-18/src/lib.rs:here}} ``` -示例 20-18: 使用 `Arc` 和 `Mutex` 在 worker 间共享信道的接收端 +示例 20-18: 使用 `Arc` 和 `Mutex` 在 worker 间共享接收者 -在 `ThreadPool::new` 中,将信道的接收端放入一个 `Arc` 和一个 `Mutex` 中。对于每一个新 worker,克隆 `Arc` 来增加引用计数,如此这些 worker 就可以共享接收端的所有权了。 +在 `ThreadPool::new` 中,将接收者放入一个 `Arc` 和一个 `Mutex` 中。对于每一个新 worker,克隆 `Arc` 来增加引用计数,如此这些 worker 就可以共享接收者的所有权了。 通过这些修改,代码可以编译了!我们做到了! @@ -350,10 +355,9 @@ warning: field is never read: `thread` 49 | thread: thread::JoinHandle<()>, | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -warning: 3 warnings emitted - +warning: `hello` (lib) generated 3 warnings Finished dev [unoptimized + debuginfo] target(s) in 1.40s - Running `target/debug/main` + Running `target/debug/hello` Worker 0 got a job; executing. Worker 2 got a job; executing. Worker 1 got a job; executing. @@ -387,5 +391,6 @@ Worker 2 got a job; executing. [creating-type-synonyms-with-type-aliases]: ch19-04-advanced-types.html#类型别名用来创建类型同义词 [integer-types]: ch03-02-data-types.html#整型 -[storing-closures-using-generic-parameters-and-the-fn-traits]: -ch13-01-closures.html#使用带有泛型和-fn-trait-的闭包 +[fn-traits]: ch13-01-closures.html#将被捕获的值移出闭包和-fn-trait +[builder]: https://doc.rust-lang.org/std/thread/struct.Builder.html +[builder-spawn]: https://doc.rust-lang.org/std/thread/struct.Builder.html#method.spawn diff --git a/src/ch20-03-graceful-shutdown-and-cleanup.md b/src/ch20-03-graceful-shutdown-and-cleanup.md index 6beeb19..8c85e2e 100644 --- a/src/ch20-03-graceful-shutdown-and-cleanup.md +++ b/src/ch20-03-graceful-shutdown-and-cleanup.md @@ -2,7 +2,7 @@ > [ch20-03-graceful-shutdown-and-cleanup.md](https://github.com/rust-lang/book/blob/main/src/ch20-03-graceful-shutdown-and-cleanup.md) >
-> commit 322899b375d071e4d96aaf29ce25c1a4b4ec65da +> commit 3e5105b52f7e8d3d95def07ffade4dcb1cfdee27 示例 20-20 中的代码如期通过使用线程池异步的响应请求。这里有一些警告说 `workers`、`id` 和 `thread` 字段没有直接被使用,这提醒了我们并没有清理所有的内容。当使用不那么优雅的 ctrl-c 终止主线程时,所有其他线程也会立刻停止,即便它们正处于处理请求的过程中。 @@ -28,7 +28,7 @@ {{#include ../listings/ch20-web-server/listing-20-22/output.txt}} ``` -这告诉我们并不能调用 `join`,因为只有每一个 `worker` 的可变借用,而 `join` 获取其参数的所有权。为了解决这个问题,需要一个方法将 `thread` 移动出拥有其所有权的 `Worker` 实例以便 `join` 可以消费这个线程。示例 17-15 中我们曾见过这么做的方法:如果 `Worker` 存放的是 `Option`,就可以在 `Option` 上调用 `take` 方法将值从 `Some` 成员中移动出来而对 `None` 成员不做处理。换句话说,正在运行的 `Worker` 的 `thread` 将是 `Some` 成员值,而当需要清理 worker 时,将 `Some` 替换为 `None`,这样 worker 就没有可以运行的线程了。 +这里的错误告诉我们并不能调用 `join`,因为只有每一个 `worker` 的可变借用,而 `join` 获取其参数的所有权。为了解决这个问题,需要一个方法将 `thread` 移动出拥有其所有权的 `Worker` 实例以便 `join` 可以消费这个线程。示例 17-15 中我们曾见过这么做的方法:如果 `Worker` 存放的是 `Option`,就可以在 `Option` 上调用 `take` 方法将值从 `Some` 成员中移动出来而对 `None` 成员不做处理。换句话说,正在运行的 `Worker` 的 `thread` 将是 `Some` 成员值,而当需要清理 worker 时,将 `Some` 替换为 `None`,这样 worker 就没有可以运行的线程了。 为此需要更新 `Worker` 的定义为如下: @@ -48,7 +48,7 @@ 文件名:src/lib.rs -```rust,ignore +```rust,ignore,does_not_compile {{#rustdoc_include ../listings/ch20-web-server/no-listing-05-fix-worker-new/src/lib.rs:here}} ``` @@ -56,7 +56,7 @@ 文件名:src/lib.rs -```rust,ignore +```rust,ignore,not_desired_behavior {{#rustdoc_include ../listings/ch20-web-server/no-listing-06-fix-threadpool-drop/src/lib.rs:here}} ``` @@ -64,52 +64,36 @@ ### 向线程发送信号使其停止接收任务 -有了所有这些修改,代码就能编译且没有任何警告。不过也有坏消息,这些代码还不能以我们期望的方式运行。问题的关键在于 `Worker` 中分配的线程所运行的闭包中的逻辑:调用 `join` 并不会关闭线程,因为他们一直 `loop` 来寻找任务。如果采用这个实现来尝试丢弃 `ThreadPool` ,则主线程会永远阻塞在等待第一个线程结束上。 - -为了修复这个问题,修改线程既监听是否有 `Job` 运行也要监听一个应该停止监听并退出无限循环的信号。所以信道将发送这个枚举的两个成员之一而不是 `Job` 实例: - -文件名:src/lib.rs - -```rust,noplayground -{{#rustdoc_include ../listings/ch20-web-server/no-listing-07-define-message-enum/src/lib.rs:here}} -``` +有了所有这些修改,代码就能编译且没有任何警告。不过也有坏消息,这些代码还不能以我们期望的方式运行。问题的关键在于 `Worker` 中分配的线程所运行的闭包中的逻辑:调用 `join` 并不会关闭线程,因为他们一直 `loop` 来寻找任务。如果采用这个实现来尝试丢弃 `ThreadPool`,则主线程会永远阻塞在等待第一个线程结束上。 -`Message` 枚举要么是存放了线程需要运行的 `Job` 的 `NewJob` 成员,要么是会导致线程退出循环并终止的 `Terminate` 成员。 +为了修复这个问题,我们将修改 `ThreadPool` 的 `drop` 实现并修改 `Worker` 循环。 -同时需要修改信道来使用 `Message` 类型值而不是 `Job`,如示例 20-23 所示: +首先修改 `ThreadPool` 的 `drop` 实现在等待线程结束前显式丢弃 `sender`。示例 20-23 展示了 `ThreadPool` 显式丢弃 `sender` 所作的修改。我们使用了与之前处理线程时相同的 `Option` 和 `take` 技术以便能从 `ThreadPool` 中移动 `sender`: 文件名:src/lib.rs -```rust,ignore +```rust,noplayground,not_desired_behavior {{#rustdoc_include ../listings/ch20-web-server/listing-20-23/src/lib.rs:here}} ``` -示例 20-23: 收发 `Message` 值并在 `Worker` 收到 `Message::Terminate` 时退出循环 - -为了适用 `Message` 枚举需要将两个地方的 `Job` 修改为 `Message`:`ThreadPool` 的定义和 `Worker::new` 的签名。`ThreadPool` 的 `execute` 方法需要发送封装进 `Message::NewJob` 成员的任务。然后,在 `Worker::new` 中当从信道接收 `Message` 时,当获取到 `NewJob`成员会处理任务而收到 `Terminate` 成员则会退出循环。 +示例 20-23: 在 join worker 线程之前显式丢弃 `sender` -通过这些修改,代码再次能够编译并继续按照示例 20-20 之后相同的行为运行。不过还是会得到一个警告,因为并没有创建任何 `Terminate` 成员的消息。如示例 20-24 所示修改 `Drop` 实现来修复此问题: +丢弃 `sender` 会关闭信道,这表明不会有更多的消息被发送。这时 worker 中的无限循环中的所有 `recv` 调用都会返回错误。在示例 20-24 中,我们修改 `Worker` 循环在这种情况下优雅地退出,这意味着当 `ThreadPool` 的 `drop` 实现调用 `join` 时线程会结束。 文件名:src/lib.rs -```rust,ignore +```rust,noplayground {{#rustdoc_include ../listings/ch20-web-server/listing-20-24/src/lib.rs:here}} ``` -示例 20-24:在对每个 worker 线程调用 `join` 之前向 worker 发送 `Message::Terminate` - -现在遍历了 worker 两次,一次向每个 worker 发送一个 `Terminate` 消息,一个调用每个 worker 线程上的 `join`。如果尝试在同一循环中发送消息并立即 join 线程,则无法保证当前迭代的 worker 是从信道收到终止消息的 worker。 - -为了更好的理解为什么需要两个分开的循环,想象一下只有两个 worker 的场景。如果在一个单独的循环中遍历每个 worker,在第一次迭代中向信道发出终止消息并对第一个 worker 线程调用 `join`。如果此时第一个 worker 正忙于处理请求,那么第二个 worker 会收到终止消息并停止。我们会一直等待第一个 worker 结束,不过它永远也不会结束因为第二个线程接收了终止消息。死锁! - -为了避免此情况,首先在一个循环中向信道发出所有的 `Terminate` 消息,接着在另一个循环中 join 所有的线程。每个 worker 一旦收到终止消息即会停止从信道接收消息,意味着可以确保如果发送同 worker 数相同的终止消息,在 join 之前每个线程都会收到一个终止消息。 +示例 20-24:当 `recv` 返回错误时显式退出循环 为了实践这些代码,如示例 20-25 所示修改 `main` 在优雅停机 server 之前只接受两个请求: -文件名:src/bin/main.rs +文件名:src/main.rs ```rust,ignore -{{#rustdoc_include ../listings/ch20-web-server/listing-20-25/src/bin/main.rs:here}} +{{#rustdoc_include ../listings/ch20-web-server/listing-20-25/src/main.rs:here}} ``` 示例 20-25: 在处理两个请求之后通过退出循环来停止 server @@ -124,40 +108,38 @@ $ cargo run Compiling hello v0.1.0 (file:///projects/hello) Finished dev [unoptimized + debuginfo] target(s) in 1.0s - Running `target/debug/main` + Running `target/debug/hello` Worker 0 got a job; executing. -Worker 3 got a job; executing. Shutting down. -Sending terminate message to all workers. -Shutting down all workers. Shutting down worker 0 -Worker 1 was told to terminate. -Worker 2 was told to terminate. -Worker 0 was told to terminate. -Worker 3 was told to terminate. +Worker 3 got a job; executing. +Worker 1 disconnected; shutting down. +Worker 2 disconnected; shutting down. +Worker 3 disconnected; shutting down. +Worker 0 disconnected; shutting down. Shutting down worker 1 Shutting down worker 2 Shutting down worker 3 ``` -可能会出现不同顺序的 worker 和信息输出。可以从信息中看到服务是如何运行的:worker 0 和 worker 3 获取了头两个请求,接着在第三个请求时,我们停止接收连接。当 `ThreadPool` 在 `main` 的结尾离开作用域时,其 `Drop` 实现开始工作,线程池通知所有线程终止。每个 worker 在收到终止消息时会打印出一个信息,接着线程池调用 `join` 来终止每一个 worker 线程。 +可能会出现不同顺序的 worker 和信息输出。可以从信息中看到服务是如何运行的:worker 0 和 worker 3 获取了头两个请求。server 会在头第二个请求后停止接受请求,`ThreadPool` 的 `Drop` 实现甚至会在 worker 3 开始工作之前就开始执行。丢弃 `sender` 会断开所有 worker 的连接并让它们关闭。每个 worker 在断开时会打印出一个信息,接着线程池调用 `join` 来等待每一个 worker 线程结束。 -这个特定的运行过程中一个有趣的地方在于:注意我们向信道中发出终止消息,而在任何线程收到消息之前,就尝试 join worker 0 了。worker 0 还没有收到终止消息,所以主线程阻塞直到 worker 0 结束。与此同时,每一个线程都收到了终止消息。一旦 worker 0 结束,主线程就等待其他 worker 结束,此时他们都已经收到终止消息并能够停止了。 +这个特定的运行过程中一个有趣的地方在于:`ThreadPool` 丢弃 `sender`,而在任何线程收到消息之前,就尝试 join worker 0 了。worker 0 还没有从 `recv` 获得一个错误,所以主线程阻塞直到 worker 0 结束。与此同时,worker 3 接收到一个任务接着所有线程会收到一个错误。一旦 worker 0 结束,主线程就等待余下其他 worker 结束。此时它们都退出了循环并停止。 恭喜!现在我们完成了这个项目,也有了一个使用线程池异步响应请求的基础 web server。我们能对 server 执行优雅停机,它会清理线程池中的所有线程。 如下是完整的代码参考: -文件名:src/bin/main.rs +文件名:src/main.rs ```rust,ignore -{{#rustdoc_include ../listings/ch20-web-server/no-listing-08-final-code/src/bin/main.rs}} +{{#rustdoc_include ../listings/ch20-web-server/no-listing-07-final-code/src/main.rs}} ``` 文件名:src/lib.rs ```rust,noplayground -{{#rustdoc_include ../listings/ch20-web-server/no-listing-08-final-code/src/lib.rs}} +{{#rustdoc_include ../listings/ch20-web-server/no-listing-07-final-code/src/lib.rs}} ``` 这里还有很多可以做的事!如果你希望继续增强这个项目,如下是一些点子: