Rustの肥大化していくビルドキャッシュを掃除するツールを作りました
この記事はRust Advent Calendar 2023 シリーズ3の17日の記事です。
TL;DR
色々な作業ディレクトリに散らばってしまうrustのビルドキャッシュの大きさをリスティングしてインタラクティブに削除するTUIツールのcargo-cleanerを作りcrates.ioに公開しました。
その構成要素として作った、RwLockを魔改造したオレオレロックを作成することでメインスレッドへの通知を一本化したのでその説明も書いています。
動機
自分のRustの唯一の苦手なところがディスク問題だった
自分は仕事でも趣味でもRustを使っているのですが、大量のcargoプロジェクトを管理していると必ず出てくる問題がビルドディレクトリの肥大化問題でした。
私の中での3大ディスクを食い尽くし王は
- Rustのビルドキャッシュ
- dockerのビルドキャッシュ
- 機械学習系のデータセット
です。
ちなみに私のディレクトリをcargo cleanerでスキャンしたときのサイズはこのようになっています。(これでも3日前に10G以上のものは削除して綺麗にしたばっかりです)
自動削除系のツールは少し怖い
後述もしますが、先行ツールとしてcargo-clean-allという一定期間過ぎたプロジェクトのビルドキャッシュをすべて削除してくれているツールは存在しました。
自分は小心者なのでその手の全部削除系はちょっとなぁっていうイメージがあったので今回のツールを作りました。
TUIライブラリのratatuiに興味が出てきていた
Small String Optimization で Rust ライブラリ ratatui を最適化した話の記事がとても良すぎた結果としてratatuiに興味がでて触ってみようかなという感じでしたので
使い方
シンプルな使い方としては
cargo cleaner
と打ち込むと画面が起動します。この方法で起動すると、cargo-cleanerはHOMEディレクトリ以下の全てのディレクトリを対象に、targetディレクトリが正のサイズを持つCargoプロジェクトを探しに行きます。
dry-runしたい場合は
cargo cleaner --dry-run
とすれば、削除をしたときにリストからは消えますが、実際には削除を行いわないとなります。
キーバインド
キーバインドはvimをインスパイアして崩した感じになっています。
key | description |
---|---|
h |
ヘルプの表示 |
j or ↓ |
下に移動 |
k or ↑ |
上に移動 |
g |
リストの先頭に移動 |
G |
リストの末尾に移動 |
SPACE |
カーソルのあるファイルを選択/解除 |
v |
自動選択モードに切り替える |
V |
自動選択解除モードに切り替える |
ESC |
モードの解除 |
d |
選択したファイルを削除 |
q |
終了 |
技術的な話
全体的な話
このアプリケーションは
- TUIを表示するメインスレッド
- ディレクトリを走査してcargoプロジェクトを見つけるスレッド郡
- targetディレクトリを削除するスレッド
が協調して動く形のアプリケーションになっています。
なお、今回はタスクが大量にって言うわけでもないので(tokioのような)非同期ランタイムは使わずに非同期処理はすべてスレッドで行っています。
なので、AppのStateもLockやArcを持ったになっています。
struct App {
table_state: TableState,
items: Arc<NotifyRwLock<Vec<ProjectTargetAnalysis>>>,
selected_items: HashSet<Uuid>,
scan_progress: Arc<NotifyRwLock<Progress>>,
delete_state: Option<DeleteState>,
dry_run: bool,
mode: CursorMode,
show_help_popup: bool,
notify_tx: SyncSender<()>,
}
ratatuiについて
他のTUIライブラリの経験がないので比較にはなりませんが、C++を使ってcursesを使って自分でTUI作ってたときと比較すると、とても楽になったという感じです。
event周りの扱いがシンプル
ratatuiではライブラリ側でKeyEventなどを取得してくれるのでイベントループを作るのはかなり簡単でした。
イベントを以下のように定義して、
#[derive(Clone, Debug)]
pub enum Event {
Parent(CrosstermEvent),
AsyncUpdate,
}
以下のように書いていくだけでイベントループを作ることができます。
AsyncUpdateというのが他のスレッドから何かしらがアップデートされた時のイベントになります。
match tui.read_event()? {
Event::AsyncUpdate => {}
Event::Parent(ev) => {
if let CrosstermEvent::Key(key) = ev {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char(DELETE_COMMAND_KEY) => { ... }
...
}
}
}
}
exampleが充実している
一番いいところは、公式のexampleが充実しているのでWidgetの使い方はexampleを見れば解決するところです。
また、examplesのREADME.mdに動作のgifが貼ってあるのでどのexampleを参考にすれば良いのかをすぐに参考にできます。
スレッド間の協調について
アプリの状態
一部再掲になりますがアプリの状態は以下のようになっています。
struct App {
table_state: TableState,
items: Arc<NotifyRwLock<Vec<ProjectTargetAnalysis>>>,
selected_items: HashSet<Uuid>,
scan_progress: Arc<NotifyRwLock<Progress>>,
delete_state: Option<DeleteState>,
dry_run: bool,
mode: CursorMode,
show_help_popup: bool,
notify_tx: SyncSender<()>,
}
pub enum DeleteState {
Confirm,
Deleting(Arc<NotifyRwLock<Progress>>),
}
pub struct Progress {
pub total: usize,
pub scanned: usize,
}
ポイントになるのがNotifyRwLock
になっています。
NotifyRwLockについて
Rustを知っている方だと、NotifyRwLockという型が見慣れないと思います。
それもそのはずで、この型はこのリポジトリ内で定義しているオレオレ型になっています。
RwLockの概略
RwLockについてはある程度知っているという体で概略だけ説明します。
ざっくりというと、Rustで複数のスレッドから触るデータに対して2つのロックの仕方を提供するものになります。
- 読み込みロック
- 書き込みロック
この2つのロックはRustの参照の制約と非常に似ていて、
1つ以上の読み込みロック XOR ただ1つの書き込みロックしか存在できないようになっています。
また、Rustではこのロックの仕組みを上手く実装しており、以下のようなコードはブロックから抜けてguardがドロップする時点でロックが開放されるようになっています。
use std::sync::{Arc, RwLock};
fn main() {
let x = Arc::new(RwLock::new(1_i64));
{
let read_guard = x.read().unwrap();
println!("{}", read_guard);
}
{
let mut write_guard = x.write().unwrap();
*write_guard = 15;
}
{
let read_guard = x.read().unwrap();
println!("{}", read_guard);
}
}
RwLockの今回のユースケースでの欠点
RwLockでは、複数のスレッドから安全に読み書きできるけれども、書き込まれたことを通知することができないという欠点があります。
今回利用しているTUIツールはイベントベースで動くので他のスレッドから値が書き込まれたタイミングでメインスレッドに通知が飛んで画面に更新を入れたいという気持ちになります。
このユースケースに対応しているのが、
std::sync::mpscになります。
この2つを使うことで安全に読み書きしてかつ通知を飛ばす機構を作ることができます。(送るデータが小さければRwLockはいらない場合もあります)
小さい例は以下のようになります。
このコードは別のスレッドから、ゆっくりデータを送ってメインスレッドではその送られてきたというイベントをもとに表示している形になります。
use std::sync::{mpsc::channel, Arc, RwLock};
use std::time::Duration;
fn main() {
let x = Arc::new(RwLock::new(1_i64));
let (tx, rx) = channel::<bool>();
std::thread::spawn({
let (tx, x) = (tx.clone(), x.clone());
move || {
for i in 1..10 {
*x.write().unwrap() = i;
tx.send(false).unwrap();
std::thread::sleep(Duration::from_millis(100));
}
}
});
std::thread::spawn({
let (tx, x) = (tx.clone(), x.clone());
move || {
for i in 1..5 {
*x.write().unwrap() = i * 100;
tx.send(false).unwrap();
std::thread::sleep(Duration::from_millis(500));
}
tx.send(true);
}
});
loop {
let finished = rx.recv().unwrap();
if finished {
break;
}
println!("{}", x.read().unwrap());
}
}
NotifyRwLock
上の2つを組み合わせたものがこのNotifyRwLockになります。
mpscのサンプルでは書き込こんでロックを開放する => tx.sendを呼ぶというルールで通知を行っていましたが、このNotifyRwLockではWriteGuardのDrop時にtx.sendを自動で呼ぶように作っています。
なので、別のスレッドに対してSender(tx)を以下のようにばらまいて、メインスレッドにReceiver(rx)を持たせてイベント作成で利用するという形にしています。
ばらまいているコード
// start find job
let (notify_tx, notify_rx) = std::sync::mpsc::sync_channel(1);
let search_root = args
.search_root
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().expect("can not found HOME_DIR"));
let scan_workers = args.scan_workers.unwrap_or((num_cpus::get() - 1).max(1));
let (analysis_receiver, scan_progress) =
find_cargo_projects(search_root.as_path(), scan_workers, notify_tx.clone());
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let app = App::new(args.dry_run, notify_tx, scan_progress.clone());
let items = Arc::clone(&app.items);
イベントを作成しているコード
pub fn read_event(&mut self) -> anyhow::Result<Event> {
loop {
if event::poll(Duration::from_micros(100))? { // Keyイベントなどがあるか?
return Ok(event::read().map(Event::Parent)?);
} else if self.async_update_rx.try_recv().is_ok() { // NotifyRwLockからの通知があるか?
return Ok(Event::AsyncUpdate);
}
}
}
このような機構を作って、複数スレッドでの状態管理しています。
イメージではReactのstate hookに近いイメージかもしれないですがRustだとペアを持たずに作ることができます。
最後に
ここまで読んでいただいて、本当にありがとうございます。
よかったらリポジトリにスターを教えていただけると励みになります!
Discussion