临界区:多线程同步
对于多线程的程序来说,线程之间的同步永远是个重要的问题。如果多个线程都要存取同样的对象(如
存取同样的内存变量或读写同一个文件等),而一个线程操作的结果反过来又会影响另一个线程的运行
的时候,同步问题就变得异常重要。
产生同步问题的根源在于线程之间的切换是无法预测的,一个线程无法知道什么时候自己的时间片会结
束,也无法知道下一个时间片会被分配给哪个线程。事实上,线程可以在任何地方被Windows打断,惟
一可以确定的事实就是线程只能在两条指令之间被打断,因为指令是CPU执行的最小单位,线程不可能
在一条指令执行到一半的时候被打断。
对于单线程的程序来说,主线程在单个时间片结束的时候被Windows挂起,然后在轮到下一个时间片的
时候继续执行,这中间整个进程的环境不会有任何改变,因为进程中不存在其他线程。但对于多线程的
程序来说,在主线程挂起的过程中可能会有子线程被分配了时间片,如果子线程在执行中改变了主线程
正在存取的对象,就可能会引发错误的结果。举一个例子来说明,假定往银行账户里存钱的操作步骤有3
步:
(1)获取账户的余额。
(2)将用户存入的数额和余额相加,得到新的余额。
(3)将账户中的余额数据更新为新的数值。
现在从两个不同的储蓄所里同时向一个账户存钱,假设原来的余额是10 000元,储蓄所A要存入1 000
元,储蓄所B要存入2 000元,如果没有同步机制,就可能发生下面的情况:
① 蓄所A首先执行第(1)步,获得余额数据10 000,然后进行第(2)步运算得到结果11 000元。
② 这时储蓄所B的业务也刚好发生,在储蓄所A计算第(2)步的过程中,储蓄所B执行了第(1)步,由
于储蓄所A还没有执行到第(3)步,所以账户余额还没有被更新,储蓄所B得到的余额数据还是10 000
元。
③ 储蓄所B计算新余额,得到结果12 000元。
④ 在储蓄所B计算新余额的过程中,储蓄所A执行了第(3)步,将余额更新为11 000元。
⑤ 最后,储蓄所B执行了第(3)步,将自己的计算结果12 000元更新到余额数据中。
结果就是储蓄所A的业务实际上是丢失了;另一种情况,假如储蓄所B的动作很快,在上面的第④步骤发
生之前,在第③步骤中就将计算结果12 000更新到余额数据中了,那么在第④步骤中储蓄所A的计算结
果11 000就会将12 000覆盖,这时的结果就是储蓄所B的业务丢失了,所以同步问题产生的错误结果是
很难预测的。
将这个比喻引伸到两个线程的同步问题上就表现在:假如线程A将某个内存变量的值取到eax寄存器中,
准备在经过运算后将结果写回去,这时被Windows切换到线程B中,线程B在这个时间片中对同一个内
存变量进行了修改,当切换回线程A的时候,线程A在上一个时间片中刚取到eax中的数值和内存变量中
的值就不同步了,计算结果当然就是错误的。
有人可能会认为出现这种情况的概率是很低的,线程中有这么多条指令要执行,难道偏偏就在程序取完
数据还没开始处理的时候被系统打断吗?通过下面的例子就可以发现发生这种事情的可能性有多大。
VC6++新建一个win32应用程序,代码如下:
// ThreadSyn.c
#include
#include "resource.h"
HWND hWinMain = NULL;
HWND hWinCount = NULL;
DWORD dwThreads = 0;
DWORD dwOption = 0;
#define F_STOP 0x0001
DWORD dwCounter1 = 0;
DWORD dwCounter2 = 0;
char szStop[] = TEXT("停止计数");
char szStart[] = TEXT("计数");
DWORD WINAPI Counter(LPVOID lpParameter);
LRESULT CALLBACK DialogProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM
lParam);
int WINAPI WinMain(IN HINSTANCE hInstance, IN HINSTANCE hPrevInstance, IN
LPSTR lpCmdLine, IN int nShowCmd )
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL,
DialogProc, 0);
return 0;
}
LRESULT CALLBACK DialogProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM
lParam)
{
int i = 0;
HANDLE hThread = NULL;
switch ( uMsg )
{
case WM_TIMER:
SetDlgItemInt(hWinMain, IDC_COUNTER1, dwCounter1, FALSE);
SetDlgItemInt(hWinMain, IDC_COUNTER2, dwCounter2, FALSE);
break;
case WM_COMMAND:
if ( LOWORD(wParam) == IDOK )
{
if ( dwThreads )
{
dwOption |= F_STOP;
KillTimer(hWnd, 1);
}
else
{
dwCounter1 = 0;
dwCounter2 = 0;
while ( i < 10 )
{
hThread = CreateThread(NULL, 0,
Counter, NULL, 0, NULL);
CloseHandle(hThread);
i++;
}
SetTimer(hWnd, 1, 500, NULL);
}
}
break;
case WM_CLOSE:
if ( !dwThreads )
EndDialog(hWnd, 0);
break;
case WM_INITDIALOG:
hWinMain = hWnd;
hWinCount = GetDlgItem(hWinMain, IDOK);
break;
default:
return FALSE;
}
return TRUE;
}
DWORD WINAPI Counter(LPVOID lpParameter)
{
DWORD dwTmp;
dwThreads++;
SetWindowText(hWinCount, szStop);
dwOption &= ~F_STOP;
while ( !(dwOption & F_STOP) )
{
++dwCounter1;
dwTmp = dwCounter2;
++dwTmp;
dwCounter2 = dwTmp;
}
dwThreads--;
SetWindowText(hWinCount, szStart);
return 0;
}
// resource.h
#define IDD_MAIN 101
#define IDC_COUNTER1 1000
#define IDC_COUNTER2 1001
#define IDC_STATIC -1
// ThreadSyn.rc
#include "resource.h"
#include "afxres.h"
//
// Dialog
//
IDD_MAIN DIALOG DISCARDABLE 0, 0, 187, 69
STYLE DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "多线程同步"
FONT 10, "宋体"
BEGIN
DEFPUSHBUTTON "计数",IDOK,66,48,50,14
LTEXT "计数器一:",IDC_STATIC,7,7,42,8
LTEXT "计数器二:",IDC_STATIC,7,27,43,8
EDITTEXT IDC_COUNTER1,69,7,88,14,ES_AUTOHSCROLL | ES_READONLY
EDITTEXT IDC_COUNTER2,69,25,89,14,ES_AUTOHSCROLL | ES_READONLY
END
上面的程序中使用了2个计数器,dwCounter1 和dwCounter2,这2个计数器的值每隔半秒就会被更
新到文本框中,计数器一dwCounter1增加计数只使用了一条指令:
++dwCounter1;
所以,即使多个线程也不存在同步问题,不会产生错误,可是第二个计数器dwCounter2则使用了3条
指令来增加计数:
dwTmp = dwCounter2;
++dwTmp;
dwCounter2 = dwTmp;
在这3条指令执行的过程当中,如果在一个线程中在前2条或者1条指令刚刚执行完毕,就被其它的线程
打断并且其它的线程修改了dwCounter2的值,那么就会产生计数丢失。程序当中用了10个线程进行计
数,如果2个计数器都不存在同步问题,那么结果应该是一样的,可是事实是计数器二存在同步问题,那
么我们就运行看一看结果的差距有多大:
程序运行10秒之后,计数器一的值是125788870, 计数器二的值是26052074,
125788870 - 26052074 = 99736796
从结果中可以看出短短10秒钟,计数器二因为同步问题就丢掉了99736796次计数,丢失率高达%
79.3,可见,同步问题产生错误的几率是多么的大。可见这绝对不是偶尔发生一次两次的事情,大家可
以想像一下,如果有人往一个银行账户中汇款,三笔汇款中丢了两笔,人们会有何感想呢?
了解了同步问题产生的根源,再提出解决方案是很简单的,这在其他的应用程序中早有体现,如各种多
用户版的数据库在操作记录之前都要对记录进行锁定,保证一条记录在同一时刻只能被一个对象操作;
Windows中的写文件函数在遇到其他程序正在写入中的时候会返回共享错误,而不是不管青红皂白直接
写入了事。类似的例子还可以找到很多,归纳起来不外乎一点:就是保证整个存取过程的独占性,在一
个线程对某个对象进程操作的过程中,需要有某种机制阻止其他线程的操作。
将这个思路用于多线程之间的同步,可以设计出一些方案来:
(1)设置一个“允许操作”标志变量,当线程需要进行独占操作的时候将标志位复位,操作完成后将标志
位置位,任何线程如果需要对对象操作,操作之前必须判断标志位是否置位,如果没有则等待。
(2)如果觉得上面的方法存在CPU占用率的问题,可以使用事件对象来代替自己定义的标志变量。
(3)使用临界区对象(Critical Section Objects)。
考察这些方案,其实方案1和2不一定就能正常工作,因为设置标志和测试标志这个过程是由多条指令完
成的,这些指令本身就存在同步问题,比如某个线程测试到标志变量变为“允许”状态,然后它将标志变
量的状态复位并开始操作数据,但如果线程在测试标志变量和将标志变量复位之间被打断的话,其他线
程可能在这期间也在做同样的事情。将上面的例子按照方案1和2修改后运行,就可以发现计数值还是不
同步的。
其实Windows提供了专门的解决方案——使用临界区对象。
临界区也是Windows中的一种对象,从理解的角度看,同样可以把它看做是一种标志,只不过多个线程
同时操作这个“标志”的时候,由Windows负责标志测试中的同步问题罢了。
临界区对象是定义在数据段中的一个CRITICAL_SECTION结构,结构的具体字段不必关心,也不应该
关心,因为它的维护和测试工作都是由Windows来完成的,只需把它想像成一个标志就可以了,结构应
当定义成全局变量,因为在各线程中都要测试它。
定义了CRITICAL_SECTION结构后,必须首先对它进行初始化:
VOID InitializeCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // critical section
);
lpCriticalSection参数指向数据段中定义的CRITICAL_SECTION结构。
假如将需要独占的工作看成是使用一个单人更衣室,那么标志就相当于更衣室门上的牌子,当一个人进
入更衣室的时候,将牌子翻到“里面有人”这一面,出来的时候将牌子翻回到“里面无人”这一面,上面的
方法1和2就相当于谁先看到这个牌子,谁就可以进入,当几个人同时看到牌子的时候就产生矛盾了。如
果使用临界区,就相当于门口站了一个工作人员(这里就是Windows),只有向他申请后获得允许的人
才可以进入,其他的人即使同时提交了申请,也将暂时被拦在外面。
所以,定义并初始化临界区以后,当需要对只能独占的数据进行操作的时候,可以先向Windows递交“
进入更衣室”的申请,只有里面没有人,Windows才会答复,这个工作由EnterCriticalSection函数来
完成:
VOID EnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // critical section
);
如果当前由其他线程拥有临界区,函数不会返回,如果函数返回就表示现在可以独占数据了。调用
EnterCriticalSection函数可以看成是让Windows检测标志,如果是“不允许”则等待;是“允许”则将
标志修改为“不允许”状态并返回。
当完成操作的时候,还要将临界区交还Windows,以便其他线程可以申请使用,这个工作由
LeaveCriticalSection函数完成:
VOID LeaveCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // critical section
);
LeaveCriticalSection函数的功能可以看成是将标志从“不允许”改回“允许”状态。
当程序不再使用临界区的时候,必须使用DeleteCriticalSection将它删除:
VOID DeleteCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // critical section
);
现在用临界区来修改一下前面的程序:
// ThreadSyn.c
#include
#include "resource.h"
HWND hWinMain = NULL;
HWND hWinCount = NULL;
DWORD dwThreads = 0;
DWORD dwOption = 0;
#define F_STOP 0x0001
DWORD dwCounter1 = 0;
DWORD dwCounter2 = 0;
char szStop[] = TEXT("停止计数");
char szStart[] = TEXT("计数");
CRITICAL_SECTION stCS;
DWORD WINAPI Counter(LPVOID lpParameter);
LRESULT CALLBACK DialogProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM
lParam);
int WINAPI WinMain(IN HINSTANCE hInstance, IN HINSTANCE hPrevInstance, IN
LPSTR lpCmdLine, IN int nShowCmd )
{
DialogBoxParam(hInstance, MAKEINTRESOURCE(IDD_MAIN), NULL,
DialogProc, 0);
return 0;
}
LRESULT CALLBACK DialogProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM
lParam)
{
int i = 0;
HANDLE hThread = NULL;
switch ( uMsg )
{
case WM_TIMER:
EnterCriticalSection(&stCS);
SetDlgItemInt(hWinMain, IDC_COUNTER1, dwCounter1, FALSE);
SetDlgItemInt(hWinMain, IDC_COUNTER2, dwCounter2, FALSE);
LeaveCriticalSection(&stCS);
break;
case WM_COMMAND:
if ( LOWORD(wParam) == IDOK )
{
if ( dwThreads )
{
dwOption |= F_STOP;
KillTimer(hWnd, 1);
}
else
{
dwCounter1 = 0;
dwCounter2 = 0;
while ( i < 10 )
{
hThread = CreateThread(NULL, 0,
Counter, NULL, 0, NULL);
CloseHandle(hThread);
i++;
}
SetTimer(hWnd, 1, 500, NULL);
}
}
break;
case WM_CLOSE:
if ( !dwThreads )
{
DeleteCriticalSection(&stCS);
EndDialog(hWnd, 0);
}
break;
case WM_INITDIALOG:
hWinMain = hWnd;
hWinCount = GetDlgItem(hWinMain, IDOK);
InitializeCriticalSection(&stCS);
break;
default:
return FALSE;
}
return TRUE;
}
DWORD WINAPI Counter(LPVOID lpParameter)
{
DWORD dwTmp;
dwThreads++;
SetWindowText(hWinCount, szStop);
dwOption &= ~F_STOP;
while ( !(dwOption & F_STOP) )
{
EnterCriticalSection(&stCS);
++dwCounter1;
dwTmp = dwCounter2;
++dwTmp;
dwCounter2 = dwTmp;
LeaveCriticalSection(&stCS);
}
dwThreads--;
SetWindowText(hWinCount, szStart);
return 0;
}
上面修改的代码,增加了临界区对象用于同步:
CRITICAL_SECTION stCS;
在计数的时候:
EnterCriticalSection(&stCS);
++dwCounter1;
dwTmp = dwCounter2;
++dwTmp;
dwCounter2 = dwTmp;
LeaveCriticalSection(&stCS);
通过EnterCriticalSection和LeaveCriticalSection保证操作的原子性,同时在显示计数值的时候:
EnterCriticalSection(&stCS);
SetDlgItemInt(hWinMain, IDC_COUNTER1, dwCounter1, FALSE);
SetDlgItemInt(hWinMain, IDC_COUNTER2, dwCounter2, FALSE);
LeaveCriticalSection(&stCS);
这样也保证了显示的时候的原子性,于是你看到的结果是,2个文本框的值始终保持一致,程序在运行
10秒钟之后,计数器的值是2026824,26052074 - 2026824 = 24025250,可见在不同步的时候
错误计数的值要远远大于正确的值,正确计数的值只有不同步的时候计数值的%7.8,效率远远不如不
同步的时候,这说明花在等待上的时间实在是太多了,但这是多线程为了保持数据同步所必须付出的代
价。
本文的理论部分来源于罗云彬32位汇编语言,源程序也是由32位汇编源程序改写而来,想知道汇编的写
法,请看他的那本书。
转载请注明出处:
author: cnhnyu
e-mail: cnhnyu@gmail.com
qq: 94483026
阅读(4500) | 评论(0) | 转发(0) |