PythonでCPUの温度などを測定する【軽量版】

以前書いた[PythonでラズパイのCPU温度を測定する](https://qiita.com/genshibangou16/items/ffb96765a56ccab9b62d)にある`temp.py`を実行すると、whileループで発火するごとにCPUを数パーセント使用していました。
モニタリング用のスクリプトですから極力軽くなくてはいかんということで改良したいと思います。

ちなみに改良後はこんな感じの出力になりました。
左からCPU温度、周波数、全体の使用率、コア毎の使用率です。
![110255.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684034/f6e68480-4d06-e706-a058-69b6290b2733.png)

#原因
何が重かったかといいますと、LinuxのOSコマンドを実行するために用いた`subprocess`というモジュールです。
こちらについては[やはりpythonのsubprocessでcatするのはまちがっている。](https://qiita.com/genshibangou16/items/0bc0450b629454b0dfd5)で詳しく触れていますので是非ご覧ください。
簡単に言ってしまえば、テキスト取得のためだけにわざわざ子プロセスを立ち上げるので遅いということです。
`open()`というファイル操作用組み込み関数を使えばPythonだけで完結しますので凡そ50倍以上速くなります。

#改善点
改善したのは主に四点です。

1. `subprocess`を`open()`に置き換え
2. `while`のループは遅いということなので`for`に置き換え
3. `print`内の文字列操作をformatで書き換え
4. 動作電圧表示を廃止し、使用率を表示

一つめが今回の目玉です。
以前は`subprocess`を用いて`vcgencmd`というRaspberry Pi OS謹製コマンドを実行していました。
これだとRaspberry Pi OSでしか実行できませんし、そもそも`vcgencmd`はシェルで実行するように設計されていますのでPythonから打つには向いていません。
Pythonで監視とは名ばかりのサブプロセス爆弾はもうやめよう、ということで`vcgencmd`を使わずにCPU情報を取得する点も今回のサブ目玉です。

二つめは微々たるものですが気分的に書き換えておきました。
三つめは主に可読性アップが目的です。
四つめは動作電圧を確認するメリットをあまり見いだせなかったのと、動作電圧を記録したファイルが見当たらなかったためです。
代わりにCPU使用率を表示するようにしました。

#コード
こちらが改良後のコードです。

```python:temp.py
#!/usr/bin/python3.7

import time
import sys

#CPU使用時間取得関数
def get_data():
    with open('/proc/stat') as r: #CPU統計ファイル読み込み
        stats = r.readlines() #行ごとにリスト化
        stat = [line.strip().split() for line in stats if 'cpu' in line] #cpuが含まれている行をピックアップして改行文字を削除、空白区切りで二重リスト化
    rs = [] #使用時間が入るリストを宣言
    for data in stat: #統計からCPU全体と論理コアそれぞれのデータを取り出す
        busy = int(data[1]) + int(data[2]) + int(data[3]) #Busy状態の時間を求める
        all = busy + int(data[4]) #全体の時間を求める
        rs.append([all, busy]) #リストに追加
    return rs #値を返す

pre_data = get_data() #初回のCPU使用時間取得
ave = 0 #平均計算用の変数宣言

time.sleep(1) #一秒待機

try: #以下のexcept KeyboardInterruptでCtrl+Cをキャッチするための正常時動作部分
    for i in range(60): #60回繰り返し
        with open('/sys/class/thermal/thermal_zone0/temp') as t: #CPU温度読み込み
            temp = int(t.read()) / 1000 #型変換して単位を合わせる
        with open('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq') as f: #CPU周波数読み込み
            freq = int(f.read()) #型変換

        now_data = get_data() #現在のCPU使用時間取得
        rates = [] #CPU使用率が入るリストを宣言

        for j in range(5): #CPU全体+論理コア数(4)回繰り返し
            now = now_data[j] #現在の使用時間から求めるCPUのデータを取り出す
            pre = pre_data[j] #1秒前の使用時間から求めるCPUのデータを取り出す
            rate = (now[1] - pre[1]) / (now[0] - pre[0]) * 100 #(Busy状態 / 全体) * 100でCPU使用率を求める
            rates.append(rate) #CPU使用率をリストに追加

        #formatメソッドを用いて整形し書き出し
        print("Temp:{0:>6.2f}'C, Freq: {1:.2f}GHz, CPU:{2:>5.1f}% [{3:>3.0f},{4:>3.0f},{5:>3.0f},{6:>3.0f}] ({7:>2})".format(temp, freq / 1000000, rates[0], rates[1], rates[2], rates[3], rates[4], i + 1))

        ave += temp #平均温度用に現在の温度を加算
        pre_data = now_data #次の一秒前データ用に現在のデータを保存
        time.sleep(1) #一秒待機
    print("Average: {:.2f}'C (60s)".format(ave / 60)) #ループ終了後に平均を書き出し
except KeyboardInterrupt: #Ctrl+Cをキャッチ
    sec = i + 1 #終了時の経過時間取得
    print(" Aborted.\nAverage: {0:.2f}'C ({1}s)".format(ave / sec, sec)) #平均温度を書き出し
    sys.exit() #正常終了
```

##CPU使用率
解説に先立ってCPU使用率の求め方を別に記したいと思います。
[こちら](http://my-web-site.iobb.net/~yuki/2017-10/raspberry-pi/cpustat/)を参考にさせて頂きました。

CPU使用率とは要するに全時間に対するCPUがビジー状態だった時間の割合ですから、Ubuntuが用意してくれているCPU統計からCPUの使用時間を取得してやれば、以下のような原理で求められます。

|累積CPU稼働時間(s)|...|904|905|906|907|908|...|
|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|累積Busy状態時間(s)|...|302|302.5|302.6|303|303|...|
|差分CPU稼働時間(s)|...|1|1|1|1|1|...|
|差分Busy状態時間(s)|...|-|0.5|0.1|0.4|0|...|
|CPU使用率(%)|...|-|50%|10%|40%|0%|...|

コードの大まかな流れは「データ取得(pre)」→「1秒待機」→「データ取得(nowと次回のpre)」→「計算」→「データ取得(次回のnow)」→（繰り返し）です。

UbuntuのCPU統計は`/proc/stat`に記されており、マニュアルから必要事項を抜粋すると以下の通りです。

```bash:man
/proc/stat
    kernel/system statistics.  Varies with architecture.  Common entries include:
        cpu 10132153 290696 3084719 46828483 16683 0 25195 0 175628 0
        cpu0 1393280 32966 572056 13343292 6130 0 17875 0 23933 0
        #上の文字列を整形して右のようにします[[cpu, 10132153, 290696, 3084719, 46828483, 16683, 0, 25195, 0, 175628, 0], [cpu0, 1393280, 32966, 572056, 13343292, 6130, 0, 17875, 0, 23933, 0]]
            The amount of time, measured in units of USER_HZ (1/100ths of a second on most architectures, use sysconf(_SC_CLK_TCK) to obtain the right value), that the system ("cpu" line) or the specific CPU ("cpuN" line) spent in various states:
                user   (1) Time spent in user mode.　#ユーザーによってビジー
                nice   (2) Time spent in user mode with low priority (nice). #ユーザーによる優先度の低いプロセスによってビジー
                system (3) Time spent in system mode. #システムによってビジー
                idle   (4) Time spent in the idle task.  This value should be USER_HZ times the second entry in the /proc/uptime pseudo-file. #アイドル
```

こちらを参考に必要なデータを取り出せるよう整形していきます。
`subprocess`で取得するのでしたら`grep`という便利なものがりますが今回は使えません。
ですので`open()`に含まれる`readlines()`というものを使います。
[こちら](https://note.nkmk.me/python-grep-like/)の記事を参考に、`grep`と同様の動作を再現し、さらに加工して`CPU > 項目`という二重リストにします。（上記マニュアル内コメント参照）

この二重リストから`for`でCPU全体と論理コア毎のデータを取り出してBusy状態と全体の経過時間を計算し、`CPU全体と論理コア > 経過時間`の二重リストにして返します。
ここではリストの2,3,4番目の項目の総和でBusy状態、そしてBusy状態とIdle状態(5番目の項目)の総和で全体の経過時間を取得しています。
以上が使用時間取得関数`get_data()`の処理です。
以降は解説の中で触れていきたいと思います。

##解説
大体コメントで記しましたが、上から順に扱っていきたいと思います。
僕自身初心者ですので過剰気味に解説していきます。

#####`import`
まず必要なモジュールを`import`していきます。
今回は待機用に`time`モジュールと、終了用に`sys`モジュールを読み込みました。
これに加えて改良前のコードには`subprocess`が含まれていましたが削除しました。

#####`def get_data()`
CPU使用率に関しては既に別項で扱いましたが、`def`で累積使用時間を取得する関数を宣言しています。

#####`pre_data`と`ave`、`time.sleep(1)`
ループに入る前に初回計算用のデータを取得して、CPU平均温度を求めるための変数を宣言します。
そして一秒待機します。
待機しないで進むと`/proc/stat`の更新間隔より早く`now_data`が取得されてしまうので、差分がゼロになってしまい0で割るなと怒られます。

#####`try`と`except`
ループ全体を`try`で囲むことで`Ctrl + C`の入力をエラーとしてではなく例外としてキャッチできるようにしてあります。
`Ctrl + C`が入力された時点で`except KeyboardInterrupt`に飛んで平均温度を計算したのち正常終了とします。

#####`for i in range(60)`
iに0~59までの数字を代入しながら処理を繰り返してくれます。
10000000回繰り返すと0.04秒ほど`while`で繰り返すより早いそうです。
今回は誤差の範囲にもなりませんがカウンタを用意しなくてよいのがスマートで気に入りました。

#####`with open()`
`subprocess`に代わって導入したファイルを開く組み込み関数です。
前述の通り、詳しくは[こちら](https://qiita.com/genshibangou16/items/0bc0450b629454b0dfd5)を見てください。
Python内部で処理が完結するので軽量化に貢献してくれています。

#####`now_data`と`rates`
現在の累積CPU使用時間を取得しています。
CPU使用率を代入するための空リストを宣言しています。

#####`for j in range(5)`
CPU全体とコア毎にデータを取り出して処理します。
現在の累積使用時間`now_data`と一秒前の累積使用時間`pre_data`の差分を求めてCPU使用率を計算し、結果をリストにして返します。
（`i`じゃないインデックスはなんて名前にすればいいんでしょうね。）

#####`print`
formatメソッドを用いて見やすくなるように整えて書き出しています。
平均値の除算などの簡単な計算もformatの内部でできるのでとても便利ですね。

#####`ave`と`pre_data`、`time.sleep(1)`
現在の温度を、平均温度を取得するために`ave`に加えています。
次回のCPU使用率の計算のために`pre_data`に先ほど取得した現在のデータを代入しておきます。
一秒待機したのち処理がループされます。

#CPU情報を`vcgencmd`を使わずに取得
既にふれていますがPythonから`vcgencmd`で情報を取得するのはとても非効率です。
`/proc/stat`のようにUbuntuでデータをファイルにしてくれているのでそちらから取得するようにしました。

##CPU温度
こちらは結構有名な場所かと思います。

```bash
cat /sys/class/thermal/thermal_zone0/temp
#温度の1000倍が出力される
```

`vcgencmd`だと単位やらがついてきますが、数字だけですのでスクリプトに埋め込むにはこちらから取得するのがよさそうです。
除算もプログラム内でしたら苦になりませんし、なぜか有効数字はこちらのほうが大きいといいことづくめです。

##CPU周波数
これは見つけるのに苦労しましたが、如何なる事にも偉人というのはいらっしゃるものです。ありがたや。

[Ubuntu CPUfreq その3 - CPUのcpufreqフォルダー内のファイルについて](https://kledgeb.blogspot.com/2013/06/ubuntu-cpufreq-3-cpucpufreq.html)

```bash
#/sys/devices/system/cpu/cpu*/cpufreqにcpu(コア)の情報がまとまっています
#cpuinfo_cur_freqはハードウェアから取得した値
#scaling_cur_freqはLinuxカーネルから取得した値

cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq
#Permission denied
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq
#cpu0のCPU周波数が表示されます
```

場所を見つけたのはいいのですが、二つ問題に遭遇しました。
一つめはハードウェアから取得した数値は`root`しか見れないことで、もう一つはcpuコア毎にファイルが違うことです。
できればハードウェアから取得した数値を参照したいですが`root`なしで実行したいですし、cpuコア毎に周波数が違えばその平均を取りたいですが処理が4倍になるので避けたいです。

これらの問題を解決するために力技で検証してみました。
8つの値がすべて一致すれば一つの値を代表サンプルとしても問題はないと思うので全4コアの`cpuinfo_cur_freq`と`scaling_cur_freq`をそれぞれ取得し比較することを10000回繰り返します。
結果、95%程の割合ですべて一致しました。
どうも周波数変更時にずれが発生するようで、検証間にスリープをはさんだりすると95%程、何もせずひたすら繰り返すと100%一致でした。
また、ずれが発生するのは主にハードウェアとカーネルの間のようで、コア間の一致は悪くても99%程でした。
そこまで厳密になる必要もないですし、1秒ごとのサンプリングでは影響は少ないだろうということで今回は`cpu0`の`scaling_cur_freq`を代表値として採用することにしました。

Linuxでは（Raspberry Piでは？）コア毎に周波数を変化させて省電力化するような制御は行っていないのですね。
このあたり詳しい方がいらっしゃいましたらお教え頂ければ幸いです。

##CPU電圧
こちらはファイルの場所を見つけることができませんでした。
本気で探せば見つかるかもしれませんが、元から無くても良いか的な値でしたのでこの際削ることとしました。
もしご存じの方がいらっしゃればお教えください。

#追記
上記スクリプトを実行していたところ、極稀にですが`ZeroDivisionError: division by zero`というエラーに遭遇しました。
エラーメッセージの通り「ゼロで割ってんじゃねぇよぉ！」（数学的にゼロで割るというのは定義できないのでご法度）という魂の訴えですので対処してあげましょう。
今回の場合は`now_data`が処理遅れなど何らかの理由で`pre_data`と同値となってしまい、`rate = (now[1] - pre[1]) / (now[0] - pre[0]) * 100`にて分母がゼロとなりエラーが発生しています。
ですので`now_data`を取得した時点で`pre_data`と比較をして、同値の場合には0.1秒待機して一致しなくなるまで再帰的に取得するようにしています。
取り敢えずこれでエラーとは遭遇していないので解決とします。
また、温度だけでなくCPU使用率なども平均を表示するようにしました。
コピー用としてコメントを記述していないものを貼っておきます。

```python:temp.py
#!/usr/bin/python3.7

import time
import sys

def get_data():
    with open('/proc/stat') as r:
        stats = r.readlines()
        stat = [line.strip().split() for line in stats if 'cpu' in line]
    rs = []
    for data in stat:
        busy = int(data[1]) + int(data[2]) + int(data[3])
        all = busy + int(data[4])
        rs.append([all, busy])
    return rs

def assign():
    now_d = get_data()
    if now_d == pre_data:
        time.sleep(0.1)
        assign()
    else:
        return now_d

pre_data = get_data()
duration = 60
ave_temp = 0
ave_freq = 0
ave_cpu = 0

time.sleep(1)

try:
    for i in range(duration):
        with open('/sys/class/thermal/thermal_zone0/temp') as t:
            temp = int(t.read()) / 1000
        with open('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq') as f:
            freq = int(f.read()) / 1000000

        now_data = assign()
        rates = []

        for j in range(5):
            now = now_data[j]
            pre = pre_data[j]
            rate = (now[1] - pre[1]) / (now[0] - pre[0]) * 100
            rates.append(rate)

        print("Temp:{0:>6.2f}'C, Freq: {1:.2f}GHz, CPU:{2:>5.1f}% [{3:>3.0f},{4:>3.0f},{5:>3.0f},{6:>3.0f}] ({7:>2})".format(temp, freq, rates[0], rates[1], rates[2], rates[3], rates[4], duration - i))

        ave_temp += temp
        ave_freq += freq
        ave_cpu += rates[0]
        pre_data = now_data
        time.sleep(1)
    print("Average: {0:.2f}'C, {1:.2f}GHz, {2:.1f}% ({3}s)".format(ave_temp / duration, ave_freq / duration, ave_cpu / duration, duration))

except KeyboardInterrupt:
    sec = i + 1
    print(" Aborted.\nAverage: {0:.2f}'C, {1:.2f}GHz, {2:.1f}% ({3}s)".format(ave_temp / sec, ave_freq / sec, ave_cpu / sec, sec))
    sys.exit()
```

#まとめ
重かったスクリプトを軽量化しようとして取り組み始め、PythonでCPUをモニターするからにはPythonだけで完結させようという目的も加わり、結局大幅な軽量化に成功しました。
知らなかった超便利な関数も知ることができ、日々勉強を痛感しております。
また、この記事を書いている途中にもコードを書き直したりして少し洗練することができました。見直しは大切ですね。
長々とした記事になってしまいましたが最後まで読んでいただきありがとうございました。
ご指摘などありましたらビシバシとお寄せいただければ幸いです。