《Win32多线程程序设计》读书笔记(二)
第二章
这章开始,正式进入多线程编程
创建线程
我们看看以下代码
#include <stdio.h>
#include <Windows.h>
DWORD WINAPI ThreadFunc(LPVOID);
int main()
{
HANDLE hThread;
DWORD threadId;
hThread = CreateThread(NULL,
0,
ThreadFunc,
0,
0,
&threadId
);
printf("Thread is running...\n");
for (int i = 0; i < 20; i++)
printf("Main:%d\n", i + 1);
return 0;
}
DWORD WINAPI ThreadFunc(LPVOID p)
{
for (int i = 0; i < 20; i++)
printf("Thread:%d\n", i+1);
return 0;
}
其中,DWORD
表示一个双字(Double Word)
,而一个字(Word)
代表两个字节(Byte)
,所以DWORD
表示4
个字节,共32
位(bit)。也就是unsigned long
。你会发现原定义是typedef unsigned long DWORD;
。这里的DWORD
是函数返回值类型。
WINAPI
实际上是_stdcall
,代表函数的调用形式。
LPVOID
实际上是void far*
类型,是一种指针类型,而far类型和near类型实际上只在16
位机上需要用到,far原本的作用是让指针指向另一个段内地址。在32
位机中没有far和near的区别。
HANDLE
表示句柄,即某个资源的标识符,此时用于标识线程,因为我们要创建线程。
CreateThread的函数原型为:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStatckSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
其中,lpThreadAttributes
描述线程的security (安全)属性,NULL
表示缺省。
dwStatckSize
描述线程堆栈的大小,0
表示缺省:1
MB。
lpStartAddress
描述开始的起始地址,即函数指针。
lpParameter
作为参数,传入上面函数指针指定的函数中。
dwCreationFlags
运行线程暂时挂起,默认情况是立即执行。
lpThreadId
产生的线程的id会被传回到这里。
所以,最上面的代码,就是一个默认的开启线程,执行ThreadFunc函数的程序。
运行结果:
可以发现,主线程和新开启的线程之间的执行顺序是未知的,这是根据操作系统的分配策略而产生的结果。
创建多个线程
我们知道了CreateThread函数可以创建线程,返回一个句柄,那么我们就可以通过for
循环来生成多个线程,并进行分析:
#include <stdio.h>
#include <Windows.h>
DWORD WINAPI MyThreadFunction(LPVOID);
int main()
{
HANDLE hThread;
DWORD threadId;
for (int i = 0; i < 5; i++)
{
hThread = CreateThread(NULL,
0,
MyThreadFunction,
(LPVOID)(i+1),
0,
&threadId
);
if (hThread)
{
printf("Thread%d launched...\n", i+1);
}
}
Sleep(3000);
return 0;
}
DWORD WINAPI MyThreadFunction(LPVOID p)
{
for (int i = 0; i < 10; i++)
printf("%d\n", (int)p);
return 0;
}
之前提到CreateThread函数可以给要调用的函数传参数,这里用强制类型转换:(LPVOID)(i+1)
,如果不强转,是会报错的。
Sleep(3000);
让主函数在结束前休止3秒钟,目的是等候所有线程均启动并执行完毕,否则可能有的线程还未启动或还未执行完毕主函数就返回了。
结果为:
1到5都无序地被打印了10次。虽然是Thread1-5,但实际上第二次运行可能是Thread1 2 4 3 5也说不准。
如果我们printf("%d%d%d%d%d%d%d%d\n",(int)p,(int)p,(int)p,(int)p,(int)p,(int)p,(int)p,(int)p);
,那么可能还会出现333344444444
或3333
的情况,这就是竞争条件。
无序的原因,正是因为之前提到的:无法预测。
我们的任务就是学会从多线程程序中获得可预期的结果,这也是《Win32多线程程序设计》这本书要教我们的内容。
上面的问题体现了目前多线程程序的几个特点:
- 无法预期
- 执行次序无法保证
- Task Switches 可能随时随地发生
- 线程对小的改变高度敏感
- 线程并不总是立刻启动
核心对象 (Kernel Objects)
CreateThread函数返回的变量是一个句柄 (Handle),它还通过参数输出了一个线程id(Thread ID)。
线程id可以独一无二地表示系统任意进程中的某个线程。
句柄被称为核心对象 (Kernel Objects),由KERNEL32.DLL管理。它其实是一个指针,指向操作系统内存空间中的某个东西,但你没有权限直接获取那个东西,这是出于维护操作系统的完整性和安全性。
Win32核心对象清单:
- 进程 (processes)
- 线程 (threads)
- 文件 (files)
- 事件 (events)
- 信号量 (semaphores)
- 互斥器 (mutexes)
- 管道 (pipes。分为 named 和 anonymous 两种)
GDI对象和核心对象的不同点
GDI对象有单一拥有者,不是进程就是线程。
核心对象可以有一个以上的拥有者,可以跨进程。核心对象保持了一个引用计数 (reference count) 来记录有多少handles对应到此对象,对象也同时记录哪一个进程或者线程是拥有者。调用类似CreateThread这种传回handle的函数,count会加1,调用CloseHandle函数,count会减1,一旦count为0,核心对象即销毁。
CloseHandle的重要性
完成工作后,应该调用CloseHandle函数释放核心对象。
CloseHandle的函数原型为:
BOOL CloseHandle(
HANDLE hObject
);
hObject
代表一个已经打开的对象handle。
如果成功,返回TRUE
,后则返回FALSE
,如果失败,可以调用GetLastError获取到失败的原因。
如果我们没有事后CloseHandle的习惯,虽然操作系统会自动把count减1,但是我们自己的逻辑就会出问题。
如果一个进程频繁产生worker 线程,我们总是不关闭线程的handle,则会把它们全部堆给操作系统去“善后”,操作系统帮我们清理,这样则会出现资源泄露 (resource leaks)。这不是我们期望的结果。
tips:
worker 线程:指完全不牵扯到图形用户接口 (GUI),只做运算的线程。
优化之前的代码
了解了CloseHandle的重要性,我们就要优化之前的代码,加入CloseHandle了。
#include <stdio.h>
#include <Windows.h>
DWORD WINAPI MyThreadFunction(LPVOID);
int main()
{
HANDLE hThread;
DWORD threadId;
for (int i = 0; i < 5; i++)
{
hThread = CreateThread(NULL,
0,
MyThreadFunction,
(LPVOID)(i+1),
0,
&threadId
);
if (hThread)
{
printf("Thread%d launched...\n", i+1);
//call CloseHandle here.
CloseHandle(hThread);
}
}
Sleep(3000);
return 0;
}
DWORD WINAPI MyThreadFunction(LPVOID p)
{
for (int i = 0; i < 10; i++)
printf("%d\n", (int)p);
return 0;
}
这样的代码,才更加严谨。
线程核心对象和线程的区别
线程的handle指向 “线程核心对象” ,而不指向线程本身。所以,可以在线程结束前关闭其handle,正如上面的代码中,我们实际在线程结束前就关闭了它的handle。
线程对象的默认引用计数是2,调用CloseHandle,count减1,线程结束,count减1,只有这两件事情都发生,这个线程对象才会被真正清除。
线程结束代码 (Exit Code)
之前的代码用Sleep函数来等待线程执行完毕,但实际上,如果CPU忙碌,可能等待结束时线程还在执行。
所以我们有个新的函数GetExitCodeThread可以用:
BOOL GetExitCodeThread(
HANDLE hThread,
LPDWORD lpExitCode
);
其中,hThread
是要传入的线程的handle。
lpExitCode
是一个指向DWORD
的指针,用以接受结束代码 (exit code)。
成功返回TRUE
,不成功返回FALSE
并可用GetLastError获取错误信息。如果线程结束,则lpExitCode
带回结束代码,否则带回STILL_ACTIVE
。
需要特别注意的是,不能通过GetExitCodeThread函数的返回值来判断一个线程是否结束,因为在一个线程还没有所谓的结束代码时,它就返回TRUE
了。所以,不能从它的返回值知道线程在运行,还是已经结束,但返回值是STILL_ACTIVE
。
强制结束一个线程
如果想要暴力一点,可以用ExitThread函数,它的原型为:
VOID ExitThread(
DWORD dwExitCode
);
dwExitCode
指定调用此函数的线程的结束代码。
也就是说,在一个线程调用的函数中,调用这个函数,假设ExitThread(6)
,那么,这个线程立即结束,并且结束代码为6。
需要注意的是,ExitThread函数从不返回任何值,也就是说,调用它以后的任何操作(同一作用域内)将无效。
如果在主线程中使用ExitThread函数,则会导致主线程结束但"worker线程"继续存在,这样会跳过runtime library
中的清理(cleanup)函数,导致已开启的文件没有清理,这样不好。
如果结束主线程,则会导致其他所有线程被强制结束。
主线程(primary thread) ,负责GUI消息循环。它的结束会导致程序结束、其他线程被强制关闭,其他线程没有机会被清理。
MTVERIFY错误处理
MTVERIFY是一个宏,适用于GUI程序也适用于Console程序。它实际上是记录并解释Win32 GetLastError()而获得描述。如果Win32函数失败,MTVERIFY会打印出简短的文字说明。
使用方法,就是MTVERIFY(...
),其中...
是一个Win32函数。比如MTVERIFY(CloseHandle(hThrd));
微软的多线程模型
线程分为 GUI 线程和 worker 线程两种。
GUI线程负责建造窗口以及处理消息循环。
worker线程负责执行纯粹运算工作。
多线程设计的成功关键
- 各线程的数据要分离开,避免使用全局变量
- 不要在线程之间共享GDI对象
- 确定你知道你的线程状态,不要径自结束程序而不等待它们的结束
- 让主线程处理用户界面(UI)。
本懒狗更新于2020.04.13,可以进入下一章啦!