目次


安全なシグナル処理のために再入可能ファンクションを使う

コードのバグを防ぐために、いつ、どのように再入可能を使うべきか

Comments

プログラミング初期の時代には、再入不可(non-reentrancy)であることはプログラマーにとって脅威ではありませんでした。ファンクションには同時アクセスが許されず、割り込みもありませんでした。また、かつてのC言語実装の多くでは、ファンクションは単一スレッドのプロセスで動作すると想定されていました。

ところが今や、並行プログラミングは全く普通のことであり、今度はその落とし穴を意識しておく必要があります。この記事では、ファンクションが再入不可であるために起こり得る問題と、並行プログラミングについて説明します。また、シグナルの生成と処理では特に話が複雑になります。シグナルの持つ非同期性のため、再入不可のファンクションをシグナル処理ファンクションがトリガーした時に発生するバグを見つけ出すのは、簡単なことではありません。

下記のリストが、この記事で説明する内容です。

  • 再入可能の定義と、再入可能ファンクションのPOSIXリスト
  • 再入不可なために引き起こされる問題の例
  • 下にあるファンクション確実に再入可能とするための方法
  • コンパイラー・レベルで再入可能を扱うための方法

再入可能とは何か?

再入可能(reentrant)なファンクションというのは、同時に一つ以上のタスクが、(データが改変されることを心配せず)並行して使うことのできるものを言います。逆に再入不可(non-reentrant)のファンクションというのは、一つ以上のタスクで共有することができないものです。共有するためには、セマフォーを使うことによって、あるいは重要なコード部分の実行中には割り込みを不可とすることによって、相互排除が保証されている必要があります。再入可能なファンクションには、任意の時に割り込むことができ、データを失うことなく後から処理を続行できます。再入可能なファンクションであれば、ローカル変数を使うか、あるいはグローバル変数を使用している場合にはデータを保護するようになっています。

再入可能なファンクションは、

  • 連続的なコールに対して静的データを保持せず、
  • 静的データに対するポインターを戻さず(すべてのデータはファンクションを呼び出す側が提供する)、
  • ローカル・データを使う、あるいはグローバル・データの場合はそのローカル・コピーを作ることで確実に保護し、
  • 再入不可のファンクションを呼んではなりません。

再入可能とスレッド・セーフとを混同しないでください。プログラマーの観点からは、これらは別々の概念です。ファンクションは再入可能であることもでき、スレッド・セーフであることもでき、その両方であることもでき、どちらでもない、ということも可能です。再入不可のファンクションを、複数のスレッドが使うことはできません。さらに、再入不可のファンクションをスレッド・セーフにすることもできません。

IEEE 1003.1では、再入可能なUNIX®ファンクションを118リストアップしていますが、ここでは再掲しません。参考文献にリンクを挙げておきますので、それを見てください。

その他のファンクションは再入不可ですが、その理由は次の通りです。

  • mallocまたはfreeを呼んでいる
  • 静的なデータ構造を使うものとして知られている
  • 標準I/Oライブラリーの一部になっている

シグナルと再入不可のファンクション

シグナルというのはソフトウェア割り込みです。シグナルによって、非同期イベントを処理できるようになります。プロセスに対してシグナルを送るには、受信されるシグナルのタイプに対応して、プロセス・テーブルにある項目のシグナルフィールド・ビットをカーネルがセットします。ANSI Cプロトタイプでのシグナルファンクションは次の通りです。

void (*signal (int sigNum, void (*sigHandler)(int))) (int);

別の表現では、下記のようになります。

typedef void sigHandler(int);
SigHandler *signal(int, sigHandler *);

捉えたシグナルをプロセスが処理する場合には、そのプロセスが実行する通常の命令シーケンスは、一時的にシグナルハンドラーによって割り込みをかけられます。するとプロセスの実行が継続されますが、今度はシグナルハンドラー中の命令が実行されます。シグナルハンドラーが戻ると、プロセスはシグナルを捉えた時に実行していた通常の命令シーケンスの実行を継続します。

ところで、シグナルハンドラーの中では、シグナルを捉えた時にプロセスが何を実行していたかは分かりません。もし、プロセスがmallocを使ってヒープ上のメモリー割り当てを行っている最中に、シグナルハンドラーからmallocを呼んだとしたらどうでしょう。あるいは、グローバル・データ構造を操作している最中のファンクションを呼んだ時に、その同じファンクションをシグナルハンドラーから呼んだとしたらどうでしょう。mallocの場合であれば、プロセスに大混乱が起きる可能性があります。なぜならmallocは通常、割り当てられた領域に対するリンク・リストを持っており、そのリストを変更している最中であったかも知れないからです。

割り込みは、複数命令が必要なC演算子の始めと終わりの間にも発生する可能性があります。プログラマーのレベルではアトミックに見える命令(つまり、それよりも小さな操作には分割できない)であっても、実際にはその演算を完了するために一つ以上のプロセッサー命令を使うかも知れません。例えば、次のようなCコードを見てください。

temp += 1;

x86プロセッサーでは、このステートメントをコンパイルすると次のようになります。

mov ax,[temp]
inc ax
mov [temp],ax

これは明らかに、アトミックではない演算です。

次の例を見れば、変数を修正している最中にシグナルハンドラーが実行すると何が起き得るかが分かるでしょう。

リスト1. 変数を修正している最中にシグナルハンドラーを実行する
#include <signal.h>
#include <stdio.h>
struct two_int { int a, b; } data;
void signal_handler(int signum){
   printf ("%d, %d\n", data.a, data.b);
   alarm (1);
}
int main (void){
 static struct two_int zeros = { 0, 0 }, ones = { 1, 1 };
 signal (SIGALRM, signal_handler);
 data = zeros;
 alarm (1);
while (1)
  {data = zeros; data = ones;}
}

このプログラムは、dataに0を入れ、1を入れ、0を入れ、1を入れ、と繰り返しながら永遠に続けます。一方、1秒に1度、アラームシグナルハンドラーが現在の内容を出力します。(このプログラムでは、シグナルが発生した時にハンドラー外でprintfが呼ばれることはないので、ハンドラーの中でprintfを呼んでも安全です。) このプログラムから、どのような出力が想定できるでしょう? 本来は、0, 0 または1, 1と出力するはずです。ところが実際の出力は次のようになります。

0, 0
1, 1
(Skipping some output...)
0, 1
1, 1
1, 0
1, 0
...

大部分のマシンでは、新しい値をdataに保存するためには幾つかの命令が必要であり、しかも値は一度に1ワードずつ保存されます。もし、こうした命令の間でシグナルが発生すると、ハンドラーはdata.aが0、またdata.bが1、あるいはその逆、と見るかも知れません。一方、割り込みを受けない単一命令でオブジェクトの値を保存できるマシンでこのコードを実行すれば、ハンドラーは常に0, 0 または1, 1と出力します。

シグナルに関してさらに複雑なのは、テスト・ケースを実行しただけでは、シグナルに関するバグの心配は無い、とは言えないのです。これは、シグナル生成が非同期である、という性格によるものです。

再入不可のファンクションと静的変数

シグナルハンドラーが、再入不可であるgethostbynameを使うと考えてみてください。このファンクションは、その値を静的オブジェクトで返します。

static struct hostent host; /* result stored here*/

そしてこれは毎回、同じオブジェクトを再使用します。次の例では、たまたまシグナルがmainでのgethostbynameへのコールの最中に到着すると、あるいは、コールの後であってもプログラムがまだその値を使っている間であれば、プログラムが要求している値を破壊してしまうことになります。

リスト2. gethostbynameの危険な使い方
main(){
  struct hostent *hostPtr;
  ...
  signal(SIGALRM, sig_handler);
  ...
  hostPtr = gethostbyname(hostNameOne);
  ...
}
void sig_handler(){
  struct hostent *hostPtr;
  ...
  /* call to gethostbyname may clobber the value stored during the call
  inside the main() */
  hostPtr = gethostbyname(hostNameTwo);
  ...
}

ところが、プログラムがgethostbynameを使わなければ、あるいは同じオブジェクトに情報を返すファンクションを何も使わなければ、あるいは、使う度にその前後でシグナルを必ずブロックすれば、安全なのです。

多くのライブラリー・ファンクションは、常に同じオブジェクトを再使用して、固定オブジェクトとして値を返すので、同じ問題を引き起こします。もし、供給されるオブジェクトをファンクションが使用して修正してしまうと、そのファンクションは再入不可になる可能性があります。もし2つのコールが同じオブジェクトを使うとすると、お互いに妨害し合うかも知れません。

ストリームを使ってI/Oを行う時にも似たことが起こります。シグナルハンドラーがfprintfでメッセージを出力し、シグナルが到着した時にプログラムは同じストリームを使ってfprintfコールの最中、という場合を考えてみてください。どちらのコールも同じデータ構造、つまりストリーム自体、の上で操作されているので、シグナルハンドラーのメッセージとプログラムのデータの両方とも、おかしくなる可能性があります。

サード・パーティーのライブラリーを使用する場合には、そのライブラリーのどの部分が再入可能であり、どの部分が再入不可なのかが分からないので、さらに複雑になります。標準ライブラリーの場合と同じように、同じオブジェクトを使って、固定オブジェクトとして値を返すライブラリー・ファンクションが多数あるかも知れません。そうしたファンクションは再入不可になります。

幸い多くのベンダーは、最近では標準Cライブラリーの再入可能版を提供するようになっています。対象のライブラリーに提供されているドキュメンテーションをよく見て、プロトタイプ、ひいては標準ライブラリー・ファンクションの使い方に何か変更がないかを確認する必要があります。

確実に再入可能とするためのベスト・プラクティス

プログラムを確実に再入可能とするためには、下記の5つのベスト・プラクティスを守ることです。

プラクティス1

静的データに対するポインターを戻すと、ファンクションが再入不可になる可能性があります。例えば文字列を大文字に変換するstrToUpperファンクションは、次のようにも実装できます。

リスト3. 再入不可なstrToUpper
char *strToUpper(char *str)
{
        /*Returning pointer to static data makes it non-reentrant */
       static char buffer[STRING_SIZE_LIMIT];
       int index;
       for (index = 0; str[index]; index++)
                buffer[index] = toupper(str[index]);
       buffer[index] = '\0';
       return buffer;
}

このファンクションを再入可能にしたものは、このファンクションのプロトタイプを変更することで実装できます。次のリストは、出力文字列を出力文字列にストレージを提供するものです。

リスト4. 再入可能なstrToUpper
char *strToUpper_r(char *in_str, char *out_str)
{
        int index;
        for (index = 0; in_str[index] != '\0'; index++)
        out_str[index] = toupper(in_str[index]);
        out_str[index] = '\0';
        return out_str;
}

出力に対するストレージを、呼び出し側のファンクションが提供することによって、そのファンクションが確実に再入可能になります。この場合、ファンクション名に「_r」というサフィックスを付けることで再入可能なファンクションを表す、という標準的なルールに注意してください。

プラクティス2

データの状態を記憶すると、ファンクションは再入不可になります。別々のスレッドが次々にファンクションを呼び、そのデータを使っている他のスレッドに知らせることなく、データを修正する可能性があります。あるデータの状態、例えば作業バッファーやポインターなどを、連続したコールに影響されずにファンクションの中で維持したいのであれば、呼び出し側がこのデータを提供する必要があります。

次の例では、あるファンクションが、連続した小文字の文字列を返します。strtokサブルーチンの場合と同じように、文字列は最初のコールでのみ提供されます。文字列の最後に達すると、ファンクションは\0を戻します。このファンクションは次のように実装することができます。

リスト5. 再入不可なgetLowercaseChar
char getLowercaseChar(char *str)
{
        static char *buffer;
        static int index;
        char c = '\0';
        /* stores the working string on first call only */
        if (string != NULL) {
                buffer = str;
                index = 0;
        }
        /* searches a lowercase character */
        while(c=buff[index]){
         if(islower(c))
         {
             index++;
             break;
         }
        index++;
       }
      return c;
}

このファンクションは、変数の状態を保存しているので、再入可能ではありません。再入可能にするためには、呼び出し側が静的データ、つまりindex変数を保持する必要があります。このファンクションを再入可能としたものは次のように実装することができます。

リスト6. 再入可能なgetLowercaseChar
char getLowercaseChar_r(char *str, int *pIndex)
{
        char c = '\0';
        /* no initialization - the caller should have done it */
        /* searches a lowercase character */
       while(c=buff[*pIndex]){
          if(islower(c))
          {
             (*pIndex)++; break;
          }
       (*pIndex)++;
       }
         return c;
}

プラクティス3

大部分のシステムでは、mallocfreeは、どのメモリー・ブロックがフリーであるかを記録する静的データ構造を使うため、再入可能ではありません。その結果、メモリーを割り当てたり解放したりするライブラリー・ファンクションは、どれも再入可能ではありません。結果を保存するための空間を割り当てるファンクションも、その中に含まれます。

ハンドラーでメモリー割り当てをせずにすませるには、シグナルハンドラーが使う空間を事前に割り当てておくのが一番です。また、ハンドラーでメモリーを解放せずにすませるためには、解放されるべきオブジェクトにフラグを立てるか、あるいはそのオブジェクトを記録し、解放されるのを待っているものが無いかどうかを時々プログラムでチェックするのが一番です。ただし、これは十分注意して行う必要があります。オブジェクトをチェーン上に置くのはアトミックではなく、もし同じことをする別のシグナルハンドラーが割り込むと、オブジェクトの一つを「失う」ことになります。ただし、シグナルが到着した時にハンドラーが使うストリームを、プログラムが使う可能性がないことが分かっているのであれば、安全だと言えます。また、プログラムが他のストリームを使うのであれば、それも問題ありません。

プラクティス4

バグのないプログラムを書くには、errnoh_errnoなど、プロセス全体に渡るグローバル変数の扱いに十分注意するようにします。次のコードを考えてみてください。

リスト7. errnoの危険な使い方
if (close(fd) < 0) {
  fprintf(stderr, "Error in close, errno: %d", errno);
  exit(1);
}

closeシステム・コールによるerrno変数の設定と、その戻り、という非常に短い時間の隙間でシグナルが生成されたと仮定してください。生成されたシグナルはerrnoの値を変更し、プログラムは予期せぬ振る舞いをするようになります。

下記のようにシグナルハンドラーの中でerrnoの値を保存し、回復するようにすれば、この問題を解決することができます。

リスト8. errnoの値を保存し、回復する
void signalHandler(int signo){
  int errno_saved;
  /* Save the error no. */
  errno_saved = errno;
  /* Let the signal handler complete its job */
  ...
  ...
  /* Restore the errno*/
  errno = errno_saved;
}

プラクティス5

もし、下にあるファンクションが致命的重要部分の処理の最中であり、そしてシグナルが生成、処理されるとすると、そのファンクションは再入不可になる可能性があります。下記のようにシグナルセットとシグナルマスクを使うことによって、コードの重要部分を特定なシグナルセットから保護することができます。

  1. 現在のシグナルセットを保存する
  2. シグナルセットを、望まないシグナルでマスクする
  3. 重要な部分のコードが終了するまで待つ
  4. 最後に、シグナルセットをリセットする

このプラクティスの概要は次の通りです。

リスト9. シグナルセットとシグナルマスクを使う
sigset_t newmask, oldmask, zeromask;
...
/* Register the signal handler */
signal(SIGALRM, sig_handler);
/* Initialize the signal sets */
sigemtyset(&newmask); sigemtyset(&zeromask);
/* Add the signal to the set */
sigaddset(&newmask, SIGALRM);
/* Block SIGALRM and save current signal mask in set variable 'oldmask'
*/
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
/* The protected code goes here
...
...
*/
/* Now allow all signals and pause */
sigsuspend(&zeromask);
/* Resume to the original signal mask */
sigprocmask(SIG_SETMASK, &oldmask, NULL);
/* Continue with other parts of the code */

sigsuspend(&zeromask); をスキップしてしまうと、問題を引き起こします。シグナルへのブロック解除とプロセスが実行する次の命令との間には、クロック・サイクルに少しの隙間が必要であり、この時間ウィンドウ幅内で発生するシグナルは、どれも失われます。sigsuspendファンクション・コールは、シグナルマスクをリセットしてプロセスをスリープ状態にする処理を一つのアトミック操作で行うことで、この問題を解決します。この時間ウィンドウ幅内でシグナルを生成しても害がないことが確実であれば、sigsuspendをスキップし、直接シグナルリセットに行くことができます。

コンパイラー・レベルで再入可能を扱う

ここで私は、コンパイラー・レベルで再入可能を扱うためのモデルを提案したいと思います。高水準言語用に新しいキーワード、reentrantを導入し、ファンクションには、そのファンクションが再入可能であることを保証するreentrant規定子を与えるのです。例えば次のようなものです。

reentrant int foo();

このディレクティブは、この特定ファンクションを特別に扱うよう、コンパイラーに対して指示します。コンパイラーは、そのシンボル・テーブルにこのディレクティブを保存し、中間コード生成フェーズの期間中にそれを利用します。これを実現するには、コンパイラーのフロント・エンドに少し設計変更が必要です。この再入可能規定子は、次のようなガイドラインに従う必要があります。

  1. 連続的なコールに対して静的データを保持しない
  2. グローバル・データのローカル・コピーを作ることで、グローバル・データを保護する
  3. 再入不可のファンクションを呼んではならない
  4. 静的データへの参照を返さない。また、ファンクションの呼び出し側が、すべてのデータを提供する

ガイドライン1はタイプ・チェックによって、またファンクションの中に何か静的ストレージ宣言があった場合にエラー・メッセージを投げることで実現できます。これはコンパイルのうちの、意味解析フェーズの期間中に行うことができます。

ガイドライン2、つまりグローバル・データの保護、は2つの方法で実現できます。基本的な方法は、ファンクションがグローバル・データを修正した場合にエラー・メッセージを投げることです。より高度な手法としては、グローバル・データが改変されないような方法で中間コードを生成することです。先のプラクティス4で説明した手法と似た手法を、コンパイラーのレベルで実装することができます。コンパイラーはそのファンクションに入るにあたって、コンパイラーが生成する一時的な名前を使って(操作すべき)グローバル・データを保存し、そのファンクションを終了する時にそのデータを取り戻します。コンパイラーが生成する一時的な名前を使ってデータを保存するのは、コンパイラーでは普通に行われることです。

ガイドライン3を守るためには、アプリケーションが使用するライブラリーを含めて、再入可能なファンクション全てをコンパイラーが事前に知っている必要があります。ファンクションに関する、この追加的情報は、シンボル・テーブルに保存されます。

最後に、ガイドライン4は既にガイドライン2によって保証されています。ファンクションに静的データが無い場合に、静的データへの参照を返さないのは当然です。

私が提案するモデルによって、再入可能なファンクションとするためのガイドラインに従いやすくなると思います。また、このモデルを使うことによって、再入可能に関する予期せぬバグからコードを守ることができるでしょう。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=230114
ArticleTitle=安全なシグナル処理のために再入可能ファンクションを使う
publish-date=01202005