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

前言

最近在跟着《Rust 程序设计语言》一书学习Rust,在学习最后一章构建多线程 Web 服务器的最后两节时,我遇到了一些问题,并尝试进行解释,接下来分享给大家。

将单线程服务器变为多线程服务器

按照20.2.将单线程服务器变为多线程服务器 一节的指引,编写多线程TCP服务如下:

rust 复制代码
use std::net::{TcpListener, TcpStream}; // TcpListener 用于监听 TCP 连接
use std::io::prelude::*; // 引入读写流所需的特定 trait,比如 Read trait
use std::fs;
use std::thread;
use std::time::Duration;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;

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

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").expect("Failed to bind to address");
    println!("Server listening on port 7878...");

    // 实例化线程池,包含4个子线程
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                pool.execute(|| {
                    handle_connection(stream); // 处理请求
                });
            }
            Err(e) => {
                eprintln!("Error: {}", e);
            }
        }
    }
}

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).expect("Failed to bind to read data"); 

    let get = b"GET / HTTP/1.1\r\n";
    let sleep = b"GET /sleep HTTP/1.1\r\n";

    let response = if buffer.starts_with(get) { // 如果请求根目录,返回一个HTML文件
        let contents = fs::read_to_string("./html/hello.html").unwrap();

        format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        )
    } else if buffer.starts_with(sleep) { // 如果访问 /sleep 路径,则模拟执行一个耗时5s的慢操作,再返回一个HTML文件
        thread::sleep(Duration::from_secs(5));
        
        let contents = fs::read_to_string("./html/hello.html").unwrap();

        format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        )
    } else { // 其他路径一律返回404
        "HTTP/1.1 404 Not Found\r\n\r\n404 Not Found".to_string()
    };

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

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

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

        // 跨进程传递消息
        let (sender, receiver) = mpsc::channel();

        // Rust 所提供的通道实现是多生产者,单消费者,所以需要共享消费者
        let receiver = Arc::new(Mutex::new(receiver));

        // with_capacity用于创建一个预先分配了指定容量的空 Vec,避免动态分配内存和复制数据的开销
        let mut workers = Vec::with_capacity(size);

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

        ThreadPool {
            workers,
            sender,
        }
    }

    pub fn execute<F>(&self, f: F)
        where
            F: FnOnce() + Send + 'static
            // FnOnce()表示闭包可以被调用一次。在这个多线程的环境下,每个任务只需要被执行一次,所以需要这个约束
            // Send表示闭包所有权可以在线程间传递
            // 'static表示这个闭包可以存活整个程序的生命周期。这是因为无法预知任务何时会被执行完,所以需要保证它在任何时候都是有效的
    {
        // f在编译时不知道具体的大小,所以需要用Box包装一层,以在堆上分配内存?
        let job = Box::new(f);

        // 将处理请求的函数发送给子线程
        self.sender.send(job).unwrap();
    }
}

// 子线程
struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

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 {} got a job; executing.", id);

                job(); // job 就是 handle_connection
            }
        });

        Worker {
            id,
            thread,
        }
    }
}

这个程序预期的执行结果是,当TCP服务启动之后,先在浏览器中访问 http://localhost:7878/sleep 触发一个慢操作,然后立即新开tab页访问 http://localhost:7878 可以发现对根目录的访问并没有因为慢操作而被阻塞,这也是本节要展示的将单线程服务器变为多线程服务器后的优势。

最关键的代码在 Worker 的 impl 块中 ,子线程通过 loop 循环不断尝试获取 receiver 的锁,以监听主线程发送的数据,并完成对当前请求的处理。

现在对 Worker 的实现进行如下两种修改:

改法一:loop循环内部使用一个变量保存 receiver.lock().unwrap() 返回的值

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

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

              job();
          }
      });

      Worker {
          id,
          thread,
      }
  }
}

改法二:loop 循环改成 while let

rust 复制代码
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 {} got a job; executing.", id);

                job();
            }
        });

        Worker {
            id,
            thread,
        }
    }
}

修改之后,再到浏览器中进行相同的操作,会发现先进行的慢操作对后续的访问形成阻塞。 我们查看书中的说明:

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

相反通过使用 loop 并在循环块之内而不是之外获取锁和任务,lock 方法返回的 MutexGuard 在 let job 语句结束之后立刻就被丢弃了。这确保了 recv 调用过程中持有锁,而在 job() 调用前锁就被释放了,这就允许并发处理多个请求了。

但估计看完这段文字大家可能还是不明所以,英文原版反而能给出更多的信息:

The code in Listing 20-20 that uses let job = receiver.lock().unwrap().recv().unwrap(); works because with let, any temporary values used in the expression on the right hand side of the equals sign are immediately dropped when the let statement ends. However, while let (and if let and match) does not drop temporary values until the end of the associated block. In Listing 20-21, the lock remains held for the duration of the call to job(), meaning other workers cannot receive jobs.

翻译一下就是:

示例 20-20 中使用 let job=receiver.lock().unwrap().recv().uwrap();之所以有效,是因为使用 let,当let语句结束时,等号右侧表达式中使用的任何临时值都会立即删除。但是 while let(以及if let和match)直到关联块结束时才丢弃临时值。在示例 20-21中,锁在对 job() 的调用期间保持不变,这意味着其他 Worker 无法接收任务。

三种写法的效果不同是由于Rust对不同变量生命周期的处理不同所导致的 。 对于临时变量,只在当前语句内有效,当前语句执行结束就失效了。具体来说在 let job = receiver.lock().unwrap().recv().unwrap();中等号右边所产生的所有中间值都只是临时变量,则在当期语句结束后就会被释放,由此当这行代码执行完成,即子线程从主线程中接受到任务之后,对 receiver 的锁就会被释放掉,其他线程才可以拿到 receiver 的锁,进而处理后续的请求。这就是原写法能够正常工作的原因。

但在第一种改法中,我们使用了一个变量 rx 保存了 receiver.lock().unwrap() 的返回值,这就导致在 loop 循环中,当前线程对 receiver 的锁会在本次循环结束后被释放,其他线程才有机会获取到 receiver 的锁,这实际就将对请求的处理改成串行了,如果上一个请求需要执行慢操作,自然会阻塞下一个请求的处理。

在第二种改法中,由于在 while let,if let,match 语句会在自己的作用域内一直持有临时变量,导致 while let Ok(job) = receiver.lock().unwrap().recv()中,虽然 job 只是一个临时变量,但会在本次循环中一直保留,结果与第一种改法一样。

优雅停机与清理

第二个点是在20.3.优雅停机与清理一节中,按照书中指引,可得到代码如下:

rust 复制代码
use std::net::{TcpListener, TcpStream}; // TcpListener 用于监听 TCP 连接
use std::io::prelude::*; // 引入读写流所需的特定 trait,比如 Read trait
use std::fs;
use std::thread;
use std::time::Duration;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;

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

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").expect("Failed to bind to address");
    println!("Server listening on port 7878...");

    // 实例化线程池
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(1) { // 仅循环一次
        match stream {
            Ok(stream) => {
                pool.execute(|| {
                    handle_connection(stream);
                });
            }
            Err(e) => {
                eprintln!("Error: {}", e);
            }
        }
    }

    println!("Shutting down.");

    // 当循环执行完成后,代码运行到此处时,pool将会被丢弃,触发drop方法
    // drop方法向所有子线程发送终止命令
}

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).expect("Failed to bind to read data");
 
	// 同样的路径处理
    let get = b"GET / HTTP/1.1\r\n";
    let sleep = b"GET /sleep HTTP/1.1\r\n";

    let response = if buffer.starts_with(get) {
        let contents = fs::read_to_string("./html/hello.html").unwrap();

        format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        )
    } else if buffer.starts_with(sleep) {
        thread::sleep(Duration::from_secs(10));
        
        let contents = fs::read_to_string("./html/hello.html").unwrap();

        format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        )
    } else {
        "HTTP/1.1 404 Not Found\r\n\r\n404 Not Found".to_string()
    };

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

enum Message {
    NewJob(Job),
    Terminate,
}

// 线程池
pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Message>,
}

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

        // 跨进程传递消息
        let (sender, receiver) = mpsc::channel();

        // Rust 所提供的通道实现是多生产者,单消费者,所以需要共享消费者
        let receiver = Arc::new(Mutex::new(receiver));

        // with_capacity用于创建一个预先分配了指定容量的空 Vec,避免动态分配内存和复制数据的开销
        let mut workers = Vec::with_capacity(size);

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

        ThreadPool {
            workers,
            sender,
        }
    }

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

        self.sender.send(Message::NewJob(job)).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        println!("Sending terminate message to all workers.");

        for _ in &mut self.workers {
            self.sender.send(Message::Terminate).unwrap();
        }

        println!("Shutting down all workers.");

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

            // join需要thread的所有权,通过调用 Option 上的 take 将 thread 移动出 worker
            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
                println!("worker {} joined.", worker.id);
            }
        }
    }
}

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

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Message>>>) ->
        Worker {

        let thread = thread::spawn(move ||{
            loop {
                let message = receiver.lock().unwrap().recv().unwrap();

                match message {
                    Message::NewJob(job) => {
                        println!("Worker {} got a job; executing.", id);

                        job();
                    },
                    Message::Terminate => {
                        println!("Worker {} was told to terminate.", id);

                        break;
                    },
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

这个程序预期的运行结果是,当TCP服务启动之后,在浏览器中访问 http://localhost:7878/sleep 触发一次慢处理,只要有一次请求,程序就会关闭所有的子线程,终端中的打印类似于:

bash 复制代码
Server listening on port 7878...
# 这里发起请求
Shutting down.
Sending terminate message to all workers.
Worker 2 got a job; executing.
Worker 0 was told to terminate.
Shutting down all workers.
Shutting down worker 0
Worker 3 was told to terminate.
Worker 1 was told to terminate.
worker 0 joined.
Shutting down worker 1
worker 1 joined.
Shutting down worker 2
Worker 2 was told to terminate.
worker 2 joined.
Shutting down worker 3
worker 3 joined.

第一个需要注意的点是,处理请求的子线程不一定是线程池中的第一个子线程,但一定是第一个获取 receiver锁 的子线程。所以 Worker 2 got a job; executing. 这行具体打印的是第几个 Worker 不是固定的。

第二个需要注意的点是在 ThreadPool 实现 Drop 的块中,通过一个 for 循环向所有的子线程发送终止命令:

rust 复制代码
for _ in &mut self.workers {
     self.sender.send(Message::Terminate).unwrap();
 }

这行代码其实没有什么问题,但无论是英文原版还是中文翻译,对这段代码的解释都是"向每个 Worker发送一个 Terminate 消息",这会造成一个误解------每个 Worker 是按顺序收到终止命令的。 但 send 方法其实没有指定发给哪个子线程,只有当前拥有 receiver 锁的子线程才能收到本次循环发送的消息,进而退出 loop 循环,不再继续在 loop 中尝试获取 receiver 的锁以监听消息,因此后续的终止命令就由其他子线程接收。同时,消息的发送的先后顺序和接受的先后顺序没有关系,可能先发送的消息比后发送的消息更晚被接收,这就导致虽然 for 循环是按顺序遍历的, 但 Worker [n] was told to terminate. 的打印顺序并不是按遍历顺序打印的。

不过有一个顺序是固定的,那就是 Shutting down worker [n]. 和 worker [n] joined. 这两行一定是按照顺序打印的。这很好理解,因为这两个打印是在另外一个 for 循环中按顺序执行的:

rust 复制代码
for worker in &mut self.workers {
    // 按顺序调用join(),阻塞主线程继续执行
    // 阻塞的时长主要取决于当前执行任务的线程什么时候执行完,可以访问/sleep路径验证
    println!("Shutting down worker {}", worker.id);

    // join需要thread的所有权,通过调用 Option 上的 take 将 thread 移动出 worker
    if let Some(thread) = worker.thread.take() {
        thread.join().unwrap();
        println!("worker {} joined.", worker.id);
    }
}

但问题来了,为什么这里要使用两个 for 循环,一个发送终止命令,另一个调用 join 方法,不能在一个 for 循环中发送终止命令后马上调用 join 方法吗? 我们对代码进行改造:

rust 复制代码
impl Drop for ThreadPool {
    fn drop(&mut self) {
        println!("Sending terminate message to all workers.");

        for worker in &mut self.workers {
            self.sender.send(Message::Terminate).unwrap();

            println!("Sended terminate message to worker {}.", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }

            println!("worker {} joined.", worker.id);
        }

        println!("Shutting down all workers.");
    }
}

发现当我们在浏览器中发起请求后,程序并没有关闭所有的子线程并退出,控制台中的打印类似于:

bash 复制代码
Server listening on port 7878...
Shutting down.
Sending terminate message to all workers.
Sended terminate message to worker 0.
Worker 0 got a job; executing.
Worker 1 was told to terminate.

这里奇怪的地方不止程序没有按预期关闭所有的子线程,还有为什么 Sended terminate message to worker 0. 之后打印的是 Worker 1 was told to terminate. ?

其实前面提到的两个需要注意的点就已经可以解释在这个现象了。处理任务的是 Worker 0,在执行 job 时 Worker 0 已经失去receiver的锁,所以它收不到终止命令,转而由其他子线程接收。这里是 Worker 1 收到终止命令,所以虽然 for 循环中是按顺序发送终止命令,但首先被终止的是 Worker 1 而非 Worker 0。

接下来我们再解释为什么要使用两个 for 循环,书中有说明如下:

现在遍历了 worker 两次,一次向每个 worker 发送一个 Terminate 消息,一个调用每个 worker 线程上的 join。如果尝试在同一循环中发送消息并立即 join 线程,则无法保证当前迭代的 worker 是从通道收到终止消息的 worker。 为了更好的理解为什么需要两个分开的循环,想象一下只有两个 worker 的场景。如果在一个单独的循环中遍历每个 worker,在第一次迭代中向通道发出终止消息并对第一个 worker 线程调用 join。如果此时第一个 worker 正忙于处理请求,那么第二个 worker 会收到终止消息并停止。我们会一直等待第一个 worker 结束,不过它永远也不会结束因为第二个线程接收了终止消息。死锁!

这段话可能不太好理解,但牢记前面提到的两个需要注意的点,如果我们在发送终止命令之后,马上调用 join 方法阻塞主线程,则主线程将在暂停循环,直到子线程结束后再接着执行下一次循环。但通过之前的解释我们知道,当前 for 循环中的 Worker 可能正在执行慢处理,收不到终止命令,反而是其他空闲的子线程收到命令,然后结束自身 loop 循环。但 join 方法却是在当前 Worker 上调用的,对于正在执行任务的 Worker 来说,当前任务完成后,由于没有收到终止消息,它会继续loop循环,所以主线程的等到遥遥无期,for 循环无法继续往下执行。

但换成使用两个 for 循环的方案,第一次 for 循环按照子线程的数量发送终止命令,即使正在执行任务的子线程一开始收不到消息,但等到其处理完成,其他子线程已经结束了,它可以从容的获取到 receiver 锁,收到最后一次发送的终止命令,进而结束自己的 loop 循环。第二个 for 循环按顺序等待子线程执行完毕,如果恰好遍历到了正在执行任务的子线程也没有关系,因为按照刚才所述,这个子线程最终也会收到终止命令,只不过这段阻塞的时长就取决于当前任务什么时候执行完,然后主线程才能继续等待其他子线程结束,虽然剩下的子线程早就收到终止命令进而结束执行了。

参考

Rust 临时变量的生命周期

Rust 关于 let 语句中以下划线变量名需要注意的一个点, _, _var, var 三者在生命周期上的区别

相关推荐
brief of gali8 分钟前
记录一个奇怪的前端布局现象
前端
Json_181790144801 小时前
电商拍立淘按图搜索API接口系列,文档说明参考
前端·数据库
风尚云网1 小时前
风尚云网前端学习:一个简易前端新手友好的HTML5页面布局与样式设计
前端·css·学习·html·html5·风尚云网
木子02041 小时前
前端VUE项目启动方式
前端·javascript·vue.js
GISer_Jing2 小时前
React核心功能详解(一)
前端·react.js·前端框架
捂月2 小时前
Spring Boot 深度解析:快速构建高效、现代化的 Web 应用程序
前端·spring boot·后端
深度混淆2 小时前
实用功能,觊觎(Edge)浏览器的内置截(长)图功能
前端·edge
Smartdaili China2 小时前
如何在 Microsoft Edge 中设置代理: 快速而简单的方法
前端·爬虫·安全·microsoft·edge·社交·动态住宅代理
秦老师Q2 小时前
「Chromeg谷歌浏览器/Edge浏览器」篡改猴Tempermongkey插件的安装与使用
前端·chrome·edge
滴水可藏海2 小时前
Chrome离线安装包下载
前端·chrome