win32api之线程知识梳理(四)

发布时间 2023-03-22 21:09:05作者: 亨利其实很坏

什么是线程

线程是附属在进程上的执行实体, 是代码的执行流程

一个进程可以包含多个线程, 但一个进程至少要包含一个线程


进程与线程的关系

可以将进程比作一个房子,它是一个容器,可以包含很多个线程(居住者)同时工作。线程可以在进程中进行交互和共享资源(房间、厨房等)。与居住在房子里的人一样,线程需要执行某些任务,并且它们可以使用相同的内存和资源来完成它们的工作


线程涉及API

CreateThread

CreateThread函数用于创建一个新线程,该线程在进程空间内独立运行。该函数返回新线程的句柄,以及线程的唯一标识符

在调用该函数后,需要通过 CloseHandle 函数关闭线程句柄,否则会导致资源泄漏

HANDLE CreateThread(
  LPSECURITY_ATTRIBUTES lpThreadAttributes,  //线程安全属性,可设置为 NULL
  SIZE_T dwStackSize,  //新线程的栈大小,若为 0 则使用默认大小
  LPTHREAD_START_ROUTINE lpStartAddress,  //线程函数的地址,即新线程所要执行的函数
  LPVOID lpParameter,  //传递给线程函数的参数
  DWORD dwCreationFlags,  //控制线程创建的标志,如是否立即启动线程等
  LPDWORD lpThreadId  //返回值,指向接收线程标识符的变量
);

线程函数

线程函数是线程执行的代码,它会在调用CreateThread函数创建线程后被调用, 其返回值为DOWRD类型, 此值会传递给GetExitCodeThread函数。如果线程函数执行完毕后不返回任何值,则默认返回0

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
    return 0;
}

以下是一个创建线程并给先传递参数的实例,CreateThread 函数创建了一个新的线程并传递了一个 int 类型的参数 count。新线程的入口点是 ThreadProc 函数,该函数接受一个 LPVOID 类型的参数 lpParam,在这里将其转换为 int* 类型的指针,然后使用该参数进行迭代计数

include <Windows.h>
include <stdio.h>

DWORD WINAPI ThreadProc(LPVOID lpParam) {
    int* pCount = (int*)lpParam;
    for (int i = 0; i < *pCount; i++) {
        printf("Thread: %d\n", i + 1);
        Sleep(1000);
    }
    return 0;
}

int main() {
    int count = 5;
    HANDLE hThread;
    DWORD threadId;

    hThread = CreateThread(NULL, 0, ThreadProc, &count, 0, &threadId);

    if (hThread == NULL) {
        printf("Failed to create thread (%d)\n", GetLastError());
        return 1;
    }

    WaitForSingleObject(hThread, INFINITE);

    CloseHandle(hThread);

    return 0;
}

GetExitCodeThread

GetExitCodeThread函数用于获取指定线程的退出代码, 其语法格式如下所示:

BOOL GetExitCodeThread(
  HANDLE  hThread,  //要查询退出代码的线程句柄
  LPDWORD lpExitCode  //指向一个变量的指针,用于接收线程的退出代码
);

此函数的使用实例如下:

include <Windows.h>
include <stdio.h>

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{	
	for (int i = 0; i < 5; i++)
	{
		Sleep(500);
		printf("%d\n",i);
	}
	//printf("Hello from new thread!\n");
	return 1;
}

int main()
{	
	DWORD i;  //用于接收线程的退出代码
	hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
	WaitForSingleObject(hThread, INFINITE);
	GetExitCodeThread(hThread, &i);  //获取线程的退出代码
	printf("线程退出代码是:%d", i);
	return 0;
}

线程参数的生命周期

创建线程时需要注意向线程传递的参数的生命周期。一般情况下,线程创建后会立即运行,并且在运行过程中可能需要使用传递进来的参数。如果传递的参数的生命周期比线程短,当线程需要使用参数时,参数可能已经失效了,导致程序出错。因此,一般的做法是将参数拷贝一份给线程,在线程中使用拷贝的参数,确保参数的有效性

假设我们要创建一个线程来打印一个字符串,我们需要将该字符串作为参数传递给线程。但是,如果该字符串是在主线程中声明并初始化的局部变量,那么当主线程完成时,该字符串将被销毁,这可能会导致在线程中引用该字符串时出现问题

为了避免这种情况,可以通过以下方式解决:在主线程中使用动态内存分配函数(例如malloc)为字符串分配内存,将字符串的指针作为参数传递给线程,然后在线程完成后手动释放内存。这样,即使主线程完成并销毁了该字符串,线程仍然可以访问该字符串所在的内存空间。下面是一个示例代码

include <Windows.h>
include <stdio.h>

DWORD WINAPI PrintString(LPVOID lpParam)
{
    char* str = (char*)lpParam;
    printf("%s\n", str);
    return 0;
}

int main()
{
    char* str = (char*)malloc(sizeof(char) * 20);
    strcpy_s(str, 20, "Hello, World!");

    HANDLE hThread = CreateThread(NULL, 0, PrintString, str, 0, NULL);
    if (hThread == NULL)
    {
        printf("Failed to create thread, error code: %d\n", GetLastError());
        return 1;
    }

    WaitForSingleObject(hThread, INFINITE);

    free(str);
    CloseHandle(hThread);

    return 0;
}

线程控制函数

SuspendThread

SuspendThread函数用于暂停线程, 其语法格式如下

SuspendThread(
    _In_ HANDLE hThread  //线程句柄
);

ResumeThread

ResumeThread函数用于恢复线程, 其语法格式如下:

ResumeThread(
    _In_ HANDLE hThread  //线程句柄
    );

WaitForSingleObject

WaitForSingleObject函数是一个Windows API函数,它可以等待一个指定的内核对象变为可用。它的作用是使当前线程暂停执行,直到指定的内核对象变为有信号(signaled)状态,或者直到超时时间已过。简单来说就是等待指定线程执行结束后当前线程才能恢复执行

其语法格式如下所示:

DWORD WaitForSingleObject(
  HANDLE hHandle,   //句柄
  DWORD  dwMilliseconds  //超时时间
);

WaitForMutipleObjects

与WaitForSingleObjects函数不同的是, WaitForMutipleObjects函数可支持等待多个线程执行结束, 或者等待多个线程中其中一个执行结束

其语法格式如下:

DWORD WaitForMultipleObjects(
  DWORD        nCount,  //等待的句柄数量,即lphandles数组中句柄的个数
  const HANDLE *lpHandles,  //要等待的对象的句柄数组
  BOOL         bWaitAll,  //该值为TRUE时,只有在所有对象都变为可用之后才返回;当该值为FALSE时,只要有一个对象变为可用就返回
  DWORD        dwMilliseconds  //当该值为零时,函数不等待并立即返回。当该值为 INFINITE 时,函数无限期地等待直到句柄数组中有一个对象变为可用,或者等待失败
);

以下是WaitForMutipleObjects函数的使用实例

include <Windows.h>
include <stdio.h>

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{	
	for (int i = 0; i < 5; i++)
	{
		Sleep(500);
		printf("%d\n",i);
	}
	return 1;
}

int main()
{	
	DWORD i;
	HANDLE arrThread[2];
	arrThread[0] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
	arrThread[1] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

	WaitForMultipleObjects(2,arrThread,TRUE,INFINITE);  //等待以上2个线程执行结束
	printf("两个线程执行完毕 \n");
	
	// 关闭线程句柄
	CloseHandle(arrThread[0]);
	CloseHandle(arrThread[1]);

	return 0;
}

线程上下文

什么是线程上下文

线程上下文(Thread Context)是指在一个线程中,当前执行代码的相关信息集合,包括寄存器状态、程序计数器、线程优先级、线程的上下文安全堆栈等等。

要注意的是,若要获取线程上下文信息,需要先将线程挂起。


CONTEXT结构

CONTEXT是Windows API中用于保存线程上下文的结构体,包含了处理器的寄存器、标志和其他与处理器相关的状态信息。

由于CONTEXT结构体成员太多了, 可以通过设置ContextFlags成员的值来获取指定范围的寄存器, 以下是常用的寄存器集的描述:

  • CONTEXT_INTEGER: 包含通用寄存器集(如EAX、EBX等)和指令指针EIP。
  • CONTEXT_CONTROL: 包含指令指针EIP、代码段寄存器CS、栈指针ESP和栈段寄存器SS。
  • CONTEXT_SEGMENTS: 包含数据段寄存器DS、源段寄存器SS、堆栈段寄存器SS和附加段寄存器ES、FS和GS。
  • CONTEXT_FLOATING_POINT: 包含浮点寄存器集。
  • CONTEXT_DEBUG_REGISTERS: 包含调试寄存器集。

可以通过GetThreadContextSetThreadContext函数来获取和设置线程上下文

GetThreadContext用于获取指定线程的上下文信息,调用成功后会将获取到的上下文信息存储在CONTEXT结构体中,其语法格式如下所示:

BOOL GetThreadContext(
    _In_ HANDLE hThread,  //线程句柄
    _Inout_ LPCONTEXT lpContext  //Context结构体指针
    );

SetThreadContext用于设置指定线程的上下文,即线程寄存器和指令指针等信息,其语法结构如下所示:

BOOL SetThreadContext(
    _In_ HANDLE hThread,  //线程句柄
    _In_ CONST CONTEXT* lpContext  //Context结构体指针
);

使用实例

include <Windows.h>
include <stdio.h>

DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
	printf("Hello from new thread!\n");
	return 0;
}

int main()
{
	HANDLE hThread = CreateThread(
		NULL,           // 默认安全性描述符
		0,              // 默认堆栈大小
		ThreadProc,     // 线程函数
		NULL,           // 线程参数
		0,              // 立即启动线程
		NULL            // 不返回线程标识符
	);

	if (hThread == NULL)
	{
		printf("Failed to create thread (%d)\n", GetLastError());
		return 1;
	}
	SuspendThread(hThread);  //暂停
	CONTEXT context;  //定义线程上下文结构体
	context.ContextFlags = CONTEXT_INTEGER;  //设置线程上下文的寄存器值为CONTEXT_INTEGER
	GetThreadContext(hThread, &context);  //获取线程上下文
	printf("eax的值为%x", context.Eax);  //输出寄存器eax的值
	
	ResumeThread(hThread);  //恢复线程

	
	// 等待线程结束
	WaitForSingleObject(hThread, INFINITE);

	// 关闭线程句柄
	CloseHandle(hThread);

	return 0;
}

临界区

什么是临界区

当多个线程同时使用同一个资源(全局变量)时, 很可能会出现某些错误, 这时我们可以将这个资源变成临界资源, 然后通过临界区来使用这个临界资源

临界区(critical section)是操作系统用于同步线程之间访问共享资源的一种机制,是一段被保护的代码区域。同一时刻只允许一个线程进入临界区,其他线程需要等待当前线程退出临界区才能进入执行。

当线程进入临界区时,它会对临界资源进行加锁,这时其他线程无法访问该资源。只有当当前线程完成对临界资源的访问并释放锁后,其他线程才能进入临界区访问临界资源


什么是线程锁

线程锁实际上就是基于临界区实现的,通过在临界区代码块前加锁,在代码块结束后释放锁来保证临界区的互斥性


以下是实现线程锁的代码流程:

1.定义一个临界区

CRITICAL_SECTION cs;

2.初始化临界区

InitializeCriticalSection(&cs)

3.定义临界区范围

EnterCriticalSection(&cs);
	//使用临界资源
LeaveCriticalSection(&cs);

使用实例

以下代码是关于线程锁使用的实例, 线程1的代码执行完毕后线程2才能执行自己的代码

include <Windows.h>
include <stdio.h>
include "psapi.h"

define ARRAY_SIZE 1024

int Tickets = 10;  //定义一个全局变量,表示票数
CRITICAL_SECTION cs;  //定义临界区

DWORD WINAPI ThreadProc(LPVOID IpParameter) {
	EnterCriticalSection(&cs);  //进入临界区
	while (Tickets>0)
	{
		printf("还有%d张票,", Tickets);
		Tickets--;
		printf("卖出去一张,还剩%d张\n", Tickets);
	}
	LeaveCriticalSection(&cs);  //离开临界区
	return 0;

}

int main()
{	
	InitializeCriticalSection(&cs);  //初始化临界区
	HANDLE arrThread[2];
	arrThread[0] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);  //创建线程1
	arrThread[1] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);  //创建线程2
	WaitForMultipleObjects(2, arrThread, TRUE, INFINITE);
	CloseHandle(arrThread[0]);
	CloseHandle(arrThread[1]);
	return 0;
}

互斥体

什么是互斥体

如下图所示, 互斥体是一种用于控制线程同步的对象, 它能确保同一时刻只有一个线程进入临界区访问临界资源。

互斥体通过两种状态来控制对共享资源的访问, 分别是已锁定(0)和未锁定(1), 当一个线程从互斥体中获取锁(令牌), 其他线程就无法访问共享资源, 只有线程释放了锁(令牌), 其他线程才能继续竞争获取锁

在Windows系统中, 互斥体的实现是一个内核对象, 因此它可以跨进程使用

image-20230224110255036


涉及API

CreateMutex

CreateMutex函数用于创建或者打开一个互斥体对象,其语法格式如下:

HANDLE CreateMutex(
  LPSECURITY_ATTRIBUTES lpMutexAttributes,  //一个指向SECURITY_ATTRIBUTES结构体的指针,用于指定新的互斥对象是否可以被继承
  BOOL                  bInitialOwner,  //指定互斥体对象的初始状态,若为Flase,则创建的互斥体是有信号的(其他进程的等待线程可以使用它);若为True则是无信号的(其他进程的等待线程必须等待互斥体被释放后才能使用它)
  LPCTSTR               lpName
);

ReleaseMutex

ReleaseMutex函数用来释放进程持有的互斥体对象,其语法格式如下:

BOOL ReleaseMutex(
  HANDLE hMutex  //互斥体句柄
);

使用实例

以下代码使用互斥体实现跨进程线程同步, 首先是进程A的代码, 此处为了方便测试, 我利用getchar()来阻塞代码执行(相当于断点), 这样互斥体就没有释放锁

//进程A
include <iostream>
include <windows.h>

int main()
{	
	//创建互斥体
	HANDLE hMutex = CreateMutex(NULL,FALSE,TEXT("mutex"));
	
	//获取锁
	WaitForSingleObject(hMutex, INFINITE);

	for (int i = 0; i < 5; i++)
	{
		printf("进程A的x线程\n");
	}

	getchar();
	
	//释放锁
	ReleaseMutex(hMutex);
	
}

下面是进程B的代码, 由于进程A的代码还没有释放锁, 因此进程B的代码无法执行

//进程B
include <iostream>
include <windows.h>

int main()
{
	//创建互斥体
	HANDLE hMutex = CreateMutex(NULL, FALSE, TEXT("mutex"));

	//获取锁
	WaitForSingleObject(hMutex, INFINITE);

	for (int i = 0; i < 5; i++)
	{
		printf("进程B的y线程\n");
	}

	//释放锁
	ReleaseMutex(hMutex);

}

互斥体和线程锁的区别

  • 互斥体可以用于跨进程的线程同步,线程锁只能用于同一进程内的线程同步
  • 互斥体可以设置等待超时, 而线程锁不行。当一个线程在执行过程中因为异常情况(例如程序崩溃)而突然终止时,如果它持有了某个共享资源的互斥体,其他线程在等待这个资源时可能会进入无限等待状态,因为该互斥体没有被释放。为了避免这种情况,互斥体通常会在创建时指定一个超时时间,一旦等待时间超过了这个时间,等待线程就会放弃等待并执行其他任务,从而避免了无限等待的情况发生
  • 互斥体的效率没有线程锁的高

事件

什么是事件

事件是一种同步对象,用于线程之间的通信和协调。事件对象有两种状态:有信号状态和无信号状态。当事件对象处于有信号状态时,等待该事件的线程可以被唤醒并继续执行。当事件对象处于无信号状态时,等待该事件的线程将被阻塞,直到事件被信号化为止

通常,一个线程使用 SetEvent 函数将事件对象信号化,而另一个或多个线程使用 WaitForSingleObject 或 WaitForMultipleObjects 函数等待该事件对象的信号状态


涉及api

CreateEvent

CreateEvent函数用于创建一个事件对象, 事件对象是内核对象的一种,可用于同步进程和线程,或者通知线程事件的发生

其语法如下所示:

HANDLE CreateEvent(
  LPSECURITY_ATTRIBUTES lpEventAttributes,  //安全描述符
  BOOL                  bManualReset,  //指定事件对象的重置类型,TRUE表手动重置,FALSE表自动重置
  BOOL                  bInitialState,  //事件创建出来是否是有信号的,True表示有信号,False表示无信号
  LPCTSTR               lpName   //指定事件的名称
);

关于第二个参数的描述:当事件对象处于非信号状态时,一个线程调用WaitForSingleObject等待该事件;如果事件已经处于信号状态,则WaitForSingleObject返回。在自动重置模式下,当WaitForSingleObject返回时,事件会自动返回到非信号状态。而在手动重置模式下,事件会一直保持在信号状态,直到由调用ResetEvent显式重置


SetEvent

SetEvent函数于设置事件对象为有信号状态, 若事件对象处于无信号状态, 则将其设置为有信号状态, 若事件对象处于有信号状态,则函数不起作用。此函数通常与CreateEvent函数一起使用来实现线程同步, 在线程同步中,此函数的作用就是把自己的线程挂起, 同时唤醒其他线程

其语法格式如下:

BOOL SetEvent(
  HANDLE hEvent  //要设置事件对象的句柄
);

使用实例

这段代码实现了一个生产者-消费者的解决方案,其中两个线程分别为生产者线程和消费者线程,通过共享的仓库(Storage变量)实现数据交互。

生产者线程不断地生产产品,存储到仓库中,并唤醒消费者线程,消费者线程不断地从仓库中消耗产品,并唤醒生产者线程

include <iostream>
include <windows.h>
int ProductMax = 10;  //生产数量
int Storage = 0;  //产品仓库,每次只能存储一个产品
HANDLE EventProduct, EventConsume;  //定义事件

//生产者线程
DWORD WINAPI ThreadProduct(LPVOID Parameter) {
	for (int i = 0; i < ProductMax; i++)
	{
		WaitForSingleObject(EventProduct,INFINITE);
		Storage = 1;  //仓库置1
		printf("生产者生产了1个产品\n");
		SetEvent(EventConsume);  //挂起生产者线程,唤醒消费者线程
	}
	return 0;	
}

//消费者线程
DWORD WINAPI ThreadConsume(LPVOID Parameter) {
	for (int i = 0; i < ProductMax; i++)
	{
		WaitForSingleObject(EventConsume, INFINITE);
		Storage = 0;  //仓库置0
		printf("消费者消耗了1个产品\n");
		SetEvent(EventProduct);  //挂起消费者线程,唤醒生产者线程
	}
	return 0;
}

int main()
{	
	//创建事件
	EventProduct = CreateEvent(NULL, FALSE, TRUE, NULL);
	EventConsume = CreateEvent(NULL, FALSE, FALSE, NULL);

	//创建线程
	HANDLE hThread[2];
	hThread[0] = CreateThread(NULL, 0, ThreadProduct, NULL, 0, NULL);
	hThread[1] = CreateThread(NULL, 0, ThreadConsume, NULL, 0, NULL);
	WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
	
	//释放线程
	CloseHandle(hThread[0]);
	CloseHandle(hThread[1]);
}	

由下述执行结果可看出, 两个线程有序的完成了任务, 生产者每生产一个产品, 消费者就消耗一个产品。两个线程交替执行, 而不会出现某个线程同时执行多次

image-20230225155129555


线程互斥和线程同步

  • 线程同步是指协调多个线程的执行顺序,以避免在并发执行时出现不一致的结果或冲突的情况。线程同步可以通过多种机制实现,如互斥锁、信号量、事件等。在线程同步中,通常会存在一些共享资源,多个线程需要协调访问这些共享资源,以避免访问的冲突
  • 线程互斥是一种线程同步机制,用于确保同一时间只有一个线程能够访问共享资源。线程互斥可以使用互斥体、临界区、信号量等机制来实现