Manjusaka

Manjusaka

プロセス内の信号処理 V2について簡単に話しましょう

前回、水文プロセスにおける信号処理について簡単にを書いたところ、師匠に怒られました。前回の水文の例があまりにも古臭く、単純すぎ、 naïve だと言われました。もし将来、問題が発生した場合、私も責任を負わなければなりません。怖くなって、妹との周年記念の記事も書けず、急いで新しい記事を書いて、より優れた、便利な信号処理の方法について話したいと思います。

前回の要約#

まず、前回の記事の例を見てみましょう。

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

void deletejob(pid_t pid) { printf("タスク %d を削除\n", pid); }

void addjob(pid_t pid) { printf("タスク %d を追加\n", pid); }

void handler(int sig) {
  int olderrno = errno;
  sigset_t mask_all, prev_all;
  pid_t pid;
  sigfillset(&mask_all);
  while ((pid = waitpid(-1, NULL, 0)) > 0) {
    sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
    deletejob(pid);
    sigprocmask(SIG_SETMASK, &prev_all, NULL);
  }
  if (errno != ECHILD) {
    printf("waitpid エラー");
  }
  errno = olderrno;
}

int main(int argc, char **argv) {
  int pid;
  sigset_t mask_all, prev_all;
  sigfillset(&mask_all);
  signal(SIGCHLD, handler);
  while (1) {
    if ((pid = fork()) == 0) {
      execve("/bin/date", argv, NULL);
    }
    sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
    addjob(pid);
    sigprocmask(SIG_SETMASK, &prev_all, NULL);
  }
}

次に、いくつかの重要な syscall を復習しましょう。

  1. signal1: 信号処理関数で、ユーザーはこの関数を使って現在のプロセスに特定の信号のハンドラを指定できます。信号が発生すると、システムは特定のハンドラを呼び出して対応するロジックを処理します。
  2. sigfillset2: signal sets(信号セット)を操作するための関数の一つで、ここではシステムがサポートするすべての信号を信号セットに追加することを意味します。
  3. fork3: よく知られている API で、新しいプロセスを作成し、pid を返します。親プロセス内では、返される pid は対応する子プロセスの pid です。子プロセス内では、pid は 0 です。
  4. execve4: 特定の実行可能ファイルを実行します。
  5. sigprocmask5:プロセスの信号マスクを設定します。最初の引数が SIG_BLOCK の場合、関数は現在のプロセスの信号マスクを第三の引数で渡された信号セット変数に保存し、現在のプロセスの信号マスクを第二の引数で渡された信号マスクに設定します。最初の引数が SIG_SETMASK の場合、関数は現在のプロセスの信号マスクを第二の引数で設定された値に設定します。
  6. wait_pid6: 不正確な要約ですが、終了した子プロセスのリソースを回収し解放します。

さて、重要なポイントを復習した後、本文の重要な部分に入ります。

より優雅な信号処理手段#

より優雅なハンドラ#

まず、上記の信号処理部分のコードを再度見てみましょう。

void handler(int sig) {
  int olderrno = errno;
  sigset_t mask_all, prev_all;
  pid_t pid;
  sigfillset(&mask_all);
  while ((pid = waitpid(-1, NULL, 0)) > 0) {
    sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
    deletejob(pid);
    sigprocmask(SIG_SETMASK, &prev_all, NULL);
  }
  if (errno != ECHILD) {
    printf("waitpid エラー");
  }
  errno = olderrno;
}

ここでは、handler が他の信号によって中断されないようにするために、処理中に sigprocmask + SIG_BLOCK を使用して信号をブロックしています。論理的には問題はなさそうですが、問題があります。他に多くの異なる handler がある場合、必然的に多くの重複した冗長なコードが生成されます。では、handler の安全性を確保するために、より優雅な方法はあるのでしょうか?

あります(超大声で)。新しい syscall -> sigaction7 を紹介します。

余計なことは言わずに、まずはコードを見てみましょう。

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

void deletejob(pid_t pid) { printf("タスク %d を削除\n", pid); }

void addjob(pid_t pid) { printf("タスク %d を追加\n", pid); }

void handler(int sig) {
  int olderrno = errno;
  sigset_t mask_all, prev_all;
  pid_t pid;
  sigfillset(&mask_all);
  while ((pid = waitpid(-1, NULL, 0)) > 0) {
    deletejob(pid);
  }
  if (errno != ECHILD) {
    printf("waitpid エラー");
  }
  errno = olderrno;
}

int main(int argc, char **argv) {
  int pid;
  sigset_t mask_all, prev_all;
  sigfillset(&mask_all);
  struct sigaction new_action;
  new_action.sa_handler=handler;
  new_action.sa_mask=mask_all;
  signal(SIGCHLD, handler);
  while (1) {
    if ((pid = fork()) == 0) {
      execve("/bin/date", argv, NULL);
    }
    sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
    addjob(pid);
    sigprocmask(SIG_SETMASK, &prev_all, NULL);
  }
}

素晴らしい!皆さんは気づいたかもしれませんが、このコードは以前のコードに比べて sigaction に関連する設定が追加されています。何故でしょう?

はい、sigaction では、sa_mask を設定することで、信号処理関数が実行されている間にプロセスがどの信号をブロックするかを設定できます。

こうして、私たちのコードは以前よりも優雅になりました。もちろん、sigaction には他にも多くの便利な設定項目がありますので、ぜひ確認してみてください。

より迅速な信号処理方法#

上記の例では、信号処理関数の設定を優雅に解決しましたが、今度は全く新しい問題に直面しています。

前述のように、信号処理関数が実行されるとき、他の信号をブロックすることを選択しました。しかし、ここには問題があります。信号処理関数内のロジックが長時間かかり、原子性が必要ない(つまり、信号処理関数と同期を保つ必要がある)場合、かつシステム内で信号が高頻度で発生していると、こうしたやり方ではプロセスの信号キューが増え続け、予期しない結果を引き起こす可能性があります。

では、これを処理するためのより良い方法はあるのでしょうか?

例えば、ファイルを開き、信号処理関数内で特定の値を書き込むだけの処理を行います。そして、このファイルをポーリングし、変化があった場合にファイルの値を読み取り、具体的な信号を判断し、具体的な信号処理を行うことで、信号の確実な配信を保証し、信号処理ロジックが信号をブロックするコストを最小限に抑えることができるのではないでしょうか?

もちろん、コミュニティは皆さんがコードを書くのが難しいと知っているので、特別に新しい syscall -> signalfd8 を提供しています。

お決まりのように、まずは例を見てみましょう。

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/signalfd.h>
#include <sys/wait.h>

#define MAXEVENTS 64
void deletejob(pid_t pid) { printf("タスク %d を削除\n", pid); }

void addjob(pid_t pid) { printf("タスク %d を追加\n", pid); }

int main(int argc, char **argv) {
  int pid;
  struct epoll_event event;
  struct epoll_event *events;
  sigset_t mask;
  sigemptyset(&mask);
  sigaddset(&mask, SIGCHLD);
  if (sigprocmask(SIG_SETMASK, &mask, NULL) < 0) {
    perror("sigprocmask");
    return 1;
  }
  int sfd = signalfd(-1, &mask, 0);
  int epoll_fd = epoll_create(MAXEVENTS);
  event.events = EPOLLIN | EPOLLEXCLUSIVE | EPOLLET;
  event.data.fd = sfd;
  int s = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sfd, &event);
  if (s == -1) {
    abort();
  }
  events = calloc(MAXEVENTS, sizeof(event));
  while (1) {
    int n = epoll_wait(epoll_fd, events, MAXEVENTS, 1);
    if (n == -1) {
      if (errno == EINTR) {
        fprintf(stderr, "epoll EINTR エラー\n");
      } else if (errno == EINVAL) {
        fprintf(stderr, "epoll EINVAL エラー\n");
      } else if (errno == EFAULT) {
        fprintf(stderr, "epoll EFAULT エラー\n");
        exit(-1);
      } else if (errno == EBADF) {
        fprintf(stderr, "epoll EBADF エラー\n");
        exit(-1);
      }
    }
    printf("%d\n", n);
    for (int i = 0; i < n; i++) {
      if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) ||
          (!(events[i].events & EPOLLIN))) {
        printf("%d\n", i);
        fprintf(stderr, "epoll エラー\n");
        close(events[i].data.fd);
        continue;
      } else if (sfd == events[i].data.fd) {
        struct signalfd_siginfo si;
        ssize_t res = read(sfd, &si, sizeof(si));
        if (res < 0) {
          fprintf(stderr, "読み取りエラー\n");
          continue;
        }
        if (res != sizeof(si)) {
          fprintf(stderr, "何かが間違っている\n");
          continue;
        }
        if (si.ssi_signo == SIGCHLD) {
          printf("SIGCHLD を受信\n");
          int child_pid = waitpid(-1, NULL, 0);
          deletejob(child_pid);
        }
      }
    }
    if ((pid = fork()) == 0) {
      execve("/bin/date", argv, NULL);
    }
    addjob(pid);
  }
}

さて、このコードのいくつかの重要なポイントを紹介します。

  1. signalfd は特別なファイルディスクリプタの一種で、このファイルは読み取り可能で、select できます。指定した信号が発生したとき、返された fd から具体的な信号値を読み取ることができます。
  2. signalfd の優先度は信号処理関数よりも低いです。言い換えれば、信号 SIGCHLD に信号処理関数を登録し、同時に signalfd も登録した場合、信号が発生すると、まず信号処理関数が呼び出されます。したがって、signalfd を使用する際は、sigprocmask を利用してプロセスの信号マスクを設定する必要があります。
  3. 前述のように、このファイルディスクリプタは select 可能です。言い換えれば、select9, poll10, epoll1112 などの関数を使用して fd を監視できます。上記のコードでは、epoll を使用して signalfd を監視しています。

もちろん、ここで注意すべき点は、多くの言語が公式の signalfd API を提供していない(例えば Python)場合でも、同等の代替品を提供している可能性があることです。典型的な例は、Python の signal.set_wakeup_fd13 です。

ここで皆さんに考えてもらいたいのは、signalfd を利用する以外に、高効率で安全な信号処理を実現する方法は何かあるでしょうか?

まとめ#

私見ですが、信号処理は開発者の基本的なスキルであり、プログラム環境で遭遇するさまざまな信号を安全かつ信頼性高く処理する必要があります。また、システムは開発者の負担を軽減するために多くの優れた設計の API を提供しています。しかし、信号は本質的に通信手段の一つであり、その本質的な欠点は情報が少ないことです。多くの場合、高頻度の情報伝達が必要な場合、信号を利用することは必ずしも良い選択ではありません。もちろん、これは決定的な結論ではなく、ケースバイケースでトレードオフを行う必要があります。

これで、今週の二回目の水文が終わります(逃

参考文献#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。