在设计多线程程序的时候,时常会遇到对同一个数据进行读写操作的情况。为了保证多线程的正常运行,一般来说会对读写操作进行同步,同一时间上只能执行读或写,而不能两者同时执行。在多核环境下,若线程间采用了流水线的方式对数据进行加工,那么这样必然影响到整个流水线的工作效率。因为这样会使得某些CPU的资源得不到应用。因此,设计一种能使得读写同时进行的算法十分必要。另外,这里还对模板设计的一些相关事项进行了说明,相信对你多少会有点用。
1. 设计背景
在设计多线程程序的时候,时常会遇到对同一个数据进行读写操作的情况。为了保证多线程的正常运行,一般来说会对读写操作进行同步,同一时间上只能执行读或写,而不能两者同时执行。在多核环境下,若线程间采用了流水线的方式对数据进行加工,那么这样必然影响到整个流水线的工作效率。因为这样会使得某些CPU的资源得不到应用。因此,设计一种能使得读写同时进行的算法十分必要。
说到这里,不得不提一下目前做的项目,这个项目要求实时处理机算机屏幕的图像并通过网口进行发送。之前写的WinPcap的博文也是因为这个项目。而现在因为要充分利用多核CPU的资源,我设想用流水线加工的形式,这样各个CPU都可以专注于一个任务的执行。这里就出现了一个很困扰的问题,数据是流水下来的,线程间对数据进行读写必须进行同步,否则数据必然出错。细想一下,不能同时读写是因为读写的是同一对象,假如读写的是不同对象,那问题不就解决了?!其实在硬件层面的软件设计(比如VHDL)上早就有这个了,叫做乒乓操作。这里把乒乓操作和代列结合在一起。
2. 算法设计
类的数据结构如图1所示,包括pToRead指向的读取单元,pToWrite指向的写入单元以及读写标记nMark。其中,data为所要读写的数据,后面会把这个设计成模板的形式。
图1 类的数据结构
读写标记nMark为整型,其值所代表的意义如表1所示。
表1 nMark说明
nMark(整型) |
说明 |
3 |
读写单元均无可读数据(只能写入) |
2 |
读取单元可读,写入单元可写(读写可同时进行) |
1 |
读写单元均有可读数据(只能读取) |
<1或>3 |
无意义 |
算法的基本思想是读写分开在两个内存单元中进行,采用类似于队列的形式。当后面的数据要移到前面的时候将pToWrite和pToRead指针调换即可完成类似于队列数据移动。在指针调换那部分需要线程同步,而读写部分不需要线程同步,因为此时读写并不是针对同一个内存。另外,此算法只适用于一个线程读取一个线程写入的情况(即流水线方式的一部分)。算法的流程如图2所示。
图2 算法流程图
初始化之后nMark为3,表明当前对象两个单元均为空,可写入。由于是读写线程同时执行,所以你不会知道哪个线程较早执行。假如ReadData开始执行了,那么会发现nMark为3,那么线程阻塞并等待“写入事件”,接着WriteData执行,把数据写入pToWrite所指向单元,完成后检查pToRead单元的bRead是否为假,假的话将pToWrite与pToRead指针调换,nMark减1,然后产生事件通知读线程。这是nMark为3的情况,假如nMark为1,那么两个单元均可读,若此时执行WriteData,那么线程会阻塞并等待“读取事件”,接着ReadData执行,把数据从pToRead所指向单元读出,完成后检查pToWrite单元的bRead是否为真,真的话将pToWrite与pToRead指针调换,nMark加1,然后产生事件通知写线程。剩下就是nMark为2的情况了,这时读写线程都可以同时进行,假如读线程先读完数据,那么它就会先进入关键段,那么读线程将首先检查写单元的bRead是否为真,由于写线程还没有把bRead改为真,所以此时是不会调换指针的。在读线程离开关键段之后,写线程才可以进行关键段,此时它会检查读单元的bRead是否为假,由于刚才读线程已经读完数据,因此bRead会被赋值为假,那样就会调换指针,调换后就会变成读单元可读,写单元可写了。当然还有写线程先进入关键段的情况,结果是一样的。你可以看出实际上还是有同步,不过这个同步的代价已经非常低了。
3. 代码实现
本来一开始我只用了int作为data的类型,并且用了全局变量进行同步,这样一天时间就把代码写出来并调通了。如果只对一种数据类型进行读写,那当然很方便,但我要面对的却是几种不同的数据类型,难道写几个代码异常雷同的类吗?那样太噁心了。用模板来实现是最好的选择。
3.1. 类模板注意事项
开始把类转成类模板的时候遇到了很多问题,比如连接出错等等。下面一一说明:
1.对类模板的理解
类模板不是类,它只是一个模板。比如定义了template <typename T> class RWObj{},只有当实例化的时候才会产生类,否则它不算程序里面的东西,就像你没有钥匙进入房间一样。而给它显式实例化后(用RWObj<int>的形式表示一个类,第一次用到这个声名的时候就会进行实例化)它就拿到了钥匙正式进入房间,成为程序里头有用的东西。编译器会在遇到类模板实例化的地方,先用T(下面T都代表要实例化的类型)进行实例化,生成一个段类的代码,然后再用那段代码进行编译。
2.静态变量
静态变量必须在类的头文件中显式地声名才能被其他类调用,否则会出现连接出错。据我的理解是这样的,因为静态变量存放在堆中,会在整个程序的生命期有效,所以即使没有产生任何一个对象,你也必须保证程序能够访问得到这个变量。这样的话假如没有在头文件中声名静态变量会出现连接不到的错误也就不足为怪了。
3.模板内的类型说明
有时候我们会在类里头定义一种结构,便于封装数据。《C++ primer》里头对这种使用有了详细的说明:“如果要在函数模板内部使用这样的类型,必须告诉编译器我们正在使用的名字指的是一个类型。必须显式地这样做,因为编译器(以及程序的读者)不能通过检查得知,由类型形参定义的名字何时是一个类型何时是一个值。”实际上,我在程序中定义了一个PELEMENT的结构指针,在没有注意到这点的时候,编译总是出现PELEMENT不是类型的错误。另外,假如定义与声名放在同一个地方,那么可以不用“::”来访问类型作用域里的成员。
4.连接错误
一开始我是把声名放在.h文件把定义放在.cpp文件的,编译通过了,debug的时候却是连接不到ReadData等函数。百思不得其解,上网查了一下,发现有些人说模板必须要源代码可见,也就是必须把定义放在头文件中,但却没有说明原因。回头再翻了翻primer,仔细把模板这章看了看,忽然开窍了。“模板则不同:要进行实例化,编译器必须能够访问定义模板的源代码”。我现在使用的这种是“分别编译模型”,编译器只记住了.h文件中对模板的定义,要让编译器知道模板的定义在别的文件中则必须使用“export”来修饰,它的作用是说明定义可能在其他文件中实例化。于是我在VS2008上把这个试了下,竟然发现export是保留的关键字,以后可能会用到,但目前不能使用...虽然说微软这些年来都在支持.NET的发展,但这些都是N年前的东西了,落不落后点啊。没办法,只好将定义一同放在头文件中。(试了一下“包含编译模型”,出现一些莫名其妙的错误,由于已经可以运行也不想深究下去)
3.2. 代码
Code
1#pragma once
2
3template <typename DATA>
4class CRWObject
5{
6private:
7 // 读写单元结构
8 struct ELEMENT
9 {
10 DATA data; // 数据
11 volatile bool canRead; // 可读标记
12 };
13 typedef ELEMENT* PELEMENT;
14 static HANDLE s_hReadEvent; // 读取事件句柄
15 static HANDLE s_hWriteEvent; // 写入事件句柄
16 static CRITICAL_SECTION s_cs; // 关键段,用于线程间同步
17 volatile int m_nMark; // 读定标记
18 PELEMENT m_pToRead; // 读取单元指针
19 PELEMENT m_pToWrite; // 写入单元指针
20 ELEMENT m_element1; // 读写单元1
21 ELEMENT m_element2; // 读写单元2
22 bool CanRead(void); // 判断是否可读
23 bool CanWrite(void); // 判断是否可写
24 inline void Swap(PELEMENT &pParam1, PELEMENT &pParam2); // 交换指针
25public:
26 CRWObject(void);
27 ~CRWObject(void);
28 DATA ReadData(void); // 读数据操作
29 void WriteData(DATA data); // 写数据操作
30};
31
32template <typename DATA>
33CRITICAL_SECTION CRWObject<DATA>::s_cs;
34template <typename DATA>
35HANDLE CRWObject<DATA>::s_hReadEvent;
36template <typename DATA>
37HANDLE CRWObject<DATA>::s_hWriteEvent;
38
39template <typename DATA>
40bool CRWObject<DATA>::CanRead(void) // 判断是否可读
41{
42 // m_nMark为1或2时均返回真
43 if(m_nMark < 1)
44 return FALSE;
45 if(m_nMark < 3)
46 return TRUE;
47 return FALSE;
48};
49
50template <typename DATA>
51bool CRWObject<DATA>::CanWrite(void) // 判断是否可写
52{
53 // m_nMark为2或3时均返回真
54 if(m_nMark > 3)
55 return FALSE;
56 if(m_nMark > 1)
57 return TRUE;
58 return FALSE;
59};
60
61template <typename DATA>
62inline void CRWObject<DATA>::Swap(PELEMENT &pParam1, PELEMENT &pParam2) // 交换指针
63{
64 typename CRWObject::PELEMENT pTemp = pParam1;
65 pParam1 = pParam2;
66 pParam2 = pTemp;
67};
68
69template <typename DATA>
70CRWObject<DATA>::CRWObject(void)
71{
72 // 数据初始化
73 m_element1.canRead = FALSE;
74 m_element1.data = 0;
75 m_element2.canRead = FALSE;
76 m_element2.data = 0;
77 m_pToRead = &m_element1;
78 m_pToWrite = &m_element2;
79 m_nMark = 3;
80 s_hReadEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
81 s_hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
82 InitializeCriticalSection(&s_cs);
83};
84
85template <typename DATA>
86CRWObject<DATA>::~CRWObject(void)
87{
88 CloseHandle(CRWObject::s_hReadEvent); // 关闭事件对象
89 CloseHandle(CRWObject::s_hWriteEvent);
90};
91
92template <typename DATA>
93DATA CRWObject<DATA>::ReadData(void) // 读数据操作
94{
95 if(!CanRead())
96 WaitForSingleObject(CRWObject::s_hReadEvent, INFINITE); // 等待“读取事件”
97 PELEMENT pToRead = m_pToRead; // 获取临时指针
98 int data = pToRead->data; // 读取数据
99 EnterCriticalSection(&CRWObject::s_cs); // 进入关键段
100 pToRead->canRead = FALSE; // 可读标记标记为FALSE
101 ++m_nMark; // 完成读操作后读写标记自增
102 // 写入单元有数据则将单元前移,以便下一次读取
103 if(m_pToWrite->canRead == TRUE)
104 Swap(m_pToRead, m_pToWrite); // 调换指针实现单元前移
105 LeaveCriticalSection(&CRWObject::s_cs); // 离开关键段
106 PulseEvent(CRWObject::s_hWriteEvent); // 产生写入事件
107 return data; // 返回读取的数据
108};
109
110template <typename DATA>
111void CRWObject<DATA>::WriteData(DATA data) // 写数据操作
112{
113 if(!CanWrite())
114 WaitForSingleObject(CRWObject::s_hWriteEvent, INFINITE); // 等待“写入事件”
115 PELEMENT pToWrite = m_pToWrite; // 获取临时指针
116 pToWrite->data = data; // 写入数据
117 EnterCriticalSection(&CRWObject::s_cs); // 进入关键段
118 pToWrite->canRead = TRUE; // 可读标记标记为TRUE
119 --m_nMark; // 完成写操作后读写标记自减
120 // 读取单元没有数据则将单元后移,以便下一次写入
121 if(m_pToRead->canRead == FALSE)
122 Swap(m_pToWrite, m_pToRead); // 调换指针实现单元后移
123 LeaveCriticalSection(&CRWObject::s_cs); // 离开关键段
124 PulseEvent(CRWObject::s_hReadEvent); // 产生读取事件
125 return; // 返回
126};
4. 写在后面
这里不得不提下最初的想法。《Windows核心编程》里头对各种Windows提供的同步方式进行了介绍,其中最省时的时用volatile作为修饰,接着是原子操作,再下来是关键段和读写锁(两者差不多),最后才是事件,互斥量这些内核对象。另外,使用内核对象要付出的代价是用户模式的同步方法的差不多上百倍。所以我开始不打算用事件进行同步的,使用条件变量与读写锁相结合也能实现我想要的效果。不过估计条件变量只能在Vista以上的系统才能使用吧,程序运行的时候会出现在kernel32里头找不到入口点。所以最后也只能用事件来代替。