現在HTTPサーバーOSS lighttpdのマルチスレッド化にトライしています。
その修正がなんとか動作するところまで持って行けたので、アウトプットしないとモチベーションが上がらない色々見えてくる部分を記事にしました。主観的な記事ですがご了承ください。
マルチスレッド化によるメリット
やってみた感想も踏まえ、いくつか記載します。
並行処理化による速度向上の期待
何かボトルネックになる処理があり、その処理がボトルネックになって次の処理に移れない場合
その処理をスレッド化して非同期での処理実行させることで、全体としての処理速度向上が期待できます。
ものによってはマルチスレッドのない言語でもイベントハンドリングを利用して非同期処理にしてますよね。JavascriptのXMLHttpRequestとか。
非同期処理は偉大。
役割分担の明確化、影響の軽減
1スレッドで全部の機能を実現しようとなると、他の処理に影響しないよう処理時間が制限されてしまうなど、他を意識した作りにする必要があります。
lighttpdのプラグインもそうですし、カーネル層なんかはそうだって話を昔聞いた気がします。(…と書きつつググったらデバイスドライバにもスレッドあるんですね。)
これをきちんとスレッド分けしてあげれば、他の処理を意識せずにがっつり自分の処理に専念できます。
例えばC言語のライブラリでよくあるコールバック形式のAPI。ライブラリとして切り出されているので上位アプリの処理には依存しない作りになっています。
これも、内部的にはスレッドを立て、自身の機能を処理し終えたら応答をもらったコールバックで通知という形がよくある手だと思います。
管理データの役割が明確になる
上の2つはマルチスレッド化の目的に繋がる話ですが、こちらは実施した副作用みたいなものですかね。
1スレッドで実装すると、結局みんながみんなの処理を意識しなければならなくなり、結構情報伝達の為に管理データを1か所にまとめがちな気がします。
今回のlighttpdも、基本全ての情報がserver構造体に詰め込んでいます。
そうなってくると、もうその構造体には触れられないのがネックになったりするかと。
こういったシステムをスレッド化しようとなると、自然とスレッドごとに必要なデータを切り出して持てるようデータの見直しが入ります。
そうしないと排他・同期だらけになってスレッド化のメリットが活きてこないんですよね。
データが整理されれば、クラス図・モジュール構成図のような設計図で表現できるようになり、(コードが見やすいかどうかは置いておいて)設計がつかみやすいシステムになっていきます。
※マルチスレッドシステムは全てデータ管理が綺麗に管理されているべきって意味ではないです。自分の修正も必要最低限しか見直していないので汚いですし(-_-;)
マルチスレッド化の為に気を付ける点
ここからが主眼です。実際ハマったことをベースに、気を付ける点を記載。以下4点に分けて記載しています。
- 前提: マルチスレッド化をする目的を明確化し、データ構造を見直す
- 気を付ける点1: リソースを食う
- 気を付ける点2: シーケンスが変わる
- 気を付ける点3: 処理が複雑化する
前提: マルチスレッド化をする目的を明確化し、データ構造を見直す
・作成するスレッドは何(どのデータ)を・どうするか為のものかをちゃんと決める。
・スレッドが利用するデータを抽出し、関連するデータの構造を見直す。
まずはこの辺を考えないと、少なくとも自分のスキルでは先に進めません。
今回のlighttpdのマルチスレッド化の例では、HTTPリクエストの各TCPセッション(以下コネクション)の処理を別スレッドで行うように考えました。
構造体も元々コネクション毎に使うものが定義されているので、大きな変更なくいけるはず!
という算段でしたが、色々と問題が。これらについて気を付けないとまともに動作しません。
気を付ける点1: リソースを食う
スレッドを立てると、その分のリソースを必要になります。使えるリソースは有限なので、ここが青天井にならない工夫が必要となります。
今回はメモリ、ソケットで問題が発生しました。
メモリ使用量
まず設計段階で最初にぶち当たった壁がこちら。lighttpdの思想を確認すると、100,000 コネクションに対応できるようにするというのがテーマということでした。
メモリ使用量的に、100,000個分のコネクションを捌く数だけスレッドを立ち上げたらすぐにパンクするのが目に見えています。
今回のケースでは、複数の常駐スレッドを用意し、そこにコネクションを追加していくスレッドプールを取り入れることにしました。
また、スレッド自体もスタックサイズの設定があり、デフォルトで結構な容量を食うのでそこも注意です。(直さないと
ソケット使用量の問題
上記スレッドプールで動作を試してみると、すぐに以下のようなエラーが出るようになりました。
- read error:Resource temporarily unavailable
調べてみると、デフォルトのソケット受け入れ上限は128個だそうで。ソケットはそんな少ないんだ。と思いつつ処理を見直し。スレッドプール内部で1スレッド毎libevent2つ×内製メッセージ2つの合計4つ。スレッドはコア数×2にしていたので4 CPUなら8スレッドx4つで32個のソケット消費。上限の1/4を起動しただけで消費してます…
すぐにスレッドプール内部を見直し、ソケットを使わないよう変更しました。(select& eventfdに変更)
ここまでやって、やっと本格的な修正に着手出来ました。
気を付ける点2: シーケンスが変わる
既存のシステムをマルチスレッド化する場合だと、基本今まで動作している機能をスレッド化することが多いと思います。
ただ、並列で処理が動くように修正が行われるため、シーケンスに関しては全く同じというわけにはいきません。
特にスレッドの開始、終了は新規に考える部分ですし、使用していたデータのアクセスや値変更タイミングも今まで想定されていないものが出てきます。
並列化に合わせてデータの持ち方を変えるなり、シーケンスに手を加えるなり工夫が必要です。
案の定、今回は開始、終了両方で問題が発生しました。
同じくくりの処理は出来るだけ同じスレッドで
先ほどのread error:Resource temporarily unavailable、実はソケット受け入れ上限が原因ではなく、シーケンスの問題でした。
最初の構想では、コネクションのリクエスト受付はメインスレッドで、リクエスト情報を接続されたことをスレッドに伝えて読み込み以降はコネクション用のスレッドで実行としていました。
内部的にはこれで動作するんですが、ユーザーも含めてちゃんとシーケンスを考えるとそうはいきません。
ユーザー的には、コネクション用のスレッドがソケットにデータをwriteした時点で応答をもらえるため、次のリクエストを送信できます。
一方コネクション用のスレッドでは後処理を実行しているため、後処理で情報をクリアしてる最中に次のリクエストを受付。次のリクエストに関する情報までクリアしてしまいました。
上記の問題点はコネクションのリクエスト受付だけメインスレッドで行っていたこと。なので、コネクションのacceptまでをメインに、accept以降の処理は全てコネクション用のスレッドにお任せするべきでしたね。明確化にしたはずの処理修正で手を抜いた結果がこうなってしまいました。
FD関連のイベントやライブラリを新設してコネクション管理をコネクションスレッドに全て移行。
初めから同じ情報に関する処理は同じスレッドでやるべきでした。
特に開始・終了時に触るデータの構造には注意が必要
終了時にも問題がありました。コネクション情報の初期化処理についてです。
コネクション情報の元ネタはメインスレッドが配列持っていて、各コネクションにポインターとして割り当て。切断時は配列をずらして途中に空きが出来ないようにしていました。わかってから見ると明らかなんですが、切断時に同じコネクションのデータポインターがずれる可能性があるということになります。見逃していました。
これではコネクションのaccept/closeを繰り返すとまともに動かないのでデータ構造を見直し。ポインターが変わらず、かつ高速で情報が取得できるよう対応しました。
この辺りでやっとF5を連打しても落ちないように。
気を付ける点3: 処理が複雑化する
非同期処理を行うようにすると、非同期動作を行う為の処理が必要となり、さらに処理間で同期が必要だったりすると、同期の為の処理も必要となり、と処理が増えていき徐々に複雑になっていきます。
今回はそのせいでこんな問題が発生しました。
純粋な処理数が増え、遅くなる
何とかちゃんと連続して応答が返せるようになりました。しかしただのindex.htmlを返すだけのHTTP responseでも1秒以上かかっていました。なんと(-_-;)
原因は無駄な処理も何も考えずに「単純にスレッド作ってやり取りしましょう」って作ったスレッドプールが遅すぎました。
元々のlighttpdは
クライアント接続⇒
- acceptで状態遷移
だったのに対し、
修正後は、
クライアント接続⇒
- acceptでスレッドにFD追加する為のAPIコール
- mutex lock
- メッセージ作成の為malloc/copy, 追加待ち
- 通知の為write
- selectで通知の受信検知
- 通知の為write
- mallocメッセージ処理によりスレッドにFD追加
- 追加通知⇒追加出来たので終了、unlock
7ステップに変わった上、各処理も最適化していないため無駄が沢山。そりゃ遅いわ笑
出来るだけ無駄なコピーや処理を削り、それなりの速度になるまで修正完了。
最後に
ここまで完全に主観でつらつらと記載しました。色々失敗しながら試した上での記載なので、少しは参考になるかと思います。
想像していたよりも数段労力のいる作業で、かなり閉口しています。
並行処理だけに
とりあえず不具合もちらちらありつつ、どうにかこうにか100,000リクエスト導通するようになりました。
2018/07/01 と思ってたけど、改めて動かしてみたらまるで動かない、深夜の作業あるある笑 大分構成が変わった状態なので、データ構造の見直しから仕切り直します。
期間的には丸々1月くらいかかってますね。
ここまでならサクッと行くと思ってた先月の自分を殴りたい
まだまだ不具合だらけなので修正しつつ、先に進みたいと思います。
参考
read error:Resource temporarily unavailableに関する情報
え、サーバ増やしてませんか?ソケット設定したその後に!?
lighttpdの設計思想
History