#println #write #io-stream #io #stream #error-message

comfy-print-sync

println! 和相关函数的非 panic 版本。同步实现。

2 个版本

0.1.1 2023 年 12 月 30 日
0.1.0 2023 年 12 月 30 日

#455 in 调试

MIT 许可证

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 + RCtrl + 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_lotFairMutex 中。

常规的 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