2 个版本
0.1.1 | 2023 年 12 月 30 日 |
---|---|
0.1.0 | 2023 年 12 月 30 日 |
#455 in 调试
20KB
158 行
此包提供以下函数的非 panic 版本:
- std::print!()
- std::println!()
- std::eprint!()
- std::eprintln!()
Print! 可以 panic 吗???
令人惊讶的是,是的:[issue](https://github.com/rust-lang/rust/issues/24821)。
这种情况非常罕见,你可能永远不会看到它,你也可以通过使用此包来保证你不会看到它。
使用方法
步骤 1 - 依赖项
将 comfy-print-sync
添加到你的项目中作为依赖项。
步骤 2 - 替换宏调用
- 将所有对
std::print!()
的调用替换为comfy_print::print!
- 将所有对
std::println!()
的调用替换为comfy_print::println!
- 将所有对
std::eprint!()
的调用替换为comfy_print::eprint!
- 将所有对
std::eprintln!()
的调用替换为comfy_print::eprintln!
如果你熟悉正则表达式,可以使用 IDE 的“替换文件”命令一次性完成。
默认快捷键通常是 Ctrl + Shift + R
或 Ctrl + Shift + H
。
以下是我使用的模式(使用Jetbrains Intellij IDEs,Java的正则表达式)
- 匹配:
(?<!comfy_e?)(?<type>print!|println!|eprint!|eprintln!)
- 替换:
comfy_print_sync::comfy_${type}
第3步 - 舒适使用
优点
- 没有不安全的代码。
- 仍然是线程安全的。
- 为了避免死锁,对std(out/err)的锁以快速连续的方式获取/释放。
- 您仍然可以自由使用std::print!宏,它们与任何comfy_print_sync宏都不会冲突。
- 弹性
- 提供的宏在写入std(out/err)返回错误时不会“默默失败”。相反,这个crate将请求的打印保存在线程安全的队列中,然后稍后再次尝试打印。
- 队列确保打印将始终按照请求的顺序传递。
缺点
- 性能较差
- 一些基本的合成基准测试显示,平均而言,这个版本比std::print!慢20%。
- 与std::print!相比,这个版本的性能变化更大。
代码逻辑
存储消息的数据类型
文件:utils.rs
use std::fmt::{Display, Formatter};
#[derive(Debug, Copy, Clone)]
pub enum OutputKind {
Stdout,
Stderr,
}
pub struct Message {
string: String,
output: OutputKind,
}
impl Message {
pub fn str(&self) -> &str {
return self.string.as_str();
}
pub fn output_kind(&self) -> OutputKind {
return self.output;
}
pub fn standard(string: String) -> Self {
return Self {
string,
output: OutputKind::Stdout,
};
}
pub fn error(string: String) -> Self {
return Self {
string,
output: OutputKind::Stderr,
};
}
}
impl Display for Message {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
return write!(f, "{}", self.string);
}
}
pub fn try_write(msg_str: &impl Display, output_kind: OutputKind) -> std::io::Result<()> {
match output_kind {
OutputKind::Stdout => {
let mut stdout = std::io::stdout().lock();
write!(stdout, "{}", msg_str)?;
stdout.flush()?;
Ok(())
}
OutputKind::Stderr => {
let mut stderr = std::io::stderr().lock();
write!(stderr, "{}", msg_str)?;
stderr.flush()?;
Ok(())
}
}
}
一个简单的结构体 Message
,其中包含要打印的字符串和应该打印的位置 std(out/err)
get()
函数用于访问私有字段,以及一个- 每个打印目标的构造函数。
- 为了便于格式化,我为
Message
实现了Display
特性。
还有一个 pub fn try_write(msg_str: &impl Display, output_kind: OutputKind)
。它试图将消息打印到所需的输出,如果失败则返回错误。
这些宏作为实际代码的桥梁,就像 std::prints
一样
在文件:sync_impl.rs
pub fn _println(mut input: String) {
input.push('\n');
_comfy_print_sync(Message::standard(input));
}
pub fn _print(input: String) {
_comfy_print_sync(Message::standard(input));
}
pub fn _eprint(input: String) {
_comfy_print_sync(Message::error(input));
}
pub fn _eprintln(mut input: String) {
input.push('\n');
_comfy_print_sync(Message::error(input));
}
#[macro_export]
macro_rules! comfy_print {
($($arg:tt)*) => {{
$crate::sync_impl::_print(std::format!($($arg)*));
}};
}
#[macro_export]
macro_rules! comfy_println {
() => {
$crate::sync_impl::_println("\n")
};
($($arg:tt)*) => {{
$crate::sync_impl::_println(std::format!($($arg)*));
}};
}
#[macro_export]
macro_rules! comfy_eprint {
($($arg:tt)*) => {{
$crate::sync_impl::_eprint(std::format!($($arg)*));
}};
}
#[macro_export]
macro_rules! comfy_eprintln {
() => {
$crate::sync_impl::_eprintln("\n")
};
($($arg:tt)*) => {{
$crate::sync_impl::_eprintln(std::format!($($arg)*));
}};
}
用于存储队列打印的集合类型
use parking_lot::FairMutex;
static QUEUE: FairMutex<Vec<Message>> = FairMutex::new(Vec::new());
队列由一个标准的 Vec<Message>
组成,并包装在 parking_lot 的 FairMutex
中。
常规的 Mutex
在解锁后,将锁赋予随后执行的线程,不管哪个线程首先请求了锁。这种类型的锁在我们的用例中存在问题,我们希望确保打印的顺序与请求的顺序完全一致。如果有多个线程等待获取锁,常规的 Mutex
并不关心哪个线程先请求,这可能会导致打印的队列顺序错误。
另一方面,FairMutex
会在请求锁时使线程形成一个队列,确保首先请求锁的线程首先获得机会。
在尝试打印之前,我们需要检查队列中是否已有其他打印任务。如果有,我们不能立即打印 msg
,因为这会破坏“请求的打印 > 交付的打印”的顺序。
如果打印任务未能写入目标输出,它们将加入队列。
最终,所有 4 个宏都最终调用了 comfy_print::sync_impl::_comfy_print_sync(msg)
fn _comfy_print_sync(msg: Message)
pub fn _comfy_print_sync(msg: Message) {
let mut queue_guard = QUEUE.lock();
if queue_guard.len() == 0 {
drop(queue_guard); // release the queue's lock before locking std(out/err)
write_first_in_line(msg);
} else {
queue_guard.push(msg);
drop(queue_guard); // release the queue's lock before locking IS_PRINTING
if let Ok(_) = IS_PRINTING.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) {
write_until_empty();
}
}
}
当队列为空时
if queue_guard.len() == 0 {
drop(queue_guard);
write_first_in_line(msg);
}
我们不需要等待其他线程,可以直接尝试打印。这在大多数情况下都会发生。
由于我们不再需要队列,我们立即释放它。避免同时拥有两个锁可以有助于避免某些死锁情况。
fn write_first_in_line(msg: Message)
fn write_first_in_line(msg: Message) {
let msg_str: &str = msg.str();
if let Err(err) = try_write(&msg_str, msg.output_kind()) {
let mut queue_guard = QUEUE.lock();
queue_guard.insert(0, Message::error(
format!("comfy_print::blocking_write_first_in_line(): Failed to print first message in queue, it was pushed to the front again.\n\
Error: {err}\n\
Message: {msg_str}")));
queue_guard.insert(1, msg);
drop(queue_guard);
}
}
在这里,我们尝试写入目标输出。如果失败,我们在队列前面插入一个错误消息,然后是原始消息。
再次尝试不太可能产生任何结果,所以我们不应该做任何其他事情。
下次调用 comfy_print!
时,我们将再次尝试。
当队列有元素时
可能已经有另一个线程正在打印队列,我们使用静态原子布尔值:`IS_PRINTING` 来跟踪这一点。use std::sync::atomic::{AtomicBool, Ordering};
static IS_PRINTING: AtomicBool = AtomicBool::new(false);
} else {
queue_guard.push(msg);
if let Ok(_) = IS_PRINTING.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) {
write_until_empty(queue_guard);
}
}
我们加入队列,然后检查是否已有线程正在打印。
如果没有,我们将承担这项责任。
方法 compare_exchange
检查 IS_PRINTING == false
- 如果是:将
IS_PRINTING = true
并返回 Ok()。这意味着我们已经向其他线程发出信号,我们正在打印队列。 - 否则:只返回错误。我们不需要做其他事情,因为这意味着另一个线程已经在打印,并且我们已经在队列中推送了我们的
msg
。
为了进一步学习,我建议查看 Rust 的 原子类型 文档。
fn write_until_empty()
fn write_until_empty() {
loop {
let mut queue_guard = QUEUE.lock();
if queue_guard.len() == 0 {
drop(queue_guard);
break;
}
let msg = queue_guard.remove(0);
drop(queue_guard);
let msg_str: &str = msg.str();
let output_kind = msg.output_kind();
if let Err(err) = try_write(&msg_str, output_kind) {
let mut queue_guard = QUEUE.lock();
queue_guard.insert(0, Message::error(format!(
"comfy_print::write_until_empty(): Failed to print first message in queue, it was pushed to the front again.\n\
Error: {err}\n\
Message: {msg_str}\n\
Target output: {output_kind:?}")));
queue_guard.insert(1, msg);
drop(queue_guard);
break;
}
}
IS_PRINTING.store(false, Ordering::Relaxed); // signal other threads that we are no longer printing.
}
这里有很多事情要做,让我们一步一步来。
在循环开始时
let mut queue_guard = QUEUE.lock();
if queue_guard.len() == 0 {
drop(queue_guard);
break;
}
我们获取队列的锁,然后检查它是否为空。如果是,我们不需要做其他任何事情,只需释放锁并退出循环。
let msg = queue_guard.remove(0);
drop(queue_guard);
let msg_str: &str = msg.str();
let output_kind = msg.output_kind();
我们从队列中弹出第一个元素,然后立即释放锁。其他线程可能在等待锁,我们不再需要它。
let write_result = try_write(&msg_str, output_kind);
if let Err(err) = write_result {
let mut queue_guard = QUEUE.lock();
queue_guard.insert(0, Message::error(format!(
"comfy_print::write_until_empty(): Failed to print first message in queue, it was pushed to the front again.\n\
Error: {err}\n\
Message: {msg_str}\n\
Target output: {output_kind:?}")));
queue_guard.insert(1, msg);
drop(queue_guard);
break;
}
在这里,我们尝试写入目标输出。如果发生任何错误,这意味着我们未能打印消息。
为了通知用户错误,我们再次锁定队列,将错误插入队列前面,然后是原始消息。
IS_PRINTING.store(false, Ordering::Relaxed);
在 write_until_empty()
的末尾,我们将 IS_PRITING
设置为 false,向其他线程发出信号,我们不再承担这项责任。
QA
为什么在会隐式调用的实例中显式调用
drop(guard)
呢?
A: 为了照顾未来的自己:通过让它隐式进行,我寄希望于未来的大脑能够阅读代码并找出守卫被锁定/解锁的确切顺序。
通过显式地写出 drop(guard)
,我清楚地表明了锁的释放位置,因此未来的大脑犯错误的机会更少。
这也让其他阅读代码的程序员更容易理解我的意图。
这有点过度设计
A: 是的,我写这个crate是为了学习/练习Rust中的线程/并发。
我真的很讨厌看到 print!
调用引发panic。
你称这个版本为sync,但实际上它使用了线程
这个版本是线程安全的,也就是说,它意识到了线程,并且代码的编写考虑到 print!
可能会从不同的线程、同时调用。
然而,只有异步版本实际上会为打印队列启动一个线程,这个版本是在当前线程中完成的。
依赖
~0.4–5MB
~11K SLoC