Zellij-一个典型的 Rust程序的性能优化案例

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: Zellij是一款非常优秀的终端工作区和多路复用器(类似于tmux和screen),由于使用Rust语言开发,因此与Zellij与WebAssembly原生兼容。作为一款功能强大,同时又容易上手的终端复用工具,将会话(session)和窗口解耦,使得用户可以在单个窗口内运行多个虚拟终端,真正做到保持界面清爽还提高了工作效率。


我们程序员开发过程中,尤其是在程序调试的过程中,通常会并行开许多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;
            }
        }
    }
})

image.gif

流量冲击的解决之道

为了测试这个大规模显示流程的性能,开发者们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

image.gif

可以看到优化前tmux的性能几乎是Zellij的8倍多。

问题点定位一:MPSC通道溢出

第一个性能问题是MPSC 通道的溢出,由于 PTY 线程和屏幕线程之间没有同步控制,PTY进程发送数据的速度要远比Screen线程处理数据的速度要快很多。PTY和SCREEN之间的不平衡将在以下几个方面影响性能:

通道缓冲区空间不断增长,占用越来越多的内存

屏幕线程渲染的次数远比合理值要高,因为屏幕线程需要越来越多的时间来处理队列中的消息。

解决方案:将MPSC转换为有界通道

这个紧迫问题的解决方案是限制通道的缓冲区大小,并由此在两个线程之间创建同步关系。为此开发者们放弃了MPSC而选择了有界同步通道crossbeamcrossbeam提供了一个非常有用的宏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);
                }
            }
        }
    }
})

image.gif

改进之后的性能测试,如下:。

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

image.gif

虽然有了近一倍的性能提升,但从 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
}

image.gif

问题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来预分配一段内存
        }
    }}
}

image.gif

缓存字符宽度

我们知道一些特殊的字符比如中文全角字符会比普通的英文字符占用更多的空间。这方面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
    }
}

image.gif

加入缓存之后速度变得更快:

#[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
    }
}

image.gif

渲染速度提升

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
}

image.gif

我们知道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
    }
}}

image.gif

我们看到这个实现最小修改单位是行,还有进一步优化为仅修改行内部分变动字符的方案,这种方案大幅虽然增加了复杂性,不过也带来了非常显着的性能提升。

以下为改进后的对比测试结果:

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

image.gif

通过这一系列的改进之后,Zellij在cat一个大文件时的性能已经可以和Tmux比肩了。

结论

总结一下Zellij通过优化通道双方数据处理的不平衡关系,加入缓冲并优化渲染粒度等精彩的方式大幅提升了Zellij多路终端复用器的性能,很多优化的思路非常值得开发者们借鉴。

相关文章
|
1月前
|
Rust 安全 Linux
Rust-01 Hello Rust 10分钟上手编写第一个Rust程序 背景介绍 发展历史 环境配置 升级打怪的必经之路
Rust-01 Hello Rust 10分钟上手编写第一个Rust程序 背景介绍 发展历史 环境配置 升级打怪的必经之路
43 0
Rust-01 Hello Rust 10分钟上手编写第一个Rust程序 背景介绍 发展历史 环境配置 升级打怪的必经之路
|
2月前
|
数据采集 机器学习/深度学习 Rust
使用Rust进行线性回归的简单案例
使用Rust进行线性回归的简单案例
58 9
|
2月前
|
Rust 网络协议 安全
Rust在系统编程中的案例分享
Rust在系统编程中的案例分享
58 10
|
3月前
|
Rust 并行计算 安全
揭秘Rust并发奇技!线程与消息传递背后的秘密,让程序性能飙升的终极奥义!
【8月更文挑战第31天】Rust 以其安全性和高性能著称,其并发模型在现代软件开发中至关重要。通过 `std::thread` 模块,Rust 支持高效的线程管理和数据共享,同时确保内存和线程安全。本文探讨 Rust 的线程与消息传递机制,并通过示例代码展示其应用。例如,使用 `Mutex` 实现线程同步,通过通道(channel)实现线程间安全通信。Rust 的并发模型结合了线程和消息传递的优势,确保了高效且安全的并行执行,适用于高性能和高并发场景。
64 0
|
3月前
|
开发框架 Android开发 iOS开发
跨平台开发的双重奏:Xamarin在不同规模项目中的实战表现与成功故事解析
【8月更文挑战第31天】在移动应用开发领域,选择合适的开发框架至关重要。Xamarin作为一款基于.NET的跨平台解决方案,凭借其独特的代码共享和快速迭代能力,赢得了广泛青睐。本文通过两个案例对比展示Xamarin的优势:一是初创公司利用Xamarin.Forms快速开发出适用于Android和iOS的应用;二是大型企业借助Xamarin实现高性能的原生应用体验及稳定的后端支持。无论是资源有限的小型企业还是需求复杂的大公司,Xamarin均能提供高效灵活的解决方案,彰显其在跨平台开发领域的强大实力。
46 0
|
4月前
|
Rust 程序员 开发者
使用 Rust 开发一款类似于 GitBook 的程序
**Rust新手开发者分享开源项目 Typikon**:模仿MDBook,致力于简单Markdown到在线书的渲染。[GitHub](https://github.com/auula/typikon)上可找到源码,欢迎初学者一同学习与贡献。体验轻松构建静态网站,探索Rust之旅。🌟 加入讨论,共建更易用的GitBook替代品。在线文档见[https://typikonbook.github.io](https://typikonbook.github.io)。
39 1
|
5月前
|
Rust 安全 开发者
Rust语言的Hello, World! 程序解析
Rust语言的Hello, World! 程序解析
|
6月前
|
数据采集 缓存 Rust
通过Rust实现公司电脑监控软件的性能优化算法
使用Rust语言开发高效的公司电脑监控软件,通过实时监测CPU、内存、网络等性能数据,确保企业环境的稳定性。文中通过代码示例展示了数据采集模块,如读取CPU使用率,并利用缓存机制减少文件系统访问,提升性能。此外,还介绍了如何将监控数据通过HTTP客户端提交到网站进行分析和管理,以优化运维流程。
251 3
|
6月前
|
Rust 算法 IDE
我们的第一个Rust程序
Rust语言学习者,通常都会有其它语言的背景,很少有人从零入门Rust。 Rust语言应该有一门适合初学者的教程,来帮助大家使用Rust作为入门语言,我非常希望有人能使用原汁原味Rust的思维在计算机世界攻城略地。
我们的第一个Rust程序
|
6月前
|
Rust 算法 安全
Rust中的宏与编译时性能优化
本文深入探讨了Rust编程语言中的宏(Macros)及其在编译时性能优化方面的应用。我们将了解宏的基本概念,探索它们在元编程和性能优化中的潜力,并通过实例展示如何使用宏来优化Rust代码的性能。
下一篇
无影云桌面