第十五课 多线程编程


本课中,我们将学习如何进行多线程编程。另外我们还将学习如何在不同的线程间进行通信。

理论:

前一课中,我们学习了进程,其中讲到每一个进程至少要有一个主线程。这个线程其实是进程执行的一条线索,除此主线程外您还可以给进程增加其它的线程,也即增加其它的执行线索,由此在某种程度上可以看成是给一个应用程序增加了多任务功能。当程序运行后,您可以根据各种条件挂起或运行这些线程,尤其在多CPU的环境中,这些线程是并发运行的。这些是在W32下才有的概念,在WIN16下并没有等同的概念。
在同一进程中运行不同的线程的好处是这些线程可以共享进程的资源,如全局变量、资源等。当然各个线程也可以有自己的私有栈用于保存私有数据。另外每个线程需要保存其运行上下文以便在线程切换时能够记住或恢复其上下文,当然这是由操作系统来完成的,对于用户是透明的。
我们大体上可以把线程分成两大类:
  1. 处理用户界面的线程:该类线程产生自己的窗口并负责处理相关的窗口消息。用户界面线程遵守WIN16下的互斥原则,即没一刻仅有一个用户界面线程使用USER和GDI库中的内核函数,也就是说当一个用户界面程序在进入GDI或USER中时,内核不允许重入。由此我们可以推论出WIN95的该部分内核的代码是遵守16位模式的。而WINOWS NT是纯的32位操作系统,所以不存在这个问题。
  2. 工作者线程:该类线程不用处理窗口界面,当然也就不用处理消息了。它一般都运行在后台干一些计算之类的粗,这大概也是把它叫做工作者线程的原因吧。

运用W32的多线程模式来编程,我们可以遵循某种策略:即让主线程仅来做用户界面的工作,而其它繁重的工作则交由工作者线程在后台完成。这就好比我们日常生活中的许多例子。譬如:政府管理者好比是用户界面线程,它负责听取民意,给职能部门分配工作,然后把工作成果汇报给公众。而具体的职能部门就是工作者线程,它负责完成下达的具体工作。如果让政府管理这来具体地做每一件事,它必须作一件事后再做另一项,那它就不能及时来听取和反馈民意。这样就无法管理好一个国家了。当然即使采用多线程制,政府管理部门也不一定就能管理好国家,但是程序却可以采用多线程机制来管理好她自己的工作。我们可以调用CreateThread函数来生成新线程。该函数的语法如下:

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

生成一个线程的函数和生成一个进程基本相同。
lpThreadAttributes  -->如果您想要线程有缺省的安全属性,可以置该值为NULL。
dwStackSize --> 指定线程的堆栈大小。如果为0,那线程的大小和进程相同。
lpStartAddress--> 线程函数的起始地址。注意该函数仅接收一个32位的参数和返回一个32位的值。(该参数可以是一个指针,而且进程的线程可以直接存取进程定义全局变量,所以您大可不必担心不能如何把大量的参数传递给线程)。
lpParameter  --> 传递给线程的上下文。
dwCreationFlags -->如果是0的话则表示创线程建后立即启动,相反的是标志位CREATE_SUSPENDED,这样您需要稍后显示地让该线程运行。
lpThreadId --> 内核给新生成的线程分配的线程ID。

如果生成线程成功的话,CreateThread函数就返回新线程的句柄。否则返回NULL。
如果没有给参数dwCreationFlags指定CREATE_SUSPENDED的话,该线程就会立即运行。如果不这样,我们上面说了,需要显示地启动该线程,要这样做您需要调用ResumeThread函数。
在线程返回后(线程的执行类似与执行一个函数,如果它调用了最后一条指令后,在汇编中是ret,那么该线程就结束了,除非您让它进入一个循环,譬如我们讲的用户界面线程就是如此,只不过它不退出的原因是进入的循环是在{while ( GetMessage(...))...}中,如果您没有给它传递一个值为0的消息,那它可不会退出),系统会自动调用ExitThread函数透明地处理线程一些退出时的清理工作。当然您可以自己调用该函数,但似乎没有什么意义。要得到退出时的退出码,您可以调用GetExitCodeThread函数。
如果您想结束一个程序,可以调用TerminateThread函数,不过使用该函数要小心行事,因为该函数一旦被调用线程就会退出,这样它就没有机会来做清理自己的工作了。

现在我们来看看线程间的通讯机制。
总的说来一共有三种方法:

上面我们说了线程会共享进程的资源,其中全局变量也包括在内,所以线程可以通过使用全局变量来通讯。但是这种办法的明显的缺点是在有多个线程存取同一个全局变量时,必须考虑同步的问题。譬如:有一个有十个成员变量的结构体,其中一个线程在对起赋值时,假设只更新了五个成员变量的值,这时内核的调度线程剥夺其运行权给另一个线程,这样接下来的线程如果想要用该全局结构体变量,它的值就显然不对了。另外多线程的程序也很难调试,尤其这些错误很隐蔽和很难复现时。如果两个线程都是用户界面线程时,用WINDOWS的消息机制来进行线程间的通讯是比较方便的.
您所要做的只是自定义一些windows消息(注意不要和windows的预定义的消息冲突),然后在线程之间传递可以了。您可以这样来定义消息,把WM_USER(它的值等于0x0400)当作基数,然后顺序地去加序号,譬如:

        WM_MYCUSTOMMSG equ WM_USER+100h

小于WM_USER 的值是Windows系统的保留值,大于该值留给用户来使用。
如果其中有一个线程是工作者线程的话,那就不能用该种方法来进行通讯了,这是因为工作者线程没有消息队列。您应当用下面这种策略来进行工作者线程和用户界面线程之间的通讯:

                            User interface Thread ------> global variable(s)----> Worker thread
                            Worker Thread  ------> custom window message(s) ----> User interface Thread

稍后我们的例子中将讲解这种通讯办法。
最后的办法是事件对象。您可以把事件对象看作是一种标志。如果事件对象的状态是无信号的话,说明该线程正在睡眠或挂起,在该种状态下系统是不会给该线程分配CPU时间片的。当一个线程的状态转成有信号时,WINDOWS就会唤醒该线程并且让它正常运行。

例子:

您可以下载例子并运行thread1.exe,然后激活菜单项"Savage Calculation",然后程序开始执行指令"add eax,eax ",一共执行600,000,000次,您会发现在这个过程当中,用户界面将停止响应,您既不能使用菜单,也不能使用移动窗口。等到计算完成后,会弹出一个对话框,关闭掉对话框后窗口才可以和当初一样正常运行了。
为了避免这种不便,我们把计算的工作放入到一个单独的工作者线程中去,而主窗口仅仅响应用户的活动。您可以看到虽然用户界面的反应比平常时慢了,但还是可以工作的。

.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
 

分析:

主程序的主线程是一个用户界面线程,它有一个普通窗口。用户选择菜单项"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

我们看到上面的线程的代码仅仅是做简单的计数工作,因为我们设了一个很大的基数,所以该线程会持续一段您能感觉得到的时间,当结束后它会向主线程发送WM_FINISH消息。WM_FINISH消息是我们自己定义的,它的定义如下:

WM_USER消息是我们能够使用的最小消息值。
显然我们一看到WM_FINISH,就能从字面上理解该消息的意义。主线程接收到该消息后,会弹出一个对话框告诉用户,计算线程已经结束了。
通过线程之间的通讯,用户可以多次选择"Create Thread",那样就可以运行多个计算线程了。
本例子中,线程之间的通讯是单向的。如果您想让主线程也能向工作者线程发送消息的话,譬如加入一个菜单项来控制工作者线程的结束,您可以这样做: 设立一个全局变量,当线程启动前,我们设置它的值为FALSE,当用户激活了我们加的菜单项时,该值变成TRUE。在线程的代码段ThreadProc中每次减1前,判断该值,如果为TRUE的话线程就结束循环体中的计算并退出线程。


翻译:Lxx.阿龙,校对:LuoYunBin's Win32 ASM Page, http://asm.yeah.net/