欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Windows线程同步——临界区对象

程序员文章站 2022-07-05 10:36:41
...

1. 概述

如果有多个线程试图同时访问临界区,那么在有一个线程进入临界区后,其他试图访问的线程将被挂起(经过自旋之后还没启用的话),直到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到对临界区的互斥访问。(临界区中一般都是一个简短的代码段)在WINDOWS中,临界区是一种应用层的同步对象,非内核对象。对于这句话的解释是这样的:临界区(Critical Section)是Win32中提供的一种轻量级的同步机制,与互斥(Mutex)和事件(Event)等内核同步对象相比,临界区是完全在用户态维护的,所以仅能在同一进程内供线程同步使用,但也因此无需在使用时进行用户态和核心态之间的切换,工作效率大大高于其它同步机制。对于用户线程和内核线程的相关解释已经在这篇博客中进行了说明。

2. 临界区实现原理

2.1 底层原理

首先来看一下使用到的临界区API,使用到的为下面四个函数

InitializeCriticalSection()	//初始化临界区
DeleteCriticalSection()	//删除临界区
EnterCriticalSection()	//进入临界区
LeaveCriticalSection()	//离开临界区
在临界区的实现过程中,涉及到一个叫RTL_CRITICAL_SECTION的结构体,它的定义是这样的

typedef struct _RTL_CRITICAL_SECTION {
    PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
    //
    //  The following three fields control entering and exiting the critical
    //  section for the resource
    //

    LONG LockCount;
    LONG RecursionCount;
    HANDLE OwningThread;        // from the thread's ClientId->UniqueThread
    HANDLE LockSemaphore;
    ULONG_PTR SpinCount;        // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;

对于其中的字段,解释

DebugInfo:指向一个调试用的数据,该结构的类型为RTL_CRITICAL_SECTION_DEBUG
LockCount: 初始值-1,若结果大于等于0,表示该临界区已被线程占用。
OwningThread: 当前拥有临界区的线程
RecursionCount:所有者线程连续进入临界区的次数
LockSemaphore: 内核对象句柄,用于告知操作系统,该临界区目前处于空闲状态,用于唤醒因等待临界区而挂起的线程

在多处理器系统中,如果临界区已被占用,那么线程就自旋SpinCount次去获取临界区,而不是通过阻塞等待的方式去获取临界区。如果在自旋的过程中临界区空间可用,就可以直接进入临界区,减少等待时间(如果进入等待状态,需要用户态内核态的切换,代价较大)。主要意思就是为了提高效率。

要是在自旋完了之后还没能够运行线程,就会被内核对象挂起。但是临界区真正用内核对象挂起线程之前会自旋好几次,因此你看对象里就有一个自旋锁的计数。你可以改这个自旋锁的数量。当然不是说让你直接修改对象的成员变量!你可以在初始化的时候指定自旋锁的数量,用这个API:InitializeCriticalSectionAndSpinCount。在这里小说一下临界区为什么会自旋。因为程序从用户态转到内核模式需要昂贵的开销(大概数百个CPU周期),很多情况下,A线程还没完成从用户态转到内核态的操作呢,B线程就已经释放资源了。于是临界区就先隔一段时间自旋一次,直到所有自旋次数都耗尽,就创建个内核对象然后挂起线程。但是,如果您的机器只有一个CPU,那么这个自旋次数就没用了,操作系统直接会无视它。原因如下:你自旋着呢,操作B线程释放不了资源,于是你还不如直接切入等待状态让B来释放资源。动态更改自旋数量请使用SetCriticalSectionSpinCount,别直接更改对象成员变量。

2.2 实现过程

下面将对临界区的相关API函数进行分析和说明,主要分析了调用相关函数之后,系统执行的操作

InitialzeCriticalSection
在初始化的过程中,会测试CPU的数量,若CPU数量为1,则忙等待没有意义。则SpinCount=0,

若CPU数量大于1,则设置SpinCount,在进入临界区时,会采取主动进入策略。

EnterCriticalSection
(1)若临界区还未被占用,则更新临界区数据结构,表示调用线程已经获得访问临界区的权限,返回。
(2)若线程在已经获取访问权限的情况下,再次EnterCriticalSection,则更新线程获取访问的次数(即连续Enter的次数)。

(3)若临界区已经其他线程占用,则当前线程 通过SpinCount来控制忙等的次数,在SpinCount已经等于0还没有获得临界区对象的情况下,函数直接通过临界区对象内部的事件对象进行等待(等待及唤醒涉及到用户态和内核态的切换,不是最优方案,优先采用自旋的方式进入临界区)。忙等待是通过对LockCount进行原子读写操作实现。

LeaveCriticalSection
(1)_RTL_CRITICAL_SECTION数据结构中相关标志位设置 ,比如RecursionCount--,如果为0,表示没有线程占用临界区
(2)将当前占有线程句柄设为0,表示现在临界区目前处于有信号状态,可以被获取
(3)若有其他线程在等待,唤醒等待线程

2.3 临界区的注意事项

(1)进入灵界区和离开临界区是成对操作,进入临界区必须要有离开临界区否则临界区保护的共享资源将永远不会被释放。
(2)在使用临界区时,临界区间使用的代码最好简短,减少其他线程的等待时间,提高程序性能。
(3)临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。
(4)临界区是用户态下的对象,非内核对象,所以在使用时无需再用户态和内核态之间切换,效率明显要比其他用户互斥的内核对象高。
(5)所有自旋锁都失败了之后会创建一个内核对象然后等待这个内核从而进入到内核模式
(6)临界区对象使用前必须初始化,不初始化会崩溃
(7)临界区对象不是内核对象,因此不能继承,不能跨进程,也不能用waitfor什么的函数来限定时间等待。这个很好理解,你想想WaitFor要求传一个句柄,而临界区对象的类型都不是句柄,也不能用CloseHandle来关闭

3. 代码示例

这里直接给出了示例代码,后面截图中包含没加临界区和加了临界区的结果对比

DWORD WINAPI my_thread1(LPVOID m_pParameter);	//用户线程1
DWORD WINAPI my_thread2(LPVOID m_pParameter);	//用户线程2
UINT count = 0;									//全局的计数函数
CRITICAL_SECTION m_CS;							//定义一个临界区结构体对象

int _tmain(int argc, _TCHAR* argv[])
{
	system("color f0");
	int count_size(50);
	::InitializeCriticalSection(&m_CS);		//初始化临界区
	CreateThread(NULL, NULL, my_thread1, &count_size, NULL, NULL);	//用户线程1
	CreateThread(NULL, NULL, my_thread2, &count_size, NULL, NULL);	//用户线程2
	system("pause");
	::DeleteCriticalSection(&m_CS);			//删除临界区
	return 0;
}

//用户线程1
DWORD WINAPI my_thread1(LPVOID m_pParameter)
{
	UINT* my_count = (UINT*)m_pParameter;
	cout << "thread1" << endl;
	for (int i = 0; i < *my_count; ++i)
	{
		::EnterCriticalSection(&m_CS);	//进入临界区
		count = i;
		cout << count << "\t";
		::LeaveCriticalSection(&m_CS);	//离开临界区
	}
	return 0;
}

//用户线程2
DWORD WINAPI my_thread2(LPVOID m_pParameter)
{
	UINT* my_count = (UINT*)m_pParameter;
	cout << "thread2" << endl;
	for (int i = 0; i < *my_count; ++i)
	{
		::EnterCriticalSection(&m_CS);	//进入临界区
		count = i;
		cout << count << "\t";
		::LeaveCriticalSection(&m_CS);	//离开临界区
	}
	return 0;
}
没有引入临界区

Windows线程同步——临界区对象

引用临界区

Windows线程同步——临界区对象