C言語によるgraceful shutdownの実装考察
TL;DR
-
コメント のとおり、signalとpipeを併用するのが一般的なようです。しばらく調べた上で再度記事を更新したいと思います。記事自体は勉強メモとして残しておきます。
-> とりあえず実装して動作確認まで行ったコード: https://github.com/knknkn1162/c_sample/blob/d899d90f27479c00cd9a1992a83aa4ad99ba6ed7/shutdown/signal_piping.c -
minimalなお題で、C言語によるgraceful shutdownの実装を
sigaction
を用いる方法と、pthread_cancel & sigwait
を用いる方法の2種類で行ってみた。 -
どちらも厳密には規格に明記されていないということで歯切れが悪い実装だが、実用的にまぁ大丈夫なんじゃない?と個人的には思っている
-
その他、これ使えるかもと思ったけど無理筋だった関数についても、簡単に説明した
参考文献
- The Linux Programming Interface(TLPI) http://man7.org/tlpi/
- manコマンド
- http://pubs.opengroup.org/onlinepubs/9699919799/
本題
問題設定
terminalから数字を読み取り、計算結果を返す
(今回は計算自体はとりあえず何かを想定しているので、単に2倍したものを返すだけの計算にした)
Ctrl-Cで適切にshutdownさせたい。
環境
$ uname -a
Linux vagrant-ubuntu-trusty-64 3.13.0-101-generic #148-Ubuntu SMP Thu Oct 20 22:08:32 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux
$ gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/4.8/lto-wrapper
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 4.8.4-2ubuntu1~14.04.3' --with-bugurl=file:///usr/share/doc/gcc-4.8/README.Bugs --enable-languages=c,c++,java,go,d,fortran,objc,obj-c++ --prefix=/usr --program-suffix=-4.8 --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --with-gxx-include-dir=/usr/include/c++/4.8 --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --enable-gnu-unique-object --disable-libmudflap --enable-plugin --with-system-zlib --disable-browser-plugin --enable-java-awt=gtk --enable-gtk-cairo --with-java-home=/usr/lib/jvm/java-1.5.0-gcj-4.8-amd64/jre --enable-java-home --with-jvm-root-dir=/usr/lib/jvm/java-1.5.0-gcj-4.8-amd64 --with-jvm-jar-dir=/usr/lib/jvm-exports/java-1.5.0-gcj-4.8-amd64 --with-arch-directory=amd64 --with-ecj-jar=/usr/share/java/eclipse-ecj.jar --enable-objc-gc --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --with-tune=generic --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu
Thread model: posix
gcc version 4.8.4 (Ubuntu 4.8.4-2ubuntu1~14.04.3)
vagrantの"ubuntu/trusty64"
をそのまま使ってる
準備
まず、Ctrl-Cで適切にshutdownさせる必要ない場合、だいたいプログラムはこんな感じで良いだろう:
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_SIZE 100
long doCalc(long a);
int main(int argc, char *argv[]) {
char buf[BUF_SIZE];
long num;
while(1) {
if(fgets(buf, BUF_SIZE, stdin) == NULL) {
perror("fgets");
exit(1);
}
if(strlen(buf) > 20) {
fprintf(stderr, "> [error] too big!\n");
continue;
}
if(buf[0] == '\n') {
continue;
}
num = atol(buf);
printf("> %ld -> %ld\n", num, doCalc(num));
}
return 0;
}
long doCalc(long num) {
return num*2;
}
これに対応する結果:
$ ./a.out
234
> 234 -> 468
3515
> 3515 -> 7030
34139
> 34139 -> 68278
234
> 234 -> 468
^C
$
Ctrl-C と言う前提がなければ、以下のように、標準入力の受付しているところで"quit"なりを打ち込んで終了処理を実行するみたいな実装にすれば良いので、述べるまでもなく簡単:
// while 文中に入れる
if(strcmp(buf, "quit\n") == 0) {
printf("quit..\n");
break;
}
// whileから抜けて終了処理
さて、お題の実現に際して、今回は、sigactionを用いる方法と、pthread_cancel & sigwaitを用いる方法の2種類を実装した。
sigaction
まずは、main関数のはじめにsignal補足させるような実装書く:
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sa.sa_handler = signal_handler;
if(sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
signal_handler
を以下のように定める:
volatile sig_atomic_t sigFlag = 0;
void signal_handler(int sig) { sigFlag = 1; }
使い方としては、SIGINTを補足したら、sigFlag
がONになり、sa.sa_flags = 0;
としているため、blocking関数であるfgets()
が終了するので、if(sigFlag) { ... }
とかけば良い1。今回の場合だと、if文の内部でshutdown処理をかけば良いということになる。(ちなみにsignal handlerに処理を長々と書くのは一般的には避けるべきである2)
sig_atomic_t
の詳細はTLPI 21.1.3にあるが、上のような書き方をすることで、signal_handler
がatomicになる。
以上を踏まえた上で実装例を見てみよう。
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <limits.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#define BUF_SIZE 100
volatile sig_atomic_t sigFlag = 0;
void signal_handler(int sig) { sigFlag = 1; }
long doCalc(long a);
void doShutdown(void);
int main(int argc, char *argv[]) {
char buf[BUF_SIZE];
long num;
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sa.sa_handler = signal_handler;
if(sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
while(1) {
if(sigFlag) {
// do shutdown
doShutdown();
break;
}
// Strictly speaking, when the signal is caught, we don't know whether the function restarts or fails with error.
// See also `Interruption of system calls and library functions by signal handlers` in `man 7 signal`.
// But we assume that fgets use read syscall internally and fails with EINTR error, which is probably true.
if(fgets(buf, BUF_SIZE, stdin) == NULL) {
if(sigFlag == 1 && errno == EINTR) {
printf("shutdown..\n");
// do shutdown
doShutdown();
break;
}
perror("fgets");
exit(1);
}
if(strlen(buf) > 20) {
fprintf(stderr, "> [error] too big!\n");
continue;
}
if(buf[0] == '\n') {
continue;
}
num = atol(buf);
printf("> %ld -> %ld\n", num, doCalc(num));
}
return 0;
}
void doShutdown(void) {
sigset_t mask, prevMask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_SETMASK, &mask, &prevMask);
printf("graceful shutdown..\n");
// go shutdown..
sleep(2);
sigprocmask(SIG_SETMASK, &prevMask, 0);
}
long doCalc(long num) {
return num*2;
}
こんな感じになる。whileの中のfgets
の最中にSIGINTが飛ぶかもしれないのに対する対応。fgetsが終わってEINTRを返すのでsigFlag=1と合わせて、signal補足したかどうかの判定をしている。
あと忘れがちなのは、fgets
が済んで、doCalc
付近でSIGINTが飛ぶかもしれない(doCalcが重い処理だったら可能性十分にある)のでその対応。この場合は、計算が終わったらwhile文の先頭でsigFlagのif文を通過することになる。
doShutdown
関数のshutdown中はSIGINTにshutdown処理を一切邪魔されたくないので、sigprocmask
関数にて、SIGINTをブロックして対応している。
さて、ここまで実装を見ていった上で、これで完璧だ! と言いたいところなのだが、実は(blocking関数である)fgets()が終了するので
という部分、全てのUNIX実装で成り立つかどうかはSUSに明記されていない。( https://linuxjm.osdn.jp/html/LDP_man-pages/man7/signal.7.html のシグナルハンドラーによるシステムコールやライブラリ関数への割り込みの節を見ること。fgetsは残念ながらない) もちろん、自分の環境では問題ない部分。
ただ、fgets
自体は、straceで確認しても、ソースコードのぞいて見ても、read
を内部で使っている。
read自体は、失敗して、EINTRを返すと書かれているので、fgets
もおんなじことが言える?と思って上のような、実装にしている。ちょっと歯切れが悪いですね..
pthread_cancel & sigwait
signalのことについてちょっと調べたことのある人なら、signal handlerなんて使わずsigwait
の方が良いみたいな記事( http://d.hatena.ne.jp/yupo5656/20060114/p1 とか )を見かけたことがあるのではないかと思う。3
けれど、sigwait単体で本記事のお題の実装をするのはたぶん無理そう。理由は、この関数自体はfgets
を終わらせることができないからだ。(sigwaitを用いた場合については、下の節にちょっと書いた。)
今回は、sigwait
が重要と言うよりは、fgets
をcancelできるpthread_cancel
の方が主役だ。
ポイントとしては、multithreadで実装して、signalを受ける部分をmain threadにするところと、signal押されたら、main threadがsignalに反応して、pthread_cancel
でworker threadをキャンセル処理してあげるところ:
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#define BUF_SIZE 128
#define DIGIT_MAX 20
void* reactor(void*);
long doCalc(long a);
int main(int argc, char *argv[]) {
pthread_t pt;
sigset_t ss;
int sig;
void* res;
int flag = 1;
sigemptyset(&ss);
sigaddset(&ss, SIGINT);
sigprocmask(SIG_BLOCK, &ss, NULL);
pthread_create(&pt, NULL, &reactor, NULL);
sigemptyset(&ss);
sigaddset(&ss, SIGINT);
while(flag) {
printf("[main thread] sigwait ready..\n");
if(sigwait(&ss, &sig)) {
continue;
}
switch(sig) {
case SIGINT:
printf("[main thread] catch sigint\n");
flag = 0;
break;
default:
break;
}
}
printf("[main thread] exit\n");
if(pthread_cancel(pt) != 0) {
perror("pthread_cancel");
exit(1);
}
// termination
if(pthread_join(pt, &res) != 0) {
perror("pthread_join");
exit(1);
}
if(res == PTHREAD_CANCELED) {
printf("[main thread]: reactor was cancelled\n");
}
return 0;
}
void* reactor(void* arg) {
char buf[BUF_SIZE];
long num;
// If you do cleanup process, register the handler by `pthread_cleanup_push(3)`
// and restore the default setting by `pthread_cleanup_pop(3)`.
// void pthread_cleanup_push(void (*routine)(void *), void *arg);
while(1) {
if(fgets(buf, BUF_SIZE, stdin) == NULL) {
perror("fgets");
exit(1);
}
if(strlen(buf) > DIGIT_MAX) {
fprintf(stderr, "> [error] too big!\n");
continue;
}
if(buf[0] == '\n') {
continue;
}
num = atol(buf);
printf("> %ld -> %ld\n", num, doCalc(num));
}
// void pthread_cleanup_pop(int execute);
pthread_exit(NULL);
}
long doCalc(long num) {
return num*2;
}
reactorの部分は、whileの部分をpthread_create
で別スレッド化している。sigprocmask
しているので、デフォルトのシグナル処理(SIGINTなので、term(プロセス終了))は起動している全スレッドでブロックされていることに注意。(reactorのスレッドはSIGINTにより直接終了することがない) その上で、main thread上ではsigwait
(もちろんsigwaitinfo
使ってもいいよ) でシグナルを受け取れるのだ!
shutdownに際して必要な処理は、void pthread_cleanup_push(void (*routine)(void *), void *arg);
でhandlerを指定してやる。mallocされているpointerをarg引数にを突っ込んでhandler内で、そのpointerをfree(3)する感じで使う。
blocking関数のfgets
でcancellationさせることを期待している。cancellationについては、TLPIのChapter32が詳しい。4
ただ、今回文字読み取りで用いているfgets
は
The following functions may be cancellation points according to POSIX.1-2001 and/or POSIX.1-2008:
に当てはまる関数なので、絶対にcancellation pointとは言えない。なので、厳密な意味ではcancallationできることを保証しない。 この方法は自身の環境ではうまくいくことは確認したが、ちょっと歯切れが悪い...
sigwaitやsignalfdについて
sigwait
単体やsignalfd
でsignalを補足してみたいな手も考えたが、こちらは、blocking関数であるfgetsを本質的に終わらせられないので、これらを用いての実装は無理そう。(もちろん、multithreadにして、signalfd + pthread_cancelで前節と似たようなことをすればできるが、ほとんど似たような実装になってしまうので、本記事では割愛。5
とちゅうまでsigwait
を用いて書いたけど、こりゃ無理やな、ってなった実装 .. https://github.com/knknkn1162/c_sample/blob/30ac8eb139f4e2e1978869905b5e059d47ccb3a0/shutdown/sigwait_process.c
とちゅうまでsignalfd
単体で頑張って見たけど、fgetsがblockingな以上無理筋やな、って思った実装 ..
https://github.com/knknkn1162/c_sample/blob/8b2cc059c71718c363f0e3b2fd4fef5c3a2219be/shutdown/signalfd.c
両者ともに、どのようにfgets
の終了へと導くのかが、ちょっとできなさそう (もちろん、kill(2)
用いれば、別プロセスを終了させることができるが、graceful shutdownの前提から反するので却下)
on_exit, atexitについて
もしかしたら使えるかも、と思ったが、これらは、
a function to be called at normal process termination
と書いてあって、signal(Ctrl-C)からのterminationは異常終了なのでダメ。atexit
または on_exit
は指定しても呼ばれない。
(終了直後にecho $?
とすればステータスが130(=128+2)となる。(SIGINT=2))
終わりに
fgets
を途中終了させる方法を2種類実装したが、厳密には規格外の実装となってしまい、両方ともちょっと歯切れが悪い。
対処方法としては2つの方向性があって、
-
read(2)
のような、より低水準な関数を代わりに使うこと。read(2)
はcancellation pointであることが要求されていて、かつ、signal捕捉によりEINTRでreadが失敗するので、問題ない - OSによって、
fgets
に代替するような関数に置き換えて実装にする。
前者のread
を用いる欠点としては、改行処理が\n\r
と\r
と\n
の場合があると思うので、そこの分岐がめんどくさそうと言うのがある。fgetsは\n
でうまくやってくれるし使いやすい。6 後者への対応も個人レベルでやろうとするとかなり泥臭い感じがする。
完全を期すなら前者の対処方法で手間を多めにかけるしかないように見える。
他にもこれどう?とか、ここの解釈が実はちがうんじゃない? ってのがあったら教えてくださいませ
-
このシグナルの処理、TLPIにもイディオムとして頻繁に出てきて、シグナルをかなり行儀よく扱える&実装も簡単にすむので、個人的には
sigwait
よりも多用する。 ↩ -
避けるべき理由は色々ある。元々、signal_handlerの中にかける関数はかなり限定されていて(https://linuxjm.osdn.jp/html/LDP_man-pages/man7/signal.7.html の
async-signal-safe functions
を見ること)printf
とか日常的に使うでしょ、っていう関数が使えなかったりする。また、signalはqueueされないので、signal_handler内の処理を大きくすると、signal handlerの実行中にさらに飛んできた(複数回の)signalがいくつか消滅してしまう。ということで、signal_handler内の処理をできるだけ簡素にすることは合理性がある。 ↩ -
なぜ
signal(2)
を用いずにsigaction(2)
を用いるのかの詳細については、TLPIの22.7を見れば良い。ざっくりまとめてしまうと、sigactionの方が高機能で、移植性が高いというのが1点。また、シグナルが受信される度にシグナルハンドラーがリセットされてしまい、呼び出し途中のシグナルハンドラーの処理がどっかいっちゃう、とか signal_handler内でsignalが発生すると、再帰的にsignal_handlerが呼ばれることになってしまう(stack overflowの問題発生するかも) (sigactionはsignal_handlerが呼び終わってから、「溜まっている」signalを処理する)といった問題点がある。 ↩ -
とはいえ、結構マイナーな概念なので、補足。thread処理を安全に終了させる(キャンセルできる)関数として
pthread_cancel(3)
が用意されている。とはいえ、どのタイミングでもキャンセルできる訳ではなく、cancellation pointと言って、ここで打ち切っていいよ的な関数が定められている (https://linuxjm.osdn.jp/html/LDP_man-pages/man7/pthreads.7.html の取り消しポイントの節に一覧がある) ↩ -
signalfd自体については、https://codezine.jp/article/detail/4803 がわかりやすい。signalをfile descriptorとして扱えるため、多重IO化(select, poll, epoll)できて嬉しいとかのメリットがある。 ↩
-
あと、行読み取りはfgets(もしくはgetline)使うのがスタンダードよね。graceful shutdownやcancellation対応のためだけにreadを使うの抵抗ある。 ↩