现在的位置: 首页 > 综合 > 正文

【Windows】线程漫谈(二)

2018年07月10日 ⁄ 综合 ⁄ 共 16113字 ⁄ 字号 评论关闭

【Windows】线程漫谈——线程同步之关键段

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

 

关键段

关键段(Critical Section)是一小段代码,它在执行之前需要独占对一些共享资源的访问权。这种方式可以让多行代码以“原子方式”对资源进行操控。这里的原子方式,指的是代码知道除了当前线程之外,没有其他任何线程会同时访问该资源。当然,系统仍然可以暂停当前线程去调度其他线程。但是,在当前线程离开关键段之前,系统是不会去调度任何想要访问同一资源的其他线程的。

下面的代码展示了Critical Section的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const
int COUNT = 10;
int
g_nSum = 0;
CRITICAL_SECTION g_cs;//CRITICAL_SECTION struct
  
DWORD
WINAPI FirstThread(
PVOID
pvParam){
    EnterCriticalSection(&g_cs);//Try enter critical section
    g_nSum = 0;
    for(int
n = 1 ; n <= COUNT ; n++) g_nSum+=n;
    LeaveCriticalSection(&g_cs);
    return(g_nSum);
}
  
DWORD
WINAPI SecondThread(PVOID
pvParam){
    EnterCriticalSection(&g_cs);//Try enter critical section
    g_nSum = 0;
    for(int
n = 1 ; n <= COUNT ; n++) g_nSum+=n;
    LeaveCriticalSection(&g_cs);
    return(g_nSum);
}

假如没有上面的EnterCriticalSection和LeaveCriticalSection,当两个线程函数分别在两个线程中执行的时候,g_nSum的状态是不可预计的。

在上面的代码中,首先定义了一个叫g_cs的CRITICAL_SECTION数据结构,然后把任何需要访问共享资源(这里的g_nSum)的代码放在EnterCriticalSectionLeaveCriticalSection之间。这里需要注意的是,关键段需要用在所有的相关线程中(即:上面的两个线程函数都要放在关键段中),否则共享资源还是有可能被破坏(只要对线程调度有清晰的认识就很容易理解其中的原因)。另外,在调用EnterCriticalSection之前需要调用InitializeCriticalSection初始化,当不需要访问共享资源的时候,应该调用DeleteCriticalSection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Sample C/C++, Windows, link to kernel32.dll */
#include <windows.h>
   
static
CRITICAL_SECTION cs; /* This is the critical section object -- once initialized,
                               it cannot be moved in memory */
                            /* If you program in OOP, declare this as a non-static member in your class */
   
/* Initialize the critical section before entering multi-threaded context. */
InitializeCriticalSection(&cs);
   
void
f()
{
    /* Enter the critical section -- other threads are locked out */
    EnterCriticalSection(&cs);
   
    /* Do some thread-safe processing! */
   
    /* Leave the critical section -- other threads can now EnterCriticalSection() */
    LeaveCriticalSection(&cs);
}
   
/* Release system object when all finished -- usually at the end of the cleanup code */
DeleteCriticalSection(&cs);

 

关键段工作原理

EnterCriticalSection会检查CRITICAL_SECTION中某些成员变量,这些成员变量表示是否有线程正在访问资源:

  • 如果没有线程正在访问资源,那么EnterCriticalSection会更新成员变量,以表示调用线程已经获准对资源的访问,并立即返回,这样线程就可以继续执行。
  • 如果成员变量表示调用线程已经获准访问资源,那么EnterCriticalSection会更新变量,以表示调用线程被获准访问的次数
  • 如果成员变量表示其他线程已经获准访问资源,那么EnterCriticalSection会使用一个事件内核对象把当前线程切换到等待状态。这样线程不会像前一篇讲的旋转锁(spinlock)那样耗费CPU。

关键段的核心价值在于它能够以原子的方式执行所有这些测试。另外TryEnterCriticalSection跟EnterCriticalSection一样拥有对共享资源的检测能力,但是不会阻塞调用线程。

 

关键段与旋转锁

关键段的另一个核心价值在于它可以使用旋转锁来对共享资源进行一定时间的“争用”,而不是立刻让线程进入等待状态、进入内核模式(线程从用户模式切换到内核模式大约需要1000个CPU周期)。因为,很多情况下共享资源不太会占用太长的时间,如果因为一个即将释放的共享资源而将线程切换到内核模式,将得不偿失。所以默认情况下在关键段阻塞线程之前,会多次尝试用旋转锁来“争用”共享资源,如果在这期间“争用”成功,那么EnterCriticalSection就会返回,代码将进入关键段执行;如果没有成功,则会将线程切换到等待状态。需要注意的是:只有在多核情况下才能够使关键段尝试这种特性。

为了在使用关键段的时候同时使用旋转锁,必须用如下函数来初始化关键段:

1
2
3
4
BOOL
WINAPI InitializeCriticalSectionAndSpinCount(
  __out  LPCRITICAL_SECTION lpCriticalSection,
  __in  
DWORD dwSpinCount
);

下面的函数用以改变关键段的旋转次数:

1
2
3
4
DWORD
WINAPI SetCriticalSectionSpinCount(
  __inout  LPCRITICAL_SECTION lpCriticalSection,
  __in    
DWORD dwSpinCount
);

关键段还可以和条件变量配合使用,这部分内容将在下一篇涉及。

更多关于关键段的内容可以参见:http://blog.csdn.net/morewindows/article/details/7442639

最后,设计一个简单的带一个缓冲队列的Log方法,要求线程安全,下面给出C++的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void
Log(
int nLevel,
const
WCHAR
* message)
{
    struct
DelayedLogInfo
    {
        int
level;
        std::wstring message;
    };
    static
std::list<DelayedLogInfo> c_LogDelay; //log记录的缓冲队列
  
  
    if
(TryEnterCriticalSection(&g_CsLog)) //获得整个log的访问权限,如果失败则尝试在else里面获得对队列的访问权限
    {
        EnterCriticalSection(&g_CsLogDelay);//读队列前,获得表示”队列“的变量的访问权限
  
        while
(!c_LogDelay.empty())//循环把队列中的东西全都写掉
        {
            DelayedLogInfo& logInfo = c_LogDelay.front();
            LogInternal(logInfo.level, logInfo.message.c_str());
  
            c_LogDelay.erase(c_LogDelay.begin());
        }
  
        LeaveCriticalSection(&g_CsLogDelay);//释放表示”队列“的变量的访问权限
  
        //代码到这里释放了队列这个共享对象,因此,在下面这真正写入log时,其他试图写log的线程将只能向缓冲队列中写数据
  
        // Log the message
        LogInternal(nLevel, message);
  
        LeaveCriticalSection(&g_CsLog);
    }
    else
    {
        EnterCriticalSection(&g_CsLogDelay);
//写队列前,获得表示”队列“的变量的访问权限
  
        DelayedLogInfo logInfo = {nLevel,  message};
        c_LogDelay.push_back(logInfo);//写队列
  
        LeaveCriticalSection(&g_CsLogDelay);//释放表示”队列“的变量的访问权限
    }
}
 
 

【Windows】线程漫谈——线程同步之Slim读/写锁

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

 

Slim读/写锁

SRWLock的目的和关键段相同,对一个资源进行保护,构造了一段“原子访问”的代码,不让其他线程访问它。但与关键段不同的是SRWLock允许区分想要读取资源值的线程和想要写入资源值的线程,因为仅仅读取资源是不会破坏数据的,下面是Slim读/写锁的简单用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SRWLOCK g_srwLock
...
//init SRWLock
InitializeSRWLock(&g_srwLock);
...
//当需要写入资源的时候申请"排他锁"
AcquireSRWLOckExclusive(&g_srwLock);
//执行写入动作
...
//写入结束后释放"排他锁"
ReleaseSRWLockExclusive(&g_srwLock);
//-----------------------------------------
//当需要读取时申请"共享锁"
AcquireSRWLockShared(&g_srwLock);
//执行读操作
...
//读取结束后释放"共享锁"
ReleaseSRWLockShared(&g_srwLock);
  
  
//系统会自动清理g_srwLock,没有别的清理函数

可以看到,存在两种锁用于此机制:"排他锁"和"共享锁"。排他锁需要任何中锁都不存的情况下才能返回,否则阻塞;共享锁在没有排他锁的情况下就可以返回,哪怕有其他共享锁也没关系。这样在写操作之前申请排他锁,在读之前申请共享锁就可以保证共享资源数据不会被意外破坏。另外,只有初始化函数而没有释放函数。

看似SRWLock更关键段十分相似,但相比关键段SRWLock缺乏下面两个特性:

1、不存在对应的TryEnterXXX函数,如果锁已被占用那么只能选择阻塞调用线程;

2、不能递归的获得SRWLock。也就是说一个线程不能为了多次写入资源而多次锁定资源。而关键段可以做到。回想一下,因为关键段在Enter的时候将判断当前线程是否是共享资源的占有者,如果是则会“放行”,并增加引用计数。然而SRWLock始终不关心调用线程是谁。

 

SRWLock配合条件变量

SRWLock的另一个有用之处就是能配合“条件变量”使用,来完成一些更复杂的同步任务。我们假设如下场景:有一个共享的队列,2个服务端线程负责读取队列中的条目以处理,2个客户端线程负责写入队列中的条目以使服务先端线程处理,当队列中没有条目的时候应当挂起服务端线程,直到有条目进入时才被唤醒,另一方面,当队列已满时,客户端线程应当挂起直到服务端至少处理了一个条目,以释放至少一个条目的空间。

首先创建几个共享资源锁,其中SRWLOCK是上文提到的读写锁,CONDITION_VARIABLE就是这里的条件变量:

1
2
3
SRWLOCK  g_srwLock;
CONDITION_VARIABLE g_cvReadyToProduce;//读取线程用于通知写入线程可以开始写入
CONDITION_VARIABLE g_cvReadyToConsume;//写入线程用于通知读取线程可以开始读取

设计如下客户端写入线程流程:

image

AcquireSRWLockExclusive(&g_srwLock):在写入之前获得排他锁,如果有其他锁,则阻塞;

SleepConditionVariableSRW(&g_cvReadToProduce,&g_srwLock,INFINITE,0):当队列已满时,等待g_cvReadToProduce变量信号(此信号应该由做读取操作的服务端线程发起)。参数:&g_srwLock,INFINITE,0 分别表示暂时释放g_srwLock锁,并永久等待变量信号;

Write Queue...:对队列的写操作,如果在上一步时经过Sleep,并临时释放了g_srwLock锁,在这一步会自动重新获得g_srwLock锁;

ReleaseSRWLockExclusive(&g_srwLock):写完队列后释放排他锁;

WakeAllConditionVariable(&g_cvReadToConsume):向所有正在等待队列中条目的服务端线程发起g_cvReadToConsume信号,通知他们开始读取队列;

 

设计如下的服务端读取流程:

image

AcquireSRWLockShared(&g_srwLock):在写入之前获得共享锁,如果有排他锁,则会阻塞;

SleepConditionVariableSRW(&g_cvReadToConsume,&g_srwLock,INFINITE,CONDITION_VARIABLE_LOCKMODE_SHARED):当队列空时,等待g_cvReadToConsume变量信号(此信号应该由做写入操作的客户端线程发起)。参数:&g_srwLock,INFINITE,CONDITION_VARIABLE_LOCKMODE_SHARED 分别表示共享g_srwLock锁(不释放),并永久等待变量信号;

Read Queue...:对队列的读操作;

ReleaseSRWLockShared(&g_srwLock):读完队列后释放共享锁;

WakeAllConditionVariable(&g_cvReadToProduce):向所有正在等待队列至少有一个空间条目的客户端线程发起g_cvReadToProduce信号,通知他们可以开始写入队列;

 

上面的过程中我们对一个场景做了设计用到了SRWLock和所谓的条件变量,总结一下条件变量的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CONDITION_VARIABLE g_cvReadyToProduce;
//创建变量
  
VOID
WINAPI InitializeConditionVariable(
  __out  PCONDITION_VARIABLE ConditionVariable
);
  
//等待变量信号
BOOL
WINAPI SleepConditionVariableSRW(
  __inout  PCONDITION_VARIABLE ConditionVariable,
  __inout  PSRWLOCK SRWLock,
  __in    
DWORD dwMilliseconds,
  __in    
ULONG Flags
);
  
//发出变量信号,以唤醒正在等待变量的线程
VOID
WINAPI WakeAllConditionVariable(
  __inout  PCONDITION_VARIABLE ConditionVariable
);

前面一篇讲到关键段的时候也提到过条件变量,事实上,关键段也可以使用条件变量,只是API不太相同:

1
2
3
4
5
6
7
8
9
BOOL
WINAPI SleepConditionVariableCS(
  __inout  PCONDITION_VARIABLE ConditionVariable,
  __inout  PCRITICAL_SECTION CriticalSection,
  __in    
DWORD dwMilliseconds
);
  
VOID
WINAPI WakeAllConditionVariable(
  __inout  PCONDITION_VARIABLE ConditionVariable
);

另外,除了WakeAllConditionVariable还有一个WakeConditionVariable,顾名思义,后者是单单唤醒一个正在等待变量的线程,这类似于”事件的自动重置”。

关于更多条件变量的信息可以参见:Using Condition Variables

 

【Windows】线程漫谈——线程同步之等待函数和事件内核对象

 

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

 

用内核对象进行线程同步

内核对象:Windows操作系统使用内核对象来管理进程、线程、文件等诸多种类的大量资源。内核对象的创建通常是通过Windows API,比如CreateThread将创建一个线程内核对象,并返回一个内核对象句柄。内核对象实际上是一小块内存,其中包括了引用计数、安全性描述等信息,操作系统通过这一小段内存来管理对应的内核资源。内核对象的实际内存地址并非句柄所展示的,它们在进程内的内核对象句柄表中有映射。

在前几篇中,介绍了在用户模式下的线程同步机制:InterLocked系列、关键段、Slim读写锁。这些同步机制可以在进行线程同步的同时让线程保持在用户模式下。然而用户模式下的线程同步机制有时不能满足我们的要求。从这篇开始,将介绍使用内核对象进行线程同步。在考虑是使用用户模式的同步机制还是使用内核对象来同步的时候,需要综合考量,尽量使用用户模式的线程同步机制。

内核对象普遍存在两种状态,要么是触发,要么是未触发。每种内核对象在这两个状态间切换过程都有其特殊的特点,比如进程内核对象在创建的时候总是处于未触发状态,当进程终止时,会变成触发状态;而且进程内核对象永远不会从触发态变回未触发态。于是,如果我们的线程需要等待子进程终止时才继续,那么可以将线程进入等待状态,当子进程标识的进程内核对象变成触发状态的时候唤醒线程!我们所需要的仅仅是Windows为我们提供的等待函数。

 

等待函数

等待函数能够是一个线程进入等待状态,直到指定的内核对象被触发为止。这些等待函数有:

1
2
3
4
DWORD
WINAPI WaitForSingleObject(
  __in 
HANDLE hHandle,
  __in 
DWORD dwMilliseconds
);

当线程调用WaitForSingleObject的时候,第一个参数hObject用来标识内核对象,第二个参数是个超时时间(传入INFINITE表示永远等待直到触发)。

1
2
3
4
5
6
DWORD
WINAPI WaitForMultipleObjects(
  __in 
DWORD nCount,
  __in 
const
HANDLE
*lpHandles,
  __in 
BOOL bWaitAll,
  __in 
DWORD dwMilliseconds
);

WaitForMultipleObjects允许调用线程检查多个内核对象的触发状态。

使用等待函数有时是会改变内核对象的状态的。比如:线程正在等待一个自动重置事件对象,当事件对象被触发的时候,函数会检测到这一情况并返回调用线程,但是在返回之前,他会使事件变为非触发状态。等待函数在不同的内核对象上调用并返回所引起的这样类似的“副作用”是不同的。在对内核对象展开介绍后,将看到这一点。

如果多个线程在同时等待同一个内核对象,那么任何一个线程都有可能被唤醒。我们不应该做出类似“谁先等待谁就先唤醒”这样的假设。

接下来,将分几篇的内容分别介绍那些与线程同步有关的内核对象。

 

事件内核对象

事件内核对象分为手动重置和自动重置,区别在于当对象从未触发状态变成触发状态后,会不会自动重置回未触发状态。与其他内核对象相同,事件内核对象也包括引用计数。下面的函数CreateEvent用以创建事件内核对象:

1
2
3
4
5
6
HANDLE
CreateEvent(
  LPSECURITY_ATTRIBUTES lpEventAttributes, 
  BOOL
bManualReset, 
  BOOL
bInitialState, 
  LPTSTR
lpName 
);

lpEventAttributes:会被忽略,必须为NULL(这里是MSDN的说法,在Windows Via C/C++中,暗示了这个参数与内核对象的安全属性和共享特性有关。不过通常都会传入NULL,因为不太会考虑让一个事件对象变为可继承)

bManualReset:手动重置(TRUE)还是自动重置(FALSE)

bInitialState:初始状态为触发(TRUE)还是非触发(FALSE)

lpName:用于共享内核对象的机制,这里与线程同步无关,不再阐述,传入NULL即可。

返回值:事件对象的句柄

Windows Vista还支持下面这个函数CreateEventEx来创建事件内核对象,详情请参见MSDN:

1
2
3
4
5
6
HANDLE
WINAPI CreateEventEx(
  __in_opt  LPSECURITY_ATTRIBUTES lpEventAttributes,
  __in_opt 
LPCTSTR lpName,
  __in     
DWORD dwFlags,
  __in     
DWORD dwDesiredAccess
);

 

我们使用下面的函数来控制事件对象的状态:

1
2
3
4
5
6
7
8
9
10
11
BOOL
SetEvent(
  HANDLE
hEvent 
); //将内核对象设置为触发状态
  
BOOL
ResetEvent( 
  HANDLE
hEvent 
);//将内核对象设置为非触发状态
  
BOOL
PulseEvent(
  HANDLE
hEvent 
); //触发内核对象并立即将其重置为未触发状态,会唤醒正在等待的线程

如果用图来表示他们的作用就很直观了:

image

另外OpenEvent通常用于进程同步,但前提是事件内核对象必须有别名(通过给某些内核对象起别名,是一种共享内核对象的方式):

1
2
3
4
5
HANDLE
OpenEvent( 
  DWORD
dwDesiredAccess, 
  BOOL
bInheritHandle, 
  LPCTSTR
lpName 
);

 

下图描述了事件内核对象的示例用法(同一进程内):

image

主线程先创建一个事件内核对象,并创建两个线程,这两个线程的执行需要依赖主线程的准备工作,因此调用WaitForSingleObject等待事件对象触发。主线程完成准备工作后,调用SetEvent使对象变成触发状态,这时两个子线程将被唤醒开始执行代码。

在后面的篇章中,将继续介绍其他可以用来线程同步的内核对象。

 

【Windows】线程漫谈——线程同步之计时器内核对象

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

 

可等待的计时器内核对象

下面的函数CreateWaitableTimer用以创建一个计时器内核对象:

1
2
3
4
5
HANDLE
WINAPI CreateWaitableTimer(
  __in_opt  LPSECURITY_ATTRIBUTES lpTimerAttributes,
  __in     
BOOL bManualReset,
  __in_opt 
LPCTSTR lpTimerName
);

第一和第三个参数与内核对象的共有特性有关,与线程同步无关,这里不再阐述,通常传入NULL即可。参数bManualReset表示手动重置(TRUE)还是自动重置(FALSE)。如果是手动重置的话,当内核对象触发时,所有等待的线程都将变为可调度状态;如果是自动重置的话,只有一个线程会被切换到可调度状态。计时器对象被创建的时候总是非触发状态,只有调用下面的SetWaitableTimer才能启动计时器并在设置的时间触发对象:

1
2
3
4
5
6
7
8
BOOL
WINAPI SetWaitableTimer(
  __in     
HANDLE hTimer,
  __in     
const LARGE_INTEGER *pDueTime,
  __in     
LONG lPeriod,
  __in_opt  PTIMERAPCROUTINE pfnCompletionRoutine,
  __in_opt 
LPVOID lpArgToCompletionRoutine,
  __in     
BOOL fResume
);
  • hTimer:计时器对象句柄
  • pDueTime:表示计时器第一次触发的时间。详见下面的例子
  • lPeriod:表示计时器第一次触发后,以什么频度触发,详见下面的例子
  • pfnCompletionRoutine:当触发时需要执行的异步过程调用(asynchronous procedure call, APC),感觉在线程同步方面使用不多,此处略过,详情可查看MSDN
  • lpArgToCompletionRoutine:异步过程调用的传入参数
  • fResume是否唤醒休眠的系统

下面这个例子把计时器触发事件设置为2008年1月1日下午1:00,之后每隔6小时触发一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HANDLE
hTimer;
SYSTEMTIME st;
FILETIME ftLocal,ftUTC;
LARGE_INTEGER liUTC;
  
hTimer = CreateWaitableTimer(NULL,FALSE,NULL);
  
st.wYear    = 2008;
st.wMonth   = 1;
st.wDayOfWeek   = 0;
st.wDay     = 1;
st.wHour    = 13;
st.wMinute  = 0;
st.wSecond  = 0;
st,wMilliseconds= 0;
  
SystemTimeToFileTime(&st,&ftLocal);
  
LocalFileTimeToFileTime(&ftLocal,&ftUTC);
  
liUTC.LowPart   = ftUTC.dwLowDateTime;
liUTC.HighPart  = ftUTC.dwHighDateTime;
  
SetWaitableTimer(hTimer,&liUTC,6*60*60*1000,NULL,NULL,FALSE);

上面的代码利用SYSTEMTIME结构和FILETIME结构来转换LARGE_INTEGER结构。对于SYSTEMTIME,可用SystemTimeToFileTime转化成FILETIME;而可用LocalFileTimeToFileTime把FILETIME转成UTC时间,因为SetWaitableTimer的第二个参数始终认为是UTC时间。FILETIME时间更LARGE_INTEGER具有相同的二进制结构,但是由于FILETIME对齐到32位边界,而LARGE_INTEGER对齐到64位边界,因此,为了避免数据对齐问题,不要直接将FILETIME强制转换成LARGE_INTEGER传入SetWaitableTimer。顺便提一句,LARGE_INTEGER是以100ns为计时粒度,并且它是个联合体,如果在支持64位整数的编译器中,LARGE_INTEGER只有一个QuadPart成员。除了为LARGE_INTEGER传入一个绝对时间,也可以为其传入一个相对时间,比如:

1
2
LARGE_INTEGER li;
li.QuadPart = -(5 * 10000000);

lPeriod是以毫秒为单位的间隔时间。如果传入0,计时器只会在指定时间触发一次,不会间隔触发。

 

SetTimer和计时器内核对象

任何有经验的Windows开发人员会立即把计时器内核对象跟用户计时器(通过SetTimer函数来设置)进行比较。两者最大的区别在于用户计时器需要在应用程序中使用大量的用户界面基础设施,从而消耗更多的资源。另外,计时器内核对象可以在多个线程间共享(通过某种共享内核对象的方式),而且可以具备安全性。

用户计时器通过产生WM_TIMER消息,将其送回调用SetTimer线程(对于回调计时器来说),或者创建窗口(对于基于窗口的计时器来说)。因此,当一个用户计时器触发的时候只有一个线程能被通知;而内核对象的触发可以通知多个线程。

如果打算计时器触发时做一些与用户界面相关的操作,使用用户计时器会方便一些。

 

【Windows】线程漫谈——线程同步之信号量和互斥量

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等

 

信号量内核对象

信号量内核对象用来进行资源计数,它包含一个使用计数、最大资源数、当前资源计数。最大资源数表示信号量可以控制的最大资源数量,当前资源数表示信号当前可用的资源数量。

设想一个场景:需要开发一个服务器进程,最多同时运行5个线程来响应客户端请求,应该设计一个“线程池”。最开始的时候,5个线程都应该在等待状态,如果有一个客户端请求到来,那么唤醒其中的一个线程以处理客户端请求,如果同时的请求数量为5,那么5个线程将全部投入使用,再多的请求应该被放弃。也就是说,随着客户端请求的增加,当前资源计数随之递减。

我们可能需要这样的一个内核对象来实现这个功能:初始化5个线程并同时等待一个内核对象触发,当一个客户端请求到来时,试图触发内核对象,这样5个线程中随机一个被唤醒,并且自动使内核对象变为未触发。外部判断上限是否到达5。表面看来似乎用“自动重置的事件对象”即可实现这个功能啊,为什么要涉及到信号量呢?因为信号量还可以控制一次唤醒多少个线程!!而且这个例子只是信号量的一个用途,后面我们会看到一个更实际的用途。

总结一下,信号量内核对象是这样的一种对象:它维护一个资源计数,当资源计数大于0,处于触发状态;资源计数等于0时,处于未触发状态;资源计数不可能小于0,也绝不可能大于资源计数上限。下图展示了这种内核对象的特点:

image

如上图,只有资源计数>0时才是触发状态,资源=0时为未触发状态,而WaitForSingleObject成功将递减资源计数,调用ReleaseSemaphore将增加资源计数。

下面两个函数CreateSemaphoreCreateSemaphoreEx用于创建信号量对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HANDLE
WINAPI CreateSemaphore(
  __in_opt  LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,//内核对象安全描述符
  __in     
LONG lInitialCount,//资源计数的初始值
  __in     
LONG lMaximumCount,//资源计数的最大值
  __in_opt 
LPCTSTR lpName
//内核对象命名
);
  
HANDLE
WINAPI CreateSemaphoreEx(
  __in_opt    LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
  __in       
LONG lInitialCount,
  __in       
LONG lMaximumCount,
  __in_opt   
LPCTSTR lpName,
  __reserved 
DWORD dwFlags,
  __in       
DWORD dwDesiredAccess
);

任何进程可以用OpenSemaphore来得到一个命名的信号量:

1
2
3
4
5
HANDLE
WINAPI OpenSemaphore(
  __in 
DWORD dwDesiredAccess,
  __in 
BOOL bInheritHandle,
  __in 
LPCTSTR lpName
);

线程通过调用ReleaseSemaphore来递增资源计数,不一定每次只递增1,可以设置递增任意值。当将要超过资源上限值的时候,ReleaseSemaphore会返回FALSE。

1
2
3
4
5
BOOL
WINAPI ReleaseSemaphore(
  __in      
HANDLE hSemaphore,
  __in      
LONG lReleaseCount,//可以设置递增的值
  __out_opt 
LPLONG lpPreviousCount//返回先前的资源计数
);

 

互斥量内核对象

互斥量(mutex)用来确保一个线程独占对一个资源的访问。互斥量包含一个使用计数、线程ID和一个递归计数,互斥量与关键段的行为几乎相同(因为它记录了线程ID和递归计数,使得互斥量可以支持递归调用的情况)。互斥量的规则十分简单:如果线程ID为0(即没有线程独占它),那么它处于触发状态,任何试图等待该对象的线程都将获得资源的独占访问;如果线程ID不为0,那么它处于未触发状态,任何试图等待该对象的线程都将等待。

可以使用CreateMutex或者CreateMutexEx创建互斥对象:

1
2
3
4
5
6
7
8
9
10
11
12
HANDLE
WINAPI CreateMutex(
  __in_opt  LPSECURITY_ATTRIBUTES lpMutexAttributes,
  __in     
BOOL bInitialOwner,//初始化对象的状态,如果传入FALSE则会初始化为触发状态,如果传入TRUE,那么对象的线程ID会被设置成当前调用线程,并初始化为未触发
  __in_opt 
LPCTSTR lpName
);
  
HANDLE
WINAPI CreateMutexEx(
  __in_opt  LPSECURITY_ATTRIBUTES lpMutexAttributes,
  __in_opt 
LPCTSTR lpName,
  __in     
DWORD dwFlags,
  __in     
DWORD dwDesiredAccess
);

一如既往,OpenMutex用于打开一个已经命名的互斥量内核对象:

1
2
3
4
5
HANDLE
WINAPI OpenMutex(
  __in 
DWORD dwDesiredAccess,
  __in 
BOOL bInheritHandle,
  __in 
LPCTSTR lpName
);

线程在获得对独占资源的访问权限之后,可以正常执行相关的逻辑,当需要释放互斥对象的时候可以调用ReleaseMutex

1
2
3
BOOL
WINAPI ReleaseMutex(
  __in 
HANDLE hMutex
);

互斥量与其他内核对象不同,它会记录究竟是哪个线程占用了共享资源,结合递归计数,同一个线程可以在获得共享资源之后继续访问共享资源,这个行为就像关键段一样。然而互斥量和关键段从本质上是不同的,关键段是用户模式的线程同步方法,而互斥量是内核模式的线程同步方式。

 

介绍完这两个内核对象后,我们思考一下前面在【Windows】线程漫谈——线程同步之Slim读/写锁中设计的一个场景:有一个共享的队列,2个服务端线程负责读取队列中的条目以处理,2个客户端线程负责写入队列中的条目以使服务先端线程处理,当队列中没有条目的时候应当挂起服务端线程,直到有条目进入时才被唤醒,另一方面,当队列已满时,客户端线程应当挂起直到服务端至少处理了一个条目,以释放至少一个条目的空间。

现在我们来用信号量和互斥量来实现同样的功能,下面的流程图分别是客户端写入线程和服务端读取线程的逻辑:

1.首先创建一个互斥量对象m_hmtxQ,并初始化为未触发状态;之后创建一个信号量对象,并设置最大资源计数为队列的长度,初始化资源计数为0,正好表征队列元素的个数。

1
2
m_hmtxQ = CreateMutex(NULL,FALSE,NULL);
m_hsemNumElements = CreateSemaphore(NULL,0,nMaxElements,NULL);

2.设计客户端核心逻辑如下图:

image

WatiForSingleObject:试图获得队列的独占访问权限,对于这个队列无论是读还是写都应该是线程独占的。因此,使用互斥量对象来同步;

ReleaseSemaphore:试图增加一个资源计数,表征客户端想要向队列中增加一个元素,当然队列可能现在已经满了,对应的资源计数已达到计数上限,此时ReleaseSemaphore会返回FALSE,这样客户端就不能像队列中插入元素。反之,如果ReleaseSemaphore返回TRUE,表示队列没有满,客户端可以向队列中插入元素。

ReleaseMutex:无论客户端是否能够像队列中插入元素,在结束访问后,都应该释放互斥对象,以便其他线程能够进入临界资源。

3.设计服务端核心逻辑如下图:

image

WatiForSingleObject:试图获得队列的独占访问权限,对于这个队列无论是读还是写都应该是线程独占的。因此,使用互斥量对象来同步;

WaitForSingleObject(m_hsemNumElements…):试图检查信号量对象是否是触发状态。只有是触发状态的信号量对象,线程才能进入;也就意味着:队列中只要有元素(资源>0,触发状态),服务端就能读取。反之,如果队列中没有元素(资源=0,未触发状态),服务端将暂时不能访问队列,这时应该立即释放Mutex。

ReleaseMutex:无论客户端是否能够像队列中插入元素,在结束访问后,都应该释放互斥对象,以便其他线程能够进入临界资源。

 

转自:http://www.cnblogs.com/P_Chou/archive/2012/07/13/semaphore-and-mutex-in-thread-sync.html

 

 

 

抱歉!评论已关闭.