Tutorial 15: Multithreading Programming

このチュートリアルでは、マルチスレッドプログラムについて説明する。 おもに、スレッド間におけるデータのやり取りの方法を研究する。
 ソース   リソーススクリプト   実行結果 

Theory:

前章ではプロセスには少なくとも1つのメインスレッドがあることを学んだ。 そのスレッドというのは、一連の実行コードのことで、プログラム中で自由にスレッドを作ることができる。 マルチタスクはシステムでいくつものプロセスを同時に実行させるものだが、 マルチスレッドはマルチタスクと考え方は同じで、 1つのプロセスでいくつものスレッドが同時に実行するものである。 実装においては、スレッドはマインプログラムと同時に実行されていく関数と考えることができる。 同じ関数をいくつも実行させることもできるし、 要求に応じた関数をいくつも実行させることができる。 マルチスレッドはWin32固有のもので、Win16には備わっていない。

スレッドは同じプロセス上で実行されるので、 グローバルに割り当てられたメモリ領域に確保したリソースはどのスレッドからでもアクセスできる。 しかしながら、作成されたスレッドには、それぞれ固有のスタックエリアを持っており、 当然、スレッド毎のローカル変数はプライベート(他のスレッドからアクセスできない)である。 スレッドは固有のレジスタセットも保持しており、 スレッドのスイッチ(切り替え)が起こったときにそれまで動作していたスレッドのレジスタの状態を記憶していったん中断し、 またその中断していたスレッドが起動されたときに、保持していたレジスタ状態を元に戻して処理を続行させる。 これは内部的にWindowsが行う処理である。

以下にスレッドの2つの分類の説明を記述する。

  1. ユーザインターフェイススレッド
    このタイプのスレッドは自分のウィンドウをもち、Windowsからのメッセージを受け取る。そのユーザからメッセージを受けることによって何らかの処理を行うので、このような名前になっている。
    このタイプのスレッドは Win16ミューテックスのルールを前提としており、ただ一つのユーザインタフェーススレッドしか16bitのuserカーネル、gdiカーネルを使用できない。(カーネルの排他制御)
    Windows95 API関数群は、16bitコードで書かれており、Win16ミューテックスはWindows95に独特のものである。WindowsNTは32bitコードで記述されているため、当然Win16ミューテックスを使用していないので、NTで動作するユーザインターフェイススレッドはWindows95より軽快に動作する。
  2. ワーカースレッド
    このタイプのスレッドはウィンドウを生成しない。もちろんウィンドウメッセージを受け取ることはなく、主にバックグラウンドで与えられた処理を行う。それゆえ、このような名前となっている。

Win32のマルチスレッドプログラムを行うときに以下のような戦略をとることをアドバイスする。 メインスレッドはユーザインターフェイス処理を行い、 他のスレッドにはバックグラウンドで泥臭い処理を行わせたほうがよい。 この方法だと、メインスレッドが課長で、他のスレッドは平社員のような関係で、 実際に仕事を行うのは担当員で、課長の仕事は部長から請けた仕事を平社員に割り当てることと、 その仕事の進捗を部長に報告することである。 もし、平社員に仕事を割り当てることなく自分で全てやってしまった場合、 部長への報告や部長から仕事を請ける余裕がなくなってしまう。

これと同じことがウィンドウでも発生し、 もし、メインスレッドで非常に時間のかかる処理を行っている場合、 その処理が終わるまでユーザの入力を処理できなくなってしまう。 なので、非常に時間のかかる処理を行うスレッドを新たに作成し、 メインスレッドがユーザの入力を待ち受けられるようにする必要がある。

ともかく、CreateThread関数をCALLすればスレッドが作成できる。プロトタイプは以下のとおりだ。

CreateThread proto lpThreadAttributes : DWORD,\
                   dwStackSize        : DWORD,\
                   lpStartAddress     : DWORD,\
                   lpParameter        : DWORD,\
                   dwCreationFlags    : DWORD,\
                   lpThreadId         : DWORD

CreateThread関数はCreateProcess関数に非常によく似ている。

CreateThread関数の呼び出しが成功すれば、新しく作成したスレッドのハンドルを返す。 失敗すればNULLが返る。 dwCreationFlagsにCREATE_SUSPENDEDをセットしていなければ、 作成と同時にスレッド関数が実行される。 もし、CREATE_SUSPENDEDが指定されていれば、 ResumeThread関数がCALLされるまで停止している。

スレッド関数が ret命令(少し違うがC言語で言う return)で制御を返した時、 スレッド関数がCALLする代わりに、Windowsが暗黙的にExitThread関数をCALLする。 もちろん、自分の作成したスレッド関数の中でExitThread関数をCALLすることもできるが、 意味が無い。

GetExitCodeThread関数をCALLすることにより、スレッドの終了コードを取得できる。 もし他のスレッドからあるスレッドを終了させたければ、 TerminateThread関数をCALLすればよいのだが、切羽詰った状況でないと使用してはならない。 というのは、TerminateThread関数を使用した場合、 対象のスレッドはクリーンアップコードの実行 (オープンしたファイルをクローズしたり、メモリを解放したりなど)も行わずに、 を即座に終了させるからである。

では、今度はスレッド間の共同作業について説明しよう。
主に以下の3つの項目からなる。

まずは、グローバル変数を使用する方法の説明だが、 スレッド間でグローバル変数といったプロセスに確保されたリソースを共有できるので、 このグローバル変数を使用することにより、共同作業を行う。 しかしながら、この方法は「同期」という点に注意しなければならない。 例えば、10個のメンバを持つ構造体を2つのスレッドが同時に使用していたとしよう。 そして、片方のスレッドがその10個のメンバのうち5個目までデータの入れ替えをしていた時に、 Windowsが突然もう片方のスレッドに切り替えた場合、どうなるであろうか? その切り替わったスレッドは、矛盾したデータを使用することになる。 マルチスレッドプログラムにミスは許されず、 しかも、デバッグやメンテナンス作業が非常に複雑である。 得てしてこの手のバグというものは、ランダムに発生するため見つけ出すのが非常に難しい。

次にWindowsメッセージを使用する場合だが、 スレッドのタイプが全てユーザインターフェイススレッドであれば何の問題もなく、 双方向通信が可能である。 やらなければならないことは、自分の作成するスレッドで有効となるWindowsメッセージをカスタマイズするということだけだ。 基本値としてWM_USERを使用して、以下のようにWindowsメッセージをカスタマイズする。

WM_MYCUSTOMMSG equ WM_USER+100h

WindowsはWM_USERより大きな値は使用しないことになっているので、 WM_USERを超える値を独自のWindowsメッセージの値として割り当てることができる。

ただしWindowsメッセージを使うこの方法では、 一つがユーザインターフェイススレッドで、もう一つがワーカースレッドだった場合、 それらのスレッド間で双方向に通信できない。 というのは、ワーカースレッドは自分のウィンドウを持っていないため、メッセージキューも当然無い。 したがってこの場合は、以下のような方法をとることになる。

    ユーザインターフェイススレッド ------> グローバル変数               ----> ワーカースレッド
    ワーカースレッド               ------> カスタムウィンドウメッセージ ----> ユーザインターフェイススレッド

今回のサンプルでもこの方法を用いている。

最後のイベントを用いる方法だが、この場合はイベントオブジェクトをフラグの一種として考え、 イベントオブジェクトが「非シグナル状態」ならスレッドは停止していおりCPUタイムスライスは割り当てられない。 一方、もしイベントオブジェクトが「シグナル状態」だった場合、 Windowsはスレッドをたたき起こし、スレッドは割り当てられている仕事をはじめることになる。

Example:

example zipファイルをダウンロードして解凍すると、 thread1.exe という実行ファイルがあるので、起動してみよう。 そして、"Savage Calculation" というメニューアイテムをクリックする。 これは6億回 "add eax,eax" という実行コードを繰り返すもので、 その間、メインウィンドウに対してウィンドウを動かしたり、 メニューをクリックしたりといったあらゆる操作ができないことを確認せよ。 計算(6億回のループ)が終了すれば、メッセージボックスが表示され、 その後通常にメインウィンドウに対して操作が行える。

このような、ユーザに対して不適切なインターフェイスを避けるために、 計算ルーチンをワーカースレッドに処理してもらい、 メインスレッドはユーザからの入力を受け付けるようにした方がよい。 メインウィンドウの応答が多少遅くなるかもしれないが、反応はするので、 ユーザが何もできないという状況には陥らなくなる。

.386
.model flat,stdcall
option casemap:none
WinMain proto :DWORD,:DWORD,:DWORD,:DWORD
include \masm32\include\windows.inc
include \masm32\include\user32.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\user32.lib
includelib \masm32\lib\kernel32.lib

.const
IDM_CREATE_THREAD equ 1
IDM_EXIT equ 2
WM_FINISH equ WM_USER+100h

.data
ClassName db "Win32ASMThreadClass",0
AppName db "Win32 ASM MultiThreading Example",0
MenuName db "FirstMenu",0
SuccessString db "The calculation is completed!",0

.data?
hInstance HINSTANCE ?
CommandLine LPSTR ?
hwnd HANDLE ?
ThreadID DWORD ?

.code
start:
   invoke GetModuleHandle, NULL
   mov   hInstance,eax
   invoke GetCommandLine
   mov CommandLine,eax
   invoke WinMain, hInstance,NULL,CommandLine, SW_SHOWDEFAULT
   invoke ExitProcess,eax

WinMain proc hInst:HINSTANCE,hPrevInst:HINSTANCE,CmdLine:LPSTR,CmdShow:DWORD
   LOCAL wc:WNDCLASSEX
   LOCAL msg:MSG
   mov  wc.cbSize,SIZEOF WNDCLASSEX
   mov  wc.style, CS_HREDRAW or CS_VREDRAW
   mov  wc.lpfnWndProc, OFFSET WndProc
   mov  wc.cbClsExtra,NULL
   mov  wc.cbWndExtra,NULL
   push hInst
   pop  wc.hInstance
   mov  wc.hbrBackground,COLOR_WINDOW+1
   mov  wc.lpszMenuName,OFFSET MenuName
   mov  wc.lpszClassName,OFFSET ClassName
   invoke LoadIcon,NULL,IDI_APPLICATION
   mov  wc.hIcon,eax
   mov  wc.hIconSm,eax
   invoke LoadCursor,NULL,IDC_ARROW
   mov  wc.hCursor,eax
   invoke RegisterClassEx, addr wc
   invoke CreateWindowEx,WS_EX_CLIENTEDGE,ADDR ClassName,ADDR AppName,\
          WS_OVERLAPPEDWINDOW,CW_USEDEFAULT,\
          CW_USEDEFAULT,300,200,NULL,NULL,\
          hInst,NULL
   mov  hwnd,eax
   invoke ShowWindow, hwnd,SW_SHOWNORMAL
   invoke UpdateWindow, hwnd
   .WHILE TRUE
           invoke GetMessage, ADDR msg,NULL,0,0
           .BREAK .IF (!eax)
           invoke TranslateMessage, ADDR msg
           invoke DispatchMessage, ADDR msg
   .ENDW
   mov    eax,msg.wParam
   ret
WinMain endp

WndProc proc hWnd:HWND, uMsg:UINT, wParam:WPARAM, lParam:LPARAM
   .IF uMsg==WM_DESTROY
       invoke PostQuitMessage,NULL
   .ELSEIF uMsg==WM_COMMAND
       mov eax,wParam
       .if lParam==0
           .if ax==IDM_CREATE_THREAD
               mov eax,OFFSET ThreadProc
               invoke CreateThread,NULL,NULL,eax,\
                                       0,\
                                       ADDR ThreadID
               invoke CloseHandle,eax
           .else
               invoke DestroyWindow,hWnd
           .endif
       .endif
   .ELSEIF uMsg==WM_FINISH
       invoke MessageBox,NULL,ADDR SuccessString,ADDR AppName,MB_OK
   .ELSE
       invoke DefWindowProc,hWnd,uMsg,wParam,lParam
       ret
   .ENDIF
   xor   eax,eax
   ret
WndProc endp

ThreadProc PROC USES ecx Param:DWORD
       mov ecx,600000000
Loop1:
       add eax,eax
       dec ecx
       jz  Get_out
       jmp Loop1
Get_out:
       invoke PostMessage,hwnd,WM_FINISH,NULL,NULL
       ret
ThreadProc ENDP

end start

Analysis:

メインプログラムは普通のウィンドウでメニューアイテムがある。 ユーザが"Create Thread"メニューアイテムを選択したら、 プログラムは以下のような処理を行いスレッドを作成する。

.if ax==IDM_CREATE_THREAD
    mov eax,OFFSET ThreadProc
    invoke CreateThread,NULL,NULL,eax,NULL,0,ADDR ThreadID
    invoke CloseHandle,eax

上記の処理は、ThreadProcという名前の関数を実行するスレッドを作成する、 というものだ。 この処理が成功すると、CreateThread関数はすぐに制御を返し、 ThreadProc関数が実行される。 ここで取得するスレッドハンドルは使用しないので、 すぐに閉じている。 そうしないと、ほんの少しではあるがメモリがもったいない。 ただ、スレッドハンドルを閉じることが スレッドを終了することではないということに気をつけなければならない。 単に、スレッドハンドルを今後使用することができない、ということになるだけだ。 (スレッドハンドルを使用してスレッドの制御をするのだが、今回の例ではそこまで詳しく解説しない)

ThreadProc PROC USES ecx Param:DWORD
       mov ecx,600000000
Loop1:
       add eax,eax
       dec ecx
       jz  Get_out
       jmp Loop1
Get_out:
       invoke PostMessage,hwnd,WM_FINISH,NULL,NULL
       ret
ThreadProc ENDP

これを見ればわかるとおり、 ThreadProc関数は尋常じゃないほどループし、 そのループがやっと終わった時に、 WM_FINISHメッセージをメインウィンドウに送信する。 WM_FINISHメッセージは以下のようにカスタマイズしてある。

WM_FINISH equ WM_USER+100h

WM_USERに100を加える必要はないのだが、そうした方がより安全なのである。

ちなみに、WN_FINISHメッセージはこのウィンドウだけに有効なもので、 WM_FINISHメッセージがメインウィンドウに送られた時に、 計算が終了したと言うメッセージボックスが出力される。

"Create Thread" 関数をCALLした数だけ(ただし成功した場合)、 スレッドを作成できるので、複数のスレッドを作成することができる。 この例では、スレッド間のやりとりは一方通行で、 作成したスレッドがメインウィンドウに通知するだけでる。 もしメインウィンドウからワーカースレッドに対して、 メッセージを送信したければ、以下のようにやればよい。

ユーザが"Kill Thread"メニューアイテムをクリックすると、 メインプログラムはコマンドフラグをTRUEにする。 そして、そのコマンドフラグがTRUEかどうかを判定するThreadProc関数のループ内で TRUEだということを認識すると、スレッドを終了し、制御を返すことになる。


[戻る]