🧹

Rustの肥大化していくビルドキャッシュを掃除するツールを作りました

2023/12/17に公開

この記事はRust Advent Calendar 2023 シリーズ3の17日の記事です。

TL;DR

色々な作業ディレクトリに散らばってしまうrustのビルドキャッシュの大きさをリスティングしてインタラクティブに削除するTUIツールのcargo-cleanerを作りcrates.ioに公開しました。

https://crates.io/crates/cargo-cleaner

デモ画像

その構成要素として作った、RwLockを魔改造したオレオレロックを作成することでメインスレッドへの通知を一本化したのでその説明も書いています。

動機

自分のRustの唯一の苦手なところがディスク問題だった

自分は仕事でも趣味でもRustを使っているのですが、大量のcargoプロジェクトを管理していると必ず出てくる問題がビルドディレクトリの肥大化問題でした。
私の中での3大ディスクを食い尽くし王は

  1. Rustのビルドキャッシュ
  2. dockerのビルドキャッシュ
  3. 機械学習系のデータセット

です。

ちなみに私のディレクトリをcargo cleanerでスキャンしたときのサイズはこのようになっています。(これでも3日前に10G以上のものは削除して綺麗にしたばっかりです)

私のディレクトリをcargo cleanerでスキャンしたサイズのリスト

自動削除系のツールは少し怖い

後述もしますが、先行ツールとして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);
    }
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=cbf5254237e8a6cbb545edb96bf0d14d

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());
    }
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=527aa0dab740db240c5e2a0ba89b6ad5

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