原文:http://www.vckbase.com/index.php/wv/1315.html
简序
大学毕业前的最后一学期,在一家公司实习,当时的工作需要用到一些操作系统提供的组件。那时候只知道COM这个名词,并不知道到底是怎么回事,只知道上网到处找别人的源码解决自己的问题;那段日子到现在回忆起来都是灰色的,每天呆坐在电脑前,一个网站一个网站的查找自己需要的源码。但并不清楚自己到底在做什么;那时候对自己能不能成为一个程序员充满了怀疑。在实习结束返校的火车上,一夜间,我把一本《COM本质论》翻看了120多页。当我和当时的女友吹嘘自己一夜可以看100多页书的时候,她马上问我:看懂多少?当时我哑口无言。她忍受不了我那段日子的失落和抱怨,从那时候起,我们结束了那段简短的感情。到如今我还在一个人漂泊着,而上周她成为了别人的妻子。想不到用什么方式去纪念我迄今为止经历过的唯一一段感情,我和她的感情并不完全是因为COM结束的,但由于对COM的迷惑,使我走向了迷茫,失落;对自己失去了信心,在她面前变成了一个悲观失望的人。写这篇文章权当对这份感情的一份纪念吧。
企者不立,跨着不行。很多格言都告诉我们做什么事情都必须从基础开始,对COM的理解也是这个道理。当三年前我看《COM 本质论》的时候,对虚函数也只是一知半解,只是知道通过它可以实现多态。但到底怎么实现就不清楚了。看不懂COM太正常了。知道看过Stanley B.Lippman的《Inside the C++ Object Model》,对C++的内存结构有了基本的理解,我才明白了接口的意义。这篇文章是写给初学者的,顺便给大家一些建议,如果一本书你看不懂的时候,可以先放放,先找一些基础的读物来看看。这样可以少走一些弯路。
Don Box 在《COM 本质论》中说,对接口,类对象和套间有了彻底的理解,那么使用COM,没有翻不过去的山头。如果你对C++有深入的理解,那么《COM本质论》中对接口和类对象的阐述很清晰,理解并不困难。但套间是一个比较抽象的概念,而书上对这部分只是理论的叙述,没有提供具体的例子,理解起来就更困难了。在此我把自己找到的一些例子和自己的理解总结以下,以期给初学者提供一些入门的方法。闲话打住,开始正文吧。
一、关于多线程(Multithreading)
子曰:本立道生。也就是说我们明白事物所存在的原因,自然也就明白事物是怎么回事了。如果我们清楚了套间(Apartment)的产生原因,再去理解套间,就容易许多了。我们先来看看,为什么需要套间?套间是为解决多线程中使用组件而产生的,首先我们来了解一下多线程。
1、理解进程(Processes)和线程(Threading)
理解线程,先从进程(Processes)开始,一般书上对进程的描述都比较抽象,都说进程是一个运行的程序的实例,进程拥有内存,资源。我这儿试着用一段汇编程序来解释一下进程,看看能不能帮你加深一下印象。我们先来看一段简单的汇编程序(你不理解汇编的话,建议找本书看看,一点不懂汇编,很难对其它高级语言有太深的理解)。
01.
; 汇编程序示例
02.
data_seg segment ;定义数据段
03.
n_i dw ?
04.
data_seg ends
05.
06.
stack_seg segment ;定义堆栈
07.
dw 128 dup(0)
08.
tos label word
09.
statck_seg ends
10.
11.
code1 segment ;定义代码段
12.
main proc far
13.
assume cs:ccode,ds;data,seg,ss:stack_seg
14.
start:
15.
move ax,stack_seg ;将定义的堆栈段的地址保存到ss
16.
mov ss,ax
17.
mov sp,offset tos ;将堆栈的最后地址保存到sp,堆栈是从下到上访问的
18.
19.
push ds ;保存旧的数据段
20.
sub ax,ax
21.
push ax
22.
23.
mov ax,data_seg ;将定义的数据段保存到ds
24.
mov ds,ax
25.
26.
call fact ;调用子函数
27.
28.
……. ;其它操作省略
29.
ret ;返回到系统
30.
main endp
31.
32.
fact proc near ;子函数定义
33.
34.
…… ;具体操作省略
35.
ret ;返回到调用处
36.
fact endp
37.
38.
code1 ends
39.
end start
40.
示例1:汇编程序结构
从以上程序我们看到,一个程序可以分为代码段,数据段,堆栈段等几部分。汇编编译器在编译的时候会将这些文件转化为成一个标准格式(在windows下被称为PE文件格式)的文件(很多时候可执行文件被命名为二进制文件,我不喜欢这个名字,我觉得它容易给人误解;事实上计算机上所有的文件都是0和1组成的,都是二进制文件;真正不同的就是处理这些文件的方式;EXE文件需要操作系统来调用,TXT文件需要写字本来打开;但其本质上并没有什么不同,只是在不同的组合上,二进制数有不同的意义)。该文件格式会把我们的代码按格式安放在不同的部分。程序必须在内存中,才可以执行。在程序运行前,操作系统会按照标准格式将这些内容加载到内存中。这些数据加载到内存中也需要按照一定的格式,CPU提供了DS,CS,SS等段寄存器,这样代码段的开始位置需要被CS指定,数据段的开始位置需要用DS来指定,SS需要指向堆栈的开始位置等。在DOS下,每次只能运行一个程序,这些内容基本构成了进程。但在Windows下,丰富了进程的内容,还包括一些数据结构用来维护我们程序中用到的图标,对话框等内容,以及线程。其实进程就是程序在内存中的组织形式,有了这样的组织形式,程序才可能运行。也就是说,当程序加载到内存中去后,就形成了一个进程。
我们知道,CPU中拥有众多的寄存器,EAX,EBX等,而CPU的指令一般都是通过寄存器来实现的。其中有一个寄存器叫做EIP(Instruction Pointer,指令寄存器),程序的有序执行,是靠它来完成的。看下面的例子:
1.
……
2.
mov eax,4
3.
mov ebx,5
4.
……
假如我们的程序运行到mov eax,4,那么EIP就会指向该句代码所在的内存的地址。当这行代码执行完毕之后,那么EIP会自动加一,那么它就会指向mov ebx,4。而程序的执行就是靠EIP的不断增加来完成的(跳转的话,EIP就变成了跳转到的地址)。在Windows系统下,进程并不拥有EIP,EAX,那么只有进程,一个程序就无法运行。而拥有这些寄存器的是线程,所以说进程是静态的。
我们知道一个CPU下只有一个EIP,一个EAX,也就是说同一时刻只能有一个线程可以运行,那么所说的多线程又是什么呢?事实上同一时刻也只有一个线程在运行,每个线程运行一段时间后,它会把它拥有的EIP,EAX等寄存器让出来,其它线程占有这些寄存器后,继续运行。因为这段时间很短,所以我们感觉不出来。这样我们就可以在一边听音乐的时候,一边玩俄罗斯方块了。为了实现不同的线程之间的转换,CPU要求操作系统维护一份固定格式的数据(该数据存在于内存中),这份数据叫做Task-State Segment(TSS),在这份数据结构里,维护着线程的EAX,EIP,DS等寄存器的内容。而CPU还有一个寄存器叫做Task Register(TR),该寄存器指向当前正在执行的线程的TSS。而线程切换事实上就是TR指向不同的TSS,这样CPU就会自动保存当前的EAX,EBX的信息到相应的TSS中,并将新的线程的信息加载到寄存器。
事实上线程不过上一些数据结构,这些结构保存了程序执行时候需要的一些信息。我们可以在windows提供的头文件中找到一些影子,安装VC后在它的include目录下有一个Winnt.h文件。在该文件中,我们可以找到这样一个struct(_CONTEXT)。这就是线程切换时需要的数据结构(我不确定Windows内部是否用的就是这个结构,但应该和这份数据相差无几)。
01.
//
02.
// Context Frame
03.
//
04.
// This frame has a several purposes: 1) it is used as an argument to
05.
// NtContinue, 2) is is used to constuct a call frame for APC delivery,
06.
// and 3) it is used in the user level thread creation routines.
07.
//
08.
// The layout of the record conforms to a standard call frame.
09.
//
10.
11.
typedef
struct
_CONTEXT {
12.
13.
//
14.
// The flags values within this flag control the contents of
15.
// a CONTEXT record.
16.
//
17.
// If the context record is used as an input parameter, then
18.
// for each portion of the context record controlled by a flag
19.
// whose value is set, it is assumed that that portion of the
20.
// context record contains valid context. If the context record
21.
// is being used to modify a threads context, then only that
22.
// portion of the threads context will be modified.
23.
//
24.
// If the context record is used as an IN OUT parameter to capture
25.
// the context of a thread, then only those portions of the thread''s
26.
// context corresponding to set flags will be returned.
27.
//
28.
// The context record is never used as an OUT only parameter.
29.
//
30.
31.
DWORD
ContextFlags;
32.
33.
//
34.
// This section is specified/returned if CONTEXT_DEBUG_REGISTERS is
35.
// set in ContextFlags. Note that CONTEXT_DEBUG_REGISTERS is NOT
36.
// included in CONTEXT_FULL.
37.
//
38.
39.
DWORD
Dr0;
40.
DWORD
Dr1;
41.
DWORD
Dr2;
42.
DWORD
Dr3;
43.
DWORD
Dr6;
44.
DWORD
Dr7;
45.
46.
//
47.
// This section is specified/returned if the
48.
// ContextFlags word contians the flag CONTEXT_FLOATING_POINT.
49.
//
50.
51.
FLOATING_SAVE_AREA FloatSave;
52.
53.
//
54.
// This section is specified/returned if the
55.
// ContextFlags word contians the flag CONTEXT_SEGMENTS.
56.
//
57.
58.
DWORD
SegGs;
59.
DWORD
SegFs;
60.
DWORD
SegEs;
61.
DWORD
SegDs;
62.
63.
//
64.
// This section is specified/returned if the
65.
// ContextFlags word contians the flag CONTEXT_INTEGER.
66.
//
67.
68.
DWORD
Edi;
69.
DWORD
Esi;
70.
DWORD
Ebx;
71.
DWORD
Edx;
72.
DWORD
Ecx;
73.
DWORD
Eax;
74.
75.
//
76.
// This section is specified/returned if the
77.
// ContextFlags word contians the flag CONTEXT_CONTROL.
78.
//
79.
80.
DWORD
Ebp;
81.
DWORD
Eip;
82.
DWORD
SegCs;
// MUST BE SANITIZED
83.
DWORD
EFlags;
// MUST BE SANITIZED
84.
DWORD
Esp;
85.
DWORD
SegSs;
86.
87.
//
88.
// This section is specified/returned if the ContextFlags word
89.
// contains the flag CONTEXT_EXTENDED_REGISTERS.
90.
// The format and contexts are processor specific
91.
//
92.
93.
BYTE
ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
94.
95.
} CONTEXT;
好了,线程就先讲这么多了。如果对进程和线程的内容感兴趣,可以到Intel的网站下载PDF格式的电子书《IA-32 Intel Architecture Software Developer’s Manual》,纸版的书也可以在这儿预定(他们会免费邮寄给你)。通过这套书,你可以对CPU的结构有一个清晰的认识。另外可以找几本讲解Windows系统的书看看,不过这类的好书不多,最著名的是《Advance Windows》,不过也是偏向于实用,对系统结构的讲解不多。也是,要完全去了解这部分的细节,太困难了,毕竟微软没有给我们提供这部分的源码。幸好,其实我们理解它大致的原理就足够用了。
2、多线程存在的问题
我们首先看一段多线程程序(该程序可以在Code的MultiThreading中找到):
01.
#include < iostream >
02.
#include < windows.h >
03.
04.
int
g_i = 10;
//一个全局变量
05.
06.
DWORD
WINAPI ThreadProc(
LPVOID
lpv)
07.
{
08.
g_i += 10;
09.
std::cout <<
"In the Thread "
<< ::GetCurrentThreadId() <<
",the first g_i is "
<< g_i <<
"!"
<< std::endl;
10.
Sleep(5000);
//睡眠
11.
g_i += 10;
12.
std::cout <<
"In the Thread "
<< ::GetCurrentThreadId() <<
",the secend g_i is "
<< g_i <<
"!"
<< std::endl;
13.
return
0;
14.
}
15.
16.
int
main(
int
argc,
char
* argv[])
17.
{
18.
19.
DWORD
threadID[2];
20.
HANDLE
hThreads[2];
21.
22.
for
(
int
i = 0; i <= 1; i++ )
//创建两个线程
23.
hThreads[i] = ::CreateThread(NULL,
24.
0,
25.
ThreadProc,
26.
NULL,
27.
0,
28.
&threadID[i]);
29.
30.
31.
WaitForMultipleObjects(2,hThreads,TRUE,INFINITE);
//等待线程结束
32.
33.
for
(i = 0; i <= 1; i++ )
34.
::CloseHandle(hThreads[i]);
//关闭线程句柄
35.
system
(
"pause"
);
36.
return
0;
37.
}
38.
示例程序2-多线程程序
这段程序的本意是让全局变量累次加10,并打印出操作后的数值。但我们运行程序后的结果如下,可以看到程序的运行结果非我们所愿。打印出的结果是一串乱序的文字。
如何解决这个问题呢?我们需要利用同步机制来控制我们的多线程程序,现在我们使用临界区来解决这个问题。代码如下:(在Code的MultiThreading中将进入临界区和离开临界区的代码前的注释去掉就可以了)
01.
#include < iostream >
02.
#include < windows.h >
03.
04.
int
g_i = 10;
//一个全局变量
05.
06.
CRITICAL_SECTION cs;
//一个临界区变量
07.
08.
DWORD
WINAPI ThreadProc(
LPVOID
lpv)
09.
{
10.
EnterCriticalSection(&cs);
//进入临界区
11.
12.
g_i += 10;
13.
std::cout < <
"In the Thread "
< < ::GetCurrentThreadId() < <
",the first g_i is "
< < g_i < <
"!"
< < std::endl;
14.
::LeaveCriticalSection(&cs);
15.
Sleep(5000);
//睡眠
16.
EnterCriticalSection(&cs);
17.
g_i += 10;
18.
std::cout < <
"In the Thread "
< < ::GetCurrentThreadId() < <
",the secend g_i is "
< < g_i < <
"!"
< < std::endl;
19.
::LeaveCriticalSection(&cs);
20.
return
0;
21.
}
22.
23.
int
main(
int
argc,
char
* argv[])
24.
{
25.
26.
DWORD
threadID[2];
27.
HANDLE
hThreads[2];
28.
InitializeCriticalSection(&cs);
29.
for
(
int
i = 0; i < = 1; i++ )
//创建两个线程
30.
hThreads[i] = ::CreateThread(NULL,
31.
0,
32.
ThreadProc,
33.
NULL,
34.
0,
35.
&threadID[i]);
36.
37.
WaitForMultipleObjects(2,hThreads,TRUE,INFINITE);
//等待线程结束
38.
for
(i = 0; i < = 1; i++ )
39.
::CloseHandle(hThreads[i]);
//关闭线程句柄
40.
41.
system
(
"pause"
);
42.
return
0;
43.
}
再次运行,结果就是我们所需要的了。
如上所示我们通过在代码中加入EnterCriticalSection和LeaveCriticalSection来实现对数据的保护,如我们只在程序开头和结尾填加这两个函数的话,也不会太复杂,但是这样也就失去了多线程的意义。程序不会更快,反而会变慢。所以我们必须在所有需要保护的地方,对我们的操作进行保护。程序如果庞大的话,这将是一个烦琐而枯燥的工作,而且很容易出错。如果是我们自己使用的类的话,我们可以选择不使用多线程,但组件是提供给别人用的。开发者无法阻止组件使用者在多线程程序中使用自己提供的组件,这就要求组件必须是多线程安全的。但并不是每个开发者都愿意做这样的工作,微软的COM API设计者为了平衡这个问题,就提出了套间的概念。
注意:以上只是一个简单的例子,事实上多线程中需要保护的部分一般集中在全局数据和静态数据之上,因为这样的数据每个进程只有一份,如上所示的g_i。(想对多线程程序有更深入的认识,可以找侯捷翻译的《Win32多线程程序设计》看看,90年代出的书,到现在还畅销,足可以说明它的价值)
二、套间所要解决的问题
从多线程的描述中,我们知道,套间所要解决的问题是帮助组件的开发者在实现多线程下调用组件时候的同步问题。我们还是先看一段简短的程序。
我们首先使用ATL创建一个简单的组件程序,该程序有一个接口(ITestInterface1),该接口支持一个方法TestFunc1。(该组件可以在附加的源码的“Apartment\TestComObject1”目录下找到)我们通过以下的程序调用该组件。(该程序可以在附加的源码的“Apartment\ErrorUseApartment”目录下找到)
01.
#define _WIN32_WINNT 0x0400
02.
#include < windows.h >
03.
#include < iostream >
04.
05.
#include "..\TestComObject1\TestComObject1_i.c"
06.
#include "..\TestComObject1\TestComObject1.h"
07.
08.
DWORD
WINAPI ThreadProc(
LPVOID
lpv)
09.
{
10.
11.
HRESULT
hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
12.
13.
if
( FAILED(hr) )
14.
{
15.
std::cout <<
"CoinitializeEx failed!"
<< std::endl;
16.
return
0;
17.
}
18.
19.
ITestInterface1 *pTest = NULL;
20.
21.
hr = ::CoCreateInstance(CLSID_TestInterface1,
22.
0,
23.
CLSCTX_INPROC,
24.
IID_ITestInterface1,
25.
(
void
**)&pTest);
26.
27.
if
( FAILED(hr) )
28.
{
29.
std::cout <<
"CoCreateInstance failed!"
<< std::endl;
30.
return
0;
31.
}
32.
33.
hr = pTest->TestFunc1();
34.
35.
if
( FAILED(hr) )
36.
{
37.
std::cout <<
"TestFunc1 failed!"
<< std::endl;
38.
return
0;
39.
}
40.
41.
pTest->Release();
42.
::CoUninitialize();
43.
return
0;
44.
}
45.
46.
int
main(
int
argc,
char
* argv[])
47.
{
48.
HRESULT
hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
49.
50.
if
( FAILED(hr) )
51.
{
52.
std::cout <<
"CoinitializeEx failed!"
<< std::endl;
53.
return
0;
54.
}
55.
56.
ITestInterface1 *pTest = NULL;
57.
58.
hr = ::CoCreateInstance(CLSID_TestInterface1,
59.
0,
60.
CLSCTX_INPROC,
61.
IID_ITestInterface1,
62.
(
void
**)&pTest);
63.
64.
if
( FAILED(hr) )
65.
{
66.
std::cout <<
"CoCreateInstance failed!"
<< std::endl;
67.
return
0;
68.
}
69.
70.
DWORD
threadID;
71.
HANDLE
hThreads = ::CreateThread(NULL,
//创建一个进程
72.
0,
73.
ThreadProc,
74.
NULL,
//将pTest作为一个参数传入新线程
75.
0,
76.
&threadID);
77.
hr = pTest->TestFunc1();
78.
79.
if
( FAILED(hr) )
80.
{
81.
std::cout <<
"TestFunc1 failed!"
<< std::endl;
82.
return
0;
83.
}
84.
85.
::WaitForSingleObject(hThreads,INFINITE);
//等待线程结束
86.
::CloseHandle(hThreads);
//关闭线程句柄
87.
pTest->Release();
88.
::CoUninitialize();
89.
system
(
"pause"
);
90.
return
0;
91.
}
该段程序将main中定义的ITestInterface1对象,通过指针传到了新建的线程中。运行该段程序,结果如下,又是一串乱序的文字串。也就是说我们需要在TestComObject1中对TestFunc1进行线程同步控制。但大多数人并不想这样做,因为我们开发的组件大多数情况下并不会在多线程执行。但为了避免低概率事件发生后的不良后果,套间出场了。
三、套间如何实现数据的同步
我们已经知道套间的目的是用来实现数据的同步,那么套间如何来实现呢?如果我们能保证COM对象中的函数只能在该对象中的另一个函数执行完以后,才能开始执行(也就是说组件中的函数只能一个一个的执行),那么我们的问题就可以解决了。是的,你可以发现,这样的话,就失去了多线程的优势;但套间的目的是保证小概率下的线程安全,损耗一些性能,应该比出现逻辑错误强点。
那么又如何保证同一对象下的所有方法都必须按顺序逐个执行呢?微软的COM API设计者们借用了Windows的消息机制。我们先来看一下windows的消息机制图。
我们可以看到所有线程发出的消息都回首先放到消息队列中,然后在通过消息循环分发到各自窗口去,而消息队列中的消息只能一个处理完后再处理另一个,借助消息机制,就可以实现COM的函数一个一个的执行,而不会同时运行。Windows的消息机制是通过窗口来实现的,那么一个线程要接收消息,也应该有一个窗口。 COM API的设计者在它们的API函数中实现了一个隐藏的窗口。在我们调用CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)的时候,会生成这个窗口。(如果你对softice等动态调试工具熟悉的话,可以通过跟踪源码来跟踪CoInitializeEx函数,可以发现它会调用API函数CreateWindowEx)。该窗口是隐藏的,有了这个窗口,就可以支持消息机制,就有办法来实现对象中函数的逐一执行。这样当对象指针被传到其它线程的时候,从外部调用该对象的方法的时候,就会先发一个消息到原线程,而不再直接访问对象了。套间的原理大致就是这样。我们再来看看COM中的套间类型。
四、套间的类型
我们首先看看ATL为我们提供的线程类型:Single,Apartment,Both,Free。我们还是通过例子来说明它们的不同。我们仍然用我们使用刚才实现的TestComObject1来进行测试,先对它实现的唯一方法进行一下说明。
1.
STDMETHODIMP CTestInterface1::TestFunc1()
2.
{
3.
// TODO: Add your implementation code here
4.
std::cout <<
"In the itestinferface1''s object, the thread''s id is "
<< ::GetCurrentThreadId() << std::endl;
5.
return
S_OK;
6.
}
该方法非常简单,就是打印出该方法运行时,所在的线程的ID号。如果在不同的线程中调用同一个对象的时候,通过套间,发送消息,最终该对象只应该在一个线程中运行,所以它的线程ID号应该是相同的。我们将通过该ID值来验证套间的存在。
1、Single
先来看我们的示例程序(在Code/Apartment/SingleApartment目录下可以找到该工程):
01.
#define _WIN32_WINNT 0x0400
02.
#include < windows.h >
03.
#include < iostream >
04.
05.
#include "..\TestComObject1\TestComObject1_i.c"
06.
#include "..\TestComObject1\TestComObject1.h"