Rust第十五节 - 无谓并发

15 无谓并发

讲无畏并发之前,我们首先来看看并发编程和并行编程的区别: 前者允许程序中的不同部分相互独立地运行,而后者则允许程序中的不同部分同时执行 但是从以往的经验来看,这类场景的编程往往是容易出错的。

Rust为我们提供了所有权和类型系统,相比于在运行时遭遇并发缺陷后花费大量时间来重现特定的问题场景,Rust编译器会直接拒绝不正确的代码并给出解释问题的错误提示信息。我们将这一特性称为无畏并发

本章讨论的内容讨论:

  • 如何创建线程来同时运行多段代码。
  • 使用通道在线程间发送消息的消息传递式并发。
  • 允许多个线程访问同一片数据的共享状态式并发。
  • Sync trait与Send trait,能够将Rust的并发保证从标准库中提供的类型扩展至用户自定义类型。

15.1 使用线程同时运行代码

首先我们来看看线程和进程的区别: 在大部分现代操作系统中,执行程序的代码会运行在进程(process)中,操作系统会同时管理多个进程。类似地,程序内部也可以拥有多个同时运行的独立部分,用来运行这些独立部分的就叫作线程(thread)

通过使用线程,我们可以将计算机操作拆分成多个线程同时运行提高性能。但是这样会导致一些问题,例如:

  • 当多个线程以不一致的顺序访问数据或资源时产生的竞争状态(race condition)。
  • 当两个线程同时尝试获取对方持有的资源时产生的死锁(deadlock),它会导致这两个线程无法继续运行。
  • 只会出现在特定情形下且难以稳定重现和修复的bug。

15.1.1 使用Spawn创建线程

我们可以使用thread::spawn来创建线程,它接受一个闭包,如:

rust 复制代码
use std::thread;
use std::time::Duration;

thread::spawn(|| {
    for i in 1..10 {
        println!("【新】线程中的数据:{}", i);
        thread::sleep(Duration::from_millis(1))
    }
});

for i in 1..5 {
    println!("【主】线程中的数据:{}", i);
    thread::sleep(Duration::from_millis(1))
}

// 【主】线程中的数据:1
// 【新】线程中的数据:1
// 【主】线程中的数据:2
// 【新】线程中的数据:2
// 【主】线程中的数据:3
// 【新】线程中的数据:3
// 【主】线程中的数据:4
// 【新】线程中的数据:4
// 【新】线程中的数据:5

执行完之后,我们会发现新线程中循环并没有执行完,这是因为执行到【新】线程中的数据:5时这个时候,主线程就结束了,所以新线程也不会继续执行。那么我们应该怎么解决这个问题呢?

15.1.2 使用join句柄等待所有线程结束

我们可以使用thread::spawn返回的句柄,它是一个自持有所有权的JoinHandle,调用它的join方法可以阻塞当前线程直到对应的新线程运行结束。如下:

rust 复制代码
use std::thread;
use std::time::Duration;

let handle = thread::spawn(|| {
    for i in 1..10 {
        println!("【新】线程中的数据:{}", i);
        thread::sleep(Duration::from_millis(1))
    }
});

for i in 1..5 {
    println!("【主】线程中的数据:{}", i);
    thread::sleep(Duration::from_millis(1))
}

handle.join().unwrap();

// 【主】线程中的数据:1
// 【新】线程中的数据:1
// 【主】线程中的数据:2
// 【新】线程中的数据:2
// 【新】线程中的数据:3
// 【主】线程中的数据:3
// 【主】线程中的数据:4
// 【新】线程中的数据:4
// 【新】线程中的数据:5
// 【新】线程中的数据:6
// 【新】线程中的数据:7
// 【新】线程中的数据:8
// 【新】线程中的数据:9

我们可以看这里顺序,显示主、新线程的循环同时执行,主线程内容打印完之后,新线程继续执行直到打印结束。仔细想想,这样之后,是不是提高了性能?那如果将句柄执行的位置修改一下呢?如下:

rust 复制代码
use std::thread;
use std::time::Duration;

let handle = thread::spawn(|| {
    for i in 1..10 {
        println!("【新】线程中的数据:{}", i);
        thread::sleep(Duration::from_millis(1))
    }
});
handle.join().unwrap();
for i in 1..5 {
    println!("【主】线程中的数据:{}", i);
    thread::sleep(Duration::from_millis(1))
}
// 【新】线程中的数据:1
// 【新】线程中的数据:2
// 【新】线程中的数据:3
// 【新】线程中的数据:4
// 【新】线程中的数据:5
// 【新】线程中的数据:6
// 【新】线程中的数据:7
// 【新】线程中的数据:8
// 【新】线程中的数据:9

// 【主】线程中的数据:1
// 【主】线程中的数据:2
// 【主】线程中的数据:3
// 【主】线程中的数据:4

我们会发现,我们的句柄阻塞了主线程的执行,所以我们在使用线程时一定得想清楚了。

15.1.3 在线程中使用 move 闭包

move闭包常常被用来与thread::spawn函数配合使用,它允许你在某个线程中使用来自另一个线程的数据。 我们先来写一个获取环境参数的例子:

rust 复制代码
use std::thread;
use std::time::Duration;

let v = vec![1, 2, 3];

let handle = thread::spawn(|| {
    println!("{:?}", v);
});

handle.join().unwrap();

上面的例子编译会失败。由于Rust不知道新线程会运行多久,所以它无法确定v的引用是否一直有效。 如果我们在途中使用drop(v),将数据v给清除掉,那么程序就会报错。

这个时候,我们可以通通过在闭包前添加move关键字,我们会强制闭包获得它所需值的所有权,而不仅仅是基于Rust的推导来获得值的借用。

rust 复制代码
use std::thread;
let v = vec![1, 2, 3];

let handle = thread::spawn(move || {
    println!("{:?}", v);
});

handle.join().unwrap();

这个时候能成功编译,并且v的所有权已经移交到闭包内。

15.2 使用消息传递在线程间转移数据

Rust在标准库中实现了一个名为通道(channel)的编程概念,它可以被用来实现基于消息传递的并发机制。

通道由发送者接受者组成。某一处代码可以通过调用发送者的方法来传送数据,而另一处代码则可以通过检查接收者来获取数据。当你丢弃了发送者或接收者的任何一端时,我们就称相应的通道被关闭(closed)了

接下来我们编写两个线程,一个用于发送消息,另外一个用于接受消息。

rust 复制代码
use std::{sync::mpsc, thread};

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    let val = String::from("hello world");
    tx.send(val).unwrap();
});

let receive_val = rx.recv().unwrap();
println!("{}", receive_val);

上面代码使用mpsc::channel()创建消息通道,其中mpsc表示multiple producer,single consumer。它会返回一个元祖类型,第一项表示消息发送者,第二项表示消息接受者。

通道的接收端有两个可用于获取消息的方法:recvtry_recv。我们使用的recv(也就是receive的缩写)会阻塞主线程的执行直到有值被传入通道。try_recv方法不会阻塞线程,它会立即返回Result<T,E>,当线程需要一边接受消息一边完成其他工作时我们可以使用try_recv。我们可以编写出一个不断调用try_recv方法的循环,并在有消息到来时对其进行处理,而在没有消息时执行。

15.2.1 通道和所有权转

所有权规则在消息传递的过程中扮演了至关重要的角色,因为它可以帮助你写出安全的并发代码。接着上面的例子,我们在线程里面去打印val

rust 复制代码
use std::{sync::mpsc, thread};

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    let val = String::from("hello world");
    tx.send(val).unwrap();
    println!("{}", val); // 报错,这里会发生所有权的转移
});

let receive_val = rx.recv().unwrap();
println!("{}", receive_val);

这里send函数会获取val的所有权,并且在参数传递时将它转移给接受者。 所有权帮我们规避了一个大问题: 一旦这个值被发送到了另外一个线程中,那个线程就可以在我们尝试重新使用这个值之前修改或丢弃它。这些修改极有可能造成不一致或产生原本不存在的数据,最终导致错误或出乎意料的结果。

15.2.2 发送多个值并观察接收者的等待过程

我们再来写一个发送多个值的用例:

rust 复制代码
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    let arr = vec![String::from("hello"), String::from("world")];
    for val in arr {
        tx.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
});

for receive_val in rx {
    println!("接受到:{}", receive_val);
}

在主线程中,我们会将rx视作迭代器,遍历拿到接受的值。我们并没有在主线程的for循环中执行暂停或延迟指令,这也就表明主线程确实是在等待接收新线程中传递过来的值。

15.2.3 通过克隆发送者创建多个生产者

上面的例子都是一个生产者发送消息,接下来我们试着创建多个生产者来发送消息。

rust 复制代码
use std::time::Duration;
use std::{sync::mpsc, thread};
let (tx, rx) = mpsc::channel();

// 第二个生产者
let tx1 = mpsc::Sender::clone(&tx);

// 线程1
thread::spawn(move || {
    let arr = vec![String::from("hi"), String::from("ni hao")];
    for val in arr {
        tx.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
});

// 线程2
thread::spawn(move || {
    let arr = vec![String::from("hello"), String::from("world")];
    for val in arr {
        tx1.send(val).unwrap();
        thread::sleep(Duration::from_secs(1));
    }
});

for receive_val in rx {
    println!("接受到:{}", receive_val);
}

// 打印
// 接受到:hello
// 接受到:hi
// 接受到:world
// 接受到:ni hao

如果你在实验时为不同的线程调用了含有不同参数的thread::sleep函数,那么输出结果的差异有可能更为显著且难以确定。

15.3 共享状态的并发

消息传递确实是一种不错的并发通信机制,但它并不是唯一的解决方案。接下来,我们会先来讨论共享内存领域中一个较为常见的并发原语:互斥体(mutex)

15.3.1 互斥体一次只允许一个线程访问数据

访问互斥体中的数据,线程必须首先发出信号来获取互斥体的锁(lock)。锁是互斥体的一部分,这种数据结构被用来记录当前谁拥有数据的唯一访问权。通过锁机制,互斥体守护(guarding)了它所持有的数据。 互斥体是出了名的难用,因为你必须牢记下面两条规则

  • 必须在使用数据前尝试获取锁。
  • 必须在使用完互斥体守护的数据后释放锁,这样其他线程才能继续完成获取锁的操

接下来我们来演示一个单线程环境里面使用互斥体:

rust 复制代码
let m = Mutex::new(1);
{
    let mut num = m.lock().unwrap();
    *num += 1;
}

println!("{:?}", m); // Mutex { data: 6 }

当我们获取到锁,我们可以将num视为一个指向内部数据的可变引用,从而去修改他的值。

多个线程间共享Mutex<T>

现在,让我们试着在多线程环境中使用Mutex<T>:

rust 复制代码
use std::{sync::Mutex, thread};

let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
    let handle = thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle)
}

for h in handles {
    h.join().unwrap();
}

println!("{:?}", *counter.lock().unwrap())

在我们执行后,我们会发现counter被移动到了handle指代的线程后,阻止了我们在第二个线程中调用lock来再次捕获counter。我们不应该将counter的所有权移动到到多个线程中。这个时候我们是不是可以使用用Rc来创建多重所有权去解决呢?

多线程与多重所有权

我们来试试用Rc来解决这个问题:

rust 复制代码
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
    let handle = thread::spawn(move || {
        let clone_counter = Rc::clone(&counter);
        let mut num = clone_counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle)
}

for h in handles {
    h.join().unwrap();
}

println!("{:?}", *counter.lock().unwrap())

我们运行后发现报错:

rust 复制代码
`Rc<Mutex<i32>>` cannot be sent between threads safely
within `{closure@src/main.rs:117:36: 117:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`

这段话告诉我Mutex<i32>类型无法安全地在线程间传递,该类型不满足trait约束Send。好家伙,又引入新的trait。我们会在后面章节详细讲到该特征。它确保了我们在线程中使用的类型能够在并发环境下正常工作。但是不幸的是,Rc<T>并未实现该特征。

Rc<T>在跨线程使用时并不安全。当Rc<T>管理引用计数时,它会在每次调用clone的过程中增加引用计数,并在克隆出的实例被丢弃时减少引用计数,但它并没有使用任何并发原语来保证修改计数的过程不会被另一个线程所打断

原子引用计数Arc<T>

Arc<T>的类型,它既拥有类似于Rc<T>的行为,又保证了自己可以被安全地用于并发场景。原子是一种新的并发原语,我们可以参考标准库文档中的std::sync::atomic部分来获得更多相关信息。你现在只需要知道:原子和原生类型的用法十分相似,并且可以安全地在多个线程间共享。

那么标准库的类型为什么不默认使用Arc<T>来实现呢? 这是因为我们需要付出一定的性能开销才能够实现线程安全,而我们只应该在必要时为这种开销买单。

rust 复制代码
use std::sync::Arc;
use std::{sync::Mutex, thread};

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
    let handle = thread::spawn(move || {
        let clone_counter = Arc::clone(&counter);
        let mut num = clone_counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle)
}

for h in handles {
    h.join().unwrap();
}

println!("{}", *counter.lock().unwrap()) // 10

你可以使用本节中的程序结构去完成比计数更为复杂的工作。基于这个策略,你可以将计算分割为多个独立的部分,并将它们分配至不同的线程中,然后使用Mutex<T>来允许不同的线程更新计算结果中与自己有关的那一部分

15.3.2 RefCell<T>/Rc<T>Mutex<T>/Arc<T>之间的相似性

Mutex<T>与Cell系列类型有着相似的功能,它同样提供了内部可变性。我们在第15章使用了RefCell<T>来改变Rc<T>中的内容,而本节按照同样的方式使用Mutex<T>来改变Arc<T>中的内容。

另外还有一个值得注意的细节是,Rust并不能使你完全避免使用Mutex<T>过程中所有的逻辑错误。回顾第15章中讨论的内容,使用Rc<T>会有产生循环引用的风险。两个Rc<T>值在互相指向对方时会造成内存泄漏。与之类似,使用Mutex<T>也会有产生死锁(deadlock)的风险。当某个操作需要同时锁住两个资源,而两个线程分别持有其中一个锁并相互请求另外一个锁时,这两个线程就会陷入无穷尽的等待过程。

如果你对死锁感兴趣,不妨试着编写一个可能导致死锁的Rust程序。然后,你还可以借鉴其他语言中规避互斥体死锁的策略,并在Rust中实现它们。标准库API文档的Mutex<T>MutexGuard页面为此提供了许多有用的信息。

15.4 使用Sync trait和Send trait对并发进行扩展

15.4.1 允许线程间转移所有权的Send trait

只有实现了Send trait的类型才可以安全地在线程间转移所有权。除了Rc等极少数的类型,几乎所有的Rust类型都实现了Send trait:如果你将克隆后的Rc值的所有权转移到了另外一个线程中,那么两个线程就有可能同时更新引用计数值并进而导致计数错误。因此,Rc只被设计在单线程场景中使用,它

15.4.2 允许多线程同时访问的Sync trait

只有实现了Sync trait的类型才可以安全地被多个线程引用。智能指针Rc<T>同样不满足Sync约束,其原因与它不满足Send约束类似。而正如"在多个线程间共享Mutex<T>"一节中讨论的那样,智能指针Mutex<T>是Sync的,可以被多个线程共享访问。

15.4.3 手动实现Send和Sync是不安全的

手动实现这些trait涉及使用特殊的不安全Rust代码。我们将在第19章讨论这一概念,目前你需要注意的是,当你构建的自定义并发类型包含了没有实现Send或Sync的类型时,你必须要非常谨慎地确保设计能够满足线程间的安全性要求。Rust官方网站中的The Rustonomicon文档详细地讨论了此类安全性保证及如何满足安全性要求的具体技术。

相关推荐
C澒10 小时前
多场景多角色前端架构方案:基于页面协议化与模块标准化的通用能力沉淀
前端·架构·系统架构·前端框架
崔庆才丨静觅10 小时前
稳定好用的 ADSL 拨号代理,就这家了!
前端
魔力军10 小时前
Rust学习Day2: 变量与可变性、数据类型和函数和控制流
开发语言·学习·rust
江湖有缘10 小时前
Docker部署music-tag-web音乐标签编辑器
前端·docker·编辑器
恋猫de小郭11 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅18 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606119 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了19 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅19 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅19 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端