我们程序员开发过程中,尤其是在程序调试的过程中,通常会并行开许多Terminal窗口。不过时间一久,可能就会忘了每个终端都是用于来干嘛的。于是,如何让界面保持清爽,同时又不降低工作效率,成了很多开发者的最大痛点。
Zellij是一款非常优秀的终端工作区和多路复用器(类似于tmux和screen),由于使用Rust语言开发,因此与Zellij与WebAssembly原生兼容。作为一款功能强大,同时又容易上手的终端复用工具,将会话(session)和窗口解耦,使得用户可以在单个窗口内运行多个虚拟终端,真正做到保持界面清爽还提高了工作效率。
笔者注意到在过去的几个月中,Zellij的开发团队一直在对Zellij进行优化与排坑,他们发布了一些很多意义的技术博客来记录整个优化过程。博客中展示了一些非常值得总结和重视的问题,通过他们的分享我们可以看到,Zellij的开发者们提出了很多创造性的解决方案。通过两个主要的技术提升点,他们大幅调优了Zellij在大量显示刷新场景下的性能。下面我把相关技术博客为大家进行解读。
Zellij看似简单,但实则代码量庞大,细抠所有技术细节,可能会把读者完全绕晕。因此本文使用的代码示例都是简化后的版本,仅用于讨论问题的示例。
问题一巨大流量的冲击
Zellij 是一个终端多路复用器,就像我们刚刚在截图中展示的那样,它允许用户创建多个“选项卡”和“窗口”,Zellij 会为每个终端窗口进行状态保持,其中状态信息包括文本、样式以及窗口内光标位置等要素。这种设计可以方便用户每次连接到现有会话时都保证用户体验的一致性,并可以支持用户在内部选项卡之间自由切换。不过状态在之前版本中 Zellij 窗口中显示大量数据时,性能问题会非常明显。例如,cat输入一个非常大的文件,这时Zellij会比裸终端仿真器慢得多,甚至比与其他终端多路复用器也慢。下面笔者将带着大家共同深入研究这个问题。
Zellij使用多线程架构,PTY线程和Screen渲染线程执行特定任务并通过MPSC 通道互相通信。其中PTY线程查询PTY,也就是用户屏幕上的输入、输出,并将原始数据发送到Screen线程。该线程解析数据并建立终端窗口的内部状态。PTY线程会将终端的状态呈现到用户屏幕上,并向Screen线程发送渲染请求。
一种是轮循机制:PTY 线程不断轮询 PTY,以查看它在异步数据接收的while循环中是否有新数据。如果没有接收到数据,则休眠一段固定的时间。
另一种是POLL机制:让数据流来驱动更新,这种设计一般认为效率比较高。如果 PTY 有大量数据流涌入,那么用户将在屏幕上实时看到这些数据的更新。
让我们看一下代码:
task::spawn({ async move { // TerminalBytes是异步数据流 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 { task::sleep(max_render_pause).await; } } } })
流量冲击的解决之道
为了测试这个大规模显示流程的性能,开发者们cat了一个 2,000,000 行的bigfile文件,并使用hyperfine基准测试工具,并使用--show-output参数来测试标准输出场景,并使用tmux进行对比。
hyperfine --show-output "cat /tmp/bigfile"在 tmux 内运行的结果:(窗口大小:59 行,104 列) 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 hyperfine --show-output "cat /tmp/bigfile"在 Zellij 内部运行的结果:(窗口大小: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
可以看到优化前tmux的性能几乎是Zellij的8倍多。
问题点定位一:MPSC通道溢出
第一个性能问题是MPSC 通道的溢出,由于 PTY 线程和屏幕线程之间没有同步控制,PTY进程发送数据的速度要远比Screen线程处理数据的速度要快很多。PTY和SCREEN之间的不平衡将在以下几个方面影响性能:
通道缓冲区空间不断增长,占用越来越多的内存
屏幕线程渲染的次数远比合理值要高,因为屏幕线程需要越来越多的时间来处理队列中的消息。
解决方案:将MPSC转换为有界通道
这个紧迫问题的解决方案是限制通道的缓冲区大小,并由此在两个线程之间创建同步关系。为此开发者们放弃了MPSC而选择了有界同步通道crossbeam,crossbeam提供了一个非常有用的宏select!。此外,开发者们还删除了自定义的后台轮询的异步流实现,转而使用 async_stdFile以获得“异步 i/o”效果。
我们来看看代码中的变化:
task::spawn({ async move { let render_pause = Duration::from_millis(30); let mut render_deadline = None; let mut buf = [0u8; 65536]; let mut async_reader = AsyncFileReader::new(pid); // 用async_std实现异步IO //以下是异步实现在deadline时进行特殊处理 loop { 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 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 # 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 的数据来看,Zellij仍然可以做得更好。
问题二,渲染和数据解析的性能
接下来开发者们又将管道绑定到屏幕线程,如果提高屏幕线程中两个相关作业的性能,能够使整个过程运行得更快:解析数据并将其渲染到用户终端。屏幕线程的数据解析部分的作用是将ANSI/VT等控制指令(如\r\n这样的回车或者换行符)转化为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 }
问题2的解决方案-内存预分配
解析器执行最频繁的操作就是给一行文字内添加显示的字符。特别是在行尾添加字符。这个动作主要涉及将那些TerminalCharacters推入到列向量中。每个推送都涉及一个从堆上分配一段内存空间,这个内存分配的操作是非常耗时的,这点笔者在之前的博客《一行无用的枚举代码,却让Rust性能提升10%》中有过介绍。因此可以通过在每次创建行或调整终端窗口大小时预分配内存,来获得性能上的提升。所以开发者们从改变 Row(行)类的构造函数开始:
impl Row { pub fn new() -> Self { Row { columns: Vec::new(), } }} } 对此: impl Row { pub fn new(width: usize) -> Self { Row { columns: Vec::with_capacity(width),//通过指定capacity来预分配一段内存 } }} }
缓存字符宽度
我们知道一些特殊的字符比如中文全角字符会比普通的英文字符占用更多的空间。这方面Zellij 又引入了unicode-width crate 来计算每个字符的宽度。
在Zellij给一行内容中添加字符时,终端仿真器需要知道该行的当前宽度,以便决定是否应该将字符换行到下一行。所以它需要不断地查看和累加行中前一个字符的宽度。因为需要找到一个计算字符宽度的方法。
代码如下:
#[derive(Clone, Copy)]struct TerminalCharacter { 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 } }
渲染速度提升
Screen 线程的渲染部分本质上执行与数据解析部分反向操作。它获取由上述数据结构表示的每个窗口状态,并将其转换为 ANSI/VT 的控制指令,以发送到操作系统自身的终端仿真器并对其解释执行。也就是说对于普通字符就进行显示渲染,如果是控制符则发给系统shell执行。
fn render(&mut self) -> String { let mut vte_output = String::new(); 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 { vte_output.push_str(&new_styles); // 如果不是一类字符,则在此替换处理 } vte_output.push(t_character.character); } character_styles.clear(); } vte_output }
我们知道STDOUT写入是一种非常耗费性能的操作,为此开发者们再次寄出缓冲区这个神器。该缓冲区主要跟踪最新与次新渲染请求的差异,最终只将缓冲区内这些不同的差异部分进行渲染。
代码如下:
#[derive(Debug)]pub struct CharacterChunk { 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 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 # 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
通过这一系列的改进之后,Zellij在cat一个大文件时的性能已经可以和Tmux比肩了。
结论
总结一下Zellij通过优化通道双方数据处理的不平衡关系,加入缓冲并优化渲染粒度等精彩的方式大幅提升了Zellij多路终端复用器的性能,很多优化的思路非常值得开发者们借鉴。