本文我将用一张思维导图来总结“用户模式下线程同步”的各个知识点,然后再分析书中给出的“Queue”示例代码。
Queue示例代码分析:
一、功能简介
该示例实现了一个简单的“client/server”模式的“producer .vs consumer”案例。待保护资源是 一个全局queue,client端有四个writer线程向该queue发送入队request,并对request进行编号,而server端开启了两个reader线程,分别处理queue中的奇数或偶数编号的request。
该示例在普通queue的基础上,增加了元素编号,和“client/server”两端的多线程分类处理的功能。
该示例演示了“condition variable + srwlock”对“producer .vs consumer”类问题的处理。
二、代码分析
1,CQueue类
先来看它的成员变量:该queue底层是由一个预分配大小的数组来实现的,即m_pElements。它的元素是一种两层嵌套的structure,内层是public权限的ELEMENT,供外部使用,可以设计为包含某些信息的structure,本例仅包含发送请求的线程号(client)和请求编号;外层添加了一个插入次序的编号——stamp,标记。
再来看它的成员函数:GetFreeSlot,“找空槽”,主要用于入队前的预备,返回数组下标。GetNextSlot,查找出队元素,它的返回值也是数组下标。不过,我觉得该函数命名有点怪,如果叫GetFilledSlot,可能更贴切。此外,GetNextSlot要匹配server线程号和元素的请求编号,保证server线程0处理偶数请求,线程1处理奇数请求。同样的,AddElement和GetNewElement分别表示入队和出队。
2,全局变量
因为本例中的CQueue本身不是一个线程安全的类,需要客户使用时,自行加锁,所以,本例中定义的是全局变量,被所有线程共享,而不是局部变量(存储在某个线程栈)。
CQueue g_q(10); // The shared queue
volatile LONG g_fShutdown;// Signals client/server threads to die
HWND g_hWnd; // How client/server threads give status
SRWLOCK g_srwLock; // Reader/writer lock to protect the queue
CONDITION_VARIABLE g_cvReadyToConsume; // Signaled by writers
CONDITION_VARIABLE g_cvReadyToProduce; // Signaled by readers
// Handles to all reader/writer threads
HANDLE g_hThreads[MAXIMUM_WAIT_OBJECTS];
// Number of reader/writer threads
int g_nNumThreads = 0;
2,WriterThread
Writer线程内部开启一个for循环,增加request编号。同时,它也有一个结束标志位,g_fShutdown,为了防止编译器优化,一般跨线程的标志位(bool型变量),都需要将它声明为volatile。
3,ReaderThread
Reader线程同Writer线程类似,也有for循环和结束标志位。此外,在它内部的ConsumeElement函数中,还有一个while循环,用于等待符合它的线程号的新元素出现。这个设计,还是牵涉到前面所说的“server线程0处理偶数请求,线程1处理奇数请求”。
4,死锁问题
观察本来的stop按钮响应函数如下:
void Dlg_OnCommand(HWND hWnd, int id, HWND hWndCtl, UINT codeNotify) {
switch (id) {
case IDCANCEL:
EndDialog(hWnd, id);
break;
case IDC_BTN_STOP:
{
// StopProcessing can't be called from the UI thread
// or a deadlock will occur: SendMessage() is used
// to fill up the listboxes
// --> Another thread is required
DWORD dwThreadID;
CloseHandle(chBEGINTHREADEX(NULL, 0, StoppingThread,
NULL, 0, &dwThreadID));
// This button can't be pushed twice
Button_Enable(hWndCtl, FALSE);
}
break;
}
}
在他的stop分支中,它并没用直接调用“StopProcessing”函数,而是新开了一个线程,来调用 “StopProcessing”函数。这样的设计是为了防止死锁。
void StopProcessing() {
if (!g_fShutdown) {
// Ask all threads to end
InterlockedExchange(&g_fShutdown, TRUE);
// Free all threads waiting on condition variables
WakeAllConditionVariable(&g_cvReadyToConsume);
WakeAllConditionVariable(&g_cvReadyToProduce);
// Wait for all the threads to terminate & then clean up
WaitForMultipleObjects(g_nNumThreads, g_hThreads, TRUE, INFINITE);
// Don't forget to clean up kernel resources
// Note: This is not really mandatory since the process is exiting
while (g_nNumThreads--)
CloseHandle(g_hThreads[g_nNumThreads]);
// Close each list box
AddText(GetDlgItem(g_hWnd, IDC_SERVERS), TEXT("---------------------"));
AddText(GetDlgItem(g_hWnd, IDC_CLIENTS), TEXT("---------------------"));
}
}
观察“StopProcessing”函数,可以发现,它主要的作用有两个:一是修改线程标志位,让各个线程函数返回(自然介绍各个线程);另一个是等待各个线程结束后,关闭线程局部,并刷新UI窗口。
如果“StopProcessing”放在WM_COMMAND消息处理函数中直接调用,此时它就会阻塞住该消息处理函数,等待读写线程的返回。而读写线程函数中,又调用了向界面放送消息的函数,需要等待消息处理函数处理完毕,返回后,线程函数才能结束。这样一种相互等待的状况一出现,就造成了死锁。
谨记,当我们从另一个线程以阻塞方式对用户界面的内容进行同步时,同样面临死锁的危险。比如,同步对共享资源的的访问就是阻塞方式的一个例子。下面介绍一些防止死锁的技巧。
5,多线程的设计技巧
1)逻辑资源
多个对象聚在一起构成一个“逻辑”资源,我们应该为一个逻辑资源创建一把锁,而不是为每个资源对象创建一把锁。
2)获取资源的顺序,防死锁
同时访问多个逻辑资源,需要获取多把锁时,需要注意各个线程获取锁的顺序要保存一致。释放的顺序无所谓。
3)巧用临时变量,不要长时间占用锁
被锁包含的代码段,如果包含了函数调用,需要仔细分析该函数的内部机制,是否会长时间占用锁。如果有长时间占用锁的风险,可以将被保护的代码段进行更细的分割和定义临时变量作为被包含资源的替代者。