あれあれ？ CPU 増やしたのに速くならないぞ？

この記事は [モチベーションクラウドシリーズ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/mcs) 15 日目の投稿です。

:::note
弊社ではテックブログもやっておりますので、宜しければこちらもどうぞ！
https://link-and-motivation.hatenablog.com/
:::

## はじめに

Web アプリケーションを開発している皆さん！ 日夜性能問題に悩まされていると思います😅
本記事では性能問題における **「CPU 使用率の見方」** に焦点をおいて話そうかと思います！

## CPU あるある

CPU にまつわる謎？ は大体次の２ケースかな〜、と思います。

:::note
Amazon RDS (MySQL DB) の例で挙げてみます。
:::

### ① クエリ応答が遅いからスケールアップ！ → あれ？変わらないぞ？

Web アプリ開発していると、API 応答が遅い → 原因は重いクエリ (SQL) というケースはよくあるかと思います。当然速度改善したいです。お金で簡単に解決できるならそうしたい。

例えば RDS のインスタンスタイプ `db.r5.xlarge` を今使っているとしましょう。
vCPU 数は `4` です。これを **２倍性能** の `db.r5.2xlarge` にしましょう！

|       | db.r5.xlarge | db.r5.2xlarge |
|:------|:-------------|:--------------|
| **vCPU**  | 4 | 8 |
| **メモリ** | 32 GiB | 64 GiB |

さて、同クエリを実行してみると... ビックリ！ 性能２倍になった筈なのに **クエリ応答速度は変わらない！**


### ② 大変だ！ RDS の CPU 使用率が 100% になってるぞ！

次の通り RDS (MySQL) の CPU 使用率が 100% になっちゃいました。ヤバイですね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/fe0edaa9-7cc7-9c2c-2716-bf0680b03d30.png)

しかし、これって「CPU 使用率が 100% は悪！」というイメージはありますが...

* 100% でもちゃんとクエリ捌けてるけど、一体何が問題なの？
* そもそも、どういう状況になると 100% になるの？



## 最近の PC はマルチコア

昔の CPU はシングルコアでした。 Intel Pentium 4 プロセッサとかありましたね。

<img width="200" alt="咲くっとイラスト様の画像を利用させて頂きました https://sakutto-sozai.com/1242/" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/0b9cd84f-4e2d-483b-fc75-cd15c41b1d2a.png">

最近の CPU はマルチコアです。一つの物理チップの中に複数の物理コアが内蔵されている形です。例えば、Amazon EC2 のインスタンスタイプ `m5` シリーズは Intel Xeon プロセッサが搭載されています。

<img width="200" alt="CPU (自作画像)" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/da401d70-1d93-d1f0-a784-09dd7d414c7e.jpeg">

と言うことは、１物理コア当たりの性能が同じなら、４コアの CPU でプログラムを実行すると４倍のスピードで終えられるのでしょうか？

## CPU バウンドな処理をしても使用率 25% しかいかない？

実際にコードを書いて試してみましょう。

:::note
Amazon EC2 `m5.xlarge` (4 vCPUs) 上で Python コードを実行します。
:::

ひたすら `1 + 1` の CPU 演算をする Python プログラム(笑)です。

```python:cpu.py
import sys
import time

seconds = int(sys.argv[1])
timeout = time.time() + seconds

while time.time() < timeout:
    1 + 1
```

コマンドライン引数に実行時間 (秒) を取ります。`30 秒` で早速実行してみます。

```shell
# python3 cpu.py 30 &
```

コマンド `top` で結果を見てみました。

```shell
# top
top - 12:27:42 up  1:31,  1 user,  load average: 0.39, 0.12, 0.04
Tasks: 109 total,   2 running,  55 sleeping,   1 stopped,   0 zombie
%Cpu(s): 25.0 us,  0.0 sy,  0.0 ni, 75.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem : 16082416 total, 15227608 free,   142568 used,   712240 buff/cache
KiB Swap:        0 total,        0 free,        0 used. 15664280 avail Mem 
```

あれ？ **CPU が 25% しか使われていないぞ？？？**

## CPU とプロセス (スレッド)

### CPU 物理コアの実行単位はスレッド

Linux OS 上で実行中のプログラムは **プロセス** と呼ばれます。
プロセスは最低１つの **スレッド** を持ち、このスレッドが CPU の１物理コア上で実行される単位になります。

ここが一番大事な事ですが **１スレッドを複数コアにまたがって同時実行する事はできません**

例えば、単一のスレッドで動作する「プロセス１」、３つのスレッドで動作する「プロセス２」があるとします。
この場合、下図の様に各スレッドに対し、１物理コアが割り当てられます。

:::note warn
前述のサンプルコードの様に、スレッド実行する処理が「常に CPU 演算する処理」と仮定します。
:::

![川津の個人ボード (16).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/482bbf1f-dff7-daf7-c4e2-faa6831c2e82.jpeg)

### 先程のサンプルコード (CPU 使用率 25%)

先程の「`1 + 1` をシングルスレッドで繰り返し演算する」サンプルコードを見直してみましょう。

```python:cpu.py
import sys
import time

seconds = int(sys.argv[1])
timeout = time.time() + seconds

while time.time() < timeout:
    1 + 1
```

このケースでは下図の様に **１物理コアのみを専有** (※この瞬間、他プロセスの処理が無いと仮定) となります。つまり、１物理コアの占有率は **100%** ですが、システム全体でみると CPU 使用率は **25%** = &#188; なのです！

![川津の個人ボード (17).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/640097ba-cb9e-1036-44ad-46083b8ed116.jpeg)

### じゃあプロセス４つ立ち上がってると CPU 使用率 100% なの？

Linux OS 上で動くプログラムは、主に「CPU 演算」と「I/O 処理」で出来ています。
「I/O 処理」とは例えば次の様な物です。

* ファイルを開いてリードする (File I/O)
* HTTP クライアントでリクエストを投げて応答をまつ (Network I/O → ソケットファイルの読書)
* MySQL へクエリを投げて応答を待つ (Network I/O → ソケットファイルの読書)

:::note
Linux OS の世界では、全ての I/O (データ入出力) 処理は「ファイルの読み書き IF」で実現されています。ネットワークの入出力も、OS レイヤーでは「ソケットファイルの読み書き」です。
:::

例えば、次のよくある HTTP Client - GET 処理は、Network I/O 処理に当たります。

```python
# 外部ライブラリなので `pip3 install requests` が必要.
import requests

# HTTP レスポンス応答が来るまで待ってるだけ。
# ※ CPU 演算はしていない。
res = requests.get('https://example.com/')
print(res.text)
```

この間は **デバイス I/O 割り込みを待っているだけ** なので、殆ど CPU 演算はしていません。

:::note warn
I/O 待ちの間は (プロセスの) スレッドは仕事をしてませんが、Linux OS 自身は I/O 演算の為にいくらか CPU リソース消費をします。
:::

### 【例】MySQL データベースの CPU 使用率

「はじめに」で例に挙げた様に、MySQL データベースの CPU 使用率のケースは、下図の様になります。

* アプリケーションから MySQL DB への１接続が、MySQL プロセス上では１スレッドになる
* アプリケーション側のスレッドが MySQL に接続しクエリ応答を待っている間は、アプリケーション側のスレッドは CPU 演算が発生しない (※Blocking I/O の場合)

:::note
下図では、アプリケーションプロセス (Rails とか) と MySQL プロセスが、同じサーバー (Linux OS) 上にあると仮定しています。
※ 本番では RDS 等使う場合が多いと思いますが、説明の都合上です。
:::

![川津の個人ボード (18).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/1dd5b979-f7df-70cc-51ad-4958e60498a7.jpeg)

上図の例では、MySQL プロセスが稼働している物理サーバーの CPU コア数が `4` なので、 **物理コア数を超えるクエリを同時に実行して、クエリ応答が返るまで** の時間は CPU 使用率が 100% に達する可能性があります。

あとは、**１クエリ = １スレッド = １物理コア** なので、お金を払って CPU コア数を上げても、単一クエリの応答性能は変わらないですね。

## Python / Ruby でスレッド使っても速くならない。何故？

### GIL (Global Interpreter Lock) 

ここまで話して、気をつけないといけない事があります。
[GIL (Global Interpreter Lock)] を持つプログラミング言語 Ruby / Python (※正確には CPython) です。

下図の様に C/C++ の様な Native コードや Java の様なスレッドセーフな言語は、立ち上げたスレッド数分、CPU の物理コア数を有効に利用できます。

しかし、Ruby / Python は [GIL (Global Interpreter Lock)] 機構により、 **プロセス中で同時に実行できるスレッド数は常に１** に制限されています。

:::note
つまり Java は **並列処理** 、Ruby / Python は **並行処理** です。
:::

![川津の個人ボード (20).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/994d428c-d5d8-f8a8-4824-b9039ddb7be1.jpeg)

例えば、先程の Python サンプルコードを改造し、マルチスレッド対応をします。

```python:cpu_multi.py
import sys
import time
from concurrent.futures import ThreadPoolExecutor

def single_task():
    timeout = time.time() + int(sys.argv[1])

    while time.time() < timeout:
        1 + 1

# thread pool を作る.
pool = ThreadPoolExecutor(max_workers=10)
threads = int(sys.argv[2])

# thread を同時に実行開始！
for _ in range(threads):
    pool.submit(single_task)

# 開始した thread が全て終わるのを待つ.
pool.shutdown()
```

コマンドライン引数に実行時間 (秒) と並列数、を取ります。`30 秒` + `8 並列` で実行してみます。

```shell
# python3 cpu_multi.py 30 8 &
```

コマンド `top` で結果を見てみますが、 **25% (シングルスレッド相当) しか使えていません。**

```shell
# top
top - 14:26:47 up  3:30,  2 users,  load average: 0.83, 0.19, 0.06
Tasks: 111 total,   1 running,  59 sleeping,   0 stopped,   0 zombie
%Cpu(s): 25.2 us, 24.2 sy,  0.0 ni, 50.7 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem : 16082416 total, 15217816 free,   144172 used,   720428 buff/cache
KiB Swap:        0 total,        0 free,        0 used. 15662016 avail Mem
```

実は、OS Native thread はちゃんと９スレッド (※メインスレッド込みなので) あります。

```shell
# ps auxH
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
...
ec2-user 32624  0.5  0.0 765132  7544 pts/1    Sl   14:28   0:00 python cpu_multi.py 30 8
ec2-user 32624 30.7  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 29.7  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 29.7  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 30.0  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 29.5  0.0 765132  7544 pts/1    Rl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 30.2  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 30.0  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
ec2-user 32624 30.0  0.0 765132  7544 pts/1    Sl   14:28   0:01 python cpu_multi.py 30 8
```

### ちなみに JavaScript は？

JavaScript はそもそもシングルスレッドアーキテクチャです。
なので Ruby / Python と同様に単一プロセスでは複数コア数の恩恵を受ける事ができません。

* https://qiita.com/megmogmog1965/items/e661d0915148ef902620

### マルチプロセス構成を使う！

前述のケースの様に「スレッドを使った分散処理ができない」ケースでは、代わりに **マルチプロセス構成** を用いて性能の天井問題を解決します。

マルチプロセス構成を取る代表的なアプリケーションは [Nginx] や [PostgreSQL] でしょう。

:::note
Ruby (Rails) 等でも、[Puma] や [Unicorn] アプリケーションサーバーの設定でマルチプロセス構成にできます。
:::

例えば Nginx の設定ファイル `/etc/nginx/nginx.conf` には次の設定 `worker_processes {数値}` があります。

```nginx:/etc/nginx/nginx.conf
#worker_processes  4;
worker_processes  auto;
```

設定した値に応じて、リクエストを処理するワーカープロセスが (master process の子プロセスとして) 生成されます。値を `auto` に設定した場合は、CPU コア数と同等のワーカープロセスを自動で起動します。

```shell
# ps auxf
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root      3117  0.0  0.0  40072   972 ?        Ss   11:10   0:00 nginx: master process /usr/sbin/nginx
nginx     3118  0.0  0.0  40512  2932 ?        S    11:10   0:00  \_ nginx: worker process
nginx     3119  0.0  0.0  40512  2932 ?        S    11:10   0:00  \_ nginx: worker process
nginx     3120  0.0  0.0  40512  2932 ?        S    11:10   0:00  \_ nginx: worker process
nginx     3121  0.0  0.0  40512  2932 ?        S    11:10   0:00  \_ nginx: worker process
```

この場合、それぞれのワーカープロセスはシングルスレッドで動作 (※Nginx の仕様) しますが、物理コア数分のサブプロセスが存在するので、全ての物理コアを同時並列で扱う事ができます。

## おわり

いかがでしたでしょうか？
性能問題は発生すると根深く、メトリクスだけ見ても何が起きているか分からない事が多いですが、この様に原理を知っていると問題の解析がしやすくなります。

この記事が少しでも誰かの役に立つ事を願っています。


[GIL (Global Interpreter Lock)]:https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF%E3%83%AD%E3%83%83%E3%82%AF
[Nginx]:https://nginx.org/en/
[PostgreSQL]:https://www.postgresql.org/
[Puma]:https://github.com/puma/puma
[Unicorn]:https://yhbt.net/unicorn/