InfoQ Geekathon 大模型技术应用创新大赛 了解详情
写点什么

最佳实践:针对 Rust 应用 Zellij 进行故障排除和性能提升

  • 2021-08-23
  • 本文字数:6973 字

    阅读完需:约 23 分钟

最佳实践:针对Rust 应用 Zellij 进行故障排除和性能提升

在过去的几个月中,我们一直在针对我们的 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 指令:


  1. pty 读取缓冲区中没有更多数据。

  2. 自上次 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 通道的速度比后者处理它的速度要快得多。这会在几个方面影响性能:


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

  2. 屏幕线程渲染的内容过多了,因为 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 fixRange (min … max):   18.647 s … 19.803 s    10 runs# Zellij after this fixTime (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# TmuxTime (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 fixesRange (min … max):   18.647 s … 19.803 s    10 runs# Zellij after the first fixTime (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# TmuxTime (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/

活动推荐:

2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。

2021-08-23 16:531999

评论

发布
暂无评论
发现更多内容

ubuntu安装 GitLab、创建 group、user 和 project 并授权

忙着长大#

gitlab

极客时间运维进阶训练营第三周作业

chenmin

词向量word2vec(图学习参考资料1)

汀丶人工智能

图神经网络 图学习 11月月更

前端高频面试题合集(中高级必备)

loveX001

JavaScript

Python进阶(三十六)Web框架Django项目搭建全过程

No Silver Bullet

Python django 11月月更

极客时间运维进阶训练营第三周作业

LiaoWD

打破国内应用商店发展局限,vivo应用商店9.0创新突围

ToB行业头条

《数字经济全景白皮书》中国商业银行普惠金融可持续发展能力评价2022

易观分析

普惠金融 数字技术应用

【愚公系列】2022年11月 微信小程序-app.json配置属性之subpackages和preloadRule

愚公搬代码

11月月更

服务至上的时代,生态才是ToB软件厂商发展加速的油门

ToB行业头条

极客时间运维进阶训练营第三周作业

独钓寒江

谈谈前端性能优化-面试版

loveX001

JavaScript

常见的API安全漏洞类型

阿泽🧸

11月月更 API漏洞

如何搭建数据指标体系

穿过生命散发芬芳

11月月更 数据指标体系

Ubuntu部署和体验Nexus3

程序员欣宸

Docker 11月月更 nexus3

Python进阶(三十五)Fiddler命令行和HTTP断点调试

No Silver Bullet

Python fiddler 11月月更

9位资深技术专家!来自香山团队、平头哥等大咖云集的龙蜥RV专场回顾来了

OpenAnolis小助手

芯片 risc-v 龙蜥社区 2022云栖大会 技术专场

CSS学习笔记(七)

lxmoe

CSS 前端 学习笔记 11月月更

GitLab 服务的数据备份与恢复

忙着长大#

gitlab

极客时间运维进阶训练营第三周作业

曹张倪

Git 命令的基本使用clone、push 等

忙着长大#

Flowable 定时器的各种玩法

江南一点雨

Java spring springboot flowable JavaEE

Python进阶(三十四)Python3多线程解读

No Silver Bullet

多线程 Python3 11月月更

2022-11-13:以下go语言代码中,如何获取结构体列表以及结构体内的指针方法列表?以下代码应该返回{“S1“:[“M1“,“M2“],“S2“:[],“S3“:[“M1“,“M3“]},顺序不限

福大大架构师每日一题

golang AST 福大大

极客时间运维进阶训练营第三周作业

Starry

Vue内置组件之Transition(一)

Augus

vue.js 11月月更

听说过 OCI Runtime 不止 runc 还有 kata

一条肥鱼

kata

经常会采坑的javascript原型应试题

loveX001

JavaScript

20道前端高频面试题(附答案)

loveX001

JavaScript

如何在 Kubernetes 中创建命名空间?

wljslmz

Kubernetes 命名空间 11月月更

【C语言】extern 关键字

謓泽

11月月更

  • 扫码添加小助手
    领取最新资料包
最佳实践:针对Rust 应用 Zellij 进行故障排除和性能提升_文化 & 方法_Aram Drevekenin_InfoQ精选文章