CPU 使用率を常時監視する

sysinfo 0.38 で PC 全体・コアごと・アプリ自身の CPU 使用率を読み、常駐スレッドからイベントで画面に送る。2 回の計測の差で求める仕組みと、読む間隔の決め方も示す。

パフォーマンス 対象: Tauri 2.x 更新日: 読了目安: 約9分 perf-007
目次
  1. 前提条件
  2. 間隔の決め方
  3. 1. バックエンドから実装する (Rust)
  4. 2. フロントエンドから実装する (TypeScript)
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

「何もしていないのにファンが回る」「ある操作の後から CPU を使い続けている」といった問題は、使用率を数字で見続けると原因の操作を絞れます。CPU 使用率はある瞬間の値ではなく「前回から今回までの間にどれだけ動いていたか」なので、間を空けて 2 回読む必要があります。WebView の JS からは使用率の数値を読めない(分かるのは navigator.hardwareConcurrency の論理コア数だけ)ため、Rust の sysinfo クレートで読み、常駐スレッドからイベントで画面に送ります。メモリの量は メモリ使用量を取得する で扱っています。

前提条件

src-tauri で次を実行します(sys-004 と同じ 0.38 系で、system 機能だけを使います)。

cargo add sysinfo@0.38 --no-default-features --features system

画面でイベントを受ける listen の権限は core:default に含まれるので、capabilities の変更は要りません。読める値は次の 3 種類です。

値範囲意味
global_cpu_usage()0〜100PC 全体
cpus() の各 cpu_usage()0〜100論理コアごと
Process::cpu_usage()0〜100 × コア数1 つのプロセス。1 コアを使い切ると 100

プロセスの値は全コアの合計なので、全体と同じ 0〜100 の尺度にするにはコア数で割ります。また、自分のプロセスとして読めるのは Rust 側(コア・プロセス)だけです。ページの JS を動かす WebView は別のプロセスなので含まれません。

間隔の決め方

使用率は「前回読んでから今回までの平均」なので、読む間隔がそのまま平均を取る幅になります。

  • 下限: sysinfo の MINIMUM_CPU_UPDATE_INTERVAL(Windows・macOS・Linux では 200 ms)より短いと、正しい値になりません。
  • 画面に表示する: 1 秒前後。短くすると数字が跳ねて読みにくく、長くすると操作への反応が遅れます。
  • 記録や警告: 5〜10 秒ごとに読み、「高い値が何回続いたか」で判断すると、一瞬の山で誤報しません。
  • 1 回目は捨てる: 監視を始めた直後は比べる前回の値がないため、正しい使用率になりません。

監視そのものも CPU を使います。System::new_all() や refresh_all() はすべてのプロセスまで調べるので、CPU の使用率と自分のプロセスだけを読み直します。表示していない間は止められるようにもしておきます。

1. バックエンドから実装する (Rust)

監視用のスレッドは setup で 1 本だけ立てます。画面から呼ぶコマンドの中でスレッドを作ると、ページを再読み込みするたびに増えていきます(メモリリークしていないか検査する)。開始・停止と間隔は Atomic の値にしておき、コマンドから書き換えます。

use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use serde::Serialize;
use sysinfo::{get_current_pid, ProcessRefreshKind, ProcessesToUpdate, System, MINIMUM_CPU_UPDATE_INTERVAL};
use tauri::{AppHandle, Emitter, Manager, State};

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CpuSample {
    total: f32,         // PC 全体(0〜100)
    app: f32,           // このアプリの Rust 側のプロセス(0〜100 に換算)
    per_core: Vec<f32>, // 論理コアごと(0〜100)
}

/// 監視の設定。コマンドと監視スレッドの両方から触るので Atomic にする
struct CpuMonitor {
    enabled: AtomicBool,
    interval_ms: AtomicU64,
}

fn spawn_cpu_monitor(app: AppHandle) -> std::io::Result<()> {
    std::thread::Builder::new()
        .name("cpu-monitor".into())
        .spawn(move || {
            let pid = get_current_pid().ok();
            let mut sys = System::new(); // 空の状態で作り、必要な部分だけ読む
            let mut primed = false; // 比べる前回の値があるか
            loop {
                let monitor = app.state::<CpuMonitor>();
                let ms = monitor.interval_ms.load(Ordering::Relaxed);
                std::thread::sleep(Duration::from_millis(ms).max(MINIMUM_CPU_UPDATE_INTERVAL));
                if !monitor.enabled.load(Ordering::Relaxed) {
                    primed = false; // 再開したら 1 回目を捨て直す
                    continue;
                }
                sys.refresh_cpu_usage(); // 全体とコアごと
                if let Some(pid) = pid {
                    // 自分のプロセスの CPU だけを読み直す
                    sys.refresh_processes_specifics(
                        ProcessesToUpdate::Some(&[pid]),
                        true,
                        ProcessRefreshKind::nothing().with_cpu(),
                    );
                }
                if !primed {
                    primed = true;
                    continue;
                }
                let cores = sys.cpus().len().max(1) as f32;
                let sample = CpuSample {
                    total: sys.global_cpu_usage(),
                    app: pid
                        .and_then(|p| sys.process(p))
                        .map_or(0.0, |p| p.cpu_usage() / cores),
                    per_core: sys.cpus().iter().map(|c| c.cpu_usage()).collect(),
                };
                let _ = app.emit("cpu-usage", sample);
            }
        })?;
    Ok(())
}

/// 監視の開始・停止と、読む間隔の変更
#[tauri::command]
fn set_cpu_monitor(enabled: bool, interval_ms: Option<u64>, monitor: State<'_, CpuMonitor>) {
    if let Some(ms) = interval_ms {
        monitor.interval_ms.store(ms, Ordering::Relaxed);
    }
    monitor.enabled.store(enabled, Ordering::Relaxed);
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(CpuMonitor {
            enabled: AtomicBool::new(false),
            interval_ms: AtomicU64::new(1000),
        })
        .setup(|app| {
            spawn_cpu_monitor(app.handle().clone())?; // 起動時に 1 本だけ
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![set_cpu_monitor])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

止めている間もスレッドは間隔ごとに起きて設定を見るだけなので、負荷はほとんどありません。送り方は用途で選びます。

送り方向いている場面
イベント(emit)常駐スレッドから、開いている画面すべてへ(このレシピ)
Channel1 回の invoke に付いてくる進み具合(rust-013)
画面から invoke で取りに行く表示側で間隔を決めたい。System を State に持ち続けて前回の値を残す(sys-004 の形)

2. フロントエンドから実装する (TypeScript)

表示を始めるときに listen してから監視を有効にし、止める関数を返します。止めるときは unlisten() も呼びます。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type CpuSample = { total: number; app: number; perCore: number[] };

/** 表示を始め、止めるための関数を返す */
export async function startCpuMeter(el: HTMLElement, intervalMs = 1000) {
  const unlisten = await listen<CpuSample>('cpu-usage', ({ payload }) => {
    const busiest = payload.perCore.length ? Math.max(...payload.perCore) : 0;
    el.textContent =
      `全体 ${payload.total.toFixed(0)}% ・ このアプリ ${payload.app.toFixed(1)}% ・ ` +
      `最も忙しいコア ${busiest.toFixed(0)}%(${payload.perCore.length} コア)`;
  });
  await invoke('set_cpu_monitor', { enabled: true, intervalMs });
  return async () => {
    unlisten(); // 忘れると表示先の要素ごとメモリに残る
    await invoke('set_cpu_monitor', { enabled: false });
  };
}

「最も忙しいコア」も出しているのは、1 つのスレッドだけが詰まっている状態を見つけるためです。16 コアの PC では、1 スレッドが使い切っても全体の値は 6% 程度にしかなりません。

動作確認

npm run tauri dev で起動して startCpuMeter() に表示先の要素を渡すと、1 秒ごとに更新されます。次の 1 行目は何もしていないとき、2 行目は rust-013 の素数の数え上げ(1 スレッドで計算)を実行中の例です(16 コアの PC)。

全体 4% ・ このアプリ 0.1% ・ 最も忙しいコア 18%(16 コア)
全体 11% ・ このアプリ 6.2% ・ 最も忙しいコア 100%(16 コア)

このアプリの値が 100 ÷ 16 ≒ 6.25% で頭打ちになり、1 スレッドを使い切っていると分かります。同じ計算を Web Worker で動かすと、全体の値は上がってもこのアプリの値はほぼ 0 のままです。Worker は WebView のプロセスで動くためです。

よくあるエラーと対処法

  • 値が 0 のまま: 1 回目の値を使っているか、間隔が短すぎます。アプリの値だけ 0 なら、ProcessRefreshKind の with_cpu() が抜けています。
  • このアプリの値が 100 を超える: Process::cpu_usage() をコア数で割っていません。
  • タスクマネージャーの値と合わない: WebView のプロセスの分が含まれていないためです。平均を取る間隔も違うので、傾向を見る用途にとどめます。
  • 古い記事のコードがコンパイルできない: global_cpu_info() は 0.31 で廃止され、global_cpu_usage() になりました。refresh_cpu() は 0.38 には無いので refresh_cpu_usage() を使います。ほかの変更は sys-004 の「よくあるエラー」を参照してください。
  • 監視を始めると画面が固まる: refresh_all() を async でないコマンドの中で呼んでいます。同期のコマンドはメインスレッドで動くので、このレシピのように別スレッドで読みます。

OS ごとの違いと注意点

  • Linux: 間隔が短すぎると、プロセスの値が 0 ではなく最大値(コア数 × 100)に張り付きます。ほかの OS では 0 になります。
  • macOS: Mac App Store で配布するなら、ストアの規則に触れる機能を外す apple-app-store 機能を有効にします。ストア外でサンドボックスを使う場合は apple-sandbox 機能だけを使います。
  • Android: 新しい版では一般のアプリが読めるシステム情報が制限されていて、CPU の情報を取れないことがあります。
  • 複数のウィンドウ: emit は全ウィンドウに届きます。複数の画面が set_cpu_monitor を呼ぶなら、表示中の画面の数を数え、0 になったときだけ止めます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する