概要
preforkの話。preforkってなんだっけって話から広げていく
そもそもTCP通信のフロー
LinuxでTCPクライアント/サーバで通信するにはサーバ側は以下の手順を踏んで通信を待ち受ける。
socket() -> bind() -> listen() -> acceps()
単一プロセスで動くTCPサーバであれば上記の手順で十分だがクライアントが複数になった際に同時接続をされた際にクライアント1は接続可能だがクライアント2は接続を行うことができない。
複数クライアントの同時接続に対応するため,fork() システムコールを使用する。一番簡単なやり方として親プロセスでlistenまでを行い子プロセスを生成しその続きを子プロセスで行ってもらう方式。以下は簡単なサンプル。この方法によって単一プロセスのTCPサーバで起きる問題が子プロセスの生成によって起きなくなるという仕組み。
// listenまでは親でやっておきループ内で子プロセスを生成し通信を行う。 for (;;) { len = sizeof(client); sock = accept(sock0, (struct sockaddr *)&client, &len); pid = fork(); if (pid == 0) { n = read(sock, buf, sizeof(buf)); if (n < 0) { perror("read"); return 1; } write(sock, buf, n); close(sock); return 0; }
accept後にforkしてもsocketは親子で共有されるの?
sock = accept(sock0, (struct sockaddr *)&client, &len); pid = fork(); if (pid == 0) { n = read(sock, buf, sizeof(buf)); if (n < 0) { perror("read"); return 1; }
この部分。forkした後にさらっと親で開いたsocketを使ってるけどこれってなんでだろうっていう疑問。解答自体は以下の記事がとてもわかりやすかったです。ざっくり書くとオープンファイル記述(ファイルオフセットとか)はforkすると共有される。つまりacceptしたsocketを子プロセスが使って通信が可能ということ。(あくまでも共有であって子プロセスがそれぞれソケットを作っているわけではないので注意)。ちなみにいろいろ調べてて見つけたのだがRubyの場合はexec呼び出し時にfdをcloseするらしい。
manとかでfork(2)とかで調べるとでてくる。
子プロセスは親プロセスが持つ オープンファイルディスクリプタの集合のコピーを引き継ぐ。 子プロセスの各ファイルディスクリプタは、 親プロセスのファイルディスクリプタに対応する 同じオープンファイル記述 (file description) を参照する (open(2) を参照)。 これは 2 つのディスクリプタが、ファイル状態フラグ・ 現在のファイルオフセット、シグナル駆動 (signal-driven) I/O 属性 (fcntl(2) における F_SETOWN, F_SETSIG の説明を参照) を共有することを意味する。
forkモデルのTCPサーバで起きる問題
ここで起きるのがfork(2)のコストの高さです。forkはCoWが効くとは言えプロセスの生成自体は大変な作業です。詳しくは以下の記事をry
じゃあどうするかスレッドを生成する方法やイベント駆動なんかが流行ってますが記事の主題でもあるpreforkを使う方法を取り上げます。forkのコストが高いなら先にforkさせておいてクライアントが来ても生成しておいたsocketで賄おうという考え方です。ざっくり流れとしては
- ソケットを親プロセス で生成
- forkして子プロセスを作る
- 子プロセスがそれぞれaccept待ちでblockする(処理が正常に完了した場合、受け付けたソケットの 記述子である非負整数を返します。)
といった状態を作ります。この時3番目のaccept待ちでブロックしている子プロセスはクライアントが来ると複数のうちひとつのプロセスだけを起床させ起床したプロセスがその後の通信を行うことができるといった仕組みです。この時クライアントの最大同時接続数はforkした数となります。1000ユーザ接続させたいなら1000のプロセスが必要です。この辺は巡り巡ってC10k問題とか言われているやつです。
Nginxなんかだと少ないプロセス(内部的にも1スレッド)で大量のアクセスを捌いていますがpreforkとかスレッドではなくイベント駆動で処理をしています。ざっくりいうとworkerごとにイベントを監視して都度処理を行うことでコンテキストスイッチなんかの問題を軽減しています。本題とは何も関係ないのでとりあえずepoll(2)が凄すぎるって感じで辞めておきます。
同一のソケットに複数プロセスからacceptして大丈夫なの?
結論としては大丈夫らしいです。ソースを読んだわけではないですがこの辺はカーネルがよしなにやってくれるらしいです。古いカーネルだとthundering herdなんかも起きていたらしいです。(acceptmutexをアプリで実装してるケースもあるらしく古いapacheなんかはその方式で排他を行っていたらしい)
おまけ① Gracefull shutdownってどういう実装なの?
グレースフルシャットダウンの定義を以下とするなら
- 停止指示後に、新しい接続を受付しない
- 残った処理中の接続が完了するのを待ってから、プロセスを安全に停止する
TCPレイヤでやることとしたらlisten socketをcloseする。 -> 新規接続を受付しない
子プロセスは親から受けたシグナルを元に処理完了で終了するように実装する -> 安全停止
といった流れになる。Nginxをサンプルにみてみる(めちゃめちゃ端折ってます)
static void ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data) { for ( ;; ) { // SIG_QUITを受け取った子プロセスは以下に入る if (ngx_quit) { ngx_quit = 0; ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "gracefully shutting down"); ngx_setproctitle("worker process is shutting down"); if (!ngx_exiting) { ngx_exiting = 1; // 終了フラグを立てる ngx_set_shutdown_timer(cycle); // shutdownタイマーを設定する ngx_close_listening_sockets(cycle); // リスニングソケットをcloseする ngx_close_idle_connections(cycle); // アイドルコネクションをcloseする } } // 終了フラグが立っているので以下に入る if (ngx_exiting) { if (ngx_event_no_timers_left() == NGX_OK) { // ngx_event_no_timers_leftはアクティブな接続がある限りはOKにならない ngx_worker_process_exit(cycle); // 終了関数を呼び出す } } } }
ngx_event_no_timers_leftがactiveな接続を監視してなければプロセスが終了する。読んでいて気づいたがこれはバックエンドのアプリのどこかで刺さった場合はNginx自体のこの処理もTimeoutを設定してなければ永遠に動かない実装の模様。そもそもそんなのアプリ側でなんとかしろよって話だけどいつかハマりそう。ngx_event_no_timers_left
はこの辺
ngx_int_t ngx_event_no_timers_left(void) { ngx_event_t *ev; ngx_rbtree_node_t *node, *root, *sentinel; sentinel = ngx_event_timer_rbtree.sentinel; root = ngx_event_timer_rbtree.root; if (root == sentinel) { return NGX_OK; } for (node = ngx_rbtree_min(root, sentinel); node; node = ngx_rbtree_next(&ngx_event_timer_rbtree, node)) { ev = (ngx_event_t *) ((char *) node - offsetof(ngx_event_t, timer)); if (!ev->cancelable) { return NGX_AGAIN; } } return NGX_OK; }