Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

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

はじめに

こんにちは!リンクアンドモチベーションで SRE をしてます川津と申します!

Web アプリケーションを開発している皆さん! 日夜性能問題に悩まされていると思います😅

本記事では性能問題における 「CPU 使用率の見方」 に焦点をおいて話そうかと思います!

CPU あるある

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

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

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

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

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

db.r5.xlarge db.r5.2xlarge
vCPU 4 8
メモリ 32 GiB 64 GiB

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

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

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

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

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

最近の PC はマルチコア

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

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

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

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

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

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

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

filename : cpu.py

import sys
import time

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

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

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

# python3 cpu.py 30 &

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

# 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 上で実行中のプログラムは プロセス と呼ばれます。 プロセスは最低1つの スレッド を持ち、このスレッドが CPU の1物理コア上で実行される単位になります。

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

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

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

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

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

filename : cpu.py

import sys
import time

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

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

このケースでは下図の様に 1物理コアのみを専有 となります。つまり、1物理コアの占有率は 100% ですが、システム全体でみると CPU 使用率は 25% = ¼ なのです!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GIL (Global Interpreter Lock)

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

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

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

つまり Java並列処理Ruby / Python並行処理 です。

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

filename : 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 並列 で実行してみます。

# python3 cpu_multi.py 30 8 &

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

# 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 はちゃんと9スレッド (※メインスレッド込みなので) あります。

# 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 と同様に単一プロセスでは複数コア数の恩恵を受ける事ができません。

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

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

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

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

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

#worker_processes  4;
worker_processes  auto;

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

# 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 の仕様) しますが、物理コア数分のサブプロセスが存在するので、全ての物理コアを同時並列で扱う事ができます。

おわり

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

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