Manjusaka

Manjusaka

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

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

前回の要約#

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

#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 を提供しています。しかし、信号は本質的に通信手段の一つであり、その本質的な欠点は伝達される情報が少ないことです。多くの場合、高頻度の情報伝達が必要な場合、信号を利用することは必ずしも良い選択ではありません。もちろん、これは決定的な結論ではなく、ケースバイケースでトレードオフを行う必要があります。

これで、今週の二回目の水文は終了です(逃

参考文献#

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