Rust 项目实战:多线程 Web 服务器

第七部分:Rust 项目实战 本部分通过两个实际项目案例展示 Rust 的应用场景与实现细节。第一个项目是命令行工具,演示如何解析命令行参数并处理文件内容;第二个项目是构建一个简单的 Web 服务,展示 Rust 在网络开发中的强大能力。通过两个实际项目案例,你学会了如何使用 Rust 开发命令行工具和 Web 服务。Rust 的工具链和框架生态在实际开发中表现出了高效和安全的特性。下一部分将介绍 Rust 的性能优化和进阶技术,包括宏、测试和调试工具。目标:开发一个 Rust 命令行工具,用于统计文本文件中的单词数量。 阅读详情

Rust 项目实战:多线程 Web 服务器

前情提要:https://blog.youkuaiyun.com/ProgramNovice/article/details/148770590

单线程 Web 服务器将依次处理每个请求,这意味着在第一个连接完成处理之前,它不会处理第二个连接。如果服务器接收到越来越多的请求,那么串行执行将越来越不理想。如果服务器接收到一个需要很长时间来处理的请求,则后续请求将不得不等待,直到长请求完成,即使新请求可以快速处理。我们需要解决这个问题,但首先我们要看看实际的问题。

模拟慢速请求

我们将了解处理缓慢的请求如何影响对单线程 Web 服务器实现的其他请求。

我们使用模拟的缓慢响应实现了对 /sleep 的请求处理,该响应将导致服务器在响应之前休眠 5 s。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--
}

新增了一种对 /sleep 请求的响应,当接收到该请求时,服务器将在呈现 hello.html 之前休眠 5 s。

使用 cargo run 启动服务器。然后打开两个浏览器窗口:一个用于 127.0.0.1:7878,另一个用于 127.0.0.1:7878/sleep。如果像以前一样多次输入 / URI,您将看到它快速响应。但是如果你输入 /sleep,然后加载 /,你会看到 / 等待,直到 sleep 了整整 5 s 才加载。

我们要实现一个线程池,避免慢速请求后面的请求等待。

使用线程池提高吞吐量

线程池是一组正在等待并准备处理任务的派生线程。当程序接收到一个新任务时,它将池中的一个线程分配给该任务,该线程将处理该任务。池中的剩余线程可用于处理在第一个线程正在处理时进入的任何其他任务。当第一个线程完成其任务的处理后,它将返回到空闲线程池,准备处理新任务。线程池允许您并发地处理连接,从而提高服务器的吞吐量。

我们将限制池中的线程数量,因为服务器的资源是有限的,也保护我们免受 DoS 攻击。进入的请求被发送到池中进行处理,线程池将维护一个传入请求队列,池中的每个线程将从这个队列中弹出一个请求,处理该请求,然后向队列请求另一个请求。使用这种设计,我们最多可以并发处理 N 个请求,其中 N 是线程数。

这种技术只是提高 Web 服务器吞吐量的众多方法之一。其他选项包括 fork/join 模型、单线程异步 I/O 模型和多线程异步 I/O 模型,等等。

初步尝试:为每个请求生成一个线程

首先,让我们探索一下,如果为每个连接创建一个新线程,我们的代码会是什么样子。正如前面提到的,这不是我们的最终计划,因为可能会产生无限数量的线程,但这是一个起点,可以首先获得一个工作的多线程服务器。然后我们将添加线程池作为改进,并且比较两种解决方案会更容易。

在单线程 Web 服务器的 main 函数中进行修改:

use std::thread;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

thread::spawn 将创建一个新线程,然后在新线程中运行闭包中的代码。

如果运行这段代码并在浏览器中加载 /sleep,然后在另外两个浏览器选项卡中加载 /,对 / 的请求不必等待 /sleep 完成。然而,正如我们所提到的,这最终将使系统不堪重负,因为你将无限制地创建新线程。

现在,是时候让 async 和 await 真正发挥作用了!

实现线程池的定义和函数声明

我们的线程池的实现将独立于我们的 Web 服务器正在做的工作。

创建一个src/lib.rs,先实现一个 ThreadPool 结构体的定义,以及 ThreadPool::new 函数,其参数 size 表示线程池内线程的最大数量。

pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

我们将实现 execute 函数,它接受给定的闭包,并将其交给池中的空闲线程运行。该函数类似于标准库 thread::spawn 函数。

我们可以将闭包作为具有三个不同特征的参数:Fn、FnMut 和 FnOnce。我们需要决定在这里使用哪种闭包。我们可以看看 thread::spawn 的签名对它的参数有什么限制。文档向我们展示了以下内容:

impl ThreadPool {
    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

F 类型参数是我们关心的,T 类型参数与返回值有关,我们不关心这个。我们可以看到 spawn 使用 FnOnce 作为 f 上的 trait 约束。因为我们最终将在 execute 中获得的参数传递给 spawn,并且运行请求的线程只会执行该请求的闭包一次,所以 FnOnce 是我们想要使用的 trait。

F 类型参数也有 Send trait 约束和 static 生命周期约束,这在我们的情况下很有用:我们需要 Send 来将闭包从一个线程转移到另一个线程,而需要 static 是因为我们不知道线程执行需要多长时间。让我们在 ThreadPool 上创建一个execute方法,它将接受 F 类型的泛型参数,并具有以下约束:

impl ThreadPool {
    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

我们仍然在 FnOnce 之后使用 (),因为这个 FnOnce 表示一个闭包,它不接受参数,返回单元类型 ()。就像函数定义一样,返回类型可以从签名中省略,但即使没有参数,仍然需要括号。

ThreadPool 结构体的定义和两个函数的声明已经完成,使用 ThreadPool 结构体代替 thread::spawn 的假设接口。

修改 main.rs 中的代码:

use multi_thread_web_server::ThreadPool;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

我们使用 ThreadPool::new 创建一个新的线程池,可配置的线程数为 4 个。然后,在 for 循环中,pool.execute 有一个类似 thread::spawn 的接口,因为它接受一个闭包,处理每一个 stream。

运行 cargo build,编译通过了。

验证 new 中的线程数

前面我们为 size 参数选择了 unsigned 类型,因为线程数为负数的池没有意义。然而,一个没有线程的池也没有意义,所以在返回 ThreadPool 实例之前,我们将添加代码来检查 size 是否大于 0,并通过 assert 让程序在接收到 0 时 panic。

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
}

我们还为 ThreadPool 添加了一些文档和文档注释。运行 cargo doc --open,在打开的 HTML 文档中点击 ThreadPool 就能查看它的一些介绍信息。

在这里插入图片描述

我们也可以将 new 更改为 build 并返回一个 Result,就像下面的定义一样:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

但是在这种情况下,我们尝试创建一个没有任何线程的线程池是不合理的,我们希望在错误时 panic。

创建存储线程的空间

既然我们有办法知道池中存储了有效数量的线程,我们就可以创建这些线程,并在返回结构体之前将它们存储在 ThreadPool 结构体中。

但是我们如何“存储”一个线程呢?让我们再看一下 thread::spawn 签名:

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

spawn 函数返回一个 JoinHandle<T>,其中 T 是闭包返回的类型。我们也使用 JoinHandle,因为我们传递给线程池的闭包将处理连接而不返回任何东西,因此 T 将是单元类型 ()。

修改 ThreadPool 的定义,使其包含 thread::JoinHandle<()> 实例的 vector。

use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

再修改 new 函数,初始化 vector 的容量为 size,设置 for 循环,运行一些代码来创建线程,并返回一个包含它们的 ThreadPool 实例。

    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }

再次运行 cargo build,编译成功。

负责将代码从线程池发送到线程的 Worker 结构体

标准库的 thread::spawn 期望得到一些代码,这些代码应该在线程创建后立即运行。然而,在本例中,我们希望创建线程并让它们等待稍后发送的代码。

我们将通过在 ThreadPool 和管理这种新行为的线程之间引入一个新的数据结构来实现这种行为。我们将此数据结构称为 Worker,这是池实现中的一个常用术语。Worker 获取需要运行的代码,并在 Worker 的线程中运行这些代码。

我们将存储 Worker 结构的实例,而不是在线程池中存储 JoinHandle<()> 实例的 vector。每个 Worker 将存储一个 JoinHandle<()> 实例。然后,我们将在 Worker 上实现一个方法,该方法将接受代码的闭包来运行,并将其发送到已经运行的线程中执行。我们还将为每个 Worker 提供一个 id,以便在进行日志记录或调试时区分池中 Worker 的不同实例。

总结一下,我们要实现这四件事:

  1. 定义一个 Worker 结构体,它包含一个 id 和一个 JoinHandle<()>.
  2. 更改 ThreadPool 的定义,包含一个Worker 实例的 vector,而不是 Vec<thread::JoinHandle<()>>。
  3. 定义一个 Worker::new 函数,该函数接受一个 id 号,并返回一个包含该 id 的 Worker 实例和一个由空闭包派生的线程。
  4. 在 ThreadPool::new 函数中,使用 for 循环计数器生成一个 id,用该 id 创建一个新的 Worker,并将该 Worker 存储在 vector 中。
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

外部代码不需要知道在 ThreadPool 中使用 Worke r结构体的实现细节,所以我们把 Worker 结构体及其函数设为 private。Worker::new 函数使用我们给它的 id,并存储一个 JoinHandle<()> 实例,该实例是通过使用空闭包生成一个新线程创建的。

我们将 ThreadPool 上的字段名称从 threads 更改为 workers,因为它现在保存 Worker 实例而不是 JoinHandle<()> 实例。

注意,如果操作系统因为没有足够的系统资源而无法创建线程,thread::spawn 将出现 panic,这在实际生产环境中很危险。实际情况下,我们可以使用 std::thread::Builder 及其派生方法。

这段代码将编译并存储作为 ThreadPool::new 参数指定的 Worker 实例的数量。但是我们仍然没有处理在 execute 中得到的闭包。让我们看看接下来该怎么做。

通过通道向线程发送请求

我们希望刚刚创建的 Worker 结构体从 ThreadPool 中保存的队列中获取要运行的代码,并将该代码发送到其线程中运行。

我们将使用通道作为作业队列,execute 将把作业从 ThreadPool 发送到 Worker 实例,后者将把作业发送到它的线程。计划如下:

  1. ThreadPool 将创建一个通道并保持发送端。
  2. 每个 Worker 获取 receiver,作为接收端。
  3. 我们将创建一个新的 Job 结构体来保存我们想要发送到通道中的闭包。
  4. execute 方法将通过发送端发送它想要执行的作业。
  5. 在它的线程中,Worker 将遍历它的接收者,并执行它接收到的所有作业的闭包。

让我们首先在 ThreadPool::new 中创建一个通道,并在 ThreadPool 实例中保存发送端。Job 结构现在还没有保存任何东西,但它将是我们发送到通道的项的类型。

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--
}

在 ThreadPool::new 中,我们创建了一个通道,并让 ThreadPool 包含 sender。这将成功编译。

让我们尝试在线程池创建通道时将通道的接收器传递给每个 Worker。我们知道我们想要在 Worker 实例产生的线程中使用 receiver,所以我们将在闭包中引用 receiver 参数。

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

代码试图将 receiver 传递给多个 Worker 实例,这是行不通的,因为 Rust 提供的通道实现是多个生产者,单个消费者。这意味着我们不能仅仅克隆通道的消费端(接收端)来修复此代码。我们也不想多次向多个消费者发送消息。我们想要一个包含多个 Worker 实例的消息列表,这样每个消息都会被处理一次。

此外,从通道队列中取出作业涉及到改变 receiver,因此线程需要一种安全的方式来共享和修改 receiver。

为了在多个线程之间共享所有权并允许线程改变值,我们需要使用 Arc<Mutex<T>> 。Arc 类型将允许多个 Worker 实例拥有 receiver,Mutex 将确保一次只有一个 Worker 从接收器获得作业。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
    }
}

在 ThreadPool::new 中,我们将接收者置于 Arc<Mutex<>> 中。对于每个新的 Worker,我们克隆 Arc 来增加引用计数,这样 Worker 实例就可以共享 receiver 的所有权。

有了这些修改,代码就可以编译了。

实现 execute 方法

最后让我们实现 ThreadPool::execute 方法。我们还将 Job 从结构体更改为 trait 对象的类型别名,该 trait 对象保存 execute 接收的闭包类型。

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

在使用获得的闭包创建新 Job 实例之后,我们将该作业发送到通道中。在 send 失败的情况下我们调用 unwrap。

如果我们停止执行所有线程,这意味着接收端已经停止接收新消息,就可能发生这种情况。目前,我们不能停止线程的执行:只要池存在,线程就会继续执行。我们使用 unwrap 的原因是我们知道失败情况不会发生,但是编译器不知道。

但我们还没有完全完成!在 Worker 中,传递给 thread::spawn 的闭包仍然只引用通道的接收端。相反,我们需要闭包永远循环,向通道的接收端请求作业,并在获得作业时运行作业。让我们对 Worker::new 函数进行如下的更改。

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

在这里,我们首先在 receiver 上调用 lock 来获取互斥锁,然后调用 unwrap 来在出现错误时发出警报。

如果互斥锁处于锁定状态,获取锁可能会失败,如果其他线程在持有锁而不是释放锁时 panic,就会发生这种情况。在这种情况下,调用 unwrap 使该线程 panic 是正确的操作。你也可以将此 unwrap 更改为 expect,并显示有意义的错误消息。

如果我们获得了互斥锁,我们调用 recv 从通道接收 Job。如果持有发送方的线程已经关闭,那么最后的 unwrap 也会跳过这里的任何错误,类似于如果接收方关闭,则 send 方法返回 Err。

对 recv 的调用会阻塞当前线程,直到有作业可用。Mutex<T> 确保一次只有一个 Worker 线程试图请求作业。

至此,多线程 Web 服务器已经能成功运行了。现在我们有了一个异步执行连接的线程池。创建的线程永远不会超过 4 个,所以如果服务器接收到大量请求,我们的系统也不会过载,但也不会停止。在浏览器打开多个网页,程序输出一些执行的信息:

Worker 0 got a job; executing.
Worker 1 got a job; executing.
Worker 2 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
...

你可能想知道为什么不按照下面所示的方式编写工作线程代码。

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

这段代码可以编译和运行,但不会产生期望的线程行为:缓慢的请求仍然会导致其他请求等待处理。原因有些微妙:Mutex 没有公共解锁方法,因为锁的所有权是基于锁方法返回的 LockResult<MutexGuard<T>> 中的 MutexGuard<T> 的生命周期。在编译时,借用检查器可以强制执行由互斥锁保护的资源不能被访问的规则,除非我们持有该锁。但是,如果我们不注意 MutexGuard<T>的生命周期,这种实现也会导致锁被持有的时间比预期的要长。

之前的代码使用 let job = receiver.lock().unwrap().recv().unwrap(); 之所以有效,是因为使用 let 时,在等号右侧的表达式中使用的任何临时值都会在 let 语句结束时立即删除。然而,while let(以及 if let 和 match)在相关块结束之前不会删除临时值。在使用 while let 的代码中,锁在调用 job() 期间保持持有,这意味着其他 Worker 实例不能接收作业。

正常关机和清理

接下来,我们将实现 Drop trait,在池中的每个线程上调用 join,这样它们就可以在关闭之前完成正在处理的请求。然后我们将实现一种方法来告诉线程它们应该停止接受新请求并关闭。要查看这段代码的实际效果,我们将修改服务器,使其在优雅地关闭线程池之前只接受两个请求。

在 ThreadPool 上实现D rop trait

让我们从在线程池上实现 Drop 开始。当池被删除时,我们的线程都应该连接起来,以确保它们完成自己的工作。

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

我们循环遍历线程池的每个 worker。我们使用 &mut 是因为 self 是一个可变引用,而且我们还需要能够改变 worker。对于每个 worker,我们打印一条消息,表示这个特定的 worker 实例正在关闭,然后我们在该 worker 实例的线程上调用 join。如果 join 调用失败,我们使用 unwrap 使 Rust 陷入 panic,并进入不正常的关闭状态。

然而,程序并不能成功编译:

在这里插入图片描述

这个错误告诉我们不能调用 join,因为我们只有每个 worker 的可变借用,join 拥有其参数的所有权。为了解决这个问题,我们需要将线程移出拥有线程的 Worker 实例,以便 join 可以使用线程。

一种解决方法是使用 Option。如果 Worker 持有 Option<thread::JoinHandle<()>>,我们可以调用 Optio n的 take 方法将值从 Some 变体中移出,并在其位置留下 None 变体。换句话说,正在运行的 Worker 在线程中会有一个 Some 变量,当我们想要清理 Worker 时,我们将 Some 替换为 None,这样 Worker 就不会有线程要运行了。

然而,只有在丢弃 Worker 时才会出现这种情况。使用 Option 之后,我们必须在访问 worker.thread 的任何地方处理 Option<thread::JoinHandle<()>>,这很繁琐。

在这种情况下,存在一个更好的替代方法:Vec::drain 方法。它接受一个 range 参数来指定要从Vec中删除哪些项,并返回这些项的迭代器。传递 .. 将从 Vec 中删除所有值。

所以我们需要像这样更新 ThreadPool 的 Drop 实现:

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

这将解决编译器错误,并且不需要对代码进行任何其他更改。

向线程发出停止监听作业的信号

程序还没有按照我们想要的方式运行。关键是由 Worker 实例的线程运行的闭包中的逻辑:目前,我们调用 join,但这不会关闭线程,因为它们永远在循环寻找作业。如果我们尝试使用当前的 Drop 实现来删除 ThreadPool,主线程将永远阻塞,等待第一个线程完成。

为了解决这个问题,我们需要改变 ThreadPool Drop 的实现,在等待线程完成之前显式地删除 sender。然后再改变 Worker 中的 loop。

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--
impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

与线程不同,这里我们需要使用 Option::take 来将 sender 移出 ThreadPool。

删除 sender 将关闭通道,这表明将不再发送消息。当这种情况发生时, Worker 实例在 loop 中对 recv 的所有调用都会返回一个错误。在这种情况下,我们应该优雅地退出循环,这意味着线程将在 ThreadPool Drop 实现调用 join 时结束。

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}

要查看这段代码的实际效果,让我们修改 main 函数,使其在优雅地关闭服务器之前只接受两个请求。

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

take 方法是在 Iterator trait 中定义的,它将迭代最多限制在前两项。 ThreadPool 将在 main 函数结束时超出作用域,并运行 Drop实现。

启动装载运行的服务器,并发出三个请求。第三个请求应该出错,程序输出为:

Worker 0 got a job; executing.
Shutting down.
Worker 1 got a job; executing.
Shutting down worker 0
Worker 3 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Worker 1 disconnected; shutting down.
Shutting down worker 2
Shutting down worker 3

打印的 Worker id 和消息可能有不同顺序。我们可以从消息中看到这段代码是如何工作的:Worker 实例 0 和 1 获得了前两个请求。服务器在第二个连接之后停止接受连接,线程池上的 Drop 实现甚至在 Worker 1 开始它的工作之前就开始执行。删除发送器将断开所有 Worker 实例的连接,并告诉它们关闭。每个 Worker 实例在断开连接时打印一条消息,然后线程池调用 join 来等待每个 Worker 线程完成。

注意这个特殊执行的一个有趣的方面:ThreadPool 丢弃了 sender,并且在任何 Worker 接收到错误之前,我们尝试加入 Worker 0。工作线程 0 还没有从 recv 获得错误,所以主线程阻塞等待工作线程 0 完成。同时,Worker 1 收到了一个作业,然后所有线程都收到了一个错误。当 Worker 0 完成时,主线程等待其余的 Worker 实例完成。在这一点上,他们都退出了循环,停止了。

我们现在已经完成了我们的项目;我们有一个基本的 Web 服务器,它使用线程池进行异步响应。我们能够执行服务器的优雅关闭,这将清理池中的所有线程。

项目地址

优快云:基于 Rust 的多线程 Web 服务器

GitHub:UestcXiye / Multi-Thread-Web-Server-based-on-Rust

超硬货,大型 Rust 项目经验分享 本篇文章中,我将分享我组织大型 Rust 项目的经验。但这绝不是权威的,只是我通过尝试和错误中发现的一些小技巧。 Cargo,作为 Rust 的构建系统,遵循约定大于配置的原则。它不仅为小型项目提供了一套良好的默认配置集,尤其为公共 crates.io 库量身定做。虽然这些默认值并不完美,但它们已经足够用了。这对整个生态系统的一致性也是值得欢迎的。 然而当涉及到大型的、多 crate 的项目时,Cargo 就不那么统一了,它被组织成一个 Cargo 工作空间。而工作空间是灵活的 —— Cargo 对工作 阅读详情

相关推荐

Rust后端开发的项目实战案例

利用 Actix-web 框架构建一个 RESTful API 服务,用于管理图书信息,包括图书的添加、查询、更新和删除功能。- API 设计:遵循 RESTful 架构原则,设计清晰的 API 接口,包括正确的 HTTP 方法、状态码和响应格式。- 文章管理:实现文章的创建、编辑、删除和查询功能。- 使用 Rust 和 Diesel 库构建一个博客平台的后端服务,包括文章管理、用户管理、评论管理等功能。- 数据库设计:设计合理的数据库结构,包括文章表、用户表、评论表等,以及它们之间的关系。

alankuo的专栏 1328

Rust语言之多线程

高效且简单的并发处理

weixin_45963786的博客 2535

Diffix3D+复现

上下载模型,将其放在一个目录中,并将目录名填在demo代码中from_pretrained的cache_dir参数里。目录结构应该是"<PATH>/nvidia/difix/…给的requirements文件中pytorch和xformers的版本没有指定,这里测试过以下版本,可以成功运行。将readme中的demo代码新建一个python脚本复制进去,然后将脚本放在src目录中运行。如果遇到网络问题无法加载huggingface上的预训练模型,可以手动从。如果出现,以下错误重新。

NeverMakeIt的博客 1085

Rust的并发编程(二) 多线程并发

文章目录Rust的并发编程(二)多线程并发创建子线程线程间通信使用通道传递数据共享内存 Rust的并发编程(二) 并发,是指在宏观意义上同一时间处理多个任务。并发的方式一般包含为三种:多进程、多线程以及最近几年刚刚火起来的协程。 多线程并发 线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。 创建子线程 Rust使用标准库中的thread模块创建线程: use

鸿鹄之志 1万+

Hadoop与Kafka集群部署与优化实践指南

分布式系统是现代大数据处理的核心架构,其中Hadoop作为分布式存储与计算框架,Kafka作为分布式消息队列系统,共同构成了实时数据处理的基础设施。通过HDFS实现海量数据存储,结合YARN进行资源调度,而Kafka则保障了数据流的实时传输与高吞吐。这种技术组合在电商用户行为分析、物联网数据采集等场景中表现卓越。部署过程中需关注硬件资源配置、关键参数调优以及集群监控方案,例如通过调整HDFS块大小和Kafka partition数量来优化性能。合理运用Flume、Spark Streaming等工具可实现系

weixin_29061997的博客 360

【板栗糖GIS】新装ArcGIS必调的12个隐藏设置——【效率翻倍指南】

本文详细介绍了新装ArcGIS后必调的12个隐藏设置,涵盖性能优化、路径管理、显示调校、工作流定制等方面,帮助用户显著提升GIS工作效率。从关闭后台地理处理到启用硬件加速,再到个性化快捷键配置,这些实用技巧能让ArcGIS运行更流畅,操作更便捷。

weixin_42524945的博客 544

Rust 项目实战:异步多线程 Web 服务器

Rust 项目实战:异步多线程 Web 服务器

ProgramNovice的博客 1万+

一窥Memory测试算法及自我修复机制

摘要 为了满足新一代设备的需求,Memory的大小每三年就会增加4倍。深亚微米设备包含大量面积更小、访问速度更快的Memory。针对此类设计制定自动测试策略可以减少ATE(自动测试设备)的时间和成本。 内存故障的行为与经典的“Stuck-At”故障不同。因此,Memory的故障模型(由于其阵列结构)与标准逻辑的设计也是不同的。Memory测试除了进行故障检测和定位,还可以实现用冗余单元对...

路科验证 2万+

Rust程序设计语言 学习路径目录

15.2 使用Deref Trait将智能指针当作常规引用处理。22.7 G-Rust是如何开发的与"Nightly Rust"16.4 使用Sync与Send Traits的可扩展并发。21.2 将单线程server变为多线程server。14. 更多关于Cargo和Crates.io的内容。21. 最后的项目:构建多线程web server。7. 使用包、Crate和模块管理不断增长的项目。8.2 使用字符串储存UTF-8编码的文本。9.1 用panic!13.1 闭包:可以捕获其环境的匿名函数。

2301_81808324的博客 166

Hermes-Agent-完整指南

Hermes Agent 是由 **Nous Research** 开发的一款**自我改进型 AI Agent**,具有内置的学习闭环系统。它的核心理念是"一个与你共同成长的 Agent"。- 能够从经验中创建技能(Skill-Learning Automated),在使用过程中自我改进, 跨会话持久记忆, 深度用户建模

liu_zhiyi的博客 3029

ios快捷指令进行钉钉打卡

首先 1、打开快捷指令 2、自动化 3、创建个人自动化-特定时间 4、添加操作-搜索“随机数” 5、最小值和最大值填充 6、添加操作-搜索“等待” 7、长按“1秒钟”,选择魔法变量,选择上一步的随机数 6、添加操作-搜索“钉钉” 7、选择钉钉-随便一个聊天窗口。 8、添加操作-搜索“等待”,这次将等待时间设为10秒 9、添加操作 “搜索天气” ,选择显示天气。 注意点: 1、此操作依赖于钉钉极速打卡,只是帮忙唤起钉钉。 2、保存自动化时有个运行前询问,务必关掉。 3、手机不能设

blaze_jack 5万+

开源基于Rust编写的Web服务器

该项目于 2024 年 5 月 1 日开始开发。

m0_52796585的博客 2631

Python爬虫:实时交通流量数据爬取与路况分析

随着城市化进程的不断推进,交通流量的管理与预测已成为智能交通系统的重要研究方向。交通流量数据不仅为城市交通管理提供了科学依据,还为驾驶员提供了实时路况信息,帮助其做出更加合理的出行决策。通过爬虫技术,我们可以实时获取城市的交通流量数据,从而分析路况,优化交通管理。在这篇博客中,我们将介绍如何使用Python编写一个爬虫来爬取实时交通流量数据,分析道路的交通状况。我们将使用最新的技术工具来构建爬虫,包括。

2201_76125261的博客 1267

【Rust自学】20.2. 最后的项目:多线程Web服务器

我们在上一篇文章中写了一个简单的本地服务器,但是这个服务器是单线的,也就是说请求一个一个进去之后我们得一个一个地处理,如果某个请求处理得慢,那后面的都得排队等着。这种单线程外部服务器的性能是非常差的。

A Rookie in San Domenico School 1971

cesium 实时轨迹

运用cesium中model和polyline添加模型和轨迹线,用CallbackProperty方法来实时改变经纬度。

God's asylum的博客 1964

锂电池充电器电路设计 里面有电路

锂电池充电器电路设计 里面有电路 3.7V升压到5V电路设计。

Rust 实战: 启动多线程 Web 服务

Rust 实战: 启动多线程 Web 服务 文章目录Rust 实战: 启动多线程 Web 服务正文0. 基本信息1. 监听 TCP 连接请求2. 创建多线程运行环境2.1 线程池 ThreadPool2.2 任务执行 Worker3. 运行效果其他资源参考连接完整代码示例 正文 0. 基本信息 Rust 语言内置提供了 TcpListener 的结构体,来实现监听 TCP 的网络连接,不过默认情况下程序都是同步并阻塞在 incoming 方法上的。 因此本篇除了介绍使用 TcpListener 监听 TCP

超悠閒的博客 1771

WINCC7.3破解文件

WINCC7.3硬件够破解文件,有需要的下载!WINCC7.3硬件够破解文件,有需要的下载!

使用rust写一个Web服务器——多线程版本

多线程版本的rust web服务器

weixin_73622063的博客 2066

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版

Part-E1-SDIO-Specification-Ver4.00-Final-120220.pdf:最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版,包括SDIO 接口的外设,SDIO 接口官方协议等。

第22章 Web服务器项目

本文介绍了使用Rust构建Web服务器的完整过程。首先从单线程基础版本开始,讲解了HTTP协议的基本结构和TCP服务器实现,包括HTTP请求/响应格式、状态码处理、内容类型判断等功能。通过RequestHandler结构体实现了请求处理和静态文件服务能力,支持GET方法和默认页面设置。代码展示了如何构建完整的HTTP响应,包括状态行、头部和正文的组织方式,并实现了404错误页面的优雅处理。该项目综合运用了Rust的所有权系统、错误处理和网络编程等特性,为后续扩展多线程架构奠定了基础。

百锦再的博客 1万+

关于 Rust程序设计语言-构建多线程 Web服务器 一章的一些问题

最近在跟着一书学习Rust,在学习最后一章的最后两节时,我遇到了一些问题,并尝试进行解释,接下来分享给大家。

Wopelo的博客 1701

Rust Rosetta Code Web开发:使用Rust构建简单的Web服务器

在Web开发领域,Rust凭借其高性能和内存安全特性正逐渐成为开发者的新选择。Rust Rosetta Code项目提供了丰富的代码示例,其中就包含了使用Rust构建Web服务器的实用实现。本文将带你了解如何利用Rust Rosetta Code中的示例,快速搭建一个功能完备的Web服务器。 ### 一、Rust Web服务器基础架构 Rust标准库提供了构建网络应用的核心组件,通过`std:

gitblog_00882的博客 523

Flame实战案例:优化Rust Web服务器的10个性能瓶颈检测技巧

想要快速定位Rust Web服务器的性能瓶颈?Flame火焰图工具是你的终极解决方案!作为一款侵入式性能分析工具,Flame能够帮助开发者直观地发现代码中的性能热点,特别适合优化Rust Web应用程序。本文将分享10个实用的Flame实战技巧,助你轻松提升服务器性能。 ## 为什么选择Flame进行Rust性能分析? Flame与其他性能分析工具的最大区别在于它的侵入式设计。不同于`perf

gitblog_00762的博客 730

Rust 实战:从零构建一个多线程 Web 服务器

Rust 实战:从零构建一个多线程 Web 服务器 在众多编程语言中,Rust 因其卓越的性能、内存安全和强大的并发能力,在系统编程领域备受青睐。构建一个 Web 服务器是学习和实践这些特性的绝佳项目

hehehaha2025的博客 216

《Rust 程序设计语言》第二十一章:期末项目:构建多线程 Web 服务器

本文介绍了如何用Rust构建一个多线程Web服务器的完整过程。主要内容包括:1. 单线程Web服务器基础 使用TCP监听器处理连接 解析HTTP请求并返回响应 实现静态文件服务(HTML页面) 多线程优化 使用线程池提高并发处理能力 通过通道实现任务分发 线程间共享接收端(Arc<Mutex>) 优雅关闭机制 实现Drop trait清理资源 通道关闭通知 线程安全退出 关键实现细节 Worker线程模型 任务队列管理 错误处理和资源释放 这个项目完整展示了Rust的系统编程能力,包括线程安全、

北京锋通科技有限公司的技术blog 798

基于Rust的多线程 Web 服务器

最终版 main.rs 文件。hello.html 文件。最终版 lib.rs 文件。404.html 文件。main.rs 文件。lib.rs 修改一。lib.rs 修改二。

支持一对一答疑的编程作家朱文伟的博客! 385
上一篇: Rust 学习笔记:关于宏的练习题
下一篇: Rust 项目实战:单线程 Web 服务器
UestcXiye
博客等级 码龄8年 9028粉丝 1306原创
评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值