地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Linux】IOバウンドの処理はどの程度まで並列数を上げればよいかの考察(ちょっと修正版)

概要

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

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

忙しい人向け

結論としては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

Socket in Linux (Part 18/24)

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

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

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

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

システムコール
↓
カーネルモードにコンテキストスイッチ
↓
処理が完了
↓
ユーザモードにコンテキストスイッチ
↓
ブロック状態から解放
補足: そもそもプロセスがblockするとは

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

www.mas9612.net

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

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

access.redhat.com

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/477419607Xwww.amazon.co.jp

本題

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

検証準備

検証は2コアのマシン。

$ nproc
2

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

@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のオーバーヘッドは削りたかったですが今回はマルチプロセスモデルをテスト用クライアントとします。)

#!/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秒で完了しました。これは当然の結果ですね。

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クライアントレベルだと実はないとかですかね。)

Man page of POLL

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使用率が高くなる問題があるらしいです。ネットワークは高速化すれば良いって話で済まないのはまさにボトルネックは常に動くを表していて面白いです。

blog.yuuk.io

【Linux】agメモ

# ディレクトリ階層の深さ指定
     --depth NUM          Search up to NUM directories deep (Default: 25)
# (マッチした)ファイル名のみを出力
  -l --files-with-matches Only print filenames that contain matches
                          (dont print the matching lines)
# マッチしなかったファイル名を表示
  -L --files-without-matches
                          Only print filenames that dont contain matches
# ファイル名にマッチしたものを出力
  -g PATTERN              Print filenames matching PATTERN
# 行番号を非表示
     --[no]numbers        Print line numbers. Default is to omit line numbers
                          when searching streams
# 隠しファイルも検索
     --hidden             Search hidden files (obeys .*ignore files)
# 全ファイルを検索(隠しファイル、無視指定ファイル含め検索)

  -u --unrestricted       Search all files (ignore .agignore, .gitignore, etc.;
                          searches binary and hidden files as well)
# 大文字小文字区別
  -s --case-sensitive     Match case sensitively
# 大文字を含んでいれば、大文字小文字区別
  -S --smart-case         Match case insensitively unless PATTERN contains
                          uppercase characters (Enabled by default)
# ファイル名を正規表現指定で制限検索(ex. ag -G '\.(c|h)' pattern)
  -G --file-search-regex  PATTERN Limit search to filenames matching PATTERN

【Bash】sedの最短一致

$ echo "<aaa>b<ccc> <ddd>e<ccc>"

という文が会った時にbというのを取り出したいケースで以下のようにsedを使おうとするとb以外の不要な部分まで取れてしまう。

$ echo "<aaa>b<ccc> <ddd>e<ccc>" | sed -r 's/<aaa>(.*)<ccc>/\1/'
b<ccc> <ddd>e

これはsedが最短一致ではなく最長一致がデフォルトで機能するためでそしてsedには最短一致なるものが機能として存在しない。なので以下のように次の<の出現でパターンマッチをいったん終了させると言った処理を書く必要がある。これで最短一致が実現可能。

echo "<aaa>b<ccc> <ddd>e<ccc>" | sed -r 's/<aaa>([^<]*).*$/\1/'
b

特定タグの中身だけ欲しいみたいなケースで使える。

echo "<html>aaa</htm><tag>bbb</tag><test>ccc</test>" | sed -r 's/^.*<tag>([^<]*).*$/\1/'
bbb

【Linux】O_LARGEFILEフラグ

linuxjm.osdn.jp

O_LARGEFILEというフラグを見つけ気になったのでメモ。

O_LARGEFILE
(LFS) off_t ではサイズを表せない (だだし off64_t ではサイズを表せる)ファ イルをオープン可能にする。この定義を有効にするためには、(どのヘッダーファイ ルをインクルードするよりも前に) _LARGEFILE64_SOURCE マクロを定義しなければ ならない。 32 ビットシステムにおいて大きなファイルにアクセスしたい場合、 (O_LARGEFILE を使うよりも) _FILE_OFFSET_BITS 機能検査マクロを 64 に セットする方が望ましい方法である (feature_test_macros(7) を参照)。

open時にoff_tサイズを超えるサイズのファイルの場合に必要となるフラグ。

linux/fs.h at 29c395c77a9a514c5857c45ceae2665e9bd99ac7 · torvalds/linux · GitHub

で、定義しておけば大体2GBのファイルまで取り扱うことができる。

#define MAX_NON_LFS ((1UL<<31) - 1) # = 大体2GB

ただ、off_t自体が以下のようになっていて最近の64bitマシンだったらあまり気にしなくてもoff64_tが自動で使われるっぽい。32btiマシンを使う際にでかめのファイルを取り扱いたいケースとかで使えたりするんですかね?

    148 #if defined(_LP64) || _FILE_OFFSET_BITS == 32
    149 typedef long     off_t;     /* offsets within files */
    150 #elif _FILE_OFFSET_BITS == 64
    151 typedef longlong_t    off_t;     /* offsets within files */
    152 #endif
    153 
    154 #if defined(_LARGEFILE64_SOURCE)
    155 #ifdef _LP64
    156 typedef   off_t      off64_t;    /* offsets within files */
    157 #else
    158 typedef longlong_t    off64_t;    /* offsets within files */
    159 #endif
    160 #endif /* _LARGEFILE64_SOURCE */