我們程式員開發過程中,尤其是在程式調試的過程中,通常會并行開許多Terminal視窗。不過時間一久,可能就會忘了每個終端都是用于來幹嘛的。于是,如何讓界面保持清爽,同時又不降低工作效率,成了很多開發者的最大痛點。 Zellij 是一款非常優秀的終端工作區和多路複用器(類似于tmux和screen),由于使用Rust語言開發,是以與
與WebAssembly原生相容。作為一款功能強大,同時又容易上手的終端複用工具,将會話(session)和視窗解耦,使得使用者可以在單個視窗内運作多個虛拟終端,真正做到保持界面清爽還提高了工作效率。
筆者注意到在過去的幾個月中,
的開發團隊一直在對 進行優化與排坑,他們釋出了一些很多意義的技術部落格來記錄整個優化過程。部落格中展示了一些非常值得總結和重視的問題,通過他們的分享我們可以看到, 的開發者們提出了很多創造性的解決方案。通過兩個主要的技術提升點,他們大幅調優了在大量顯示重新整理場景下的性能。下面我把相關技術部落格為大家進行解讀。
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,
提供了一個非常有用的宏select!。此外,開發者們還删除了自定義的背景輪詢的異步流實作,轉而使用 async_std
File以獲得“異步 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-widthcrate 來計算每個字元的寬度。
在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多路終端複用器的性能,很多優化的思路非常值得開發者們借鑒。