C/C++ _beginthreadex 多线程操作 - 线程同步

Posted cpp_learners

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C/C++ _beginthreadex 多线程操作 - 线程同步相关的知识,希望对你有一定的参考价值。

上一篇博客讲到了如何创建线程并使用线程,这一篇将讲解线程同步的操作!



一、线程同步 ----- 互斥对象

(内核级别)

互斥对象(mutex)属于内核对象,它能够确保线程拥有对单个资源的互斥访问权。

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

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

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

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

简单来讲,互斥对象就是,同一个内存变量,当线程一在操作他时,线程二就无法进行操作,得等到线程一不操作它了,线程二才可以进行操作它!

HANDLE WINAPI CreateMutexW (
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,   //指向安全属性
    _In_ BOOL bInitialOwner,   //初始化互斥对象的所有者  TRUE 立即拥有互斥体
    _In_opt_ LPCWSTR lpName    //指向互斥对象名的指针
);

前提是得要用WaitForMultipleObjects进行阻塞才可以!

用法:

// 全局变量句柄,用于互斥
HANDLE hMutex;


// 创建互斥量
hMutex = CreateMutex(NULL, FALSE, NULL);

	/* 创建线程的操作:省略一万行代码 */

// 阻塞多个线程句柄,直到子线程运行完毕,主线程才会往下走
WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);

// 关闭互斥量
CloseHandle(hMutex);

CreateMutex:参数一默认为NULL即可;参数二根据具体需求写TRUE或者NULL;参数三也写NULL吧,我都有点蒙圈了。

整体使用框架如上,create后记得要close。

提一个需求,创建n个线程,然后循环n次,奇数次对一个全局变量做加一操作,偶数次对一个全局变量做减一操作。

如果不使用CreateMutex进行互斥,线程就会同时访问到同一个全局变量,那么数据就乱了!

测试代码:

#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(void) {
	// 线程句柄
	HANDLE tHandles[NUM_THREAD];

	// 创建互斥量
	hMutex = CreateMutex(NULL, FALSE, NULL);

	// 创建五十个线程
	printf("sizeof long long :%d\\n", sizeof(long long));
	for (int 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) {

	// 等待一个内核对象变为已通知状态
	WaitForSingleObject(hMutex, INFINITE);

	for (int i = 0; i < 500000; i++) {
		num += 1;
	}

	// 释放
	ReleaseMutex(hMutex);

	return 0;
}


// 对全局变量减一操作
unsigned WINAPI threadDes(void *arg) {
	WaitForSingleObject(hMutex, INFINITE);

	for (int i = 0; i < 500000; i++) {
		num -= 1;
	}

	ReleaseMutex(hMutex);

	return 0;
}


二、线程同步 ----- 事件对象

(内核级别)

事件对象也属于内核对象,它包含以下三个成员:
● 使用计数;
● 用于指明该事件是一个自动重置的事件还是一个人工重置的事件的布尔值;
● 用于指明该事件处于已通知状态还是未通知状态的布尔值。

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

  1. 创建事件对象
    调用CreateEvent函数创建或打开一个命名的或匿名的事件对象。
  2. 设置事件对象状态
    调用SetEvent函数把指定的事件对象设置为有信号状态。
  3. 重置事件对象状态
    调用ResetEvent函数把指定的事件对象设置为无信号状态。
  4. 请求事件对象
    线程通过调用WaitForSingleObject函数请求事件对象。

创建事件对象的函数原型如下:

HANDLE CreateEvent(   
	LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性,写NULL   
	BOOL bManualReset,   // 复位方式  TRUE 必须用ResetEvent手动复原  FALSE 自动还原为无信号状态
	BOOL bInitialState,   // 初始状态   TRUE 初始状态为有信号状态  FALSE 无信号状态
	LPCTSTR lpName     //对象名称  写NULL  无名的事件对象 
);

第二个参数:就是设置信号状态的,就是它可以自动设置回无信号状态,或者手动设置!

第三个参数:就是说,返回值的这个句柄,它里面是有两种状态,有信号状态和无信号状态,当他是有信号这个状态时,WaitForSingleObject才不会卡住,否则会一直卡在有WaitForSingleObject的地方!

需求:
两个线程,一个线程统计字符数组中包含字符‘A’的个数;另一个线程统计字符数组不包含字符‘A’的个数;

测试代码

两个线程是同时执行的!

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

#define STR_LEN		100

unsigned WINAPI NumberOfA(void *arg);		// 统计字符A个数
unsigned WINAPI NumberOfOthers(void *arg);	// 统计其它字符个数

static char str[STR_LEN];
static HANDLE hEvent;


int main(void) {

	HANDLE hThread1, hThread2;
	printf("请输入一个字符串:");
	scanf_s("%s", str, sizeof(str) / sizeof(str[0]));

	// 创建事件对象,需要手动设置与释放信号,初始状态为无信号状态
	hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

	hThread1 = (HANDLE)_beginthreadex(NULL, 0, NumberOfA, NULL, 0, NULL);
	hThread2 = (HANDLE)_beginthreadex(NULL, 0, NumberOfOthers, NULL, 0, NULL);

	// 设置事件对象为有信号状态
	SetEvent(hEvent);

	// 等待信号
	WaitForSingleObject(hThread1, INFINITE);
	WaitForSingleObject(hThread2, INFINITE);

	// 直到2个线程执行完之后,再把事件设置为无信号状态(手动释放)
	ResetEvent(hEvent);
	CloseHandle(hEvent);

	return 0;
}


// 下面两个方法同时执行,访问同一个全局变量,因为没有对他做修改操作,所以没有关系
unsigned WINAPI NumberOfA(void *arg) {
	int cnt = 0;

	// 一直等待时间信号,当有事件信号后就可以执行了,否则一直卡在这里
	WaitForSingleObject(hEvent, INFINITE);

	for (int i = 0; str[i] != 0; i++) {
		if (str[i] == 'A') {
			cnt++;
		}
	}

	printf("Num of A: %d \\n", cnt);

	return 0;
}


unsigned WINAPI NumberOfOthers(void *arg) {
	int cnt = 0;

	// 一直等待时间信号,当有事件信号后就可以执行了,否则一直卡在这里
	WaitForSingleObject(hEvent, INFINITE);

	for (int i = 0; str[i] != 0; i++) {
		if (str[i] != 'A') {
			cnt++;
		}
	}

	printf("Num of others: %d \\n", cnt);

	return 0;
}

再举一个例子

模拟一个售票的流程,100张票,分为两个窗口(线程)出售!

// 创建事件对象,自动还原无信号状态,初始状态为无信号状态
hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

让信号状态自动恢复无信号状态!
上面那个例子是手动恢复的!

测试代码

线程一执行后,线程二就不会执行;线程二执行后,线程一就不会执行。
原因
在main函数中SetEvent(hEvent);后,只能有一个线程句柄去接收使用,所以呢,两个线程,要么是线程A执行完后再SetEvent(hEvent);,然后线程B在执行,执行完后再SetEvent(hEvent);,线程A接收到再执行…;反过来也是一样的道理!

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

int iTickets = 100;
HANDLE hEvent;

unsigned int WINAPI SellTicketA(void *);
unsigned int WINAPI SellTicketB(void *);

int main(void) {

	HANDLE hThreadA, hThreadB;

	// 创建线程
	hThreadA = (HANDLE)_beginthreadex(NULL, 0, SellTicketA, NULL, 0, 0);
	hThreadB = (HANDLE)_beginthreadex(NULL, 0, SellTicketB, NULL, 0, 0);

	// 创建事件对象,自动还原无信号状态,初始状态为无信号状态
	hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

	// 设置事件对象为有信号状态
	SetEvent(hEvent);

	WaitForSingleObject(hThreadA, INFINITE);
	WaitForSingleObject(hThreadB, INFINITE);
	system("pause");
	// 关闭句柄
	CloseHandle(hEvent);
	return 0;
}


unsigned int WINAPI SellTicketA(void *) {

	while (1) {
		// 等待事件信号
		WaitForSingleObject(hEvent, INFINITE);

		if (iTickets > 0) {
			Sleep(10);
			iTickets--;
			printf("A remain %d \\n", iTickets);
		
		} else {
			break;
		}

		// 设置事件对象为有信号状态
		SetEvent(hEvent);
	}

	SetEvent(hEvent);

	return 0;
}


unsigned int WINAPI SellTicketB(void *) {

	while (1) {
		WaitForSingleObject(hEvent, INFINITE);

		if (iTickets > 0) {
			Sleep(10);
			iTickets--;
			printf("B remain %d \\n", iTickets);
		
		} else {
			break;
		}

		SetEvent(hEvent);
	}

	SetEvent(hEvent);

	return 0;
}


当票买完后,就退出了。
全程两个线程是同时运行的,都是在访问同一个全局变量!
但是他们不是在同一时刻访问的,线程一访问了,线程二就不可以访问了;线程二访问了,线程一就不可以访问了。


三、线程同步 ----- 信号量

(内核级别)

内核对象的状态:
触发状态(有信号状态),表示有可用资源。
未触发状态(无信号状态),表示没有可用资源

工作原理
以一个停车场是运作为例。假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆不受阻碍的进入,然后放下车拦,剩下的车则必须在入口等待,此后来的车也都不得不在入口处等待。这时,有一辆车离开停车场,看门人得知后,打开车拦,放入一辆,如果又离开两辆,则又可以放入两辆,如此往复。这个停车系统中,每辆车就好比一个线程,看门人就好比一个信号量,看门人限制了可以活动的线程。假如里面依然是三个车位,但是看门人改变了规则,要求每次只能停两辆车,那么一开始进入两辆车,后面得等到有车离开才能有车进入,但是得保证最多停两辆车。对于Semaphore而言,就如同一个看门人,限制了可活动的线程数。

信号量的组成
  ①计数器:该内核对象被使用的次数;
  ②最大资源数量:标识信号量可以控制的最大资源数量(带符号的32位);
  ③当前资源数量:标识当前可用资源的数量(带符号的32位)。即表示当前开放资源的个数(注意不是剩下资源的个数),只有开放的资源才能被线程所申请。但这些开放的资源不一定被线程占用完。比如,当前开放5个资源,而只有3个线程申请,则还有2个资源可被申请,但如果这时总共是7个线程要使用信号量,显然开放的资源5个是不够的。这时还可以再开放2个,直到达到最大资源数量。

信号量的规则如下:
(1)如果当前资源计数大于0,那么信号量处于触发状态(有信号状态),表示有可用资源。
(2)如果当前资源计数等于0,那么信号量属于未触发状态(无信号状态),表示没有可用资源。
(3)系统绝对不会让当前资源计数变为负数
(4)当前资源计数绝对不会大于最大资源计数

信号量与互斥量不同的地方是,它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。信号量对象对线程的同步方式与前面几种方法不同,信号允许多个线程同时使用共享资源。

  1. 创建信号量

    HANDLE WINAPI CreateSemaphoreW(
     _In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,  // Null 安全属性
    	_In_ LONG lInitialCount,  //初始化时,共有多少个资源是可以用的。 0:未触发状//态(无信号状态),表示没有可用资源
    
     _In_ LONG lMaximumCount,  //能够处理的最大的资源数量   3
     _In_opt_ LPCWSTR lpName   //NULL 信号量的名称
    );
    

    参数一和参数四都写NULL就可以了!
    参数二:信号资源,写0,说明没有信号资源,写1,说明有一个信号资源。
    参数三:最多可以处理的信号量个数,至少写1吧,不然就没有意义了。

    例:

    HANDLE semOne = CreateSemaphore(NULL, 0, 1, NULL);
    
    /* 参数二:信号资源		参数三:可以处理信号的个数 */
    
    HANDLE semTwo = CreateSemaphore(NULL, 1, 1, NULL);
    

    定义了两个信号量,其中只有一个信号资源,也就是说,semOne 和 semTwo 共享这一个信号资源,谁拥有后就可以处理自己的线程函数了。

    这样即使线程同时运行,但是由于有信号量在阻塞着,所以访问共同的资源的内存(例如全局变量),也不会出现数据错乱。

    当然,信号资源一直被semTwo占用着,怎么才能给semOne使用呢?

  2. 增加信号量
    也可以叫释放信号。就是semTwo 释放掉它那个信号资源,semOne就可以得到这个信号资源,进行处理线程函数了。

    WINAPI ReleaseSemaphore(
    	_In_ HANDLE hSemaphore,   //信号量的句柄
    	_In_ LONG lReleaseCount,   //将lReleaseCount值加到信号量的当前资源计数上面 0-> 1
     	_Out_opt_ LPLONG lpPreviousCount  //当前资源计数的原始值
    );
    

    例:

    // 增加一个信号量(释放一个信号量)
    ReleaseSemaphore(semTwo, 1, NULL);
    

    将semTwo的一个信号量释放出来。

  3. 关闭句柄

    CloseHandle(
    	_In_ _Post_ptr_invalid_ HANDLE hObject
    );
    

    例:

    // 关闭句柄
    CloseHandle(semOne);
    CloseHandle(semTwo);
    

测试代码

需求:
做一个累加的功能,用户依次输入5个整数,一个线程做输入操作,一个线程做累加操作,最后输出累加结果。

线程一执行后,线程二就不会执行;线程二执行后,线程一就不会执行
原因:
只有一个信号资源,两个线程共同使用,那么是线程A先使用,然后释放掉信号资源,线程B再接着使用,再释放信号资源,以此达到线程同步运行但不同不处理数据的效果!

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

unsigned WINAPI Read(void *arg);
unsigned WINAPI Accu(void *arg);

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

int main(void) {

	HANDLE hThread1, hThread2;

	// semOne:没有资源可用,只能表示0或者1的二进制信号量,无信号
	semOne = CreateSemaphore(NULL, 0, 1, NULL);

	/* 参数二:信号资源		参数三:可以处理信号的个数 */

	// semTwo:有资源可用,有信号状态,有信号
	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;
}


unsigned WINAPI Read(void *arg) {
	
	for (int i = 0; i < 5; i++) {
		printf("请输入一个整数:\\n");

		// 等待内核对象semTwo的信号,如果有信号,继续执行;如果没有信号,等待
		WaitForSingleObject(semTwo, INFINITE);

		scanf_s("%d", &num, sizeof(num));

		// 增加一个信号量(释放一个信号量)
		ReleaseSemaphore(semOne, 1, NULL);
	}

	return 0;
}


unsigned WINAPI Accu(void * arg) {
	int sum = 0;
	for (int i = 0; i < 5; i++) {

		// 等待内核对象semOne的信号,如果有信号,继续执行;如果没有信号,等待
		WaitForSingleObject(semOne, INFINITE);

		sum += num;
		printf("sum = %d \\n", sum);

		// 增加一个信号量(释放一个信号量)
		ReleaseSemaphore(semTwo, 1, NULL);
	}

	printf("Result:%d\\n", sum);

	return 0;
}


四、线程同步 ----- 关键代码段

(用户级别):企业级开发中最常用!

线程一执行后,线程二就不会执行;线程二执行后,线程一就不会执行。

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

使用关键代码段分为以下几点:

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

    函数原型:

    InitializeCriticalSection(
    	_Out_ LPCRITICAL_SECTION lpCriticalSection
    );
    

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

    例:

    // 全局变量
    CRITICAL_SECTION g_cs;
    
    
    // 初始化关键代码段
    InitializeCriticalSection(&g_cs);
    
  2. 进入关键代码段

    函数原型:

    VOID WINAPI EnterCriticalSection(
    	_Inout_ LPCRITICAL_SECTION lpCriticalSection
    );
    

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

    例:

    // 进入临界区
    	EnterCriticalSection(&g_cs以上是关于C/C++ _beginthreadex 多线程操作 - 线程同步的主要内容,如果未能解决你的问题,请参考以下文章

    015 _beginthreadex CreateThread 函数区别

    小解_beginthreadex与_beginthreadex和CreateThread的区别

    多线程CreateThread与_beginthreadex本质区别

    秒杀多线程第二篇 多线程第一次亲密接触 CreateThread与_beginthreadex本质区别

    秒杀多线程第二篇 多线程第一次亲密接触 CreateThread与_beginthreadex本质区别

    线程的基本知识