【Linux】IOバウンドの処理はどの程度まで並列数を上げればよいかの考察

## 概要

IOバウンドについて考える機会があったのでその辺の話についてまとめてみた。ちなみにブロッキングIOしか出てきません。以下の本が大体の参考になってます。

[https://www.amazon.co.jp/exec/obidos/ASIN/4774143073/hatena-blog-22/:embed:cite]

## 忙しい人向け

結論としてはIOバウンド(ネットワーク起因)な処理はコア数関係なく増やせば増やす分だけスループットは向上する。自宅環境においてボトルネックはサーバ側の最大コネクション数になった。

ネットワーク越しのIOはクライアントだけじゃなくサービス提供側が存在して成り立つものなので並列数はその辺のサービス特性なんかも理解しつつ決めていく必要があって一概にどれくらいが良いとは言えるものでは無かった。

## 前説

どの程度まで並列数を上げるかの検証の前に一旦前提部分の知識を整理

#### IOバウンドとCPUバウンドについてと対処法方

##### CPUバウンド

CPUバウンドとはプロセスの進行速度がCPUの速度によって制限されることを意味します。圧縮/解凍や暗号化処理なんかがこれにあてはまります。画像変換処理なんかもこれです。対処法はとてもシンプルでコアの周波数をより高いものにする。または並列化が可能ならば1コアで処理させるのではなく複数コアで処理するようにプログラムを書き換える。

基本的にCPUの性能依存でコア数以上の並列度は意味を持ちません。最適化するには並列度 = コア数にするのが良いぐらいの回答になります。(intelのHTの話なんかも必要な気がしますが今回はスコープ外とします。)

CPUバウンドのアプリケーションでコア数以上の並列数を指定するとコンテキストスイッチがアプリの実行に加えて処理時間としてかかってしまうので効率はよく無いです。OSが使うCPU分を残してアプリはコアをアフィニティで指定することでもしかしたらコア数分の並列どよりも早くなるケースもあるかも知れないです。(割と思いつきなのでちょっと微妙かも)

ちなみにCPU性能律速型アプリケーションのパフォーマンスは動作クロックは性能に比例します。世の中にいるオーバークロックしたりする人たちが使ってるようなベンチマークツールなんかはこの辺が顕著にスコアに影響したりするらしいです。

あと、CPUとメモリ間のやりとりに延滞が発生すれば処理能力も低下しますが、Xeonなどの CPU は、一般市場向けの CPU よりも内部キャッシュの容量を増やす事でレイテンシを改善したりしてたりします。PCとサーバで性能差がわかりにくかったりする原因はこの辺にもあったりします。

##### I/Oバウンド

プロセスの進行速度がI/Oサブシステムの速度によって制限されることを意味します。ディスクへの操作やネットワークを介したファイルの書き込みなんかが当てはまります。対処法はその条件によって代わりますので後述します。

#### I/Oバウンドの原因

プロセスの実行中にI/O待ちになるケースとして代表的なのが以下の2つです。

* HDD/SSDへデータを書き込もうとした
* ネットワークを介してデータを送信しようとした

前者はディスクIO、後者はネットワークIOなんて言い方をしたりします。今回考えるのは後者のネットワークIOです。

ディスクIOばボトルネックになる場合は以下のような対処法がパッと出てきます。今回は細かくは見ていかないですが調査手段なんかも確立されていたりして比較的理解しやすい分野のはずです。(チューニングになると意味不明なので静観します)

* ディスクの回転数を高いものにする
* ディスクが搭載しているキャッシュサイズを大きいものにする
* RAIDを組んでRead/Write性能を上げる
* SATA接続からSAS接続などのように上位の接続方法を採用する
* 構築するファイルシステムをチューニングする

(この辺はデータベースのチューニングとかをやる際に考える必要がある項目だと思いますがマネージドDBが流行ってるので触れることはあんま無いと思うので知識としていつかちゃんと調べたい)

#### ネットワークIOとは

ネットワークIOとはざっくり何をしているのかというとユーザプロセス的にはsocketという特殊ファイルに対してread/writeをしてるだけです。もちろんユーザ空間の先でカーネルがいろいろ処理をして通信先へデータを書いたりするといった処理を行ってくれます。

細かい話をすると切りが無いですが基本的なTCPのアプリケーションはサーバもクライアントも流れ的に大体こんな感じで通信を行っていきます。

[f:id:ryuichi1208:20210215175956p:plain]

[https://www.engineersgarage.com/tutorials/socket-in-linux-part-18-24/:title]

TCPクライアントにおけるIOバウンドは図でいうとrecvfrom()の部分です。これはTCPサーバ側が処理を行っていたりデータをディスクから読んでいるみたいなケースですぐにデータを送信できない場合クライアント側はblockされます。

blockが発生する条件をまとめると以下のような場合です。

* read: クライアント側のrecvバッファにデータが到着していない
* write: サーバ側のrecvバッファがいっぱいでクライアントのsendバッファがいっぱいになった

処理としては全体的に以下のような流れになります。

```
システムコール
↓
カーネルモードにコンテキストスイッチ
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放
```

##### 補足: そもそもプロセスがblockするとは

Linux において、システムコールがブロックするとは、「プロセスが、システムコール呼び出しの延長で待状態(TASK_INTERRUPTIBLE or TASK_UNINTERRUPTIBLE) に遷移し、CPU時間を消費せずにあるイベントが完了するのを待つようになる」、ことを指します。ちなみにこの状態はpsコマンドで見えるSTATと関連します。

[https://www.mas9612.net/posts/linux-process/:embed:cite]

パイプやソケットなど、キュー(FIFO)の構造を持つファイルを読み書きしようとした時に、キューが空で読み取れるデータがない場合と、キューが満杯でこれ以上書き込めない場合には、読み書きできる状態になるまでプロセスは待ち状態になります。

キューに新しくデータが到着すると、キューが読み込み可能になります。キューに空きが出来ると、キューは書き込み可能状態になります。この辺の値はnetstatコマンドで見えるRecv-QやSend-Qなんかの値が関連してきます。

[https://access.redhat.com/documentation/ja-jp/red_hat_enterprise_linux/6/html/performance_tuning_guide/s-network-commonque-soft:embed:cite]

NICにパケットが到着してからソケットキューにデータが入ってユーザプロセスが受け取るまでの流れは以下です。右のプロセスがread()を発効するもsocket queueにデータが無い場合は左から順にパケットが入ってくるまでブロックします。

[f:id:ryuichi1208:20210215220833p:plain]

データ到着後はCPUとOS側の処理としてユーザプロセスを起こすようなイメージです。LinuxのCPUスケジューラは書籍や情報も豊富なので学習しやすいと思います。特に以下の方がすごくおすすめです。

[https://www.amazon.co.jp/%E8%A9%A6%E3%81%97%E3%81%A6%E7%90%86%E8%A7%A3-Linux%E3%81%AE%E3%81%97%E3%81%8F%E3%81%BF-%E5%AE%9F%E9%A8%93%E3%81%A8%E5%9B%B3%E8%A7%A3%E3%81%A7%E5%AD%A6%E3%81%B6OS%E3%81%A8%E3%83%8F%E3%83%BC%E3%83%89%E3%82%A6%E3%82%A7%E3%82%A2%E3%81%AE%E5%9F%BA%E7%A4%8E%E7%9F%A5%E8%AD%98-%E6%AD%A6%E5%86%85-%E8%A6%9A/dp/477419607X:embed:cite]

## 本題

ここで本題に入ります。CPUバウンドなアプリケーションでは並列度 = コア数が性能的には一番出るって話でしたがIOバウンド(ネットワーク)の場合どの程度まで並列度を上げると良いのかを検証してみます。

#### 検証準備

検証は2コアのマシン。

``` sh
$ nproc
2
```

TCPサーバとしてはflaskでhttpリクエストを受けた際にサーバがsleepすると言った擬似的に重い処理を行っているような実装を仕込んでおきます。

``` python
@app.route("/abort", methods=["GET"])
def abort():
    exit = request.args.get("exit")
    app.logger.error(exit)
    if not exit:
        os.abort()
    elif exit == "exit":
        sys.exit()
    elif exit == "sleep":
        time.sleep(10)

    return "OK"
```

以下のエンドポイントへhttpリクエストをすると10秒かかると言ったサーバを予め用意しました。

```
GET /abort?sleep
```

検証は以下のスクリプトで並列度を実行パラメータに取るようにしてます。(マルチスレッドモデルでforkのオーバーヘッドは削りたかったですが今回はマルチプロセスモデルをテスト用クライアントとします。)

``` sh
#!/bin/bash

for i in $(seq $1); do
	curl -o /dev/null -s curl http://${WEBSRV}:30001/abort\?exit\=sleep &
done
wait
```

##### 1リクエスト1並列

実行は10秒で完了しました。これは当然の結果ですね。

``` sh
time bash test.sh 1
bash test.sh 1  0.01s user 0.00s system 0% cpu 10.044 total
```

ちなみにcurlは何でblockされるかというとpoll(2)を使ってソケットを監視しているようでした。poll自体はblockingなのでOSがデータの到着を通知するまではブロックします。この間はCPUを使用することは基本ないです。(ユーザ/カーネルのコンテキストスイッチは発生します。)

```
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000)  = 0 (Timeout)
```

pollの流れは以下のようなイメージです。

```
システムコール
↓
カーネルモードにコンテキストスイッチ
↓
ファイルディスクリプタの準備ができたらユーザモードにコンテキストスイッチ
↓
準備ができたデータグラムに対するシステムコール
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放
```

(どうでもよいですがepollのような効率的なシステムコールじゃなくてpollで実装してるのは何ででしょう。ファイルディスクリプタの数に制限が無いのに加えて、ファイルディスクリプタの状態変化監視も改善されているとかその辺のメリットはhttpクライアントレベルだと実はないとかですかね。)

[https://linuxjm.osdn.jp/html/LDP_man-pages/man2/poll.2.html:title]

##### 10リクエスト10並列

こちらの実行も10秒で完了しました。コア数が2ですが基本ブロック処理なのでCPUコア数以上の値を出しても性能が良くなることが分かりました。

```
$ time bash test.sh 10
0.02s user 0.04s system 0% cpu 10.092 total
```

前述の通りblock中はCPUを使わないのでこのようなコア数以上の並列度での実行で性能が出ると言った結果になります。

##### 10000リクエスト10000並列

思い切って1000倍の数の並列度を出してみます。これが10秒で完了するのかどうかを確認です。

実行

```
reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failureupstream connect error or disconnect/reset before headers. reset reason: connection failure
```

残念クライアント側より先にサーバアプリのmaxclientsに掛かったのかそのレベルの並列度はコネクションが貼れませんでしたw

ちなみにロードアベレージ は3000を超えましたがCPU使用率は90%程度で耐えていました。

```
load average: 3544.42, 1430.31, 524.07
```

vmstatの値ではrunningとblockがとてつもない数字になっていました。この状態でもbash入力は受け付けられていたりとサーバ側に余裕はある感じでした。

```
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
336 1295      0 102984      0 164492    0    0 35540     0 3944 10386 11 81  0  9  0
89 1471      0 104164      0 113340    0    0 24908     0 3657 10493  4 97  0  0  0
1895 2912      0 103464      0  94700    0    0 470285    15 52712 351584  2 98  0  0  0
2835 7384      0 137432      0 118060    0    0 2262545   166 27198 139124  3 91  0  6  0
```

podのログを見るとサービスメッシュとして稼働しているistioが限界を迎えていたっぽいです。。podに割り当てるリソースを増やせばもしかしたら耐え得るのかもしれないですが今回は「お家k8s上の自作アプリの限界テスト」ではないのでここらで一旦まとめに入ります。

## 考察とまとめと感想

CPUバウンドな処理はコア数程度にするのが良い

IOバウンドな処理はコア数以上の並列度にすればOSがいい感じにスケジュールしてスループットは上がる。(blockされたプロセスはデータの到着を待つ。データが来たらOSがプロセスに通知して通知されたプロセスが起動するような仕組み)

この辺の並列数は実際に接続元/接続先が固定されたときに数値を算出することが可能になると思います。NICのスループットだったりクライアント側のメモリの搭載量、同じ話がサーバ側にも当てはまります。当然ですが外部サービスへの接続では同時接続数は大量にしすぎると攻撃判定されてアクセス禁止なんてことも起こりうる可能性もありそうです。きちんと並列数は管理しつつ処理を要求する必要があると感じました(とても大事)。

パフォーマンスチューニングにゴールは無いは無いのはよく言われる話でここでのチューニング項目は並列数になると思います。外部サービスを利用するならそもそも相手のサービスに負荷を掛けてしまうような動作は避けなければならないですし「単位時間あたりにどれくらいまでリクエスト可能です」みたいな規約があればそれが並列数のボトルネックになります。この辺の感所はあんまないので理解していきたいです。

## オマケ: お家k8sの話

サンプルとして作ったアプリですがこれ自体はk8s上でHPAを有効にして動かしてます。今回みたいなスパイクに弱いというのは話には聞いていて「そうなのか」程度の理解でしたがこの機会に実感できたので良かったですw

## オマケ: NICの話

10GbEを始め40GbEやインフィ二バンドみたいな高速な通信を可能にする通信規格の場合はIOバウンドは起こり得ないし幸せな世界がやってきそうなものですがそんなことはなくて今度は高速さゆえのCPU割り込みが増えてCPU使用率が高くなる問題があるらしいです。ネットワークは高速化すれば良いって話で済まないのはまさにボトルネックは常に動くを表していて面白いです。

[https://blog.yuuk.io/entry/linux-networkstack-tuning-rfs:embed:cite]