生产者消费者问题是一个多线程同步问题的经典案例,大多数多线程编程问题都是以生产者-消费者模式为基础,扩展衍生来的。在生产者消费者模式中,缓冲区起到了连接两个模块的作用:生产者把数据放入缓冲区,而消费者从缓冲区取出数据,如下图所示:
可以看出Buffer缓冲区作为一个中介,将生产者和消费者分开,使得两部分相对独立,生产者消费者不需要知道对方的实现逻辑,对其中一个的修改,不会影响另一个,从设计模式的角度看,降低了耦合度。而对于图中处在多线程环境中Buffer,需要共享给多个多个生产者和消费者,为了保证读写数据和操作的正确性与时序性,程序需要对Buffer结构进行同步处理。通常情况下,生产者-消费者模式中的广泛使用的Buffer缓冲区结构是阻塞队列。
阻塞队列的特点为:“当队列是空的时,从队列中获取元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。试图从空的阻塞队列中获取元素的线程将会被阻 塞,直到其他的线程往空的队列插入新的元素。同样,试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程使队列重新变得空闲起来,如从 队列中移除一个或者多个元素,或者完全清空队列。” 阻塞队列的实现通常是对普通队列进行同步控制,封装起来。一个linux下的通用队列定义如下,代码所示的队列没有为满的情况,不设定队列元素总数的上限:
1 template<class T>
2 class BlockQueue
3 {
4 public:
5 BlockQueue();
6 ~BlockQueue();
7
8 void Add(T *pData);
9 T *Get(int timeout=0);
10 int Count();
11 void SetBlockFlag();
12 private:
13 typedef struct _Node
14 {
15 T * m_pData;
16 struct _Node * m_pNext;
17 } Node;
18 Node * m_pHead; //队列头
19 Node * m_pTail; //队列尾
20 int m_nCount; //队列中元素个数
21
22 bool m_bBlockFlag; //是否阻塞
23 CCond m_condQueue;
24 };
25
26 template<class T>
27 BlockQueue<T>::BlockQueue():m_pHead(NULL), m_pTail(NULL), m_nCount(0), m_bBlockFlag(false)
28 {
29 }
30
31 template<class T>
32 void BlockQueue<T>::SetBlockFlag()
33 {
34 m_bBlockFlag = true;
35 }
36
37 template<class T>
38 BlockQueue<T>::~BlockQueue()
39 {
40 Node *pTmp = NULL;
41 while (m_pHead != NULL)
42 {
43 pTmp = m_pHead;
44 m_pHead = m_pHead->m_pNext;
45 delete pTmp;
46 pTmp = NULL;
47 }
48 m_pTail = NULL;
49 }
50
51 template<class T>
52 void BlockQueue<T>::Add(T *pData)
53 {
54 (m_condQueue.GetMutex()).EnterMutex();
55
56 Node *pTmp = new Node;
57 pTmp->m_pData = pData;
58 pTmp->m_pNext = NULL;
59
60 if (m_nCount == 0)
61 {
62 m_pHead = pTmp;
63 m_pTail = pTmp;
64 }
65 else
66 {
67 m_pTail->m_pNext = pTmp;
68 m_pTail = pTmp;
69 }
70 m_nCount++;
71
72 if (m_bBlockFlag)
73 {
74 m_condQueue.Signal();
75 }
76
77 (m_condQueue.GetMutex()).LeaveMutex();
78 }
79
80 template<class T>
81 T *BlockQueue<T>::Get(int timeout)
82 {
83 (m_condQueue.GetMutex()).EnterMutex();
84
85 while (m_nCount == 0)
86 {
87 if (!m_bBlockFlag)
88 {
89 (m_condQueue.GetMutex()).LeaveMutex();
90 return NULL;
91 }
92 else
93 {
94 if (m_condQueue.WaitNoLock(timeout) == 1)
95 {
96 (m_condQueue.GetMutex()).LeaveMutex();
97 return NULL;
98 }
99 }
100 }
101
102 T * pTmpData = m_pHead->m_pData;
103 Node *pTmp = m_pHead;
104
105 m_pHead = m_pHead->m_pNext;
106 delete pTmp;
107 pTmp = NULL;
108 m_nCount--;
109 if (m_nCount == 0)
110 {
111 m_pTail = NULL;
112 }
113
114 (m_condQueue.GetMutex()).LeaveMutex();
115
116 return pTmpData;
117 }
118
119 template<class T>
120 int BlockQueue<T>::Count()
121 {
122 (m_condQueue.GetMutex()).EnterMutex();
123 int nCount = m_nCount;
124 (m_condQueue.GetMutex()).LeaveMutex();
125
126 return nCount;
127 }
队列中使用了Cond条件变量,实现多线程环境中队列的同步与阻塞:
- 条件变量自己持有mutex,在向队列中Add/Get元素时,mutex将队列锁住,同一时刻只允许一个线程对队列进行操作;
- 消费者从队列中获取数据时,如果使用SetBlockFlag函数设置了阻塞模式,那么当队列元素为空时,需要阻塞在条件变量上进行等待,其他线程调用Add函数,向队列中添加元素后,唤醒阻塞在条件变量上的线程。注意这里用到的等待条件是while(count == 0)而不是if(count == 0),因为在多核处理器环境中,Signal唤醒操作可能会激活多于一个线程(阻塞在条件变量上的线程),使得多个调用等待的线程返回。所以用while循环对condition多次判断,可以避免这种假唤醒。
- 队列元素为包含T类型指针的Node类型指针,析构时只负责释放队列中的Node元素,而真正指向T类型数据的指针,需要调用者进行分配和释放,一般由生产者调用new分配,消费者使用后调用delete释放。这里需要特别注意,因为如果忘记释放,会造成内存泄露。
这段代码是我在多线程环境下,较为简单的生产者-消费者模式中,在通用的队列模型进行的修改。目前队列在服务器中稳定运行,还未出现太大的性能问题,当然这与应用模块逻辑较为简单、交易量较小有很大关。此版本中,BlockQueue的Add函数每次调用都是指向Signal操作,唤醒阻塞在环境变量上线程,如果系统中有大量的写线程,而读线程的操作只有少数,那么很多情况下Signal调用都是无意义的,系统将其忽略。如何使Signal操作变得有意义,即每次Signal操作都会唤醒阻塞线程,最初的想法是在m_nCount == 0时即队列为空时进行调用,根据这个想法修改Add函数如下:
1 template<class T>
2 void BlockQueue<T>::Add(T *pData)
3 {
4 (m_condQueue.GetMutex()).EnterMutex();
5
6 Node *pTmp = new Node;
7 pTmp->m_pData = pData;
8 pTmp->m_pNext = NULL;
9
10 bool bEmpty = false;
11 if (m_nCount == 0)
12 {
13 m_pHead = pTmp;
14 m_pTail = pTmp;
15 bEmpty = true;
16 }
17 else
18 {
19 m_pTail->m_pNext = pTmp;
20 m_pTail = pTmp;
21 }
22 m_nCount++;
23
24 if (m_bBlockFlag && bEmpty)
25 {
26 m_condQueue.Signal();
27 }
28
29 (m_condQueue.GetMutex()).LeaveMutex();
30 }
目前来看这样处理没什么逻辑问题,很快发现这样做还是会有一些Signal操作时无意义的,比如当读线程读取到队列中最后一个元素时,m_nCount--,值为零,那么此时写线程调用Add函数,因为m_nCount==0,调用了Signal操作,而这时如果没有读线程在阻塞,那么这个操作仍然会被系统忽略。仔细想想,这里不能根据m_nCount的值来判断是否有线程在等待,而应该以是否有队列阻塞来进行Signal的调用。这里可以用一个计数器来表示当前阻塞在Get操作上的线程个数,只有当它大于零时,才说明需要激活,根据这个条件,继续修改代码,下面只展示修改后与之前相比的差异代码:
1 template<class T>
2 class BlockQueue
3 {
4 public:
5 /*与之前代码相同*/
6 private:
7 /*与之前代码相同*/
8 int m_nCount;
9 int m_nBlockCnt; //阻塞线程数
10 bool m_bBlockFlag; //是否阻塞
11 CCond m_condQueue;
12 };
13
14 template<class T>
15 BlockQueue<T>::BlockQueue():m_pHead(NULL), m_pTail(NULL), m_nCount(0), m_nBlockCnt(0), m_bBlockFlag(false)
16 {}
17
18 template<class T>
19 void BlockQueue<T>::Add(T *pData)
20 {
21 (m_condQueue.GetMutex()).EnterMutex();
22
23 Node *pTmp = new Node;
24 pTmp->m_pData = pData;
25 pTmp->m_pNext = NULL;
26
27 if (m_nCount == 0)
28 {
29 m_pHead = pTmp;
30 m_pTail = pTmp;
31 bEmpty = true;
32 }
33 else
34 {
35 m_pTail->m_pNext = pTmp;
36 m_pTail = pTmp;
37 }
38 m_nCount++;
39
40 if (m_bBlockFlag && m_nBlockCnt>0) //阻塞模式,并且有线程等待时调用Signal进行唤醒
41 {
42 m_condQueue.Signal();
43 }
44
45 (m_condQueue.GetMutex()).LeaveMutex();
46 }
47
48 template<class T>
49 T *BlockQueue<T>::Get(int timeout)
50 {
51 (m_condQueue.GetMutex()).EnterMutex();
52
53 while (m_nCount == 0)
54 {
55 if (!m_bBlockFlag)
56 {
57 (m_condQueue.GetMutex()).LeaveMutex();
58 return NULL;
59 }
60 else
61 {
62 m_nBlockCnt++; //线程阻塞数增加
63 if (m_condQueue.WaitNoLock(timeout) == 1)
64 {
65 (m_condQueue.GetMutex()).LeaveMutex();
66 m_nBlockCnt--;
67 return NULL;
68 }
69 m_nBlockCnt--; //唤醒后线程阻塞数减少
70 }
71 }
72
73 T * pTmpData = m_pHead->m_pData;
74 Node *pTmp = m_pHead;
75
76 m_pHead = m_pHead->m_pNext;
77 delete pTmp;
78 pTmp = NULL;
79 m_nCount--;
80 if (m_nCount == 0)
81 {
82 m_pTail = NULL;
83 }
84
85 (m_condQueue.GetMutex()).LeaveMutex();
86
87 return pTmpData;
88 }
现在代码已经修改完成,继续分析可以看出几乎对每个函数的操作都进行了加锁保护,临界区从始到末,粒度较大;再者,队列中持有的T对象指针,均是由调用者动态分配和释放的,而内部也是有链表实现,Add和Get操作时,会调用链表元素的内存分配和释放。如此一来,在交易量很大的情况下,频繁读写,不仅会导致加锁解锁的性能消耗,也会使系统产生大量内存碎片。
未完待续……