Deep Side of Java〜Java 言語再入門 第4回 〜 アプレット、スレッド、AWT

スレッドの使い方

スレッドって何?






スレッドの使い方

スレッドって何?

そもそもスレッドは、コルーチン(非同期に協同して動作するサブルーチン)を形式化して生まれたものである。UNIXでは往々にして複数のプロセスを生成して、それらを協同させて何かの処理をさせることがあるが、複数のプロセスを作ってしまうと、それらは独自のメモリ空間で動作するので、データ共有に別な仕掛けが必要となって協同作用の幅が狭まってしまう。そこで、「軽量プロセス」、プロセスと同じく独自のスケジューリングを持つが、それでも独自のプロセスとまでは言えないような、あるプロセスに従属した「軽いプロセス」によるプログラミングモデルが開発され、これを一般に「スレッド」を呼ぶ。

なぜこのようにスレッドが重視されているのか、という理由にはこの「スレッド・プログラミング・モデル」が柔軟にコルーチンを実現できることの他に、マルチプロセッサ環境で非常に効率的なパフォーマンスが実現できることがある。つまり、スレッドはプロセスと同様に、個別にスケジューリングの対象となり、そのスケジューリングの中で複数のプロセッサにスレッドを割り当てることも可能だからである。

このスレッドは POSIX で定義され、それに合わせた POSIX Thread ライブラリ(pthreadライブラリ)が最近のUNIXでは装備されていることが多い。しかし、スレッドは論理的なアイデアであり、その実装は各OSによってばらばらであっても構わないし、それ以前からスレッドを実装していたOSの場合には、独自のシステムコールによるスレッドが実装されている場合がある。Java のスレッドは個別OSのスレッド機能を使って実装されているために、Java スレッドライブラリの興味深いメソッドはすべて native であり、実際にはCなどを使って書かれた機械語ライブラリの呼び出しに過ぎない。だから、以下はスレッドに関する基本知識と、Linux 上での実際の振舞から推測した結果であるが、そう外れてはいないという自信がある。

比較して見てみよう。

スケジューリング

プロセスもスレッドも、少なくとも見かけ上は個別にスケジュールされ、個別のタイムスライスを与えられる。そのため、タイムシェアリングによるマルチタスクの結果、1つしかプロセッサがなくても、見かけ上並行して動作する。勿論マルチプロセッサ環境では、本当に並行動作しても構わない。Linux ではスケジューリング上、各スレッドをプロセスとして扱い、スケジューリングはカーネルのスケジューラに任せている。だから、Java インタプリタを起動して ps コマンドによってプロセス一覧を見ると次のようになる。

  F   UID   PID  PPID PRI  NI   VSZ  RSS WCHAN   STAT TTY    TIME COMMAND
000   501  7813  2787   0   0  3356  1036 do_sel S   pts/0   0:00 kterm
000   501  7819  7813  11   0  3036   876 rt_sig S   pts/1   0:00 [tcsh]
000   501  8158  2787  16   0 140740 6556 nanosl S   pts/0   0:01 java TG
040   501  8205  8158  18   0 140740 6556 do_pol S   pts/0   0:00 java TG
040   501  8206  8205   2   0 140740 6556 nanosl S   pts/0   0:00 java TG
040   501  8207  8205   4   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8208  8205   4   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8210  8205  12   0 140740 6556 nanosl S   pts/0   0:00 java TG
040   501  8211  8205  16   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8212  8205  20   0 140740 6556 rt_sig S   pts/0   0:00 java TG
000   501  8215  7819  10   0  2528   876 -      R   pts/1   0:00 ps -axl --cols 130

ここで起動した「java TG」は、筆者が作成したスレッドの内容を見るプログラムである。8つのプロセスが存在していることがわかる。「java TG」はそれぞれのスレッドについての内容を報告してくれている。これの出力は次の通り。

berio.kobe-du.ac.jp sug 1014% java TG
java.lang.ThreadGroup[name=system,maxpri=10]
    Thread[Reference Handler,10,system]
    Thread[Finalizer,8,system]
    Thread[Signal Dispatcher,10,system]
    Thread[CompileThread0,10,system]
    java.lang.ThreadGroup[name=main,maxpri=10]
        Thread[main,5,main]

ゆえに、スレッドをグループ化して管理する「ThreadGroup」自体も一種のスレッドであり、このTGの出力では7つのスレッドが存在し、それに起動プロセスを加えると、UNIXプロセス数と一致する。だから、Java インタプリタは起動すると「system」スレッドグループをスレッドとして起動し、その中にスレッドとして「Reference handler,Filanlizer, Signal Dispatcher, CompileThread0」の4つのスレッドを走らせる。そして、アプリケーション用の「main」スレッドグループを作成し、その中で「main」スレッドを実行しているのである。

「system」スレッドグループの各スレッドの担当内容を推測すると大体次の通りだろう。

Reference Handler
生成されたオブジェクトを管理するスレッドであろう。
Filanizer
いわゆるGCスレッドであろう。
Signal Dispatcher
UNIXシステムから受け取ったシグナルを、適切に Java のスレッドへの割り込みとして配信するスレッドであろう。
CompleThread0
いわゆるJITコンパイラ(Just-In-Time Complier)であろう。これは Java バイトコードをターゲットマシンの機械語に変換し、動作のスピードを改善する仕掛けである。

また、Java プログラムが AWT を使うと、「AWT-EventQueue, SunToolkit.PostEventQueue, AWT-Motif」スレッドが main スレッドグループに追加される。AWT のイベント処理もスレッドによって実現されていることがわかる。

ps の出力結果に戻ろう。

  F   UID   PID  PPID PRI  NI   VSZ  RSS WCHAN   STAT TTY    TIME COMMAND
000   501  7813  2787   0   0  3356  1036 do_sel S   pts/0   0:00 kterm
000   501  7819  7813  11   0  3036   876 rt_sig S   pts/1   0:00 [tcsh]
000   501  8158  2787  16   0 140740 6556 nanosl S   pts/0   0:01 java TG
040   501  8205  8158  18   0 140740 6556 do_pol S   pts/0   0:00 java TG
040   501  8206  8205   2   0 140740 6556 nanosl S   pts/0   0:00 java TG
040   501  8207  8205   4   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8208  8205   4   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8210  8205  12   0 140740 6556 nanosl S   pts/0   0:00 java TG
040   501  8211  8205  16   0 140740 6556 rt_sig S   pts/0   0:00 java TG
040   501  8212  8205  20   0 140740 6556 rt_sig S   pts/0   0:00 java TG
000   501  8215  7819  10   0  2528   876 -      R   pts/1   0:00 ps -axl --cols 130

VSZ項目とRSS項目は消費メモリのサイズに関する項目であり、当然それぞれ同じサイズである。ここでスレッドであるかどうかを判定する項目は最初の「F(flag)」項目であり、これは pthread ライブラリを実現するために用意された clone(2) システムコールに与えられたフラグを表す。このフラグに応じて、clone(2) は親プロセスとさまざまなリソースを共有する仕様になっているのである。特に「Fの値=040」は、「CLONE_VM」(in bits/sched.h)であり、仮想メモリ空間を共有することを示している。最初の1つの java プロセスのみは、プロセスとして起動されているので「Fの値=000(通常のプロセス)」であることに注意されたい。

それゆえ、Linux の実装では、Java スレッドの本体は仮想メモリ空間を共有するプロセスである、と言うことができるが、他の実装ではその限りではないことは言うまでもない。また、スレッドは個別にスケジュールされるので、以降の話の中で、そのスケジューリングに関して一切の仮定が不可能であることをここに強調しておく。

メモリ空間

プロセスは独自のメモリ空間を持ち、相互のプロセスは仮想メモリのおかげで互いに干渉しあうことはない。それに対してスレッドは、それが起動されたプロセスと少なくともコードセグメントとデータセグメントはメモリ空間の上で共有し、スタックだけは独自のものを使う。

この事情は、Linux のような最近のUNIXでは、「デマンドコピー」によってやや複雑な様相を呈している。つまり、物理メモリを節約するために、参照のみで書き込みがなされない論理メモリは、たとえ複数あっても1つの物理メモリを重複して論理メモリ空間にマッピングすればよいという戦略が「デマンドコピー」である。簡単に言えば、書き込みがなされるメモリであっても、そのメモリに実際の書き込みがなされるまでは、単に以前と同じ物理メモリを共有しておき、書き込みがなされた時点であらたな物理メモリを要求し、その時点で初めて実メモリがすり替わるというのが「デマンドコピー」である。メモリの確保単位はページであるので、この結果書き込みがなされた部分のページだけが新規に確保された物理メモリであり、書き込みがなされないページは依然他のメモリ空間と共有されていることになる。

スレッドではこのような仕組みを前提にすると、大変都合が良い。つまり、各個別のセグメントとして見ると、次のような戦略を使える。

コードセグメント
そもそも読み込み専用なので、スレッドは親プロセスと共有する。
データセグメント
スレッドが「メモリ空間を共有」する、現実的な意味は、共通するデータセグメントに書き込みが可能なことである。そもそも共有する。
スタックセグメント
ただし、スタックは各スレッドが共有せずに独自のものを使う。しかし、デマンドコピーを前提にすれば、スレッド起動時にそれぞれのスレッドのスタック用にメモリを確保し、確保された新スタックをそれぞれのスレッドのスタックポインタが指すようにしてスレッド起動を行えば、ランダムアクセスをしないスタックの場合には、セグメント自体は共有しても構わない。つまり、メモリレイアウトの上では、すべてのスレッドが同一のメモリマップのレイアウトを持ち、しかし、スタックポインタだけがそれぞれのスレッドで独自のスタックを指す、というようにしてもメモリ利用効率が低下しないのである。

だから、Linux では単純に「CLONE_VM」(仮想メモリ空間を共有する)だけでスレッドが実装できるのである。

プロセス間通信の制御

プロセス同士がデータを交換し合うために、UNIX ではさまざまな仕組みが用意されている。パイプ・シグナル・セマフォ・メッセージ・共有メモリなどのシステムコールによる仕組みの他に、ファイルシステムを経由したファイルロック、名前付きパイプなど盛り沢山である。しかし、スレッド間の通信はメモリ空間を共有するために、単純に共有するデータセグメントを介してデータを送れば良いだけである。また、複数のスレッド間で待ち行列を実現するのであればパイプを使っても良いし、相互にシグナルを送って割り込ませることも可能である。

しかし、スレッドでは大変安易に共有するデータセグメントを介してデータ通信が出来てしまう。これは反面、無秩序にスレッドがデータを変更可能だということにも繋がる。そうなってしまえば、あるスレッドがある時点で読み込んだデータを元に更新する場合でも、「読んだ」時点から「更新する」時点の間に、別なスレッドがやはり「更新」をしてしまうかもしれない。マルチスレッドではスケジューリングに関して何の仮定も不可能だから、単純なチェック&セットでさえも、読み込んだデータが信頼できないことになってしまう。

より判りやすいケースとして、WWWサービスでの「アクセスカウンタ」を考えてみよう。「アクセスカウンタ」は次のように動作する。

  1. サーバがHTTPのリクエストに応じ、CGIなどのアクセスカウンタプログラムを起動する。
  2. ファイルなどから「現在のカウンタ値」を取得し、それに1を加算する。
  3. ファイルなどに「現在のカウンタ値」を保存する。
  4. クライアント向けの出力をする。

しかし、CGIなどはサーバによって並行して起動される可能性がある。ほぼ同時にサーバにアクセスがあった場合、別なプロセスとして複数のCGIプログラムが動作し、現実には次のように動作する可能性がある。

この時、2つのCGIでは同じ値がアクセスカウンタ値として返され、サーバ側から見た時には2つのアクセスが1つのアクセスとしての効果しか持たないのである。アクセスカウンタのような「お遊び」では重大な問題を生じなくても、これはデータベースの一貫性を崩すとんでもない結果なのである。

このような並行動作に秩序を導入するためには、「現在のカウンタ値を取得して1を加算し」、「現在のカウンタ値をセットする」2つの処理が、確実に連続して行われることを保証すれば良いのである。このためにはそれを監視してくれる「門番」が必要である。「門番」がいれば、アクセスカウンタは次のように動作する。

「チェック&セット」のような一連の処理が、他のプロセスによって割り込まれずに実行されることを「アトミック(原子的)である」と呼ぶ。だから、一連の処理が「アトミックに実行されること」を保証する機構が上の例の「門番」なのである。

現実的なCGIプログラムではこの「門番」を「ファイルロック」を使って実装し、またデータベースでは「データロック」と呼ばれる処理がこれを担当する。このような「門番」はスレッド・プラグラミングの概念では「mutex」と呼ばれ、UNIXシステムコールのレベルでは「セマフォ」として実装されているが、Java では「モニタ」というインタプリタが装備する仕組みによって、これを実現している。

mutex

しかし、スレッドで共有されるのは、単なるデータセグメントの通常のデータである。だから特定のデータオブジェクトと結びついたセマフォのような機構が必要だという結論になる。これが「mutex(MUTual EXclusion,相互排他)」である。一般にセマフォはあるスレッドがセマフォを取得しようとする時に、もし他のスレッドがすでにそのセマフォを取得していなければそのまま処理が進行し、もし他のスレッドがすでにそのセマフォを取得していれば、そのセマフォが解放されるまで処理がブロックする、という機構を持っている。これを使ってプログラムの部分を常に1つのスレッドが連続的に実行することを保証できる。Java 風に書けば次の通り。一般的に変数のチェック&セットの間に、別スレッドによって予期しない値がセットされる可能性があることに神経質になって見て頂きたい。

int [] array = new int [10];     /* 並行してアクセスされる可能性があるデータ */
Mutex arrayMutex = new Mutex();  /* に対して個別に mutex を作る */
Thread theThread;

......... スレッド内部での実行 .............
arrayMutex.getSemaphore( theThread );
for( int i = 0; i < array.size(); i++ ) {
    if( array[i] == ..... ) { ...... }
}
arrayMutex.freeSemaphore( theThread );

Mutex クラスの実装は、現実の synchronized 文を模倣すると、次のようなものである。ただし、これらのメソッドは常に「アトミック」に動作しなければならないことは言うまでもないが、synchronized 文を使わないとすると、一箇所弱い部分がある。

public class Mutex {
    static int mutexInUse = false;  /* Mutex クラス自体の mutex */
    int inUse = false;              /* 個別のインスタンス用の mutex */
    void getSemaphore( Thread th ) {
        while( true ) {
            if( ! mutexInUse ) {          /* ホントはここは危ない */
                mutexInUse = true; break; /* チェック&セットはアトミックでない */
                        /* ここだけは何かシステムレベルでのサポートが必要 */
            }
            th.yield();  /* 現時点でのタイムスライスが残っていても、継続を放棄
                            して他のスレッドをアクティヴにする。 */
        }
        while( true ) {
            /* inUse のチェック&セットがアトミックであることはすでに保証済み */
            if( ! inUse ) { 
                inUse = true; MutexInUse = false; 
                return; 
            }
            mutexInUse = false;
            th.yield();
            mutexInUse = true;
        }
    }

    void freeSemaphone( Thread thisThread ) {
        inUse = false;  /* 代入はアトミックに動作できる */
    }
}

しかし、Java では言語レベルで synchronized 文をサポートしている。これは「モニタ」と呼ばれる Java 実行環境に備わった仕組みであり、自動的に JVM の内部で mutex を実現している。だからすべての array への代入について、次のように synchronized ブロックで囲って書けば良い。すべての array への代入が synchronized で保護されている限り、これらのチェック&セットはアトミックに動作することが保証され、 array の「チェック&セット」が他のスレッドによって割り込まれることはなくなる。

int [] array = new int [10];     /* 並行してアクセスされる可能性があるデータ */
Thread theThread;

......... スレッド内部での実行 .............
synchronized( array ) {         /* 陰で array 用の mutex を作成している */
    for( int i = 0; i < array.size(); i++ ) {
        if( array[i] == ..... ) { array[i] = ...... }
    }
}

しかし、synchronized 文が保護する対象にプリミティブ型を取ることはできない。だから int 型変数 x を保護するためには、適当なロック用オブジェクトを作り、それを使って保護をする。この技法は保護対象が複雑なケースでは、可読性を上げコードを混乱とデッドロックから守ることもできる。

class SomeClass extends Thread {
    int x = 0;
    Object LockObject = new Object();
    ..........
    void someMethod( ) {
       synchronized( LockObject ) { 
    /* 同じオブジェクトに対する synchrinized 文は、同時にただ1つしか実行できない。*/
    /* だから、すべての x の代入について、synchronized 文が保護する限り、それらの */
    /* チェック&セットは確実にアトミックに行われる。*/
           if( x == 0 ) {  
               x = 10;
           }
       }

実際には先ほどの Mutex クラスの場合も、アプリケーション全体の中でただ1つのオブジェクトをロックするようにして保護すれば実現可能である。これを実現するのならばクラスオブジェクトに対して synchronized するようにすればいいのである。

public class Mutex {
    static int mutexInUse = false;
    int inUse = false;
    void getSemaphore( Thread th ) {
        while( true ) {
            /* Mutex クラスの Class クラスオブジェクトで保護する */
            synchronized( Mutex.class ) {
                if( ! mutexInUse ) {   /* 危険箇所 */
                   mutexInUse = true;
                      break;
                }
            }
            th.yield();
        }
        while( true ) {
            if( ! inUse ) { 
                inUse = true; MutexInUse = false; 
                return; 
            }
            mutexInUse = false;
            th.yield();
            mutexInUse = true;
        }
    }

    void freeSemaphone( Thread thisThread ) {
        inUse = false;
    }
}

間違えやすいことだが、次のプログラムでは正しく synchronized 文による保護はなされない。なぜなら、保護対象が操作によって変更されてしまうからである。

class SomeClass extends Thread {
    Integer X = new Integer( 0 );
    ..........
    void someMethod( ) {
       synchronized( X ) {  /* X はオブジェクトだからロックできるはずだが */
           if( X.intValue == 0 ) {  
               X = new Integer( 10 ); /* ここで X のそれまでの実体を破棄して */
           }                          /* 新しい実体を与えている。*/
       }                              /* 結果として保護が無意味になっている */

synhronized メソッドは、ロック対象のオブジェクトとして「そのクラスインスタンス(要するに this ポインタ)」を取る。(クラスメソッドの場合には、そのクラス自体)だから、同時には同一のインスタンスに対する動作は、それが synchronized メソッドならば、同時に実行されることはない。これが一番判りやすい。

class SomeClass extends Thread {
    int x = 0;   /* 保護したい */
    ..........
    synchronized void someMethod( ) { 
       if( x == 0 ) {   /* これらは synchronized( this ) { */  
           x = 10;      /* として保護されているのと同じ */
       }
    }
    synchronized void otherMethod( ) {
       if( x != 0 ) {  /* 双方が一貫性をもって実行される */
           x = 0;
       }
    }

synchronized メソッドは、その保護されたインスンタンスに対して、synchronized メソッド(複数ありうる)に同時に入ることのできるスレッドをたかだか1個に制限する。結果として、インスタンス変数に関しては、保護がなされることになる。

また、synchornized 文やメソッドによるロックは、実行効率を低下させる。長時間かかる処理をロックしないように心がける必要があることは言うまでもない。

デッドロック

mutex ような相互排他システムを装備したマルチスレッド(マルチプロセス)環境のプログラムでは、「デッドロック」(相互に必要とするオブジェクトをめぐってロックし合い、双方とも進行しなくなる)が起きる可能性がある。この条件は奥深いので、マルチスレッドプログラミングの成書を参考にされたい。ここでは「デッドロック」の可能性だけを指摘し、多重な mutex による複雑な synchronized 文を書かないように、一般的なアドバイスをするだけに止める。



copyright by K.Sugiura, 1996-2006