LoginSignup
204
148

More than 1 year has passed since last update.

シグナルハンドラにprintf()を書いてはいけない

Last updated at Posted at 2022-05-08

三行でまとめると

  • シグナルハンドラ内でprintf()してはいけない
  • というより、余計な処理を書いてはいけない
  • もう一度言う、シグナルハンドラで余計なことをするな、非常に大事なことだ

はじめに

シグナルハンドラでやってよい処理は非常に限られるのに、全くルールを守らないサンプルコードが世の中に大量に出回っている。printf()するなんてもってのほかなのだが、カジュアルにそこらじゅうで見かけて非常に悲しい。

この記事では、そんな状況を少しでも改善したいと思い初心者向きに書いたものだ。そのため、下記では、回避するにはどう実装すればよいのか、ルールを破るとどうなるのか、といった点を先に簡潔に記述する。

なぜしてはいけないのか、POSIXだとかリエントラントだとか、は下の方に追いやっている。玄人は読んでてウズウズするだろうが、細かい話はできるだけ目につかないような構成としたため了解いただきたい。

回避する方法

なにもしない

この記事をマジメに読もうとしている人をターゲットにあえて書くと、あなたの考えるシグナルハンドラに書きたい処理は、十中八九、不必要なことであったり「筋の悪い」ことであったりする。ただでさえ制約の多いシグナルハンドラであえて行うような処理ではない。

fdをcloseしたい
おそらくSIGTERMを受けてやろうと思っているのだろうが、やらなくてよい。プロセスが終了すると勝手に閉じてくれる。
メモリ領域をfreeしたい
おそらくSIGTERMを受けてやろうと思っているのだろうが、やらなくてよい。プロセスが終了すると勝手に開放してくれる。
tmpfileを消したい
おそらくSIGTERMを受けてやろうと思っているのだろうが、終了時にやるべきではない。起動時に消したり再初期化したりすればよい。
本当にやらないといけない処理なのかを今一度考えた上で「なにもしない」という選択肢を考慮すべきである。

signalfdを使う

Linux限定ではあるものの、signalfd(2)を使うと、シグナルを直接fdで取り扱うことができる。

signalfd.cのサンプルコード
signalfd.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <err.h>
#include <sys/signalfd.h>
#include <sys/select.h>
#include <sys/errno.h>

#define SYSCALLWRAP(SNAME, ...)		\
	do {							\
		int ret;					\
		ret = SNAME(__VA_ARGS__);	\
		if (ret) {					\
			err(1, #SNAME);			\
		}							\
	} while(0)


static ssize_t do_read(int fd, void *buf, size_t count) {
	size_t read_size = 0;
	while (read_size < count) {
		ssize_t ret_read = read (fd, (buf+read_size), (count-read_size));
		if (ret_read < 0) {
			// err
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				continue;
			}
			if (read_size <= 0) {
				// nothing read;
				return ret_read;
			}
			// partially read
			break;
		} else if (ret_read == 0) {
			// eof
			break;
		}
		read_size += ret_read;
	}
	return read_size;
}

static int prepare_signal_fd() {
	// prepare sigset variable
	sigset_t mask;
	SYSCALLWRAP(sigemptyset, &mask);
	SYSCALLWRAP(sigaddset, &mask, SIGINT);
	SYSCALLWRAP(sigaddset, &mask, SIGTERM);

	// prepare signalfd
	int fd = signalfd(-1, &mask, SFD_CLOEXEC);
	if (fd < 0) {
		err(1, "signalfd");
	}

	// start to block subscribed signals
	SYSCALLWRAP(sigprocmask, SIG_BLOCK, &mask, NULL);
	return fd;
}

static void signal_fd_action(int fd) {
	struct signalfd_siginfo thisinfo;
	ssize_t ret_read = do_read (fd, &thisinfo, sizeof(thisinfo));
	if (ret_read != sizeof(thisinfo)) {
		printf("invalid signalfd read size %zd %zu\n", ret_read, sizeof(thisinfo));
		return;
	}

	switch(thisinfo.ssi_signo) {
		case SIGINT:
			errx(1, "SIGINT received. Goto exit\n");
			break;
		case SIGTERM:
			errx(0, "SIGTERM received. Goto exit\n");
			break;
		default:
			printf("unknown signalfd read %u\n", thisinfo.ssi_signo);
			break;
	}
}

int main() {
	int signal_fd = prepare_signal_fd();

	// prepare fd_set for select
	fd_set def_fds;
	FD_ZERO(&def_fds);
	int max_fd = -1;
#define SETNEWFD(new_fd)			\
	do {							\
		FD_SET(new_fd, &def_fds);	\
		if(new_fd > max_fd) {		\
			max_fd = new_fd;		\
		}							\
	} while(0)
	SETNEWFD(signal_fd);

	while(1) {
		fd_set rfds;
		rfds = def_fds;
		// select
		int retval = select (1+max_fd, &rfds, NULL, NULL, NULL);
		if (retval < 0) {
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				printf("select retry errno=%d\n", errno);
				continue;
			}
			// err
			err(1, "select");
		} else if (retval == 0) {
			// timeout. go retry.
			continue;
		}

		if (FD_ISSET(signal_fd, &rfds)) {
			signal_fd_action(signal_fd);
		}
	}
	return 0;
}

とその実行結果。シグナルをハンドルせずにブロックしていて、signalfdからread()することで直接消費されるため、EINTRが発生しない点に注意してほしい。

[rarul@tina signalfd]$ ./signalfd 
^Csignalfd: SIGINT received. Goto exit

signalfdtimerfdとセットにして、積極的な使用を考慮すべきLinux限定機能として覚えておこう。

(追記)シグナルスレッド方式を使う

signalfdはLinuxの機能のため、それ以外のUNIXでは使うことができない。専用スレッドを起こしてsigwaitinfo() を使う実装が安全かつケアすべき事項が少ない方式として知られている。簡単に説明すると、シグナルを処理する専用のスレッド以外ではシグナルをブロックし、専用スレッドも特定の箇所以外ではシグナルをブロックし、特定の箇所で同期的にシグナルを受信して処理する方式である。この記事では便宜上シグナルスレッド方式と呼ぶこととする。

signal_thread.cのサンプルコード
signal_thread.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <err.h>
#include <sys/select.h>
#include <sys/errno.h>
#include <pthread.h>

#define SYSCALLWRAP(SNAME, ...)		\
	do {							\
		int ret;					\
		ret = SNAME(__VA_ARGS__);	\
		if (ret) {					\
			err(1, #SNAME);			\
		}							\
	} while(0)

static int pipe_for_signal[2] = {-1, -1};

typedef struct {
	int signum;
	union siginfo_ext_t {
		void *sigint_data;
		void *sigterm_data;
		void *sigchld_data;
	} siginfo;
} sig_pipe_data_t;

static ssize_t do_read(int fd, void *buf, size_t count) {
	size_t read_size = 0;
	while (read_size < count) {
		ssize_t ret_read = read (fd, (buf+read_size), (count-read_size));
		if (ret_read < 0) {
			// err
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				continue;
			}
			if (read_size <= 0) {
				// nothing read;
				return ret_read;
			}
			// partially read
			break;
		} else if (ret_read == 0) {
			// eof
			break;
		}
		read_size += ret_read;
	}
	return read_size;
}

static ssize_t do_write(int fd, void *buf, size_t count) {
	size_t write_size = 0;
	while (write_size < count) {
		ssize_t ret_write = write (fd, (buf+write_size), (count-write_size));
		if (ret_write < 0) {
			// err
			if (errno == EINTR || errno == EAGAIN) {
				// retry
#if 0
				// It's bad to retry in signal handler. But what to do ?
				continue;
#endif
			}
			if (write_size <= 0) {
				// nothing write;
				return ret_write;
			}
			// partially write
			break;
		} else if (ret_write == 0) {
			// eof
			break;
		}
		write_size += ret_write;
	}
	return write_size;
}


static void prepare_pipe() {
	// initialize pipe for signal thread to write info to main thread.
	SYSCALLWRAP(pipe2, pipe_for_signal, O_CLOEXEC);
}

static void *sig_thread_main(void *arg);
static void prepare_signal_thread() {
	// prepare sigset variable
	sigset_t mask;
	SYSCALLWRAP(sigemptyset, &mask);
	SYSCALLWRAP(sigaddset, &mask, SIGINT);
	SYSCALLWRAP(sigaddset, &mask, SIGTERM);

	// start to block signals
	SYSCALLWRAP(sigprocmask, SIG_BLOCK, &mask, NULL);

	// prepare thread for signal action
	pthread_t thd;
	SYSCALLWRAP(pthread_create, &thd, NULL, sig_thread_main, NULL);
	SYSCALLWRAP(pthread_detach, thd);
}

static void *sig_thread_main(void *arg) {
	sigset_t mask;
	SYSCALLWRAP(sigemptyset, &mask);
	SYSCALLWRAP(sigaddset, &mask, SIGINT);
	SYSCALLWRAP(sigaddset, &mask, SIGTERM);
	
	while (1) {
		siginfo_t info;
		memset (&info, 0, sizeof(info));
		int retval = sigwaitinfo(&mask, &info);
		if (retval < 0) {
			if (errno == EINTR || errno == EAGAIN) { continue; }
			err(1, "sigwaitinfo");
		}

		sig_pipe_data_t sig_pipe_data;
		memset (&sig_pipe_data, 0, sizeof(sig_pipe_data));
		switch (info.si_signo) {
			case SIGINT:
				printf("SIGINT received in thread\n");
				// do something for signal action
				sig_pipe_data.signum = info.si_signo;
				sig_pipe_data.siginfo.sigint_data = NULL;
				// and write info to main thread
				do_write (pipe_for_signal[1], &sig_pipe_data, sizeof(sig_pipe_data));
				break;
			case SIGTERM:
				printf("SIGTERM received in thread\n");
				// do something for signal action
				sig_pipe_data.signum = info.si_signo;
				sig_pipe_data.siginfo.sigterm_data = NULL;
				// and write info to main thread
				do_write (pipe_for_signal[1], &sig_pipe_data, sizeof(sig_pipe_data));
				break;
			default:
				printf("unknown signal num received %d\n", info.si_signo);
				break;
		}
	}
	return NULL;
}

static int sig_action(int fd) {
	// read signal info in main loop via pipe
	sig_pipe_data_t sig_pipe_data;
	memset (&sig_pipe_data, 0, sizeof(sig_pipe_data));
	ssize_t ret_read = do_read(fd, &sig_pipe_data, sizeof(sig_pipe_data));
	if (ret_read != sizeof(sig_pipe_data)) {
		printf("invalid pipe read size %zd %zu\n", ret_read, sizeof(sig_pipe_data));
		return 0;
	}

	switch(sig_pipe_data.signum) {
		case SIGINT:
			errx(1, "SIGINT received. Goto exit\n");
			break;
		case SIGTERM:
			errx(0, "SIGTERM received. Goto exit\n");
			break;
		default:
			printf("unknown signal num received %d\n", sig_pipe_data.signum);
			break;
	}
	return 0;
}

int main() {
	prepare_pipe();
	prepare_signal_thread();

	// prepare fd_set for select
	fd_set def_fds;
	FD_ZERO(&def_fds);
	int max_fd = -1;
#define SETNEWFD(new_fd)			\
	do {							\
		FD_SET(new_fd, &def_fds);	\
		if(new_fd > max_fd) {		\
			max_fd = new_fd;		\
		}							\
	} while(0)
	SETNEWFD(pipe_for_signal[0]);

	while(1) {
		fd_set rfds;
		rfds = def_fds;
		// select
		int retval = select (1+max_fd, &rfds, NULL, NULL, NULL);
		if (retval < 0) {
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				printf("select retry errno=%d\n", errno);
				continue;
			}
			// err
			err(1, "select");
		} else if (retval == 0) {
			// timeout. go retry.
			continue;
		}

		if (FD_ISSET(pipe_for_signal[0], &rfds)) {
			sig_action(pipe_for_signal[0]);
		}
	}

	return 0;
}

とその実行結果。シグナルハンドルしていないためEINTRは発生しない。

[rarul@tina sig_thread]$ ./sig_thread 
^CSIGINT received in thread
sig_thread: SIGINT received. Goto exit

sigwaitinfo() を使うともはやシグナルハンドラを使う必要がなくなり、制約なく処理を書くことができる。sigwaitinfo() の代わりにsigsuspend() を使えばシグナルハンドラを使うことができるが、その場合はself pipe trick方式と併用しないとprintf() 類を使えない制約が残る。よほどのシグナルハンドラを使う理由(リアルタイムにシグナルハンドルしたいなど)がない限りは、もはやシグナルハンドラを使わないままでよい。

今回の例ではpipeを用いてmainスレッドと同期する機構も用意した。

self pipe trickを使う

シグナルを安全にハンドルする一般的な方法としてはself pipe trickが有名である。簡単に説明すると、シグナルハンドラ内ではpipeに情報を書くということだけを行い、mainループでpipeから情報を読み出して届いたシグナルに応じた処理を行うという方法である。

self_pipe_trick.cのサンプルコード
self_pipe_trick.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <err.h>
#include <sys/select.h>
#include <sys/errno.h>

#define SYSCALLWRAP(SNAME, ...)		\
	do {							\
		int ret;					\
		ret = SNAME(__VA_ARGS__);	\
		if (ret) {					\
			err(1, #SNAME);			\
		}							\
	} while(0)

static int pipe_for_signal[2] = {-1, -1};

typedef struct {
	int signum;
	union siginfo_ext_t {
		void *sigint_data;
		void *sigterm_data;
		void *sigchld_data;
	} siginfo;
} sig_pipe_data_t;

static ssize_t do_read(int fd, void *buf, size_t count) {
	size_t read_size = 0;
	while (read_size < count) {
		ssize_t ret_read = read (fd, (buf+read_size), (count-read_size));
		if (ret_read < 0) {
			// err
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				continue;
			}
			if (read_size <= 0) {
				// nothing read;
				return ret_read;
			}
			// partially read
			break;
		} else if (ret_read == 0) {
			// eof
			break;
		}
		read_size += ret_read;
	}
	return read_size;
}

static ssize_t do_write(int fd, void *buf, size_t count) {
	size_t write_size = 0;
	while (write_size < count) {
		ssize_t ret_write = write (fd, (buf+write_size), (count-write_size));
		if (ret_write < 0) {
			// err
			if (errno == EINTR || errno == EAGAIN) {
				// retry
#if 0
				// It's bad to retry in signal handler. But what to do ?
				continue;
#endif
			}
			if (write_size <= 0) {
				// nothing write;
				return ret_write;
			}
			// partially write
			break;
		} else if (ret_write == 0) {
			// eof
			break;
		}
		write_size += ret_write;
	}
	return write_size;
}

static void sig_handler(int signum, siginfo_t *info , void *ctx ) {
	// signal handler function
	sig_pipe_data_t sig_pipe_data;
	int saved_errno = errno;

	memset (&sig_pipe_data, 0, sizeof(sig_pipe_data));
	sig_pipe_data.signum = signum;
	sig_pipe_data.siginfo.sigint_data = NULL;
	// send signal info to main loop via pipe
	do_write (pipe_for_signal[1], &sig_pipe_data, sizeof(sig_pipe_data));

	errno = saved_errno;
}

static void sig_action(int fd) {
	// read signal info in main loop via pipe
	sig_pipe_data_t sig_pipe_data;
	memset (&sig_pipe_data, 0, sizeof(sig_pipe_data));
	ssize_t ret_read = do_read(fd, &sig_pipe_data, sizeof(sig_pipe_data));
	if (ret_read != sizeof(sig_pipe_data)) {
		printf("invalid pipe read size %zd %zu\n", ret_read, sizeof(sig_pipe_data));
		return;
	}

	switch(sig_pipe_data.signum) {
		case SIGINT:
			errx(1, "SIGINT received. Goto exit\n");
			break;
		case SIGTERM:
			errx(0, "SIGTERM received. Goto exit\n");
			break;
		default:
			printf("unknown signal num received %d\n", sig_pipe_data.signum);
			break;
	}
}

static void set_fl(int fd, int flags) {
	// helper function to set flag for fd
	int val = fcntl(pipe_for_signal[1], F_GETFL, 0);
	if (val < 0) { err(1, "F_GETFL"); }
	val |= flags;
	if (fcntl(fd, F_SETFL, val) < 0) { err(1, "F_SETFL"); }
}

static void prepare_pipe() {
	// initialize pipe for signal handler
	SYSCALLWRAP(pipe2, pipe_for_signal, O_CLOEXEC);
	set_fl(pipe_for_signal[1], O_NONBLOCK);
}

static void prepare_signal_handler() {
	// install signal handler
	struct sigaction sa_sighandle;
	memset (&sa_sighandle, 0, sizeof(sa_sighandle));
	sa_sighandle.sa_sigaction = sig_handler;
	sa_sighandle.sa_flags = SA_SIGINFO;
#if 0
	// reduce EINTR retry, but not perfect.
	sa_sighandle.sa_flags |= SA_RESTART;
#endif
	SYSCALLWRAP(sigaction, SIGINT, &sa_sighandle, NULL);
	SYSCALLWRAP(sigaction, SIGTERM, &sa_sighandle, NULL);
}

int main() {
	// initialize
	prepare_pipe();
	prepare_signal_handler();

	// prepare fd_set for select
	fd_set def_fds;
	FD_ZERO(&def_fds);
	int max_fd = -1;
#define SETNEWFD(new_fd)			\
	do {							\
		FD_SET(new_fd, &def_fds);	\
		if(new_fd > max_fd) {		\
			max_fd = new_fd;		\
		}							\
	} while(0)
	SETNEWFD(pipe_for_signal[0]);

	while(1) {
		fd_set rfds;
		rfds = def_fds;
		// select
		int retval = select (1+max_fd, &rfds, NULL, NULL, NULL);
		if (retval < 0) {
			if (errno == EINTR || errno == EAGAIN) {
				// retry
				printf("select retry errno=%d\n", errno);
				continue;
			}
			// err
			err(1, "select");
		} else if (retval == 0) {
			// timeout. go retry.
			continue;
		}

		if (FD_ISSET(pipe_for_signal[0], &rfds)) {
			sig_action(pipe_for_signal[0]);
		}
	}
	return 0;
}

の実行結果はこうなる。Ctrl+Cしたときにsig_action()を呼ぶより先にselect()がEINTRでリトライしている点に注意してほしい。

[rarul@tina self_pipe_trick]$ ./self_pipe_trick
^Cselect retry errno=4
self_pipe_trick: SIGINT received. Goto exit

mainスレッド1つがselect(2)を使ったイベント多重待ちをするという典型的なループ構造を理解できない場合は、これ以前の話として、まずはselect() の使い方を理解してほしい。

mainスレッドが詰まるようなことがあると処理を行うまでの遅延が発生するが、他にいい方法もないので、これで我慢するほかはない。 (追記) self pipe trick方法は比較的有名なやり方ではあるが、ただこれでも、割り込まれるシステムコールをたくさん使っていて見切れない、シグナルをたくさん継続的に受信する、fork() と併用する場合、EINTRへの対処、などの課題が残るため、万全にしたい場合は先のシグナルスレッド方式になるかと思う。

シグナルハンドラでprintf()するとどうなるのか

printf()だけでは起こらなかったり、printf()も%指定子次第ではセーフだったりするけど、何も考えずにコードを書いた場合に起こりうるものも含め記載する。ただ、一番大事なのは、現象から原因に行き着くのが非常に難しいという点である。解析が難航し原因がわからないから徹底してコードを確認しようというフェーズになって初めてシグナルハンドラのルール違反が発覚するケースが多い。

(レア度低)

デッドロックする、無限ループする、SIGSEGVする

(レア度中)

おかしな値を読み書きする

(レア度高)

メモリを壊す、ファイルを壊す

なぜシグナルハンドラでprintf()をしてはいけないのか

POSIX

UNIXの仕様を定義しているPOSIXの2008の文章に記載があり、async-signal-safeな関数(非同期シグナル安全な関数)をシグナルハンドラで使ってよいと決めている。とっつきにくい場合は、Linuxのマニュアルのsignal-safety(7) を見るとよい。

使って良い関数(async-signal-safe)がたくさん書かれているように見えるが、実は逆で、ここにあるもの以外を使うと未定義で何が起こるか実装依存だということになる。 (未定義警察に捕まったので訂正) 未定義の動作となる。実際のところ、実装を踏まえても、ここにあるもの以外は使ってはいけないと思ってほしい。

私もマジメに精査したことはないが、大抵の場合、使えない関数が多くてまともにコードを組めない。

コンテキスト

シグナルハンドラはユーザプログラムから見て割り込みのように動く。シーケンスが途中で飛んでシグナルハンドラが呼ばれ終わると飛んだ箇所に戻ってくるという動きとなる。一見するとマルチスレッドのプログラミングを意識しておけば対策できるように思うかもしれないが、それは間違いで、シグナルハンドラ内でいくら待っていても、飛んだ箇所の続きを実行する人がいない(==待ってる自分自身)ということになる。例えば、シグナルハンドラ内でmutexのロック待ちをしてしまうと、アンロックするはずの自分自身もそこで待ち続けるため、そのままデッドロックとなる。

Linuxの場合、プロセスのPID宛にシグナルを送ると大抵はプロセスのmainスレッドでシグナルハンドリングされる。mainスレッドがシグナルをブロックしている(SIG_BLOCK)場合はこの限りではない。ただ、ほかのUNIXと同様に、そうである保証はないので、あまりスレッドを意識した対策を入れることに意味はないかと思う。Linuxではsigprocmask(2)もスレッドごとの設定になるため、強いて言えば、意識しておくべきはシグナルを受けたくないスレッドをSIG_BLOCKでブロックし続けることくらいか?

glibc実装(mallocの話)

glibcでの典型的に引っかかる例がmalloc() になる。便宜上malloc()と書いているが、当然、free()やrealloc()やcalloc()や、それらに依存するprintf(), sprintf(), fwrite()なども対象である。

シグナルハンドラはユーザプログラムから見て割り込みのように実行されるため、malloc()の中でarenaやchunkのリストを操作している最中に実行されるかもしれない。マルチスレッドやSMPでの動きを知っている人には今さらだが、リストを操作中に別の処理を割り込ませると、リストの不正な参照を起こし、無限ループやSIGSEGVを容易に起こす。シグナルハンドラ内でprintfするとよく目にする不具合がこれである。

じゃ、マルチスレッドやSMPプログラムにならいmutexで保護すればいいのでは?と考えるかもしれないが、先の通り、安直に導入するとデッドロックを起こす。mallocの中の実装では、arenaやchunkを保護するために内部にmutexを持つため、もちろんこれらでもデッドロックを引き起こしうる。こうして、デッドロックも不具合としてよく目にすることとなる。

なお、glibcがmalloc実装にどのようにmutexを使っているかについては、mallocの動作を追いかける(マルチスレッド編) - Qiitaの記事が詳しい。

glibc実装(sig_atomic_tの話)

先のPOSIXの文章ではsig_atomic_tが規定されていて、シグナルハンドラ内ではsig_atomic_t型の変数しか外と共有してはいけないと規定している。なんだかふわふわした表現だが、要は、1マシン命令でload,storeできる単位の型のグローバル変数(類)でしかシグナルハンドラの外と直接やり取りできない。

32bit CPU以上でLinuxで使ってることが多いからそこまでは困らない...とはならず、構造体の代入やmemcpy()なんかもアトミックに行えない典型的な処理となる。じゃ読み書きするコードをmutexで保護すればよいかと言われると、先の通り、デッドロックする可能性が残るため、それもできない。

この規定を無視してコードを組むと、構造体のメンバaは更新されているのにメンバbはまだ更新されていない、なんてタイミングでシグナルハンドルされることも起こりうるわけで、おかしな値を読み書きする現象につながる。構造体でなくとも、例えば32bit CPUで64bitの変数を読み書きすると、upper,lowerの2命令で処理することとなり、この2つの命令の間でシグナルハンドルが起こるとおかしな値の読み書きとなる。

ちなみにglibcではposix/bits/types.hsig_atomic_tの型を定義しているが、結局ただのintであり、SIG_ATOMIC_MINは(-2147483647-1)、SIG_ATOMIC_MAXは2147483647になっている。(stdlib/stdint.h)

ユーザプログラムの実装

素直にlibc関数を呼ぶだけのシグナルハンドラならばまだしも、ユーザランドで凝ったプログラムにしていると、async-signal-safeを無視した状態管理・エラーチェックを行っていることも多く、おかしなタイミングでのメモリ参照やファイル書き込みをするなんてケースもある。制限なしにコードを書いてしまうと、libstdc++ ですらその内部実装を把握するのが大変だというのに、その他諸々のライブラリも芋づる式にcallすることとなり、ルールを違反しているのかどうかを確認する作業すら困難になると容易に想像できるだろう。

(追記) コメントより、cppreference.comのstd::signalにC++としての記載があり多少は助けになる。

解析をする立場から

プログラムの規模にもよるが、たとえシグナルハンドラでルールを破ったコードを書いていたとしても、それに気づくのは難しいケースが多い。しかも低負荷の場合は問題なく動いてしまうことが多い。負荷やタイミングにより時々変なことが起こるという不具合の形で初めて問題が認識される。さらに、再現が難しいだけでなく、裏付けをするためコードを追加することも容易ではなく、解析者泣かせの不具合となるケースが非常に多い。極めつけは、類似の過ちを他の箇所で起こしていないかという観点で総チェックをやれと指令が出ることも多い。

はっきりいって、コードを書く一人ひとりに意識を高く持って作業をしてもらわないと防ぎようがない。

シグナル周りの雑多話

シグナルの一覧を確認する

signal(7)にLinuxで使われるシグナル一覧が載っている。番号を知りたいなら $ kill -l が早いかも。この一覧からシグナルハンドルすべきものはどれかを個人の独断で書いてみる。

    SIGHUP
    logrotateに反応すべきプログラム以外ではハンドルすべきでない。
    SIGTERM SIGINT
    よほどの強い理由で終了時にやるべきことがある場合を除いて、ハンドルすべきでない。逆に、シグナルハンドルのテストやサンプルプログラム用にSIGINTをよく使う。
    SIGQUIT
    ハンドルすべきでない。シグナルハンドラの話ではないが、外部要因でコアダンプさせる目的でSIGABRTと区別するために使っている例を見たことがある。
    SIGILL SIGBUS SIGSEGV
    ハンドルすべきでない。そもそもハンドルしてもほとんど何もできない。使うのは不具合解析のための情報を取得するときくらい?
    SIGTRAP
    ハンドルすべきでない。デバッグ目的なので使うべきときは自ずと決まる。
    SIGABRT
    ハンドルすべきでない。素直に何もせず終了させるべき。
    SIGKILL SIGSTOP
    そもそもハンドルできない。
    SIGPIPE
    ハンドルするのではなく無視(SIG_IGN)する。
    SIGALRM
    timer_settime()などを使うときに正しくハンドルするべき。ただしLinuxではtimerfdを使ったほうが良いと思う。
    SIGCHLD
    子プロセスの終了を適切に扱うためにハンドルするべき。Linux限定で、親が終了すると子にシグナルを飛ばすPR_SET_PDEATHSIGも合わせて知っておこう。
    SIGURG SIGIO SIGPOLL
    知らない。普通にselect()やepoll()に頼ったほうがよいと思う。
    SIGUSR1 SIGUSR2
    プログラムによる。恣意的に勝手な目的で使っていることがある。
    SIGFPE
    あまり詳しくない。数値計算系の人はこれをうまくハンドルしてどこで計算をやらかしたかを見ている?
    SIGTTIN SIGTTOU SIGSTKFLT SIGCONT SIGTSTP SIGXCPU SIGXFSZ SIGVTALRM SIGPROF SIGWINCH SIGLOST SIGPWR SIGSYS SIGUNUSED SIGRTMIN
    知らない。

EINTRの対処がめんどい

シグナルをハンドルすると、多くの場合syscallsを中断することになり、シグナルハンドラを実行したあとに、中断されたシステムコールがEINTRで失敗するという結果を受け取ることになる。EINTRの場合は同じシステムコールを同じように呼ぶようリトライすればよいのだが、これがなかなか面倒なことにつながる。

EAGAINEWOULDBLOCKO_NONBLOCKを選んだときにセットでケアすることが多いが、EINTRはなかなかそうはいかない。ブロックしうるすべてのsyscallsで返り値とerrnoのチェックをしなければいけないのだが、例外なくすべてのsyscallsで対処できているかを確認するのは正直厳しい。

また、read(2)write(2) の途中でシグナルハンドルした場合、引数で指定したサイズ未満を処理して抜けてくることになり、不可分な処理の途中で抜けると返って面倒になるため、引き算して残りサイズ分だけリトライするような関数でラップすることも多い。先のサンプルプログラムでは、do_read(), do_write() という関数を用意している。

SA_RESTARTを設定すればいいのでは?」という話もあるが、signal(7)の「Interruption of system calls and library functions by signal handlers」の章にあるように、昔に比べれば大幅に改善されているとはいえ、すべてのシグナル中断を解決できるわけではなく、特に部分実行が許されるものやタイムアウトがからむcallは互換性のために完全な解決は厳しい。使ったほうがよいとは言えるものの、過信はしないほうがよい。

bad knowhowになってしまうがこんな方法がある。(追記)「バッドノウハウではなく正攻法だ」の指摘があるが私はこれが正攻法だと言えるほどの自信はない。とはいえ「回避する方法」にシグナルスレッド方式として加筆することとした。

  1. プロセス内のすべてのスレッドでpthread_sigmask(3)を使いシグナルをブロックする
  2. シグナルをハンドルするためだけの新規スレッドを1つ作る
  3. この新規スレッドも下記を除き基本的にシグナルをブロックする
  4. この新規スレッドはsigsuspend(2)を繰り返し呼ぶことでこのタイミングでのみシグナルハンドルさせる

人によってはsigsuspend() ではなくsigwaitinfo(2)を使って似た設計を採用しているかもしれない。ただsigwaitinfo()sigtimedwait()sigwait() の場合、シグナルハンドラを使うことを諦めており、シグナルを同期的に受け取る仕組みとなる点が異なる。逆に、シグナルハンドラ内での制約から開放されるため、かなり自由にコードを書ける。ただ、まだそれでも制限が残っていて、select()やepoll()を使ったイベント多重待ちをすることができないので、シグナルを処理する専用のスレッドを作り、mainスレッドと同期するための機構を用意する必要は残る。

このため、私としては、Linux限定になってしまうもののどうせ変更を加えるのならばsignalfdでよいのではと考えている。

プロセスグループとシグナル

kill(2)では、1つ目の引数にPIDを指定するが、ここでマイナスの値を指定すると、指定した値を持つプロセスグループに属するプロセス全てにシグナルを送ることができる。ランチャーや親子関係を持つプロセスをまとめて管理する場合に便利である。(プロセスグループが何なのかについてまではここでは語らない。)

gdbでのシグナルの扱い

gdbでデバッグ中にシグナルを受けると、デバッグ対象プロセスにシグナルが飛ぶ前にgdbでシグナルを受け止めることができる。止めることができるというのはそれでいいんだけど、特にやることはないから無視したい(gdbでは何もせずにデバッグ対象プロセスに直接届けてほしい)というケースが多い。SIGTERMを受けてもgdbは関与せずそのままプロセスに流してほしい場合、

(gdb) handle SIGTERM nostop noprint pass

現在の設定を確認したい場合は (gdb) info signal で。

直接は関係ない話だけど、gdbserverでデバッグ中にCtrl+Cで強制的に割り込もうとすると止められないというgdbのバグがある。6年以上前に手順も原因もパッチも揃えたのに未だに修正してくれないの、無力を感じる。

Linux kernelのシグナルハンドリング

なにか書こうと思ってたけどこの記事があまりにも詳しかったのでそっちに任せた。

(追記)シグナル周りのさらに雑多な話

どんどんLinuxのことしか考えない内容になっちゃってるけどご了承を。

シグナルマスクの確認方法

/proc/[PID]/statusを見ることでプロセスごとのシグナルマスクの状況を確認することができる。

[rarul@tina ~]$ grep Sig /proc/self/status 
SigQ:	0/61472
SigPnd:	0000000000000000
SigBlk:	0000000000000000
SigIgn:	0000000000000000
SigCgt:	0000000180000400

SigQがキューイングされているシグナルの数、SigPndがプロセス宛の処理待ちシグナル数、SigBlkがブロック(SIG_BLOCK)しているシグナル(bit)、SigIgnが無視(SIG_IGN)しているシグナル(bit)、SigCgtが届いたけどまだハンドルされていないシグナル(bit)となる。/proc/[PID]/tasks/[TID]/statusにはShdPndがあり、スレッド宛の処理待ちシグナル数となる。

いずれにしてもprocfs(5)をちゃんと読もう。

シグナルハンドラの確認方法

((struct task_struct*)t)->sighand->action[0].sa.sa_handler にシグナルハンドラのユーザランドのアドレスが入る。シンボルがないとわかりにくいものの、kernelのデバッグをするときのツールにこれを表示するよう仕込んでおくとよい。ちなみに、アドレス以外としてSIG_DFL(==0) SIG_IGN(==1) SIG_ERR(==-1)が入りうる。

知っていてもあまり得をしないが、glibcはデフォルトで、SIGCANCELsigcancel_handler() を、SIGSETXIDsighandler_setxid() を、シグナルハンドラとして登録する。

fork,exec時のシグナルの設定の引き継ぎ

プロセスに届いたペンディング状態のシグナル(ハンドルされてないもの)は、fork() で子に引き継がれない。exec() では引き継ぐ。

シグナルマスクは、fork(), exec() ともに引き継がれる。

シグナルハンドラは、fork() では引き継ぎ、exec() では「シグナルハンドラが設定されたもの」がデフォルト処理(SIG_DFL)に戻りそれ以外はそのまま引き継ぐ。

とはいえ、signal(7)をちゃんと読んだ方がよい。

fork時のself pipe trickの抜け穴

fork() では fd(File Descriptor) が引き継がれるため、以下のようなケースで問題が起こりうる。

  1. self pipe trickを使ったプロセスがfork()する
  2. fork()の子プロセスにシグナルが届く
  3. 子プロセスは親からシグナルハンドラを引き継いでいるため、ハンドルされpipeにwrite()する
  4. pipeはfork()でfd共有されるため、子がpipeにwriteしたものを親がpipeからreadする

この結果、子プロセスに届いたシグナルをあたかも親プロセスに届いたかのように処理してしまう。

これを防ぐにはこうするしかない。ただし子プロセスはすぐにexec() すると想定している。

  1. fork()する直前にシグナルマスクでブロックする
  2. fork()が終わると親はシグナルマスクを元に戻す
  3. fork()が終わると子はシグナルマスクでブロックしたままexec()する
  4. 子プロセスはexec()後の新しいプログラムで初期化しシグナルハンドラを登録してからシグナルマスクのブロックを解除する

こんな話を踏まえると、もはやself pipe trickよりも、signalfdやシグナルスレッドのほうがいいや、と思うであろう。ちなみに、fork()してからexec()するまでの間にpipeをclose()しても、close()してからexec()するまでの間にシグナルは届くので、わざわざpipeを作り直してリアルタイムに反応したい場合を除き、結局シグナルマスクでブロックし続けるしかない。

SIGCHLDの扱い

子プロセスを管理するためにはSIGCHLDをハンドルする必要が出るが、色々注意点がある。

system(3)SIGCHLDとを混ぜて使うとろくなことが起こらない。SIGCHLDをハンドルせずにデフォルトのままとした上でsystem() を呼ぶか、SIGCHLDをハンドルした上ですべてfork(),exec() を呼ぶ(もしくはposix_spawn)か、の二者択一にしたほうがよい。

SIGCHLDは、子プロセスが一時停止・再開した場合にも届く。届いたら必ずwaitpid() するような構造の場合はSA_NOCLDSTOPを設定しておいたほうがよい。

SA_NOCLDWAITは、子プロセスをゾンビ化させないとあるので一見すると便利に見えるが、これをすると親がwaitpid() 類で子の状態を取得することができなくなる。正しくSIGCHLDを受けてwaitpid() 類をしたほうがよい。

longjmp

Linuxプログラミングインタフェースの21.2.1にシグナルハンドラとlongjmp() を絡めた話が載っている。またこれを受けてかシグナルハンドラでlongjmp() をするコードを多少ながら見かける。ただし、setjmp(), longjmp() 単体ですら「使うな」と言われるくらいなので、終了するシーケンスの一貫で特別にやりたいことがあるケースを除き、使うのは全くおすすめできない。siglongjmp() も同様となる。

async-signal-safe下で戦う

とはいえ、意地でもシグナルハンドラでprintf() したい人は現れて、write(1,msg,strlen(msg)) みたいにするのは序の口として、リンク集には入れなかったc - Print int from signal handler using write or async-safe functions - Stack Overflowみたいにasync-signal-safeなprintf() を自作する人も現れる。ただ、がんばる量の割に実りは少ないので、趣味ならともかく、普通の人はやらないほうがよい。私?もちろん普通の人なのでやりません。

ただ、世の中は広く、さらに変態な人たちがいて、Twitter:n_sodaより

SIGSEGV 等のシグナルハンドラで fork && exec で新規に xterm と その下で動く gdb を呼び出して attach してるのを見たことあります。

なにをいってるのこの人たち...

(追記)コメントに反応する

qiitaのコメント

(@error_401 より)

詳解UNIXプログラミング 第3版

記事を書く上で書籍を参照するというアイディアが完全に抜けていました。参考サイトに書籍を追記します。「詳解UNIXプログラミング 第3版」は私は持っていないため名前の紹介に留めます。また加筆する上で所持しているものを読み直しました。(未承諾広告)私のブログにアフィ入りで貼っとくので興味ある方よろしくお願いします。(/未承諾広告)

(@robozushi10 より)

手元にある「BINARY HACKS (オライリ)」を見ると、

(Twitter:seaoak2003 より)

オライリーの書籍「BINARY HACKS」の解説が良かった記憶

確かにちゃんと書いてました。記事書く前にちゃんと読み直すべきでした。書籍が少々古いのでこんな内容を現代風に書き直したものがあるとなおよいのですが、残念ながら、日本語のまともな書籍はもはや出版されない時代になっちゃいました。

(@tt4q より)

cppreference.com にまとめられています
https://en.cppreference.com/w/c/program/signal
https://en.cppreference.com/w/cpp/utility/program/signal

私の知識だと、UNIX likeなシステムとシグナルは切り離せない存在なので、言語仕様やtinyなOSでの対応状況は考慮外な記事になっています。申し訳ない。

Twitterのコメント

(Twitter:backflipout より)

TERMシグナル投げても死んでくれない場合

SIGTERMはデフォルトではプロセスを終了させるため、例えシグナルハンドラを設定したとしてもプロセスを終了させるように組むのが作法になる。が、凝ったことをして詰まらせたり、そもそも終了するように組んでなかったり、というヘンテコなコードもよく目にしてしまう。そういう場合SIGTERMを送っても死なない。のでSIGKILLを送って強制的に落とすことになる。そういう状況に慣れると、もはやSIGTERMを送るのが無駄だと気づくので、いきなりSIGKILLを送ることになる。こうしてSIGTERMに書かれたシグナルハンドラは無意味となる。

(Twitter:tnacigam より)

バッドノウハウぢゃないよ。むしろ正攻法だよ

(Twitter:yamasa より)

「バッドノウハウ」と書かれてるけどむしろ正攻法では。

結局sigwaitinfo() を使った方法を「回避する方法」に追記することとした。初版記事を書くときも悩んだけど、本当に「正攻法」なんでしょうか。ただでさえprintf()するサンプルコードが多いインターネットで、self pipe trickすら明記した記事が英語も含め少なく、シグナルスレッド方式にまで踏み込んで語ったページはなしに等しかったです。広まっていないのに「正攻法」とまで記事で言い切るのはさすがに気が引けます。

(Twitter:espresso3389 より、ほか類似のコメント多数)

一番いいのはシグナルなんて触らない事

この記事を書く上でそこを非常に意識しています。self pipe trickだsigwatinfo()だなんてのは二の次で「シグナルハンドラなんて使うな」という点を斜め読みしても印象に残るように書きました。

(Twitter:amedama41 より)

selectも同じくらい気軽に使い過ぎなんだよな。FD_SETSIZEにも気をつけてくれ

FD_SETSIZEの1024制限、知ってはいるんですが、1024個も開く機会がなく、プロセスのlimitでMax open filesを1024にしちゃうんですよねぇ。それで引っかかったとしてもほとんどはfdリークだし。File Descriptorは、fdの監視の仕方(ビジーループ,ポーリング,1FD1スレッドするな)、fork() でのFDのコピー、O_CLOEXEC、プロセスのFD上限、kernelのFD上限、あたりをまとめた初心者向け記事が待たれます。とりあえず紹介 → select(2)のfd_setは1024以上のfdをセットしようとすると落ちる

(Twitter:e_toyoda より)

tty系のシグナルを知らないというのはお若い

やった、らるるさんは若い。...じゃなくて、そこそこのおっさんです。...じゃなくて、SIGTTIN SIGTTOUについて説明したものが本当にどこにもないです。「Linuxプログラミングインタフェース」の「34章 プロセスグループ、セッション、ジョブコントロール」にようやく見つけた。ttyやジョブコントロールがシグナル以上のレガシーbad knowhowの塊なのは認めます。

(Twitter:a_saitoh より)

プログラムがクラッシュしたときも一時ファイルが残らないようにする方法。

open() してすぐにunlink() する方法かO_TMPFILEですね。ただ他のプロセスにも見せたいファイルだとそうはいかないので、/tmp以下にPIDかプロセス名のディレクトリを作りその中にファイルを置く、クラッシュやexit()したときに消そうと思わない、起動時に作り直すべき、が鉄則かと。プロセスの死活という意味では、親子関係ある時のSIGCHLD PR_SET_PDEATHSIGはともかく、mkfifoUNIX domain socketを使うことが多く、逆に確認が取れないshm, mqは避けます。

(Twitter:seaoak2003 より)

わたしも昔やらかしました。直すのすごく大変でした。

(Twitter:sigma_signature より)

vfprintfあってぶっ壊れたっていうバグを見た

「するな」と書いたページはそこそこあるものの、どんなふうに困るのか、どう回避するべきかまで書いたページが非常に少なかったので、この記事を書くことにした。「未定義の動作」と言われてもちゃんと動いちゃうことが多いから痛い目見るまではそのまま見過ごされるんですよねぇ。

(Twitter:tmtms より)

signalfd(2) なんてあったんだな。知らなかった。

自リプで補足されてますが、signalfdが入ったのってもう随分前なんですよね。「LinuxはUNIXのまがいもの」だった時代(==2000年代前半)とは異なり、今や新しい機能をいち早く取り入れるUNIXといっても過言じゃなくなっちゃいました。Linuxの最新を追い続けるのはしんどいですが、浸透して使われ始めた機能は、例えUNIX標準になってないものだとしても、フォローしておいたほうがよいという時代かと思います。timerfdとかinotifyとか。

(Twitter:masaru0714 より)

関数がシグナルハンドラかどうかを判別して、シグナルハンドラの中にまずいライブラリ関数呼び出しがあったら警告かエラーにする、みたいなことコンパイラでできたりしないのかな?

(RTしてたのでこの記事を読んだ感想だと解釈) いわゆる静的解析ツールのことかと思われるが、あれ使ったことある人ならわかるけど、条件分岐なんてほとんど見ずにすべての分岐をカバレッジ100%目指すが如くに働くから、ツール使うまでもないくらい簡単か、パスが多すぎて全部に目を通せないか、条件分岐多すぎてツールが諦めるか、になるんすよね。スタックの使用量上限チェッカーと同じ。そうならないようコーディング規約で縛り始めるたらもうMISRA-Cな世界へ一直線と。

はてブのコメント

(hatena:RySa より)

割り込みハンドラに時間待ち入れたくなって悩む事ありますよね。

割り込みなら割り込みスレッド方式、シグナルもシグナルスレッド方式にすればsleep()できる。が、割り込み処理中に入った次の割り込みに泣く。ストリーミング処理ならともかく、そうでないやつは一時停止してもその後復帰できるようにちゃんとケアしてほしいなぁ(gdbを多用する人の嘆き)

(hatena:hahihahi より)

「未定義で何が起こるかは実装依存だということになる。」未定義と実装依存を一緒にするなボケ

「未定義の動作なので何が起こるかわからない。何を起こっても文句を言えない。」これで満足ですか?仕様だけを正論で述べて許してもらえる環境ばかりとも限らず、結局何が起こりうるか実装踏まえた解析をしないといけないんですよ。未初期化変数やスタック破壊で実際どう動くかを見てる人は多く、私も「回り回ってビルド時刻(ビルド時のpath名)がバグを踏むかどうかの境目」な事例を見たことがある。

(hatena:ssids より)

昔はナッツシェルの UNIX C プログラミング(ライオン本)にこの手のが書いてあった

UNIX Cプログラミング (NUTSSHELL HANDBOOKS)のことかな?さすがに古すぎるし私も聞いたことがないので、今回の書籍リンクの追記からは外させてもらった。

あとがき

シグナルハンドラで余計なことをするな、この記事を読んだ人は頭に刻み込んでおいてください。

シグナルははっきり言って筋の悪いUNIX仕様だと私は思っている。が、これを前提にしたUNIXの仕組みも多いため、やめるにやめれない仕様だというのも現実である。既存UNIXの仕組みを利用するならともかく、新規に機能を実装する場合は、シグナルに依存しないやり方のほうがいいだろう。

誰か、この記事と同じようなノリで「fork()とexec()の間で余計なことをするな」という趣旨の記事を書きませんか?(追記 見つけたらここに追記します)

参考サイト

Async-signal-safeの話

シグナル全般の話

その他の話

(追記)書籍情報

  • Binary Hacks ―ハッカー秘伝のテクニック100選
    • 「#51 シグナルハンドラを安全に書く方法」
    • 「#52 sigwaitで非同期シグナルを同期的に処理する」
    • こんな話も載ってるが一般的で安全かと言われるとちょっと微妙「#53 sigsafeでシグナル処理を安全にする」「#76 sigaltstackでスタックオーバーフローに対処する」「#78 シグナルハンドラからプログラムの文脈を書き換える」「#81 SIGSEGVを使ってアドレスの有効性を確認する」
  • Linuxプログラミングインタフェース
    • Linux限定になってしまうがシグナルに関する話を網羅的に記載している。
    • 「22.9 シグナルマスクとシグナル待ち合わせ:sigsuspend()」
    • 「22.10 同期的にシグナルを待つ」 < sigwaitinfoの話
    • 「22.11 ファイルディスクリプタ経由のシグナル受信」 < signalfdの話
  • 詳解UNIXプログラミング 第3版
    • もうしわけない私は所持していないため名前の紹介だけに留める

更新履歴

  • 2022/05/15(Sun) 思ったより記事への反響が大きかったのでマジメに加筆
    • A. 「回避する方法」にシグナルスレッド方式を追加、
    • B. self pipe trickの限界を追記して紹介の順番を変更、errnoへのケアをサンプルコードに追加
    • C. 「参考サイト」に書籍の情報を追加
    • D. 「シグナル周りのさらに雑多な話」の章を追加
    • E. 「コメントに反応する」の章を追加
  • 2022/05/09(Mon) 初板を公開
  • 2022/05/07(Fri) 知り合いに下書きを提示してレビューを依頼
204
148
5

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
204
148