localtimeやstrtokは本当にスレッドセーフにできないのか (2)

localtime関数は、返却するデータを、スレッド固有データ(Thread Specific Data, 略して TSD*1 )として確保すれば、スレッドセーフな関数として実装できると思います。


グローバル変数や、関数内で宣言されたstatic変数など、「静的な記憶期間をもつ変数」は、自動変数と異なり、普通はスレッド間で共有して使用することになります。しかし、上記のTSDを用いると、「静的な記憶期間を持ちつつスレッド毎に独立した」変数を使うことができます。以下に、JMの解説を引用しておきます。

プログラムではスレッドごとに値の異なる グローバル変数や静的変数がしばしば必要となる。 複数のスレッドは 1 つのメモリ空間を共有するため、 通常の変数ではこれを実現することができない。 スレッド固有データは、 この必要性への POSIX スレッドの答えである。

それぞれのスレッドはスレッド固有データ (thread-specific data) 領域、 略して TSD 領域という プライベートなメモリブロックを保有している。 この領域は TSD キーをインデックスとして管理される。 TSD 領域では void * 型の値を TSD キーに結び付ける。 TSD キーはすべてのスレッドに共通であるが、 TSD キーに結び付けられる値はスレッドごとに異なるように することができる。

TSDはPOSIXできちんと規格化されており、主に次の型と関数を用いて取り扱います。

  • pthread_key_t
  • pthread_key_create()
  • pthread_key_delete()
  • pthread_setspecific()
  • pthread_getspecific()

詳細な使い方は、POSIXの規格かIBMによる日本語の説明*2、あるいはSunによる日本語の説明*3などを参照していただくことにして、ここではスレッドセーフ版localtimeの実装だけを示すことにします*4

// スレッドセーフ版 localtime関数の実装例
// (GCC拡張を使って良いなら、もっと簡潔に書く方法があります。詳しくは次の記事を)

#include <pthread.h>
#include <time.h>
#include <stdlib.h>

#ifdef DEBUG
 #include <stdio.h>
 #define DPRINTF(x) printf x
#else
 #define DPRINTF(x) /* void */
#endif

static pthread_key_t localtime_key;

// スレッドが消滅する時に呼ばれるフック
static void localtime_data_dtor(void* data) {
    // 注: pthread_t型がunsigned longであると仮定している↓のは、本当は良くないです。
    //     デバッグ用の記述なのでお許しを。
    DPRINTF(("thread ID %lu, TSD %p freed\n", pthread_self(), data));
    free(data);
    pthread_setspecific(localtime_key, NULL);
}

// プロセスが消滅する時に呼ばれるフック
static void localtime_key_dtor(void) {
    DPRINTF(("TSD key for localtime deleted.\n"));
    pthread_key_delete(localtime_key);
}

// 「キー」の生成は、プロセス中のどれかのスレッドが一度だけ行えばよい
static void localtime_key_init(void) {
    pthread_key_create(&localtime_key, localtime_data_dtor);
    atexit(localtime_key_dtor); // まぁこの行はなくてもいいかも…。どうせexitするのだし。
    DPRINTF(("TSD key for localtime created.\n"));
}

// 外部に公開する関数はこれだけ
struct tm* localtime(const time_t* timep) {
    static pthread_once_t once_control = PTHREAD_ONCE_INIT;
    pthread_once(&once_control, localtime_key_init);

    struct tm* ret = pthread_getspecific(localtime_key);
    if (!ret) {
        // そのスレッド用のlocaltime戻り値領域をここで確保する
        ret = malloc(sizeof(struct tm));
        if (!ret) {
            // 本当にメモリ不足なら諦める
            return NULL;
        }
        // 確保したメモリのアドレスを覚える
        pthread_setspecific(localtime_key, ret);
        DPRINTF(("thread ID %lu, TSD %p allocated.\n", pthread_self(), ret));
    } else {
        DPRINTF(("thread ID %lu, TSD = %p\n", pthread_self(), ret));
    }

    // 残りの処理はlocaltime_rに委譲
    return localtime_r(timep, ret);
}

さて、実装が完了しましたので、それを共有ライブラリ化してみます。具体的には次のようにすればOKでしょう。挙動を確認するために、-DDEBUG を指定していますが、リリースビルドであれば -DDEBUG はナシで -O2 を指定するとよいでしょう。

$ gcc -D_REENTRANT -DDEBUG -fPIC -Wall -W -c localtime.c
$ gcc -shared -Wl,-soname,libtsf.so.1 -o libtsf.so.1.0.1 localtime.o -lpthread

次のようなコードを書き、このライブラリを使ってみます。

#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>

static void call_localtime(void) {
    char buf[128];
    time_t ti = time(NULL);
    struct tm* ret = localtime(&ti);
    asctime_r(ret, buf);
    printf("Thread ID %lu, return address is %p, result = %s",
           pthread_self(), ret, buf);
    fflush(stdout);
}

static void* do_something(void* arg) {
        call_localtime();
        call_localtime();
    return arg;
}

int main(void) {
    printf("main thread ID = %lu\n", pthread_self());

    pthread_t t1, t2;
    pthread_create(&t1, NULL, do_something, NULL);
    pthread_create(&t2, NULL, do_something, NULL);

    printf("thread1 ID = %lu\n", t1);
    printf("thread2 ID = %lu\n", t2);

    call_localtime();
    call_localtime();

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    return 0;
}

メインスレッドが2つのサブスレッドを生成し、計3つのスレッドがlocaltimeを2回づつ実行し、結果を表示するというものです。コンパイルし、まずは素のlibcのlocaltime関数を使用して結果を見てみます。

$ gcc -Wall -W main.c -lpthread
$ ./a.out
main thread ID = 4144868256
Thread ID 4144855984, return address is 0x488940, result = Thu Sep  9 20:42:56 2004
Thread ID 4144855984, return address is 0x488940, result = Thu Sep  9 20:42:56 2004
Thread ID 4134362032, return address is 0x488940, result = Thu Sep  9 20:42:56 2004
Thread ID 4134362032, return address is 0x488940, result = Thu Sep  9 20:42:56 2004
thread1 ID = 4144855984
thread2 ID = 4134362032
Thread ID 4144868256, return address is 0x488940, result = Thu Sep  9 20:42:56 2004
Thread ID 4144868256, return address is 0x488940, result = Thu Sep  9 20:42:56 2004

3つのスレッド全てに、同じアドレス 0x488940 が戻っています。シングルCPUのマシンで実行したからか、どうにか運良く全てのスレッドが正しい時刻を得ているようです。


次に、スレッドセーフ版のlocaltimeに置き換えて実行してみましょう。main.c の再コンパイルは不要であることに注意してください。

$ LD_PRELOAD=./libtsf.so.1.0.1 ./a.out
main thread ID = 4144435328
TSD key for localtime created.
thread ID 4144430000, TSD 0x97bb050 allocated.
thread ID 4133940144, TSD 0x97bb0c8 allocated.
thread1 ID = 4144430000
thread2 ID = 4133940144
thread ID 4144435328, TSD 0x97bb0f8 allocated.
Thread ID 4144430000, return address is 0x97bb050, result = Thu Sep  9 20:46:30 2004
thread ID 4144430000, TSD = 0x97bb050
Thread ID 4133940144, return address is 0x97bb0c8, result = Thu Sep  9 20:46:30 2004
thread ID 4133940144, TSD = 0x97bb0c8
Thread ID 4144435328, return address is 0x97bb0f8, result = Thu Sep  9 20:46:30 2004
thread ID 4144435328, TSD = 0x97bb0f8
Thread ID 4144430000, return address is 0x97bb050, result = Thu Sep  9 20:46:30 2004
thread ID 4144430000, TSD 0x97bb050 freed
Thread ID 4133940144, return address is 0x97bb0c8, result = Thu Sep  9 20:46:30 2004
thread ID 4133940144, TSD 0x97bb0c8 freed
Thread ID 4144435328, return address is 0x97bb0f8, result = Thu Sep  9 20:46:30 2004
TSD key for localtime deleted.

return address is ... のところを見ると、スレッド毎に別々の値になっていることがわかると思います。時刻も、正しいものがキチンと得られています。問題ありません。good job.


さてさて、このように、TSDを使えば、シグネチャを変えずともスレッドセーフなlocaltime関数を作ることができます*5。がしかし、欠点もやはりあります。深刻な順に並べて、主に次の3つです。

  • (とても)遅い
  • TSDをまだサポートしていない環境では使えない
  • ISO C の規格に反する


まず速度の件ですが、手元の FC2, gcc-3.3.3, Pentium4-1.6GHz の環境では通常のlocaltimeより3倍遅かったです。これは少々遅すぎる気がします。また二番目のTSDがサポートされない環境の件も、まだまだ現実的な問題と思います。


最後にISO C の件ですが…例えばglibcのlocaltime実装[glibc-2.3.3-200405070341/time/localtime.c]を見ると、次のように書かれています。

/* The C Standard says that localtime and gmtime return the same pointer.  */
struct tm _tmbuf;

またSUSv3にも次のように書かれています

The asctime(), ctime(), gmtime(), and localtime() functions shall return values in one of two static objects: a broken-down time structure and an array of type char. Execution of any of the functions may overwrite the information returned in either of these objects by any of the other functions.

スレッドAのlocaltime結果アドレスとスレッドAのgmtime結果アドレスを一致させることはできても、スレッドAのlocaltime結果アドレスとスレッドBのgmtime結果アドレスを一致させることはできません*6から、こ の要求を満たすことはできません。まぁ違反して何か問題が出るとは思えませんが(笑)。あと、本当にメモリが足りなくてNULLを返却せざるを得ないとき、errnoをどうするかという問題があります。SUSv3ではlocaltimeはEOVERFLOWしか返却できないことになっていますが、これをメモリ不足の意味と思えというのは無茶でしょう。


続く

*1:TLS, thread local storage と呼ぶ文化もあるようだ。Windows方面の用語かな?

*2:AIX用ですがその他のUNIXを使用している場合でも一緒です

*3:「例 2-4 スレッド固有データの初期化」を特に参照のこと

*4:実はTSDは初めて使うので、事実誤認等ありましたら教えていただけるとあり難いです

*5:Windowsの世界だと似たような関数はことごとくTLSで実装されているそうで。Windowsな人には今日の話は常識でしょうか。

*6:そうしてしまったら、今回の改良の意味がなくなる