线程同步的四种实现方式

本文详细对比了Windows平台下的互斥量、事件对象、信号量和关键代码段在实现线程同步中的特点,包括它们的内核/用户态工作原理、函数用法、适用场景及注意事项。通过实际例子演示了如何在多线程环境中合理运用这些同步机制。
摘要由CSDN通过智能技术生成

目录

内核态 

互斥变量 

事件对象

资源信号量

用户态

关键代码


内核态 

互斥变量 

 互斥对象包含一个使用数量,一个线程ID和一个计数器。其中线程ID用于标识系统中的哪个线程当前拥有互斥对象,计数器用于指明该线程拥有互斥对象的次数。

   创建互斥对象:调用函数CreateMutex调用成功,该函数返回所创建的互斥对象的句柄。

   请求互斥对象所有权:调用函数WaitForSingleObject函数。线程必须主动请求共享对象的所有权才能获得所有权。

   释放指定互斥对象的所有权:调用ReleaseMutex函数。线程访问共享资源结束后,线程要主动释放对互斥对象的所有权,使该对象处于已通知状态。

创建互斥对象函数

HANDLE
WINAPI
CreateMutexW(
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,   //指向安全属性
    _In_ BOOL bInitialOwner,   //初始化互斥对象的所有者  TRUE 立即拥有互斥体
    _In_opt_ LPCWSTR lpName    //指向互斥对象名的指针  L“Bingo”
);
  1. 第一个参数表示安全属性,这是每一个创建内核对象都会有的参数,NULL表示默认安全属性
  2. 第二个参数表示互斥对象所有者,TRUE立即拥有互斥体
  3. 第三个参数表示指向互斥对象的指针 

代码示例

下面这段程序声明了一个全局整型变量,并初始化为0。一个线程函数对这个变量进行+1操作,执行50000次;另一个线程函数对这个变量-1操作,执行50000次。两个线程函数各创建25个。因为我们使用了互斥变量,50个线程会按照一定顺序对这变量操作,因此最后结果为0。 

#include <stdio.h>
#include <windows.h>
#include <process.h>

#define NUM_THREAD	50
unsigned WINAPI threadInc(void* arg);
unsigned WINAPI threadDes(void* arg);
long long num = 0;
HANDLE hMutex;

int main() {
	//内核对象数组
	HANDLE tHandles[NUM_THREAD];
	int i;
	//创建互斥信号量
	hMutex = CreateMutex(0, FALSE, NULL);
	printf("sizeof long long: %d \n", sizeof(long long));
	for (i = 0; i < NUM_THREAD; i++) {
		if (i % 2)
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, threadInc, NULL, 0, NULL);
		else
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, threadDes, NULL, 0, NULL);
	}

	WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);
	//关闭互斥对象
	CloseHandle(hMutex);
	printf("result: %lld \n", num);
	return 0;
}

unsigned WINAPI threadInc(void* arg){
	int i;
	//请求使用
	WaitForSingleObject(hMutex, INFINITE);
	for (i = 0; i < 500000; i++)
		num += 1;
	//释放
	ReleaseMutex(hMutex);
	return 0;
}
unsigned WINAPI threadDes(void* arg){
	int i;
	//请求
	WaitForSingleObject(hMutex, INFINITE);
	for (i = 0; i < 500000; i++)
		num -= 1;
	//释放
	ReleaseMutex(hMutex);
	return 0;
}

事件对象

事件对象也属于内核对象,它包含以下三个成员:

        ●    使用计数;

        ●    用于指明该事件是一个自动重置的事件还是一个人工重置的事件的布尔值;

        ●    用于指明该事件处于已通知状态还是未通知状态的布尔值。

        事件对象有两种类型:人工重置的事件对象和自动重置的事件对象。这两种事件对象的区别在于当人工重置的事件对象得到通知时,等待该事件对象的所有线程均变为可调度线程;而当一个自动重置的事件对象得到通知时,等待该事件对象的线程中只有一个线程变为可调度线程。

1.创建事件对象

       调用CreateEvent函数创建或打开一个命名的或匿名的事件对象。

HANDLE CreateEvent(   
LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性   
BOOL bManualReset,   // 复位方式  TRUE 必须用ResetEvent手动复原  FALSE 自动还原为无信号状态
BOOL bInitialState,   // 初始状态   TRUE 初始状态为有信号状态  FALSE 无信号状态
LPCTSTR lpName     //对象名称  NULL  无名的事件对象 
);
  1. 第一个参数表示安全属性,这是创建内核对象函数都有的一个参数,NULL表示默认安全属性
  2. 第二个参数表示复位方式,如果是TRUE,则必须手动调用ResetEvent函数复位,FALSE则表示自动还原
  3. 第三个参数表示初始状态,TRUE表示初始为有信号状态,FALSE为无信号
  4. 第四个参数表示对象名称,NULL表示无名的事件对象

2. 设置事件对象状态

    调用SetEvent函数把指定的事件对象设置为有信号状态。

3. 重置事件对象状态

    调用ResetEvent函数把指定的事件对象设置为无信号状态。

4.      请求事件对象

    线程通过调用WaitForSingleObject函数请求事件对象。

代码示例 

下面这段程序是一段火车售票:线程A和B会不停的购票直到票数小于0,执行完毕。在判断票数前会先申请事件对象,购票结束或者票数小于0时则会释放事件对象(事件对象置位有信号)。因为我们使用了事件对象。两个线程会按某一顺序购票,直到票数小于0。

#include<iostream>
#include<Windows.h>
#include<process.h>
using namespace std;

//火车站卖票
int iTickets = 100;//总票数
HANDLE g_hEvent;


unsigned WINAPI SellTicketA(void* lpParam) {

	while (true) {
		WaitForSingleObject(g_hEvent, INFINITE);
		if (iTickets > 0) {
			Sleep(1);
			printf("A买了一张票,剩余%d\n", iTickets--);
		}
		else {
            SetEvent(g_hEvent);
			break;
		}
		SetEvent(g_hEvent);
	}
	return 0;
}

unsigned WINAPI SellTicketB(void* lpParam) {
	while (true) {
		WaitForSingleObject(g_hEvent, INFINITE);
		if (iTickets > 0) {
			Sleep(1);
			printf("B买了一张票,剩余%d\n", iTickets--);
		}
		else {
            SetEvent(g_hEvent);
			break;
		}
		SetEvent(g_hEvent);
	}
	return 0;
}

int main() {

	HANDLE hThreadA, hThreadB;
	hThreadA = (HANDLE)_beginthreadex(NULL, 0, SellTicketA, NULL, 0, NULL);
	hThreadB = (HANDLE)_beginthreadex(NULL, 0, SellTicketB, NULL, 0, NULL);

	CloseHandle(hThreadA);
	CloseHandle(hThreadB); 

	g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
	SetEvent(g_hEvent);
	Sleep(4000);
	CloseHandle(g_hEvent);
	system("pause");
	return 0;
}

资源信号量

信号量(semaphore)是操作系统用来解决并发中的互斥和同步问题的一种方法。与互斥量不同的地方是,它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。

创建信号量函数

HANDLE	WINAPI	
CreateSemaphoreW(
	_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,  // Null 安全属性
	_In_ LONG lInitialCount,  //初始化时,共有多少个资源是可以用的。 0:未触发状//态(无信号状态),表示没有可用资源
	_In_ LONG lMaximumCount,  //能够处理的最大的资源数量   3
	_In_opt_ LPCWSTR lpName   //NULL 信号量的名称
);
  1. 第一个参数表示安全属性,这是创建内核对象函数都会有的参数,NULL表示默认安全属性
  2. 第二个参数表示初始时有多少个资源可用,0表示无任何资源(未触发状态)
  3. 第三个参数表示最大资源数
  4. 第四个参数表示信号量的名称,NULL表示无名称的信号量对象

 增加/释放信号量

ReleaseSemaphore(
    _In_ HANDLE hSemaphore,   //信号量的句柄
    _In_ LONG lReleaseCount,   //将lReleaseCount值加到信号量的当前资源计数上面 0-> 1
    _Out_opt_ LPLONG lpPreviousCount  //当前资源计数的原始值
);
  1. 第一个参数表示信号量句柄,也就是调用创建信号量函数时返回的句柄
  2. 第二个参数表示释放的信号量个数,该值必须大于0,但不能大于信号量的最大计数
  3. 第三个参数表示指向要接收信号量的上一个计数的变量的指针。如果不需要上一个计数, 则此参数可以为NULL 。

关闭句柄


CloseHandle(
    _In_ _Post_ptr_invalid_ HANDLE hObject
);

代码示例

下面这段程序创建了两个信号资源,其最大资源都为1;一个初始资源为0,另一个初始资源为1。线程中的for循环每执行一次会将另一个要申请的信号资源的可用资源数+1。因此程序的执行结果为两个线程中的for循环交替执行。

#include<iostream>
#include<Windows.h>
#include<process.h>
using namespace std;

static HANDLE semOne;
static HANDLE semTwo;
static int num;

/*
* 信号资源semOne初始为0,最大1个资源可用
* 信号资源semTwo初始为1,最大1个资源可用
*/

unsigned WINAPI Read(void* arg) {
	int i;
	for (i = 0; i < 5; i++) {
		fputs("Input num:\n", stdout);
		printf("begin read\n");
		WaitForSingleObject(semTwo, INFINITE);
		printf("beginning read\n");
		scanf("%d", &num);
		ReleaseSemaphore(semOne, 1, NULL);
	}
	return 0;
}

unsigned WINAPI Accu(void* arg) {
	int sum = 0, i;
	for (i = 0; i < 5; ++i) {
		printf("begin Accu\n");
		WaitForSingleObject(semOne, INFINITE);
		printf("beginning Accu\n");
		sum += num;
		printf("sum=%d\n", sum);
		ReleaseSemaphore(semTwo, 1, NULL);
	}
	return 0;	
}

int main() {
	HANDLE hThread1, hThread2;
	semOne = CreateSemaphore(NULL, 0, 1, NULL);//初始值没有可用资源
	semTwo = CreateSemaphore(NULL, 1, 1, NULL);//初始值有一个可用资源


	hThread1 = (HANDLE)_beginthreadex(NULL, 0, Read, NULL, 0, NULL);
	hThread2 = (HANDLE)_beginthreadex(NULL, 0, Accu, NULL, 0, NULL);
	WaitForSingleObject(hThread1, INFINITE);
	WaitForSingleObject(hThread2, INFINITE);

	CloseHandle(semOne);
	CloseHandle(semTwo);
	system("pause");
	return 0;
}

用户态

  1. 关键代码

    关键代码段,也称为临界区,工作在用户方式下。它是指一个小代码段,在代码能够执行前,它必须独占对某些资源的访问权。通常把多线程中访问同一种资源的那部分代码当做关键代码段。

1.初始化关键代码段

    调用InitializeCriticalSection函数初始化一个关键代码段

InitialzieCriticalSection(
    _Out_ LPRRITICAL_SECTION lpCriticalSection
);

    该函数只有一个指向CRITICAL_SECTION结构体的指针。在调用InitializeCriticalSection函数之前,首先需要构造一个CRITICAL_SCTION结构体类型的对象,然后将该对象的地址传递给InitializeCriticalSection函数。

2进入关键代码

VOID
WINAPI
EnterCriticalSection(
    _Inout_ LPCRITICAL_SECTION lpCriticalSection
);

调用EnterCriticalSection函数,以获得指定的临界区对象的所有权,该函数等待指定的临界区对象的所有权,如果该所有权赋予了调用线程,则该函数就返回;否则该函数会一直等待,从而导致线程等待。

3.退出关键代码段

VOID
WINAPI
LeaveCriticalSection(
    _Inout_ LPCRITICAL_SECTION lpCriticalSection
);

 线程使用完临界区所保护的资源之后,需要调用LeaveCriticalSection函数,释放指定的临界区对象的所有权。之后,其他想要获得该临界区对象所有权的线程就可以获得该所有权,从而进入关键代码段,访问保护的资源。

4.删除临界区

WINBASEAPI
VOID
WINAPI
DeleteCriticalSection(
    _Inout_ LPCRITICAL_SECTION lpCriticalSection
);

当临界区不再需要时,可以调用DeleteCriticalSection函数释放该对象,该函数将释放一个没有被任何线程所拥有的临界区对象的所有资源。

 程序实例:

下面这段程序同样也是火车售票,其工作逻辑与上面的事件对象基本吻合。

#include<iostream>
#include<Windows.h>
#include<process.h> 
using namespace std;

int iTickets = 100;
CRITICAL_SECTION g_cs;
  
//A窗口
DWORD WINAPI SellTicketA(void* lpParam) {
	while (1) {
		EnterCriticalSection(&g_cs);//进入临界区
		if (iTickets > 0) {
			Sleep(1);
			iTickets--;
			printf("A买了一张票,剩余票数为:%d\n", iTickets);
			LeaveCriticalSection(&g_cs);
		}
		else {
			LeaveCriticalSection(&g_cs);
			break;
		}
	}
	return 0;
}

//B窗口
DWORD WINAPI SellTicketB(void* lpParam) {
	while (1) {
		EnterCriticalSection(&g_cs);
		if (iTickets > 0) {
			Sleep(1);
			iTickets--;
			printf("B买了一张票,剩余票数为:%d\n", iTickets);
			LeaveCriticalSection(&g_cs);
		}
		else {
			LeaveCriticalSection(&g_cs);
			break;
		}
	}
	return 0;
}


int main() {
	HANDLE hThreadA, hThreadB;
	hThreadA = CreateThread(NULL, 0, SellTicketA, NULL, 0, NULL);
	hThreadB = CreateThread(NULL, 0, SellTicketB, NULL, 0, NULL);

	CloseHandle(hThreadA);
	CloseHandle(hThreadB);
		
	InitializeCriticalSection(&g_cs);//初始化关键代码
	Sleep(1000);

	DeleteCriticalSection(&g_cs);
	system("pause");
	return 0;
}

各种线程同步的比较总结 

windows线程同步的方式主要有种:互斥对象、事件对象、关键代码段、信号量

        对于上面介绍的三种线程同步的方式,它们之间的区别如下所述:

        ●    互斥对象和事件以及信号量都属于内核对象,利用内核对象进行线程同步时,速度较慢,但利用互斥对象和事件对象这样的内核对象,可以在多个进程中的各个线程间进行同步

        ●    关键代码段工作在用户方式下,同步速度较快,但在使用关键代码段时,很容易进入死锁状态,因为在等待进入关键代码段时无法设定超时值。

用户级别的:关键代码段,只能本进程中

内核级别的:互斥量/事件/信号量,可以跨进程

       通常,在编写多线程程序并需要实现线程同步时,首选关键代码段,由于它的使用比较简单,如果是在MFC程序中使用的话,可以在类的构造函数Init中调用InitializeCriticalSection函数,在该类的析构函数中调用DeleteCriticalSection函数,在所需保护的代码前面调用EnterCriticalSection函数,在访问完所需保护的资源后,调用LeaveCriticalSection函数。可见,关键代码段在使用上是非常方便的,但有几点需要注意:

  1. 在程序中调用了EnterCriticalSection后,要相应的调用LeaveCriticalSection函数,否则其他等待该临界区对象所有权的线程将无法执行。
  2. 如果访问关键代码段时,使用了多个临界区对象,就要注意防止线程死锁的发生。另外,如果需要在多个进程间的各个线程间实现同步的话,可以使用互斥对象和事件对象或者信号量

比较

互斥量

Mutex

事件对象

Event

信号量对象

Semaphore

关键代码段(临界区)CriticalSection

是否为内核对象

速度

较慢

较慢

较慢

多个进程种的线程同步

支持

支持

支持

不支持

发生死锁

组成

一个线程ID:用来标识哪个线程当前拥有该互斥量;一个计数器:用于指明该线程拥有互斥对象的次数

一个使用计数;一个布尔值:用来标识该事件是自动重置还是人工重置;一个布尔值:表示用于指明该事件处于已通知状态(有信号)还是未通知状态(无信号)

一个使用计数;

最大资源数量;

标识当前可用资源的数量

一个小代码段;

在代码能够执行钱,它必须独占对某些资源的访问权

相关函数

CreateMutex

WaitForSingleObjects

被保护的内容

ReleaseMutex

CreateEvent

ResetEvent

WaitForSingleObject

保护内容

SetEvent

CreateSemaphore

WaitForSingleObject

被保护的内容

ReleaseSemaphore

InitializeCriticalSection

EnterCriticalSection

被保护的内容

LeaveCriticalSection

DeleteCriticalSection

注意事项

谁拥有互斥对象谁释放;如果多次在同一个线程请求同一个互斥量,那么需要多次调用releaseMutex

为了实现线程间的同步,不应该使用人工重置,应该把第二个参数设置为false,设置为自动重置

它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目

防死锁:使用多个临界区对象g_csA g_csB。

类比

一把钥匙

一把钥匙

停车场与保安

电话亭

 

 

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值