9、创建线程

①什么是线程?

<1>线程是附属在进程上的执行实体,是代码的执行流程。 <2> 一个进程可以包含多个线程,但一个进程至少要包含一个线程。

可以这么理解,进程属于是空间上的概念,是代表了4GB 的虚拟内存,而线程属于是时间上的概念,也就是说线程也就是当前正在运行中的实际的代码。在任务管理器中可以看到,一个进程包含数个线程,也就是当前这个进程有数段代码正在执行。(但不一定都是同时执行) 举个例子,当前电脑中是单个单核CPU,它也是可以执行同时执行多个线程的。在单核CPU上运行多线程并非真正意义上的多线程。我们可以这么理解,在某个时间点上,只能有一段代码在执行。但CPU执行的效率特别高,切换的比较快,这会执行的A线程,这一会执行的B线程,就仿佛两段代码在同时跑。单核的情况下是不存在多线程的。在某一个时间点上只能有一段代码在执行。一个CPU核只有一套寄存器,一套寄存器必定不能同时执行多个不同的代码。

总结上可以这么理解,一个单核CPU执行代码,多线程实际上是分时段的,这个时间段执行这段代码,另一个时间段执行另一段代码。但是无论是单核还是多核,给我们的感觉都好像是多个线程同时在跑。

②创建线程

HANDLE CreateThread(
LPSECURITY_ATTRIBUTES IpThreadAttributes, //SD(安全描述符)
SIZE_T dwStackSize, // initial stack size
LPTHREAD_START_ROUTINE IpStartAddress,// thread function
LPVOID IpParameter // thread argument
DWORD dwCreationFlags, // creation option
LPDWORD IpThreadld // thread identifier
);

dwStackSize 初识堆栈大小(如果不填写就是操作系统默认给一个值) IpStartAddress 当前线程真正执行的代码的地址 IpParameter 线程所需要的参数在哪(实质上是一个指针) dwCreationFlags 创建线程的一个标识(这个成员如果填0的话,可以立即进入执行的状态),或者用 CREATE_SUSPENDED这个宏标识的话,这个线程就会处于一个挂起的状态。除非使用 ResumeThread 这个API 解除挂起状态,这个线程都不会执行。 IpThreadld 这个成员是整个函数返回的线程ID(区别于这个函数的返回值是线程句柄)

我们利用CreateThread这个API 可以创建一个新的线程,而因为没有进行线程控制,会出现“分时段性的多线程”(上方有解释),即为一段时间执行这一段,另一段时间执行另一段。我们可以用下面这段代码进行测试。

#include<stdio.h>
#include<windows.h>
DWORD WINAPI ThreadProc(LPVOID IpParameter)
{
for(int i = 0;i<5;i++)
{
Sleep(500);
printf("+++++++++%dn",i);
}
return 0;
}

int main()
{
HANDLE hThread;
hThread = CreateThread(NULL,0,ThreadProc,NULL,0,NULL);
CloseHandle(hThread);

for(int i = 0;i<5;i++)
{
Sleep(500);
printf("---------%dn",i);
}
return 0;
}

③向线程函数传递变量

ThreadProc 这个函数我们称为线程函数,每次创建线程的时候都要提供这样一个函数,而且 这个函数有特定的格式要求(有一个参数有一个返回值)。(但是这个函数只是告诉代码的位置在哪,其他的函数类型都也能用,只不过需要强制转换类型,如果遵守格式要求就能够减少麻烦)

<1> 线程参数。

如果我们想要将参数传进线程函数的话,原定参数 IpParameter 是空指针类型的,我们可以通过强制转换类型,如下:

DWORD WINAPI ThreadProc(LPVOID IpParameter)
{
   int * p = (int *)IpParameter; //强制转化
   
for(int i = 0;i < *p;i++)
{
Sleep(500);
printf("+++++++++%dn",i);
}
return 0;
}

int main()
{
HANDLE hThread;
   int n;
   
   n = 10;
hThread = CreateThread(NULL,0,ThreadProc,(LPVOID)&n,0,NULL);
CloseHandle(hThread);

for(int i = 0;i<5;i++)
{
Sleep(500);
printf("---------%dn",i);
}
return 0;
}

<2> 全局变量。

但是在我们传递线程参数的时候需要注意的一件事:举个实例,就是上方这串代码我们传递的线程参数,是位于 main 函数中的,属于局部变量位于堆栈中,就需要保证当我们创建的这个线程执行的时候,当前的这个堆栈没有被回收,即需要创建的这个线程必须要在这个 main 函数执行结束之前执行。我们通常这样理解,我们如果需要向线程传递参数我们就需要保证,参数的存活时长要大于该线程的存活时长。或者我们可以使用全局变量,因为全局变量的生命周期是一直存在的。

10、线程控制

①如何让线程停下来?

让自己停下来: Sleep()函数 让别人停下来: SuspendThread()函数 线程恢复: ResumeThread()函数

Sleep函数的意义是,当线程执行到某个阶段的时候,我们期望它停下来,就可以利用Sleep函数。但是Sleep函数只能让当前自己的函数停下来。如果我们希望让别的线程停下来,我们就可以利用 SuspendThread 函数将线程挂起,挂起的意义就是当一个线程挂起的时候,它就处于堵塞状态了,将不再占用 CPU 的时间,在该线程恢复之前都会处于堵塞状态。即一直不占用 CPU 的时间。一个线程是可以被挂起多次的,但是挂起几次就得恢复几次。

②等待线程结束:

<1> WaitForSingleObject(); <2> WaitForMultipleObjects(); <3> GetExitCodeThread();

WaitForSingleObject()这个函数的意义是,程序到这个函数这里就会处于堵塞状态,直到传进来的句柄(进程、线程)的状态发生变更(执行完毕)或者耗尽设置的等待时间,才会恢复。

DWORD WaitForSingleobject(
HANDLE hHandle, //handle to object
DWORD dwMilliseconds //time-out interval
);

如果想要一直等待直到,线程执行完毕,就可以将第二个参数设置为宏(INFINITE)。

如果想要等待多个线程,我们就需要使用WaitForMultipleObjects() 这个函数。

DWORD WaitForMultipleObjects(
DWORD nCount, //number of handles in array(句柄数量)
CONST HANDLE *1pHandles,//object-handle array(句柄数组)
BOOL bWaitAll, //wait option(等待模式)
DWORD dwMilliseconds//time-out interval(等待时间)
)

最后一个成员和WaitForSingleObject() 含义相同,第三个成员是等待模式,就是你可以指定所有对象都发生状态改变的时候结束等待(TRUE ),也可以指定有任何一个对象状态改变就结束等待。

线程函数都是有一个返回值的,其意义就是反馈函数是否执行成功,如果执行成功了返回某个值,失败了就会返回另一个值。我们就可以通过接受返回值来得到该函数是否执行成功。

GetExitCodeThread()这个函数就是能够接受到线程执行完成的返回结果的。

BOOL GetExitCodeThread(
HANDLE hThread, // handle to the thread(线程句柄)
LPDWORD 1pExitCode // termination status(返回状态)
)

使用的时候可以如这个格式将反馈结果接收:GetExitCodeThread(hThread,&dwResult);

③设置、获取线程上下文

如果说单核CPU 处理多个线程的时候,因为只有一套寄存器。当在某个时间点之后,由一个线程切换到另一个线程的时候,需要将正在进行中的线程的数据保存在一个结构体中再执行另一个线程。这个结构体为 CONTEXT,它用于存放一堆寄存器。所以我们就可以通过将线程挂起,再查看 CONTEXT中的各个寄存器的数据。

因为 CONTEXT结构体中存放了许多的寄存器,所以有个成员函数称作 ContextFlags。他能够让你想要查询那一部分的寄存器,就可以查询哪 一部分。image-20230709170144657

hThread = CreateThread(NULL,0,ThreadProc,NULL,0,NULL);

SuspendThread(hThread);

CONTEXT context;
context.ContextFlags = CONTEXT_INTEGER;

CONTEXT context; context.ContextFlags = CONTEXT_INTEGER; 利用这样一段代码,给结构体赋值就完成了一大半了。利用相应的 API 函数我们就可以将结构体赋好值,即获取线程的上下文。

BOOL GetThreadContext(
HANDLE hThread, //handle to thread with context(线程句柄)
LPCONTEXT IpContext // context structure(对应的结构体指针)
)

BOOL SetThreadContext(
HANDLE hThread, // handle to thread
CONST CONTEXT *IpContext // context structure
)

GetThreadContext(hThread,&context)我们这样使用这个 API 函数,我们需要的寄存器的值就会存在这个结构体中了。然后我们就可以打印或者获取我们想要的寄存器的值了。而且我们可以进行修改,然后利用 SetThreadContext 这个函数就可以将 CPU 中的寄存器的值进行修改了。

11、临界区

①线程安全问题:

每个线程都有自己的栈,而局部变量是存储在栈中的,这就意味着每个线程都有一份自己的“局部变量”,如果线程仅仅使用“局部变量”那么就不存在线程安全问题。 那如果多个线程共用一个全局变量呢? 如果多个线程对于全局变量的权限仅限于读,也不存在安全问题。所以如果要避免线程安全问题,就要减少多个线程共用一个全局变量而且需要保证它的权限仅限于读而非写。

②解决方法:

就需要想办法将线程变得安全。有一种解决方法是将全局变量定义为临界资源(临界资源是一次仅允许一个线程使用的资源),访问临界资源的那段程序又被称为临界区。

image-20230128204645746

据此,如果我们想要保证该全局变量的访问是安全的,我们就需要自己构建一个临界区,我们可以自己写代码实现,也可以通过操作系统的提供的 API来实现。

操作系统提供的这个 API 实现原理大概如下:设置一个全局变量为令牌。如果想要访问对应的全局变量 X 线程首先需要获取这个令牌,查看这个令牌是否有人使用。然后这个令牌的值如果代表了正在使用的话,就无法获取到全局变量X,如果代表了未被使用的话,就可以获取到该全局变量。并且在执行完代码之后,将会归还令牌,即恢复成未被使用的值。其他的线程就能够继续获取到全局变量了。

③临界区实现之线程锁:

<1> 创建全局变量 CRITICAL_SECTION cs; <2>初始化全局变量 InitializeCriticalSection(&cs); <3>实现临界区 EnterCriticalSection(&cs);//进入临界区 //使用临界资源 LeaveCriticalSection(&cs);//离开临界区

而如果我们想要保证足够的安全的话,我们需要将所有对于全局变量的读写的逻辑都放在临界区里。这才算是真正的安全。循环是while(turn != i); 这种情况下,满足了互斥的条件,这时肯定只有一个线程符合条件进入,但这种情况无法满足前进的条件。因为无法判断其他代码是否想要进入。

利用一个flag和一个true来保证逻辑,flag表示想要进入,ture表示轮到谁进入临界区。这样就能够保证访问全局变量的线程安全。(此方法也称之为线程锁)

12、互斥体

①内核级临界资源

如果临界资源是一个内核级的临界资源,如一个跨进程共享的物理页或一个文件。此时同时有两个进程想要访问一个内核级的临界资源,这时应该怎么办?

这时候就需要一种跨进程的方式来实现不同进程中的线程访问同一个内核级临界资源。

image-20230717075217022

在线程锁中我们使用的是一个全局变量,其是在应用层中,而当我们碰到内核级的临界资源的时候,我们就需要一个互斥体(可以理解成为放置在内核中的令牌)。

③互斥体的使用:

image-20230131221704852

我们可简化如图,可以将互斥体理解成为在零环中(内核中)的一个令牌。与之前的线程锁唯一的区别就在于这个互斥体是置于内核中的。这样才实现了跨进程的不同线程共同访问的目的。

HANDLE WINAPI CreateMutex(
 __in_opt  LPSECURITY_ATTRIBUTES lpMutexAttributes,//安全描述符(内核级对象都有)
 __in      BOOL bInitialOwner,
 __in_opt  LPCTSTR lpName//名字
);

bInitialOwner 如果此值为 TRUE 并且调用方创建了互斥锁,则调用线程将获得互斥锁对象的初始所有权。否则,调用线程不会获得互斥锁的所有权。若要确定调用方是否创建了互斥锁,请参阅返回值部分。

函数返回值: 如果函数成功,则返回值是新创建的互斥对象的句柄。 如果函数失败,则返回值为 NULL。若要获取扩展的错误信息,请调用 GetLastError。 如果互斥锁是命名互斥锁,并且对象在此函数调用之前存在,则返回值是现有对象的句柄,GetLastError 返回ERROR_ALREADY_EXISTS,忽略 bInitialOwner,并且不授予调用线程所有权。但是,如果调用方的访问权限有限,则该函数将失败并ERROR_ACCESS_DENIED,调用方应使用 OpenMutex 函数。

释放一个互斥体就利用 ReleaseMutex这个API ,参数只有一个句柄。

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

int main()
{
   //创建一个互斥体
HANDLE g_hMutex = CreateMutex(NULL,FALSE,"123");

   //获取令牌
WaitForSingleObject(g_hMutex,INFINITE);

for(int i = 0;i<15;i++)
{
Sleep(1000);
printf("--AAAAA-----%dn",i);
}
   //释放令牌
ReleaseMutex(g_hMutex);


return 0;
}

利用上方这个测试代码,将其放于两个编译器中执行,可以发现当 A程序执行的时候,B程序就会处于堵塞的状态,一直等待直到A程序执行完毕,

bInitialOwner 这个成员如果填的是 FLASE ,就意味着这个互斥体创建出来就能直接使用,这个成员如果填写的是 TRUE,则代表了这个互斥体是属于当前这个进程的,而如果我们想要获取“令牌”,第一个是要有对应的“信号”,第二个就是线程的拥有者。

④互斥体与线程锁的区别 <1>线程锁只能用于单个进程间的线程控制 <2>互斥体可以设定等待超时,但线程锁不能(即可以利用如WaitForSingleObject这样的API设置等待时间 ) <3>线程意外终结时, Mutex可以避免无限等待 <4>Mutex效率没有线程锁高(如果是在同一个进程中,利用线程锁能够避免创建内核对象)

我们也可以利用如下这段测试代码,保证一个进程只能运行一份(防止多开)

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

int main()
{
HANDLE hMutex = CreateMutex(NULL,FALSE,"防止多开");

DWORD dwRet = GetLastError();

if(hMutex)
{
if(ERROR_ALREADY_EXISTS == dwRet)
{
CloseHandle(hMutex);
return 0;
}
}

else
{
printf("创建失败n");
CloseHandle(hMutex);
return 0;
}

while(1)
{
Sleep(1000);
printf("6666n");
}

return 0;
}
内容来源于网络如有侵权请私信删除
你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!

相关课程