在过去的几个月中,我们一直在针对我们的 Rust 应用 Zellij 进行故障排除和性能提升。在这一过程中我们发现了一些问题和瓶颈,不得不寻找一些创造性的解决方案来处理或绕过它们。
在这篇文章中,我将介绍和说明我们最近解决的两大问题,解决它们后我们的应用性能提升到了(有时甚至超过了)类似应用的水平。
这是 Zellij 维护者和社区贡献者的共同努力成果。请参阅文末获取更多细节。
关于代码示例的说明
这篇文章中的代码示例是简化版本,只用来说明我们正在讨论的示例。因为 Zellij 是一个投入实用的应用程序,所以实际的代码往往更复杂,并且其中包含的细节完整照搬过来的话容易让人一头雾水。在本文提供的每个代码示例中,我还为想要进一步了解的读者提供了一个指向真实世界版本的链接。
下面的“链接”部分还包含了实现本文中讨论的更改的拉取请求链接。
应用程序介绍及问题描述
Zellij 是一个终端多路复用器。简而言之,它是一个在终端模拟器(例如 Alacritty、iterm2、Konsole 等)和 shell“之间”运行的应用程序。
它允许你创建多个“选项卡”和“窗格”;你还可以关闭终端模拟器,然后只要 Zellij 继续在后台运行,就可以从一个新窗口重新附加到同一个会话。
Zellij 保持每个终端窗格的状态,以便在用户每次连接到现有会话时都能够重新创建它,甚至在内部选项卡之间切换。这个状态包括了窗格的文本和样式,以及窗格内的光标位置。
在 Zellij 窗格中显示大量数据时,性能问题会非常显著。例如 cat 一个非常大的文件时,Zellij 不仅比裸终端模拟器慢很多,而且比其他终端多路复用器也会慢很多。
我们来深入研究这个流程,找出性能缺陷并讨论如何修复它们。
有问题的流
我们用的是一个多线程架构,每个主线程执行一个任务并通过一个 MPSC 通道与另一个线程通信。我们讨论的数据解析和渲染流包括了 PTY thread 和 Screen thread。
PTY thread 查询 pty——后者作为我们与 shell(或在终端内运行的其他程序)的接口——并将原始数据发送到 Screen thread。该线程解析数据并建立相关终端窗格的内部状态。
每隔一会儿,PTY thread 会决定是时候将终端的状态渲染到用户的屏幕上,并向屏幕线程发送一个 render 消息。
PTY thread 不断轮询 pty,以查看它在异步任务内的非阻塞循环中是否有新数据。如果没有接收到数据,则休眠一段固定的时间。除了它发送到屏幕进行解析的 data 指令外,它还会在以下任一情况下发送 render 指令:
pty 读取缓冲区中没有更多数据。
自上次 render 指令发送以来已经过去了 30 毫秒或更长时间。
第二种情况是出于用户体验的原因设置的。这样,如果有来自 pty 的大量数据流,用户将在屏幕上实时看到这些数据的更新。
我们看一下代码:
task::spawn({
// TerminalBytes is an asynchronous stream that polls the pty
// and terminates when the pty is closed
let mut terminal_bytes = TerminalBytes::new(pid);
let mut last_render = Instant::now();
let mut pending_render = false;
let max_render_pause = Duration::from_millis(30);
while let Some(bytes) = terminal_bytes.next().await {
let receiving_data = !bytes.is_empty();
if receiving_data {
send_data_to_screen(bytes);
pending_render = true;
}
if pending_render && last_render.elapsed() > max_render_pause {
send_render_to_screen();
last_render = Instant::now();
pending_render = false;
}
if !receiving_data {
// wait a fixed amount of time before polling for more data
task::sleep(max_render_pause).await;
}
}
}
})
复制代码
这里是真实世界的版本。
定位问题
为了衡量这个流程的性能,我们将在一个包含 2,000,000 行的文件上运行一个 cat。我们将使用优秀的 hyperfine 基准测试工具,使用 --show-output 标志来衡量 stdout(这是我们所关心的)。一个公平的对手是 tmux——一个非常稳定和成熟的终端多路复用器。
在 tmux 中运行 hyperfine --show-output "cat /tmp/bigfile"的结果:(窗格大小:59 行,104 列)
Time (mean ± σ): 5.593 s ± 0.055 s [User: 1.3 ms, System: 2260.6 ms]
复制代码
在 Zellij 中运行 hyperfine --show-output "cat /tmp/bigfile"的结果:(窗格大小:59 行,104 列)
Time (mean ± σ): 19.175 s ± 0.347 s [User: 4.5 ms, System: 2754.7 ms]
Range (min … max): 18.647 s … 19.803 s 10 runs
复制代码
成绩不怎么样!看起来我们有一些瓶颈。
第一个问题:MPSC 通道溢出
我们在这个流中遇到的第一个性能问题是我们的 MPSC 通道溢出。为了形象化这一点,让我们稍微加快一下前面的图表:
(原文动图)
由于 PTY thread 和 Screen thread 之间没有同步,因此到最后前者将数据填充到 MPSC 通道的速度比后者处理它的速度要快得多。这会在几个方面影响性能:
通道缓冲区不断增长,占用越来越多的内存
屏幕线程渲染的内容过多了,因为 PTY thread 上的 30ms 计数器逐渐失去了意义——屏幕线程需要越来越多的时间来处理队列中的消息。
解决方法:将 MPSC 通道切换为有界(实现背压)
这个问题的解决方案是通过限制 MPSC 通道的缓冲区大小在两个线程之间创建同步。为此,我们将异步通道切换到一个具有相对较小缓冲区(50 条消息)的有界同步通道。我们还将通道切换到提供了一个 select! 宏的
crossbeam,这很有用。
此外,我们删除了自定义的异步流实现,转而使用 async_std 的 File 来获得“异步 i/o”效果,而不必自己在后台不断轮询。
我们看看代码中的变化:
task::spawn({
let render_pause = Duration::from_millis(30);
let mut render_deadline = None;
let mut buf = [0u8; 65536];
// AsyncFileReader is implemented using async_std's File
let mut async_reader = AsyncFileReader::new(pid);
// "async_send_render_to_screen" and "async_send_data_to_screen"
// send to a crossbeam bounded channel
// resolving once the send is successful, meaning there is room
// for the message in the channel's buffer
loop {
// deadline_read attempts to read from async_reader or times out
// after the render_deadline has passed
match deadline_read(&mut async_reader, render_deadline, &mut buf).await {
ReadResult::Ok(0) | ReadResult::Err(_) => break, // EOF or error
ReadResult::Timeout => {
async_send_render_to_screen(bytes).await;
render_deadline = None;
}
ReadResult::Ok(n_bytes) => {
let bytes = &buf[..n_bytes];
async_send_data_to_screen(bytes).await;
render_deadline.get_or_insert(Instant::now() + render_pause);
}
}
}
}
})
复制代码
这是真实世界的版本。
接下来架构变成了大概这样:
(原文动图)
衡量性能改进成果
我们回到最初的性能测试。
以下是运行 hyperfine --show-output "cat /tmp/bigfile"时的成绩(窗格大小:59 行,104 列):
# Zellij before this fix
Range (min … max): 18.647 s … 19.803 s 10 runs
# Zellij after this fix
Time (mean ± σ): 9.658 s ± 0.095 s [User: 2.2 ms, System: 2426.2 ms]
Range (min … max): 9.433 s … 9.761 s 10 runs
# Tmux
Time (mean ± σ): 5.593 s ± 0.055 s [User: 1.3 ms, System: 2260.6 ms]
Range (min … max): 5.526 s … 5.678 s 10 runs
复制代码
提升颇为明显,但从 Tmux 的数据来看,我们发现我们还可以做得更好。
第二个问题:提高渲染和数据解析的性能
现在我们将管道绑定到了屏幕线程,如果我们提高屏幕线程中两个相关作业(解析数据并将其渲染到用户终端)的性能,应该能够让整个过程运行得更快。
加快数据解析
屏幕线程的数据解析部分的作用是获取 ANSI/VT 指令(例如:\033[10;2H\033[36mHi there! ),并将它们变成可以被 Zellij 控制的数据结构。
以下是这些数据结构的相关部分:
struct Grid {
viewport: Vec<Row>,
cursor: Cursor,
width: usize,
height: usize,
}
struct Row {
columns: Vec<TerminalCharacter>,
}
struct Cursor {
x: usize,
y: usize
}
#[derive(Clone, Copy)]
struct TerminalCharacter {
character: char,
styles: CharacterStyles
}
复制代码
可以在这里 和 这里 找到真实世界的版本。
预分配行
虽然关于这个解析器的全部作用及其中所有优化的内容超出了本文的范围,但我想谈谈我们在这里所做的一些优化,这些优化在前面所做的管道改进后是非常重要的。
我们先看看 Row,看看我们如何向它添加字符。这是解析器执行的最频繁的操作之一,特别是在行尾添加字符。这个动作主要是将那些 TerminalCharacter 推入行的 columns 向量中。每个这样的推送都涉及一个堆分配,用来调整 vector(注 1)的大小,这在性能方面是一项代价高昂的操作。我们可以在每次创建行或调整终端窗格大小时预先分配列向量来获得一些性能提升。
所以我们将这个 Row 的构造函数从:
impl Row {
Row {
columns: Vec::new(),
}
}}
}
复制代码
改成了:
impl Row {
pub fn new(width: usize) -> Self {
Row {
columns: Vec::with_capacity(width),
}
}}
}
复制代码
这里是真实世界的版本。
缓存字符宽度
某些字符比其他字符占用的空间更多。东亚字母或表情符号就是其中一些例子。Zellij 使用优秀的 unicode-width crate 来计算每个字符的宽度。
向一行添加字符时,终端仿真器需要知道该行的当前宽度,以便决定是否应该将字符换到下一行。所以它需要不断地查看和累加行中前一个字符的宽度。
由于我们需要多次查找单个字符的宽度,因此我们可以通过缓存在 TerminalCharacter 结构上调用 c.width() 的结果来提高速度。
于是这个函数(例如):
#[derive(Clone, Copy)]
character: char,
styles: CharacterStyles
}
impl Row {
pub fn width(&self) -> usize {
let mut width = 0;
for terminal_character in self.columns.iter() {
width += terminal_character.character.width();
}
width
}
}
复制代码
有了这样的缓存后快多了:
#[derive(Clone, Copy)]
struct TerminalCharacter {
character: char,
styles: CharacterStyles,
width: usize,
}
impl Row {
pub fn width(&self) -> usize {
let mut width = 0;
for terminal_character in self.columns.iter() {
width += terminal_character.width;
}
width
}
}
复制代码
这里是真实世界的版本。
渲染速度更快
屏幕线程的渲染部分本质上执行的是与数据解析部分相反的操作。它获取由上述数据结构表示的每个窗格的状态,并将其转换为 ANSI/VT 指令,以发送到用户自己的终端仿真器并由其解析。
这个 render 在 Grid 中的视口上循环,将所有字符转换为代表其样式和位置的 ANSI/VT 指令,并将它们发送到用户终端,在那里替换之前放置在先前渲染中的内容。
fn render(&mut self) -> String {
let mut character_styles = CharacterStyles::new();
let x = self.get_x();
let y = self.get_y();
for (line_index, line) in grid.viewport.iter().enumerate() {
vte_output.push_str(
// goto row/col and reset styles
&format!("\u{1b}[{};{}H\u{1b}[m", y + line_index + 1, x + 1)
);
for (col, t_character) in line.iter().enumerate() {
let styles_diff = character_styles
.update_and_return_diff(&t_character.styles);
if let Some(new_styles) = styles_diff {
// if this character's styles are different
// from the previous, we update the diff here
vte_output.push_str(&new_styles);
}
vte_output.push(t_character.character);
}
// we clear the character styles after each line
// in order not to leak styles from the pane to our left
character_styles.clear();
}
vte_output
}
复制代码
这里是真实世界的版本。
写入 STDOUT 是一项代价高昂的操作。我们可以限制写入用户终端的指令数量来提高性能。为此,我们创建了一个输出缓冲区。该缓冲区主要跟踪自上次渲染以来已更改的视口部分。然后当我们渲染时,我们从 Grid 中挑选出那些改变的部分,并只将它们发送到 stdout。
#[derive(Debug)]
pub terminal_characters: Vec<TerminalCharacter>,
pub x: usize,
pub y: usize,
}
#[derive(Clone, Debug)]
pub struct OutputBuffer {
changed_lines: Vec<usize>, // line index
should_update_all_lines: bool,
}
impl OutputBuffer {
pub fn update_line(&mut self, line_index: usize) {
self.changed_lines.push(line_index);
}
pub fn clear(&mut self) {
self.changed_lines.clear();
}
pub fn changed_chunks_in_viewport(
&self,
viewport: &[Row],
) -> Vec<CharacterChunk> {
let mut line_changes = self.changed_lines.to_vec();
line_changes.sort_unstable();
line_changes.dedup();
let mut changed_chunks = Vec::with_capacity(line_changes.len());
for line_index in line_changes {
let mut terminal_characters: Vec<TerminalCharacter> = viewport
.get(line_index).unwrap().columns
.iter()
.copied()
.collect();
changed_chunks.push(CharacterChunk {
x: 0,
y: line_index,
terminal_characters,
});
}
changed_chunks
}
}}
复制代码
这里是真实世界的版本。
当前的实现只处理整行更改。它可以进一步优化为仅发送一行中更改的部分,但在尝试时我发现它显著增加了复杂性,却没有提供非常明显的性能提升。
那么,我们来看看在所有这些改进之后获得的性能提升:
改进后运行 hyperfine --show-output "cat /tmp/bigfile"的结果:(窗格大小:59 行,104 列)
# Zellij before all fixes
Range (min … max): 18.647 s … 19.803 s 10 runs
# Zellij after the first fix
Time (mean ± σ): 9.658 s ± 0.095 s [User: 2.2 ms, System: 2426.2 ms]
Range (min … max): 9.433 s … 9.761 s 10 runs
# Zellij after the second fix (includes both fixes)
Time (mean ± σ): 5.270 s ± 0.027 s [User: 2.6 ms, System: 2388.7 ms]
Range (min … max): 5.220 s … 5.299 s 10 runs
# Tmux
Time (mean ± σ): 5.593 s ± 0.055 s [User: 1.3 ms, System: 2260.6 ms]
Range (min … max): 5.526 s … 5.678 s 10 runs
复制代码
这就完成了。我们的应用性能现在达到了成熟的终端多路复用器的水平。虽然性能肯定还有改进的空间,但它提供了相当不错和愉快的用户体验。
总结
我们用来衡量性能的测试(cat 一个大文件)只衡量了在非常特定情况下的性能。在其他场景中,Zellij 的表现有的很棒,有的并不突出。还有很重要的一点是,因为我们是在相对复杂且不是 100% 纯净的环境中测量完整应用的性能,所以这篇文章中的性能测试值应该被视为一种参考,而不是精确的结果。
Zellij 并没有声称比其他任何软件更快或更高效。在性能方面,它只是把其他软件作为灵感和榜样。
如果你在这篇文章中发现了任何错误,并想要提供更正、你的想法或反馈——请随时联系 aram@poor.dev。
如果你喜欢这篇文章并希望获得更多这方面的内容,请考虑在Twitter上关注我。
感谢你的阅读。
PR 链接
背压实现的第一个 PR
第二个 PR
提升数据和渲染性能的 PR
致谢
Tamás Kovács:解决了 MPSC 通道溢出问题,实现了背压,并审阅了这篇文章
Kunal Mohan:审查和帮助整合背压实现,以及审阅这篇文章
Aram Drevekenin:负责故障排除和实现数据 / 渲染改进
注 1:正如 luminousrhinoceros 在 Reddit 上指出的那样,这不是 100% 准确的。每当推送一个超过其当前容量的元素时,Vec 会将其容量加倍。这依旧是一项昂贵的操作,但不是每次推送都会发生。
注:本文原文版权归原作者 Aram Drevekenin 所有
原文链接:
https://www.poor.dev/blog/performance/
评论