WIN32汇编语言教程:第12章 多线程 · 12.2 多线程编程(3)
将修改后的源程序和修改前的对比一下,可以发现不同的只有两个地方:第一是处理“计数”按钮的WM_COMMAND消息中,调用_Counter子程序的指令变成了对CreateThread函数的调用,这个函数就是用来创建新线程的函数;第二是_Counter子程序的定义有点不同,这是因为用做线程入口的线程函数必须按照规定的格式定义。这两个不同点其实也可以归结为一个,因为第二个不同点实际上是对第一个不同点的配合。
是不是这样修改后程序就正常工作了呢?可以来验证一下:运行程序,按下“计数”按钮,这次在“计数”按钮被改为“停止计数”,“暂停/恢复”按钮被激活的同时,计数值可以在编辑框中显示出来了,而且在计数的过程中,可以移动程序位置、关闭程序、按动各个按钮——总之,计数线程和主线程在同时工作了。
接下来参考这个程序来探讨多线程程序的结构。
2. 多线程程序的结构
Windows中存在很多类型的对象:如窗口类、窗口、文件、菜单、图标、光标和钩子等,当一个线程创建某个对象的时候,这个对象归线程所属的进程所拥有,进程中的其他线程也可以使用它们,比如可以在主线程中打开一个文件,然后在另一个子线程中读写这个文件。
大部分类型的对象不属于创建它的线程,而是属于进程,这表现在创建对象的线程结束时,如果线程不去主动删除这些对象,系统不会自动删除它们,只有当整个进程结束时对象还没有被删除,系统才会自动删除它们。但是窗口和钩子这两种对象比较特殊,它们首先是由创建窗口和安装钩子的线程所拥有的,如果一个线程创建一个窗口或安装一个钩子,然后线程结束,那么系统会自动摧毁窗口或卸载钩子。
进程中的消息队列则与线程和窗口都是相关的,如果在一个线程中创建了一个窗口,那么Windows就会单独给这个线程分配一个消息队列,为了让这个窗口工作正常,线程中就必须存在一个消息循环来派送消息,这就是主线程中只有当创建窗口时才需要消息循环代码,不创建窗口的程序(如控制台程序)就不需要消息循环的原因。这也就意味着如果窗口是在子线程中创建的,主线程中的消息循环根本不会获得这个窗口的消息,子线程必须自己设置一个消息循环。当使用SendMessage或者PostMessage函数向一个窗口发送消息的时候,系统会先判别窗口是被哪个线程创建的,然后把消息派送到正确线程的消息队列中。
整理一下思路:如果在一个线程中创建窗口,就必须设置消息循环,有了消息循环,就必须遵循1/10秒规则,这就意味着这个线程不该用来处理长时间的工作。而在一个程序中为不同的线程设置多个消息循环,不但使代码复杂化,而且会产生诸多的其他问题,所以在多线程程序中,规划好程序的结构是很重要的。
规划多线程程序的原则就是:首先,处理用户界面(指拥有窗口和需要处理窗口消息)的线程不该处理1/10秒以上的工作;其次,处理长时间工作的线程不该拥有用户界面。根据这个规则,我们大致可以把线程分成两大类:
● 处理用户界面的线程——这些线程创建窗口并设置消息循环来负责处理窗口消息,一个进程中并不需要太多这种线程,一般让主线程负责这个工作就可以了。
● 工作线程——该类线程不处理窗口界面,当然也就不用处理消息了,它一般在后台运行,干一些繁重的需要长时间运行的粗活。
一般来说,处理用户界面的线程交给主线程来做就可以了。如果主线程中接到一个用户指令,完成这个指令可能需要比较长的时间,主线程可以建立一个工作线程来完成这个工作,并负责指挥这个工作线程。这和我们日常生活中的许多例子是很像的,比如,公司的经理就好比是用户界面线程,他负责和外界沟通,谈判业务,对董事会(指对着屏幕单击鼠标的用户)汇报,同时负责将具体的工作分配给职能部门(也就是工作线程)做,如果让经理具体地去做每一件事情,下车间去包装产品或开着卡车外出拉原料,那么他就无法管理好这个公司了。
在多线程版本的Counter.asm例子中,使用的就是这样的程序结构:主线程用来维护界面,接收用户的输入动作并安排相应的操作,工作线程则用来进行计数操作。
3. 线程之间的通信
主线程在创建工作线程的时候,可以通过参数给工作线程传递初始化数据,当工作线程开始运行后,还需要通过通信机制来控制工作线程,就像经理虽然不用亲自干活,也需要随时了解和控制情况一样;同样,工作线程有时候也需要将一些情况主动通知主线程。
线程之间的通信可以归纳为3种方法。
使用全局变量传递数据是最常用的方法,如例子中主线程通过设置全局变量dwOption中的数据位来通知工作线程,工作线程随时检查这个变量并根据要求做相应的动作;反过来,工作线程也通过设置dwOption的第2位(预定义为F_COUNTING)来控制主线程中对IDOK按钮的动作,如果F_COUNTING被置位,表示线程在运行中,这时IDOK按钮被定义为“停止计数”按钮,否则IDOK按钮被定义为“计数”按钮。使用全局变量传递数据的缺点是当
多个工作线程使用同一个全局变量时,可能会引起同步问题,在12.4节中会探讨这个问题。
第2种方法是通过发送消息来通信,如工作线程工作结束时,可以通过向主线程发送自定义的WM_XXX消息来通知主线程,这样主线程就不需要随时去检查工作线程是否结束。只要在窗口过程中处理WM_XXX消息就可以了。这种方法的缺点是无法向工作线程发送消息,因为工作线程中一般并没有消息队列,所以这种方法仅用在工作线程向主线程传递消息的应用中。
如果线程之间传递的不是数据而是代表状态的布尔值,也可以使用第3种方法,即使用事件对象来通信,相关内容会在12.3节中详细介绍。
12.2.3 与线程有关的函数
1. 创建线程
创建一个线程可以使用CreateThread函数,函数的用法是:
invoke CreateThread,lpThreadAttributes,dwStackSize,lpStartAddress,\ dwParameter,dwCreationFlags,lpThreadId .if eax mov hThread,eax .endif
函数使用的参数定义如下:
● lpThreadAttributes——指向一个SECURITY_ATTRIBUTES结构,用来定义线程的安全属性,这个结构在CreateFile函数的介绍中已经涉及过,主要用来指定句柄是否可以继承,如果想让线程使用默认的安全属性,可以将参数设置为NULL。
● dwStackSize——线程的堆栈大小。如果指定为0,那么线程的堆栈大小和主线程使用的大小相同。系统自动在进程的地址空间中为每个新线程分配私有的堆栈空间,这些空间在线程结束的时候自动被系统释放,如果需要的话,堆栈空间会自动增长。
● lpStartAddress——线程开始执行的地址。这个地址是一个规定格式的函数的入口地址,这个函数就被称为“线程函数”。
● dwParameter——传递给线程函数的自定义参数。
● dwCreationFlags——创建标志。如果是0,表示线程被创建后立即开始运行,如果指定CREATE_SUSPENDED标志,表示线程被创建后处于挂起状态,直到使用ResumeThread函数显式地启动线程为止。
● lpThreadId——指向一个双字变量,用来接收函数返回的线程ID。线程ID在系统范围内是惟一的,一些函数需要用到线程ID。
如果线程创建成功,函数返回一个线程句柄,这个句柄可以用在一些控制线程的函数中,如SuspendThread,ResumeThread和TerminateThread等函数,如果线程创建失败,那么函数返回NULL。
当程序调用CreateThread函数时,首先系统为线程建立一个用来管理线程的数据结构,其中包含线程的一些统计信息,如引用计数和退出码等,这个数据结构被称为线程对象;接下来系统将从进程的地址空间中为线程的堆栈分配内存并开始线程的执行。当线程结束时,线程的堆栈被释放,但是线程对象不会马上被释放,系统保留它以便其他线程可以通过它检测线程的有关情况,直到使用CloseHandle函数关闭线程句柄后,线程对象才会被释放。
但是线程对象也可以提前被释放,对于大部分的句柄来说(如文件句柄hFile,文件寻找句柄hFindFile等),使用CloseHandle函数关闭句柄意味着整个对象被释放,但对于线程句柄来说,关闭它仅释放线程的统计信息,并不会终止线程的执行,所以如果不再需要使用线程句柄的话,在调用CreateThread后马上就可以将它关闭掉,线程的执行并不会受影响。