「(情緒的)再入可能性」入門

これは何の話?

初学者が次のようなメソッドを書くことがある。 戻り値にしたい値が二つあったのだけど、戻り値を二つ返せないので、インスタンスフィールドで結果を受け取る、というわけだ。

//戻り値ではなく、インスタンスフィールドで結果を受け取る
List<string> filePathList = new List<string>();
List<int> fileSizeList = new List<int>();

public void DoSomething(){
    //filePathListには girlsを含むファイルパスが格納される。
    FindFileByName("girls"); 

    //filePathListには、既にgirlsがあり、そこにboysが追加される。
    //望んだ結果なの???
    FindFileByName("boys"); 
}

//keyword を含むファイルを見つける処理
private void FindFileByName(string keyword)
{
    for( ... ) //何かのループ処理
    {
      //ファイルシステムを検索し、
      //keywordを含むファイルみつける処理

      //見つかったファイルのpath と size を格納
      filePathList.Add( ... );
      fileSizeList.Add( ... );
    }
}

こういう作りのメソッドは、2回目に呼び出されたときに、1回目の結果がクリアされていないので、意図した結果にならないよね。

じゃ、戻り値を 「Tuple<string,int>で返そう」「戻り値用クラスを作ろう」というテクニックを教えると、こういうコードは無くなる・・・かというとそうでもない。 なぜか、「戻り値用クラスを作るのが面倒で・・・」とか何とかいって、またこういうコードを書いてしまう。

こういうコードは「●●●●性」が低いからよくないことが多いのよ、、、って言ってあげたいのだけど、ちょっといい言葉が思いつかない。 さて「●●●●性」ってなんだろう。

この文章が言いたいこと

長い文章なので、先に結論めいたことを言っておくと「(情緒的)再入可能性」の高いコードを書く方が良い場合が多いので、「(情緒的)再入可能性」を意識しよう、ということ。

情緒的に「再入可能性」を理解する

「●●●●性」は「(情緒的)再入可能性」ということにしよう。これは本来の「再入可能性」とはちょっと違うんだけど、初学者には不正確を承知で、「(情緒的)再入可能性」を以下のように覚えておくとよい。

  • 再入可能性とは、同一スレッドで、ある関数を2回以上実行したときに、2回目以降も意図した結果になること。

これは、本来の定義とはまぁまぁ違うんだけど、これを満たすことは(真の)再入可能性の第一歩なので、ま、いいかなと。

以後、「再入可能性」というと、だいたいは「(情緒的)再入可能性」を指します。

「再入可能性」を2値ではなく、アナログ値で考える

「(真の)再入可能性」って「高いか/低いか」とアナログ値的に考えるものではなくて、「満たすか/満たさないか」の2値で考えるもの。
だけど、「(情緒的)再入可能性」では、アナログ値的に「高さ」を考えることもなかなか有用。そこで、「高い」「低い」を次のように考えることにしよう。

  • 「再入可能性が高い」・・・メソッドに登場する変数のスコープは、ローカルなものが多い。
  • 「再入可能性が低い」・・・メソッドに登場する変数のスコープは、ローカルなものが少ない。

「再入可能性」が「高い」方が、よい結果を招くことが多いので、できる範囲で「再入可能性を高く」しよう。

冒頭のメソッドは、インスタンスフィールドのクリアをすることでも、「(情緒的)再入可能」になる。だけど、結果を戻り値で返す方が「再入可能性が高い」と考えることができる。 (また、後述の「再入」を読めばわかるけど、インスタンスフィールドのクリアでは「(真の)再入可能」にはならない。)

「再入」ってなに?

たぶん、いろいろな記事を読み漁ってもピンと来ていない人も結構いると思いますが、「再入」とは、同一スレッド上で、関数Aの実行中にそれをとめて、関数Aを呼ぶこと、なんだよね。

ここで見落としてはいけないのは「同一スレッド上で」という点。

フツーにC#だけしか触ったことない人には、「ひとつのスレッドで関数Aを実行している最中に、そのスレッドで関数Aを呼ぶ」、ってことが理解できないよね。 でも、Linuxのシグナルハンドラは、スレッドXが関数Aを実行している最中に、シグナルを受け取ると、関数Aの実行は中断されて、スレッドXでシグナルハンドラが実行される。シグナルハンドラが関数Aを呼ぶと、つまりは関数Aは「再入」された、ということになる。この時、関数Aが意図通りの結果になるように実装されていれば、「(真の)再入可能性」を満たす、とよぶわけだ。

おれら「再入」とは無縁の世界の住人でしょ?

「再入」と無縁な人にとっては、「(真の)再入可能性」って過剰品質なんだよね。だから、「(真の)再入可能性」を理解する必要も、それを満たす必要もないと思う。
でも「再入」と縁がなくても、「(情緒的)再入可能性」として、「高さ」を意識することは結構有用だと思うんです。

例えば、冒頭のメソッドは「再入可能性の高さ」を意識していれば、「インスタンスフィールドのクリアをする/しない」という低レベルな問題につまずくことはないよね。

「参照透過性」と呼んじゃだめなの?

「参照透過性」について意識することでも、コードを書くときの美的センスについて、似たような洞察が得られるかもしれないけど、「参照透過性」って「再入可能性」より潔癖な感じだよね。

「参照透過性」って、おおざっぱに言えば

  • 「引数以外、何も受け取らない」
  • 「引数が同じなら、戻り値もいつも同じ結果になる」

ってことだよね。この話題は、「引数以外、何も受け取らない」というストイックな話ではないので「再入可能性」の方がしっくりくるかな、と思う。

スレッドセーフと「再入可能性」は違う

まぁ当然ちがうんだけど「再入可能性」が高いと、スレッドセーフに改造しやすいことが多い。なので、苦労のない範囲で「再入可能性」は高くなるように心がけた方がいい。

スレッドセーフと「(真の)再入可能性」は違う

非ローカルな変数Yを使っている関数は、「(真の)再入可能性」を満たさないけど、変数Yをスレッドローカル(TLS)な変数として宣言していれば、スレッドセーフにはなる。

別の説明もできそう。

「(真の)再入可能性」を満たすコードってヘンテコなコードだよね。スレッドセーフにするならば、tをTLSにすれば済むところ、同一スレッドで実行されるから「自前でTLS的な変数s」を用意している、と思うとわかりやすいような気もする。(逆に混乱させたらスマンけど)

Wikipediaから引用し、コメント付加・改変したコード

_Thread_local  int t; // tをスレッドローカルに宣言しているので、swapはスレッドセーフになる

void swap(int *x, int *y)
{
  int s; //再入にはTLSは効果ないので、自前で退避用のsを用意

  s = t; // グローバル変数をセーブ
  t = *x;
  *x = *y;
  // ここでハード割り込みが起きて isr() が呼び出される可能性がある。
  *y = t;
  t = s; // グローバル変数をリストア
}

//↓この関数がシグナルハンドラから呼ばれる
void isr()
{
  int x = 1, y = 2;
  swap(&x, &y);
}

「再入」のコード

「再入」が同一スレッド上で実行されることを、次の汚いコードで確認できる。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <pthread.h>

//gcc hoge.c -pthread でコンパイルできるハズ。
//思い出して書いたのでコンパイルできるか確認してません。全体的に適当なコードです。
//CentOSで動いた。


volatile sig_atomic_t exit_flag = 0;

void sig_handler(int sig);

void* secondthread(void* p);

__thread int t; //tをスレッドローカルな変数にすることで、swap関数はスレッドセーフになる
//int t;

//swapは再入可能ではない
void swap(int* x, int* y, int time) {
    t = *x;
    *x = *y;
    sleep(time);
    // ここでハード割り込みが起きて isr() が呼び出される可能性がある。
    *y = t;
}

void isr()
{
    int x = 1, y = 2;
    swap(&x, &y, 0);
    printf("isr-swap %d %d\n", x, y);
}

//スレッドID取得関数
pid_t gettid(void) {
    return syscall(SYS_gettid);
}

int main() {
    printf("main-tid %d\n", gettid());

    if (signal(SIGINT, sig_handler) == SIG_ERR) {
        exit(1);
    }

    pthread_t pthread;
    pthread_create(&pthread, NULL, &secondthread, NULL);

    while (!exit_flag) {
        int x = 8, y = 9;
        swap(&x, &y, 1);
        printf("main-swap %d %d\n", x, y);
    }

    sleep(3);
    return 0;
}

void* secondthread(void* p) {
    printf("2nd-tid %d\n", gettid());
    while (!exit_flag) {
        int x = 5, y = 6;
        swap(&x, &y, 1);
        printf("2nd--swap %d %d\n", x, y);
    }
}

void sig_handler(int sig) {
    printf("sig-tid %d\n", gettid());
    isr();
    exit_flag = 1;
}