7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[メモ]C言語によるgraceful shutdownの実装考察

Last updated at Posted at 2018-08-23

C言語によるgraceful shutdownの実装考察

TL;DR

  • :new: コメント のとおり、signalとpipeを併用するのが一般的なようです。しばらく調べた上で再度記事を更新したいと思います。記事自体は勉強メモとして残しておきます。
    -> とりあえず実装して動作確認まで行ったコード: https://github.com/knknkn1162/c_sample/blob/d899d90f27479c00cd9a1992a83aa4ad99ba6ed7/shutdown/signal_piping.c

  • minimalなお題で、C言語によるgraceful shutdownの実装を sigactionを用いる方法と、pthread_cancel & sigwaitを用いる方法の2種類で行ってみた。

  • どちらも厳密には規格に明記されていないということで歯切れが悪い実装だが、実用的にまぁ大丈夫なんじゃない?と個人的には思っている

  • その他、これ使えるかもと思ったけど無理筋だった関数についても、簡単に説明した

参考文献

本題

問題設定

Problem
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 後者への対応も個人レベルでやろうとするとかなり泥臭い感じがする。

完全を期すなら前者の対処方法で手間を多めにかけるしかないように見える。


他にもこれどう?とか、ここの解釈が実はちがうんじゃない? ってのがあったら教えてくださいませ:bow:

  1. このシグナルの処理、TLPIにもイディオムとして頻繁に出てきて、シグナルをかなり行儀よく扱える&実装も簡単にすむので、個人的にはsigwaitよりも多用する。

  2. 避けるべき理由は色々ある。元々、signal_handlerの中にかける関数はかなり限定されていて(https://linuxjm.osdn.jp/html/LDP_man-pages/man7/signal.7.htmlasync-signal-safe functionsを見ること)printfとか日常的に使うでしょ、っていう関数が使えなかったりする。また、signalはqueueされないので、signal_handler内の処理を大きくすると、signal handlerの実行中にさらに飛んできた(複数回の)signalがいくつか消滅してしまう。ということで、signal_handler内の処理をできるだけ簡素にすることは合理性がある。

  3. なぜsignal(2)を用いずにsigaction(2)を用いるのかの詳細については、TLPIの22.7を見れば良い。ざっくりまとめてしまうと、sigactionの方が高機能で、移植性が高いというのが1点。また、シグナルが受信される度にシグナルハンドラーがリセットされてしまい、呼び出し途中のシグナルハンドラーの処理がどっかいっちゃう、とか signal_handler内でsignalが発生すると、再帰的にsignal_handlerが呼ばれることになってしまう(stack overflowの問題発生するかも) (sigactionはsignal_handlerが呼び終わってから、「溜まっている」signalを処理する)といった問題点がある。

  4. とはいえ、結構マイナーな概念なので、補足。thread処理を安全に終了させる(キャンセルできる)関数としてpthread_cancel(3)が用意されている。とはいえ、どのタイミングでもキャンセルできる訳ではなく、cancellation pointと言って、ここで打ち切っていいよ的な関数が定められている (https://linuxjm.osdn.jp/html/LDP_man-pages/man7/pthreads.7.html の取り消しポイントの節に一覧がある)

  5. signalfd自体については、https://codezine.jp/article/detail/4803 がわかりやすい。signalをfile descriptorとして扱えるため、多重IO化(select, poll, epoll)できて嬉しいとかのメリットがある。

  6. あと、行読み取りはfgets(もしくはgetline)使うのがスタンダードよね。graceful shutdownやcancellation対応のためだけにreadを使うの抵抗ある。

7
6
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?