COM样例(一)
——《COM线程模型》基础篇
我曾经写了一篇《COM线程模型》,受到一些好评,但也有部分人觉得其有些深,希望能提供一些基础点的文章。本系列文章是《COM线程模型》这篇文章的样例,提供一个简单的稍完整的例子以帮助理解前面文章中的内容。本文是此系列的第一篇,讲解《COM线程模型》中出现过的我以为可能需要说明的基础概念,在后继的文章中说明样例的结构,并给出相关各部分代码,以注释作为主要说明手段。
线程
线程是具有进程能动性的逻辑概念。所谓能动性是指能使某种东西按某种规则动起来的特性,而所谓的"动"就是前面的"某种东西"的状态的改变。那么进程能动性即使进程按某种规则运行的性质,这里的"某种规则"即是CPU的机器指令的定义。可以简单地将线程看成是操纵机器的工人,而机器就相当于电脑硬件,而这个工人的工作就是代码。工作由老板制定好后,就等着工人去做。没人做的事就和代码一样,没有任何意义。当一个工人去操纵机器进行工作,就相当于一个线程通过CPU执行进程中的代码。毛坯由于人操作机器而改变它的状态进而变成产品,进程因为线程让CPU执行进程中的代码(准确地说应该是CPU对指定数字的反应)而改变它所拥有的内存的状态,进而将计算结果表现出来。
线程就等同于时间,时间是具有客观世界能动性的逻辑概念,其使客观世界按上帝制订的高深莫测的规律变化其自身的状态。线程则使进程中的内容(包括代码)按CPU的机器指令的定义来改变进程的状态(其内存的数值)。
应该将线程句柄和线程区分开来。线程句柄是个对象,即一个结构,其具体定义是由操作系统决定的,并一定关联着一个线程。它相当于是一个日志记录,其和线程没有什么关系,只是一个记录其相关线程的一些状态,如线程是否在等待、线程的消息队列等,操作系统利用线程句柄对线程进行操作,如发送线程消息、等待线程结束等。
由于线程句柄只是一个对象,相当于一条记录,所以即使其关联的线程已经不存在了(线程退出了),其也是有效的。就好像一个人死了,但他的身份证还是存在的(虽然已经没有意义了)。并且即使线程句柄已经通过CloseHandle释放掉了(准确地说只是引用记数减一,因为操作系统在线程未退出时还保留着其一个引用)也并不代表线程结束了,它只是一个结构,记录线程的相关信息,与线程本身并无关系。
线程安全
前面说过线程就相当于一个工人,可以两个工人同时操作一台机器,这两个工人就必须分工合作,A工人设置了机器的加工速度为10,然后他还没有开始用10这个加工速度加工零件,B工人就又将加工速度定为了20,如果A不知道,这非常有可能导致事故发生。此时我们就说机器的加工速度的设置不安全——任何人都可以在不发出任何通知的前提下改变它。
因此就规定这个机器只能由一个工人操作,不准同时有两个工人操作这台机器,此同步手段在编程时是通过程序中只有一个线程来实现的。
此机器比较先进,可以同时做很多事情,如果只让一个工人操作效率太低。故决定让两个工人操作,但是在设置加工速度时,在设置把手处挂一个牌子,如果A已经设置了加工速度并正在加工零件,就将牌子写有“使用中”字样的一面朝外,用完后再将牌子写有“未使用”的一面朝外。这样就实现了对加工速度设置的同步。此在Windows编程时一般是通过互斥量(Mutex)来实现的。
因此,当多个线程都需要操作进程中某块内存时,这块内存就和上面的“机器的加工速度”一样,对其的改变需要施加同步手段以防止出现问题。当由于这个原因而出现问题时,此问题一般就被称为线程冲突(Race Condition)。
除了上面提到的线程冲突外,还有一种称为死锁(Deadlock)的线程安全问题。比如A工人为了加工Ⅲ零件在等待B提供的Ⅰ零件,而B正好在等待应由A加工提供的Ⅱ零件以装配Ⅰ零件。由于他们之间再没有其他任何人帮助通信或其他通信手段(如等腻了走过去看看),他们都是坐在办公室里等着对方因为零件加工好而欲提供的电话,也就永远都不会等到电话而变成一个死局。
这种就是死锁,双方互相等待,此属于逻辑错误,并不像上面的线程冲突那样可以通过固定的方法解决,这是设计算法时犯下的错误,当线程较多,相互间关系复杂时是很容易产生的,可以通过断言来帮助检查。
因此一段代码被称为线程安全的,就是指这段代码不会发生线程冲突或死锁。
线程切换
一般的PC都是只有一个CPU,而多个线程的运行使得电脑看起来好像同时在做很多事,此是通过一会做这件事,一会再做另一件事来实现的。即操作系统会频繁地让多个线程各执行一小段时间(被称为时间片),以期望通过足够的频繁而使得其好象在同时执行多个线程。当某个执行中线程执行够了它的时间片后,操作系统就会通过某些操作让另一个线程进入运行,而前者则停止运行,这里的“某些操作”就是所谓的线程切换。其具体操作就是将当前线程的运行环境(Context)保存起来,如将CPU各寄存器的值存到一个TLS(后叙)内存中,然后将欲运行的线程的运行环境,加载到当前CPU硬件以让线程运行,因此线程切换就是线程运行环境的保存和加载。
在《COM线程模型》中提到线程切换是非常耗时的,而操作系统却又极其频繁地发生线程切换,那么操作系统岂不是非常地效率低下?不是。《COM线程模型》中所谓的线程切换的耗时并不是线程切换本身耗时,而是线程发生运行方式转换(从用户方式转为内核方式),此操作一般会有1000个以上的CPU周期,此即所谓的耗时。
Windows中的线程可以运行在两种方式下,内核方式(Kernel Mode)和用户方式(User Mode)。
内核方式下是可以直接访问物理内存的,而不是进程的虚拟内存空间,此时具有多种特权。此时如果发生错误,可能是整个操作系统挂起(Halt)而不是简单地用任务管理器(或类似软件)就可以搞定的。此一般是内核代码或硬件驱动程序的工作方式。
用户方式则是普通的运行方式,只能通过进程的虚拟内存空间的映射来访问物理内存,没有特权。
Windows有三种对象:用户对象(User Object)、GDI对象(GDI Object)和内核对象(Kernel Object)。内核对象所使用的内存是在内核模式下才可访问的内存。Windows对每种内核对象都提供了一系列的API以对其操作。当调用这类API时,线程必须从用户方式转换成内核方式以操作内核对象所在的内存,这也就导致了前面提过的损耗。
当我们进行线程同步时,会调用类似WaitForSingleObject之类的等待函数,此时线程会挂起,等待指定的内核对象处于通知状态,此时就会发生上面的损耗。而COM缺省提供的线程同步功能由于其灵活性注定了不能使用用户方式的同步(如原子访问、关键代码段),因此在STA线程和MTA线程之间及各自之间的同步就使用了内核同步对象(如事件、互斥量等)来进行同步,也就导致了上面提到的损耗,也就是NA套间产生的原因及目的。
线程局部存储(Thread Local Storage)
线程局部存储(TLS)是Windows提供的一种技术,用于将一些内存和一线程关联起来,这样即使同样的代码,不同的线程访问,实际将会访问不同的内存,这和线程的堆栈是一样的道理。
那为什么不直接使用堆栈还要来个TLS?因为堆栈相当于是一个历史记录,里面的内存数值与调用顺序有着密切关系。如果希望多个函数间共享一块内存,应该使用全局变量,但是由于又想线程相关,决定将内存分配在栈上(不过这不重要),则在执行函数时必须准确知道内存分配在栈上的什么位置(也就是地址),而这个位置又需要通过另一个全局变量来进行函数间传递,因此又需要栈上一个内存,又……。这是一个死循环,这也就是为什么有TLS的存在。
TLS共提供了4个API,分别为TlsAlloc、TlsSetValue、TlsGetValue和TlsFree。调用TlsAlloc将得到一个cookie,是一个DWORD值,是个序号。然后分配一块内存,将内存的地址通过TlsSetValue和前面得到的cookie保存起来,然后在适当的时候调用TlsGetValue得到记录的地址,程序不再使用TLS的时候调用TlsFree释放前面的cookie即可。
上面的关键就在于不同的线程调用TlsGetValue,即使提供同样的cookie,返回的也不是同一个值。同样,不同的线程调用TlsSetValue,即使同样的cookie,却不是互相干涉,并且在TlsGetValude时能正确返回。故上面的cookie可以是个全局变量。如:
#include <stdio.h>
#include <windows.h>
// 出于样例,不做任何错误检查及处理
DWORD g_Cookie = static_cast< DWORD >( -1 );
DWORD g_Index = 1;
struct ABCD { long a; };
void CBA()
{
reinterpret_cast< ABCD* >( TlsGetValue( g_Cookie ) )->a = g_Index++;
Sleep( rand() % 1000 );
}
DWORD WINAPI AB( LPVOID )
{
ABCD *pTemp = new ABCD;
TlsSetValue( g_Cookie, pTemp );
CBA();
printf( "%d\n", pTemp->a );
delete pTemp;
return 0;
}
void main()
{
g_Cookie = TlsAlloc();
DWORD id = static_cast< DWORD >( -1 );
HANDLE hThreads[4];
for( unsigned long i = 0; i < 4; ++i )
hThreads[ i ] = CreateThread( NULL, 0, AB, NULL, 0, &id );
WaitForMultipleObjects( 4, hThreads, TRUE, INFINITE );
for( i = 0; i < 4; ++i )
CloseHandle( hThreads[ i ] );
TlsFree( g_Cookie );
}
输出结果如下(注意其中并没有因为g_Index++而是顺序的):
1
3
2
4
接口指针的中立形式
《COM线程模型》中已经说明,接口指针是线程相关,虽然逻辑上指向同一个对象,但不同的线程由于代理对象的原因而实际获得不同的接口指针。但由于逻辑上是同一个对象,因此应该可以有一种接口的中立形式,与线程无关,唯一表示真正的接口指针。
当我们调用CoMarshalInterThreadInterfaceInStream获得一个IStream*,以后只要调用CoGetInterfaceAndReleaseStream就可以从IStream*得到散集出来的接口,这里IStream*就是接口指针的中立形式。但如果真的要保存IStream*这个中立形式,就应该调用CoUnmarshalInterface以防止释放了IStream*。
前面多少显得有点麻烦,COM为此提供了一个对象,叫做全局接口表(Global Interface Table——GIT),其实现IGlobalInterfaceTable接口,以提供对上面所说步骤的封装。其组件类和接口的ID分别为CLSID_StdGlobalInterfaceTable和IID_IGlobalInterfaceTable。
通过调用IGlobalInterfaceTable::RegisterInterfaceInGlobal注册一个接口,并返回一个cookie,在后继的调用中使用这个cookie来表示注册的接口。这里,这个cookie就是接口的中立形式。IGlobalInterfaceTable中的另外两个方法是RevokeInterfaceFromGlobal和GetInterfaceFromGlobal,分别用于注销接口和根据cookie获得正确的接口指针。
下面提供一个使用GIT的样例以说明如何记录接口的中立形式而非直接的接口,在本系列后继的文章中给出的代码里再演示IStream*的使用方式(假设类CABCD实现一个COM组件,ABC和CBA是其实现的某个接口中的方法):
extern IGlobalInterfaceTable *g_pGIT;
STDMETHODIMP CABCD::ABC( IABC *pAbc )
{
if( !pAbc )
return E_POINTER;
// 做需要的处理,接着保留pAbc以供以后使用,比如回调
// 使用成员变量DWORD m_dwABC来保存IABC*,如下:
ASSERT( g_pGIT );
if( FAILED( g_pGIT->RegisterInterfaceInGlobal( pAbc,
IID_IABC,
&m_dwABC ) ) )
return E_FAIL;
return S_OK;
}
STDMETHODIMP CABCD::CBA()
{
if( m_dwABC == static_cast< DWORD >( -1 ) )
return E_FAIL;
// 使用成员变量m_pABC通过g_pGIT获得IABC*,如下:
IABC *pAbc = NULL;
ASSERT( g_pGIT );
if( FAILED( g_pGIT->GetInterfaceFromGlobal(
m_dwABC,
IID_IABC,
reinterpret_cast< void** >( &pAbc ) ) ) )
return E_FAIL;
// 做需要的处理,然后回调IABC中的某些方法以作类似通知的工作
ASSERT( pAbc );
pAbc->ABCD(); // 通知客户
pAbc->Release();
return S_OK;
}
回调问题
回调是一种技术,当客户欲反过来,向服务器提供服务时,就使用回调技术,这在Win32 API中很流行,还专门提供一个CALLBACK以标示一个回调函数(主要是指定调用规则)。
回调就是客户写好一个函数,然后将函数指针传给服务器,服务器在适当的时候就通过客户传进来的函数指针调用客户提供的函数以获取客户提供的服务。这其实就是服务器预留一个编程接口(前面的函数指针的原型),以增加其自身的灵活性。
这个技术非常适合通知消息的实现。当服务器完成某个任务后,其调用客户传进来的函数指针,而客户则在那个函数中编写相应的响应代码,结果就表现为客户响应了服务器的通知(任务完成了)。
在COM中,不能在接口方法中传递函数指针,因为函数指针不仅仅是个指针就完了,它还有其附加的函数调用规则、参数类型、返回值类型,不同的语言使用不同的函数调用规则,则可能参数压栈顺序不同,参数提取方式不同,而最后导致失败(当然,可以提供统一的函数调用规则,COM也正是统一使用__stdcall调用规则来解决这个问题的)。并且函数指针是个内存地址,即是进程相关的,在这个进程根据那个进程中的函数指针发起调用结果将是不可预知的,因此接口方法中是不应该传递函数指针的。
但回调技术是如此的有用,所以当要在COM接口间传递函数指针时,应将那个函数的原型定义进一个接口,改为传递接口的指针。
传递接口指针,在适当的时候(比如跨套间时),会自动生成代理对象,这样前面提到的不在同一个进程空间中的问题,就可以通过在服务器进程中加载代理对象,然后通过调用代理对象来实现回调。而由于使用了接口,所以其中的方法也就统一使用了__stdcall调用规则,因此就不存在任何问题。并且COM还提供了一组标准接口以专门实现上述的回调以支持消息通知,此组接口被称为连接点(Connection Point)技术,已成为标准的消息通知实现模型了(虽然COM+提供了另一种事件模型)。
但上面依旧有问题,就是当多线程时,由于传递的是接口,接口是线程相关的,因此需要进行同步处理,故而需要拥有正确的代理对象指针。而在如上面的样例中,由于组件是在MTA套间中运行的,代码可能被多个线程访问,需要保证不同线程调用时,发出通知消息的接口指针都能正确按照套间的规则做应有的动作,因此在组件中使用成员变量记录时不能直接记录接口指针,而必须是接口的中立形式。否则,在向STA线程回调时,可能导致向错误的线程发送消息,并进而导致同步的失败,引起系统的不稳定。