プロセスの監視をpidfdでやってみる話.

久しぶりのLinuxプログラミングの話です.

Linux で fork()してexec()した子プロセスの終了を親プロセスが時限付で待つ場合, 今までは signalfd を poll()して待っていたけど, いろいろ課題やら不便なことがあります.

それに代わる方法は無いかと,調べていたところ Linux Kernel 5.3 からサポートされた pidfd がなかなかスグレモノだという話です.

子プロセスの生成と外部コマンドの実行.

プロセスを fork() して生成した子プロセスに何か仕事をさせるサンプルはこんな感じになります.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main (int argc, char ** argv)
{
  pid_t pid = fork ();
  int status;
  
  if (pid == 0)
    {
      /* Child Process */
      execv("/bin/ls", argv);
    }
  else
    {
      /* Parent Process */
      waitpid (pid, &status, 0);
      printf ("pid:%d done, exit status: %d\n", pid,  WEXITSTATUS(status));
    }
  return 0;
}

かなり手抜きコード恐縮です, エラー処理は省いてます.

新たに生成したプロセスに /bin/ls を実行させてます.

子プロセスの処理を親プロセスは waitpid() で終了を待って、さらに終了した子プロセスを回収しています. これを行わないと子プロセスはゾンビプロセスとなります.

以下が上記プログラムの実行結果です.

$ ./sample00 
 Makefile  sample00  sample00.c
 pid:1664 done, exit status: 0

子プロセスの時限つき監視

起動した子プロセスの仕事がある一定の時間で終了しなかったことを親プロセスで検知したい、という場合はどうすれば良いでしょうか?

子プロセスは終了時に親プロセスに対してSIGCHILD (17) を投げます.

そして親プロセスは signalfd() によってシグナル受け付け用のファイルディスクリプターを生成し, そのファイルディスクリプタを poll(), select(), あるいは epoll() でタイムアウトつきで監視するようにします.

以下がそのサンプルコードになります.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <poll.h>
#include <sys/signalfd.h>
#define TIMEOUT (5 * 1000)	/* TimeOut = 5sec */
int main (int argc, char ** argv)
{
  pid_t pid = fork ();
  int status;
  sigset_t mask;
  int sfd;

  sigemptyset (&mask);
  sigaddset (&mask, SIGCHLD);
  sigprocmask(SIG_BLOCK, &mask, NULL);
  sfd = signalfd(-1, &mask, 0);
  if (sfd < 0)
    {
      perror ("signalfd");
      return -1;
    }
  if (pid == 0)
    {
      /* Child Process */
      execv("/bin/sleep", argv);
    }
  else
    {
      struct pollfd pollfd;
      int pidfd, ready;
      struct signalfd_siginfo fdsi;
      ssize_t s;

      printf ("Created process pid:%d\n", pid); 
      pollfd.fd = sfd;
      pollfd.events = POLLIN;
      ready = poll (&pollfd, 1, TIMEOUT);
      if (ready < 0)
	     {
	       perror("poll");
	       return -1;
	      }
      else if (ready == 0)
	     {
	        printf ("pid:%d Timeout\n",pid);
	     }
     else
     	{
	      s = read(sfd, &fdsi, sizeof(fdsi));
	      if (s != sizeof(fdsi))
  	    {
	        perror ("read");
	        return -1;
	    }
	   printf ("Catch signal:%d, then do waitpid()\n", fdsi.ssi_signo);
	  }
      /* Parent Process */
      pid_t dpid = waitpid (pid, &status, 0);
      printf ("pid:%d done, exit status: %d\n", dpid,  WEXITSTATUS(status));
    }
  return 0;
}

fork() で生成した子プロセスに対し, /sbin/sleep を実行させます. プログラムの引数に秒数を指定することで指定した時間、子プロセスはsleepします.

親プロセスは poll() に対して 5秒のタイムアウトを指定し SIGCHILD の受信を待ちます.

まず、タイムアウトが発生しない 2 秒を指定して実行した場合は以下の通り.

$ ./sample01 3
 Created process pid:1675
 Catch signal:17, then do waitpid()
 pid:1675 done, exit status: 0

poll()から抜けて read() すると fdsi.ssi_signo に受信した signal number が読みだせます.

この場合, SIGCHILD (17) が受信されたことが分かります.

次にタイムアウトが発生する 10 秒を指定して実行してみます.

$ ./sample01 10
 Created process pid:1756
 pid:1756 Timeout
 pid:1756 done, exit status: 0

poll() によるタイムアウトを検知することが出来ます.

このように signalfd() を使うと,

  • サンプルコードのように子プロセスの仕事をタイムアウトつきで終了監視できる.
  • 複数の子プロセスを生成した場合に同時監視ができる.

等いろいろ便利なような気がしますが、いくつか困った問題もあります.

signalfd() の困ったいくつかの問題

先にいろいろ便利そうな signalfd() ですけど、これの、というよりはSIGNAL そのものの課題がいくつかあります.

  • poll() から抜けたときに、終了した子プロセスが所望のプロセスとは限らない. 生成したsignalfd はSIGCHILD の受信を一手に引き受けているので、想定していない子プロセスの終了も検知してしまう.
  • 複数の子プロセスを監視するような設計であった場合, poll() から抜けたときに終了した子プロセスは1つとは限らない. SIGCHILD は複数回送信した場合, 1回のみ受信されるため, poll() から抜けた時点で複数の子プロセスが終了している可能性を考慮した設計が必要.
  • プログラムの規模が大きくなって全体の見通しが効かなくなった場合 どこかで同じsignal mask を設定した signalfd を生成していた場合, signal を取りこぼしてしまう可能性がある.

一番大きな問題は一番最後の問題. signalfd() の注意事項にも以下の様に書かれてます.

一つのプロセスは複数の signalfd ファイルディスクリプターを生成することができる。 これにより、異なるファイルディスクリプターで異なるシグナルを受け取ることが できる (この機能は select(2), poll(2), epoll(7) を使ってファイルディスクリプターを監視する場合に有用かもしれない。 異なるシグナルが到着すると、異なるファイルディスクリプターが利用可能に なるからだ)。 一つのシグナルが二つ以上のファイルディスクリプターの mask に含まれている場合、そのシグナルの発生はそのシグナルを mask に含むファイルディスクリプターのうちいずれか一つから読み出すことができる。

man signalfd(2) より

すなわち、同じsignal mask を設定した複数のファイルディスクリプタを生成した場合, 所望のシグナルが発生した場合、そのうちの一つのファイルディスクリプタにしか通知がいかない、ということになります.

プログラムが大規模化して、使っているライブラリの中でsignalfd() が使われてたりした場合 所望のシグナルの通知を取りこぼしてしまう、という可能性がある、ということです.

これはちょっと困りました.

プロセスを識別するファイルディスクリプタ pidfd

pidfd は Linux Kernel の新しい機能で Kernel Version 5.3 から対応されたものです.

新しい機能なので日本語の解説はここの記事以外見たことがありません.

元々は PID の再利用といったセキュリティ対策の為に作られた機能のようです. こちらを参照ください.

pidfd を生成する pidfd_open (2) の man ページに以下の様なサンプルコードが掲載されてます.

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <poll.h>
#include <stdlib.h>
#include <stdio.h>

#ifndef __NR_pidfd_open
#define __NR_pidfd_open 434   /* System call # on most architectures */
#endif

static int
pidfd_open(pid_t pid, unsigned int flags)
{
  return syscall(__NR_pidfd_open, pid, flags);
}

int
main(int argc, char *argv[])
{
  struct pollfd pollfd;
  int pidfd, ready;
  if (argc != 2) {
    fprintf(stderr, "Usage: %s <pid>\n", argv[0]);
    exit(EXIT_SUCCESS);
  }
  pidfd = pidfd_open(atoi(argv[1]), 0);
  if (pidfd == -1) {
    perror("pidfd_open");
    exit(EXIT_FAILURE);
  }
  
  pollfd.fd = pidfd;
  pollfd.events = POLLIN;
  
  ready = poll(&pollfd, 1, -1);
  if (ready == -1) {
    perror("poll");
    exit(EXIT_FAILURE);
  }
  printf("Events (%#x): POLLIN is %sset\n", pollfd.revents,
	 (pollfd.revents & POLLIN) ? "" : "not ");
  
  close(pidfd);
  exit(EXIT_SUCCESS);
}

このpidfdの面白い点は、監視するプロセスが親子関係でなくても良い、ということ.

signalfd() で SIGCHILD を監視する際は, 監視対象のプロセスは子プロセスに限られてましたが、pidfd ではその制約なく利用できるのでいろんな使い方が考えられます.

pidfd を使った子プロセス監視

先ほどの signalfd を使ったサンプルコードを pidfd を使って書きなおしたのが以下のサンプルコードです.

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <poll.h>

#ifndef __NR_pidfd_open
#define __NR_pidfd_open 434   /* System call # on most architectures */
#endif

static int
pidfd_open(pid_t pid, unsigned int flags)
{
  return syscall(__NR_pidfd_open, pid, flags);
}

#define TIMEOUT (5 * 1000)	/* TimeOut = 5sec */
int main (int argc, char ** argv)
{
  pid_t pid = fork ();
  int status;
  sigset_t mask;
  int pfd;

  if (pid == 0)
    {
      /* Child Process */
      execv("/bin/sleep", argv);
    }
  else
    {
      struct pollfd pollfd;
      int pidfd, ready;

      printf ("Created process pid:%d\n", pid);
      
      pfd = pidfd_open(pid, 0);
      if (pfd == -1)
	     {
	      perror("pidfd_open");
	      return -1;
	      }
      pollfd.fd = pfd;
      pollfd.events = POLLIN;
      ready = poll (&pollfd, 1, TIMEOUT);
      if (ready < 0)
	     {
	        perror("poll");
	        return -1;
	      }
      else if (ready == 0)
	      {
	       printf ("pid:%d Timeout\n",pid);
	      }
      else
	     {
	       printf ("process: %d done\n", pid);
	     }
      /* Parent Process */
      pid_t dpid = waitpid (pid, &status, 0);
      printf ("pid:%d done, exit status: %d\n", dpid,  WEXITSTATUS(status));
      close (pfd);
    }
  return 0;
}

まずタイムアウトが発生しない時間 3秒を指定してみます.

$ ./sample03 3
 Created process pid:1765
 process: 1765 done
 pid:1765 done, exit status: 0

次にタイムアウトが発生するように 10秒を指定してみます.

$ ./sample03 10
 Created process pid:1767
 pid:1767 Timeout
 pid:1767 done, exit status: 0

上記のようにタイムアウトを検知することができました.

poll()から抜けた時点で監視している子プロセスが終了しているのは保証されていますので、監視処理も簡単に描けるようになる、と思います.

今回のサンプルコード

今回のサンプルコードは GitHub のこのリポジトリに置いてますので、参考になれば幸いです.

本記事は先日の記事で紹介したこちらも参考にいたしました.

では