windows核心编程_线程_学习笔记

来源:互联网 发布:mac ae cs6下载 编辑:程序博客网 时间:2024/04/29 07:23

006---线程的基础知识
1.线程也是由两个部分组成的:
一个是线程的内核对象,操作系统用它来对线程实施管理。内核对象也是系统用来存放线程统计信息的地方。
另一个是线程堆栈,它用于维护线程在执行代码时需要的所有函数参数和局部变量

2.注意CreateThread函数是用来创建线程的Windows函数。不过,如果你正在编写C/C++代码,决不应该调用CreateThread。相反,应该使用VisualC++运行期库函数_beginthreadex。如果不使用Microsoft的VisualC++编译器,你的编译器供应商有它自己的CreateThred替代函数。不管这个替代函数是什么,你都必须使用。
本章后面将要介绍_beginthreadex能够做什么,它的重要性何在。

3.
HANDLE GetCurrentProcess();//伪句柄
HANDLE GetCurrentThread();//伪句柄
上面这两个函数都能返回调用线程的进程的伪句柄或线程内核对象的伪句柄。这些函数并不在创建进程的句柄表中创建新句柄。还有,调用这些函数对进程或线程内核对象的使用计数
没有任何影响。如果调用CloseHandle,将伪句柄作为参数来传递,那么CloseHandle就会忽略该函数的调用并返回FALSE。当调用一个需要进程句柄或线程句柄的Windows函数时,可以传递一个伪句柄,使该函数
执行它对调用进程或线程的操作。
例如,通过调用下面的GetProcessTimes函数,线程可以查询它的进程的时间使用情况;同样,通过调用GetThreadTimes函数,线程可以查询它自己的线程时间.


少数Windows函数允许用进程或线程在系统范围内独一无二的ID来标识某个进程或线程。
HANDLE GetCurrentProcessId();//实句柄
HANDLE GetCurrentThreadId();//实句柄
这两个函数通常不像能够返回伪句柄的函数那样有用,但是有的时候用起来还是很方便的。

4.DuplicateHandle函数能够将伪句柄变成实句柄.
eg.
DWORD WINAPI ParentThread(PVOIDpvParam)
{
 HANDLE hThreadParent;
 DuplicateHandle(GetCurentProcess(),
  GetCurrentThread(),
  GetCurentProcess(),
  &hThreadParent,
  0,
  FALSE,
  DUPLICATE_SAME_ACCESS);
 CreateThread(NULL,0,ChildThread,(PVOID)hThreadParent,0,NULL);
}

DWORD WINAPI ChildThread(PVOIDpvParam)
{
 HANDLE hThreadParent=(HANDLE)pvParam;
 FILETIME ftCreationTime,ftExitTime,ftKernelTime,ftUserTime;
 GetThreadTimes(hThreadParent,&ftCreationTime,&ftExitTime,&ftKernelTime,&ftUserTime);
 CloseHandle(hThreadParent);
 //...
}
当子线程启动运行时,它的pvParam参数包含了线程的实句柄。对传递该句柄的函数的任何调用都将影响父线程而不是子线程。

由于DuplicateHandle会递增特定对象的使用计数,因此当完成对复制对象句柄的使用时,应该将目标句柄传递给CloseHandle,从而递减对象的使用计数,这一点很重要。上面的代码
段已经显示出这一点。在调用GetThreadTimes之后,紧接着子线程调用CloseHandle,以便递减父线程对象的使用计数。
。在这个代码段中,我假设子线程不使用该句柄来调用任何其他函数。如果其他函数被调用,以便传递父线程的句柄,那么在子线程不再需要该句柄之前,不应该调用CloseHandle。


还要指出,DuplicateHandle可以用来将进程的伪句柄转换成进程的实句柄
HANDLE hProcess;
DuplicateHandle(
 GetCurrentProcess(),
 GetCurrentProcess(),
 GetCurrentProcess(),
 &hProcess,
 0,
 FALSE,
 DUPLICATE_SAME_ACCESS);


007---线程的调度、优先级和亲缘性
5.CreateProcess或CreateThread要查看是否已经传递了CREATE_SUSPENDED标志。如果已经传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果尚未传递该标志,那么该函数将线程的暂停计数递减为0。当线程的暂停计数是0的时候,除非线程正在等待其他某种事情的发生,否则该线程就处于可调度状态。当创建线程时,除了使用CREATE_SUSPENDED外,也可以调用SuspendThread函数来暂停线程的运行.该函数返回的是线程的前一个暂停计数,线程暂停的最多次数可以是MAXIMUM_SUSPEND_COUNT次(在WinNT.h中定义为127)

ResumeThread使线程成为可调度线程.该函数返回的是线程的前一个暂停计数,

在实际环境中,调用ResumeThread时必须小心,因为不知道暂停线程运行时它在进行什么操作。如果线程试图从堆栈中分配内存,那么该线程将在该堆栈上设置一个锁。当其他线程试图访问该堆栈时,这些线程的访问就被停止,直到第一个线程恢复运行。只有确切知道目标线程是什么(或者目标线程正在做什么),并且采取强有力的措施来避免因暂停线程的运行而带来的问题或死锁状态,ResumeThread才是安全的。

6.
对于Windows来说,不存在暂停或恢复进程的概念,因为进程从来不会被安排获得CPU时间。但是,曾经有人无数次问我如何暂停进程中的所有线程的运行。Windows确实允许一个进程暂停另一个进程中的所有线程的运行,但是从事暂停操作的进程必须是个调试程序。特别是,进程必须调用WaitForDebugEvent和ContinueDebugEvent之类的函数。由于OpenThread在Windows2000中是个新函数,因此我的SuspendProcess函数在Windows95或Windows98上无法运行,在WindowsNT4.0或更早的版本上也无法运行。也许你懂得为什么SuspendProcess不能总是运行,原因是当枚举线程组时,新线程可以被创建和撤消。因此,当我调用CreateToolhelp32Snapshot后,一个新线程可能会出现在目标进程中,我的函数将无法暂停这个新线程。过了一些时候,当调用SuspendProcess函数来恢复线程的运行时,它将恢复它从未暂停的一个线程的运行。更糟糕的是,当枚举线程ID时,一个现有的线程可能被撤消,一个新线程可能被创建,这两个线程可能拥有相同的ID。这将会导致该函数暂停任意些个(也许在目标进程之外的一个进程中的)线程的运行。

7.关于Sleep函数,有下面
几个重要问题值得注意:
?调用Sleep,可使线程自愿放弃它剩余的时间片。
?系统将在大约的指定毫秒数内使线程不可调度。不错,如果告诉系统,想睡眠100ms,那么可以睡眠大约这么长时间,但是也可能睡眠数秒钟或者数分钟。记住,Windows不是个实时操作系统。虽然线程可能在规定的时间被唤醒,但是它能否做到,取决于系统中还有什么操作正在进行。
?可以调用Sleep,并且为dwMilliseconds参数传递INFINITE。这将告诉系统永远不要调度该线程。这不是一件值得去做的事情。最好是让线程退出,并还原它的堆栈和内核对象。
?可以将0传递给Sleep。这将告诉系统,调用线程将释放剩余的时间片,并迫使系统调度另一个线程。但是,系统可以对刚刚调用Sleep的线程重新调度。如果不存在多个拥有相同优先级的可调度线程,就会出现这种情况。


8.调用SwitchToThread函数与调用Sleep是相似的,并且传递给它一个0ms的超时。差别是SwitchToThread允许优先级较低的线程运行。即使低优先级线程迫切需要CPU时间,Sleep也能够立即对调用线程重新进行调度。

9。
GetThreadTimes的函数返回4个不同的时间值
注意,GetProcessTimes是个类似GetThreadTimes的函数,适用于进程中的所有线程:GetProcessTimes返回的时间适用于某个进程中的所有线程(甚至是已经终止运行的线程)。
例如,返回的内核时间是所有进程的线程在内核代码中经过的全部时间的总和。
Windows98遗憾的是,GetThreadTimes和GetProcessTimes这两个函数在Windows98中不起作用。在Windows98中,没有一个可靠的机制可供应用程序来确定线程或进程已经使用了多少CPU时间。
对于高分辨率的配置文件来说,GetThreadTimes并不完美。Windows确实提供了一些高分辨率性能函数:
BOOL QueryPerformanceFrequency(LARGE_INTEGER* pliFrequency);
BOOL QueryPerformanceCounter(LARGE_INTEGER* pliCount);
使用FileTimeToQuadWord这个函数,可以通过使用下面的代码确定执行复杂的算法时需要的时间

10.
这个Boot.ini文件是Windows2000安装时产生的,不过我使用Notepad加上了最后一行代码。
这行代码告诉系统,在系统引导时,我只应该使用机器中的一个处理器。/NumProcs=1这个开
关是用来实现这一点的关键。我常常发现它对调试非常有用。

 

008用户方式中线程的同步
11
InterlockedExchangeAdd函数
互锁函数是如何运行的呢?答案取决于运行的是何种CPU平台。
对于x86家族的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。
在Alpha平台上,互锁函数能够执行下列操作:
1)打开CPU中的一个特殊的位标志,并注明被访问的内存地址。
2)将内存的值读入一个寄存器。
3)修改该寄存器。
4)如果CPU中的特殊位标志是关闭的,则转入第二步。否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。
你也许会问,执行第4步时CPU中的特殊位标志是如何关闭的呢?答案是:如果系统中的另一个CPU试图修改同一个内存地址,那么它就能够关闭CPU的特殊位标志,从而导致互锁函数返回第二步。

必须保证传递给这些函数的变量地址正确地对齐,否则这些函数就会运行失败(第1 3章将介绍数据对齐问题)。

对于互锁函数,需要了解的另一个重要问题是,它们运行的速度极快。调用一个互锁函数通常会导致执行几个CPU周期(通常小于50),并且不会从用户方式转换为内核方式(通常这需要执行1000个CPU周期)。

当然,可以使用InterlockedExchangeAdd减去一个值—只要为第二个参数传递一个负值。
InterlockedExchangeAdd将返回在*plAddend中的原始值。
InterlockedExchange和InterlockedExchangePointer能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。如果是32位应用程序,两个函数都能用另一个32位值取代一个32位值。但是,如果是个64位应用程序,那么InterlockedExchange能够取代一个32位值,而InterlockedExchangePointer则取代64位值。两个函数都返回原始值。当实现一个循环锁时,InterlockedExchange是非常有用的

最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程亲缘性)。如果采取其中的一种方法,就能够完全避免高速缓存行的各种问题。

12.
volatile类型的限定词。它告诉编译器,变量可以被应用程序本身以外的某个东西进行修改,这些东西包括操作系统,硬件或同时执行的线程等。尤其是,volatile限定词会告诉编译器,不要对该变量进行任何优化,并且总是重新加载来自该变量的内存单元的值

13.
关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,是指该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安排其他线程的运行。不过,在线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。
有一个关键问题必须记住。当拥有一项可供多个线程访问的资源时,应该创建一个CRITICAL_SECTION结构。
注意最难记住的一件事情是,编写的需要使用共享资源的任何代码都必须封装在EnterCriticalSection和LeaveCriticalSection函数中。如果忘记将代码封装在一个位置,共享资源就可能遭到破坏。
关键代码段的优点在于它们的使用非常容易,它们在内部使用互锁函数,这样它们就能够迅速运行。关键代码的主要缺点是无法用它们对多个进程中的各个线程进行同步。不过在第1 9章中,我将要创建我自己的同步对象,称为Optex。这个对象将显示操作系统如何来实现关键代码段,它也能用于多个进程中的各个线程。

当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意味着该线程必须从用户方式转入内核方式(大约1 0 0 0个C P U周期)。这种转换是要付出很大代价的。在多处理器计算机上,当前拥有资源的线程可以在不同的处理器上运行,并且能够很快放弃对资源的控制。实际上拥有资源的线程可以在另一个线程完成转入内核方式之前释放资源。如果出现这种情况,就会浪费许多CPU时间。

在内存不足的情况下,关键代码段可能被争用,同时系统可能无法创建必要的事件内核对
象。这时EnterCriticalSection函数将会产生一个EXCEPTION_INVALID_HANDLE异常。大多
数编程人员忽略了这个潜在的错误,在他们的代码中没有专门的处理方法,因为这个错误非常
少见。但是,如果想对这种情况有所准备,可以有两种选择。


14.若要将循环锁用于关键代码段,应该调用下面的函数,以便对关键代码段进行初始化
InitializeCriticalSectionAndSpinCount的第一个参数是关键代码段结构的地址。但是在第二个参数dwSpinCount中,传递的是在使线程等待之前它试图获得资源时想要循环锁循环迭代的次数。这个值可以是0至0x00FFFFFF之间的任何数字。如果在单处理器计算机上运行时调用该函数,dwSpinCount参数将被忽略,它的计数始终被置为0。这是对的,因为在单处理器计算机上设置循环次数是毫无用处的,如果另一个线程正在循环运行,那么拥有资源的线程就不能放弃它。

通过调用下面的函数,就能改变关键代码段的循环次数
DWORD SetCriticalSectionSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);同样,如果主计算机只有一个处理器,那么dwSpinCount的值将被忽略

我认为,始终都应该将循环锁用于关键代码段,因为这样做有百利而无一害。难就难在确定为dwSpinCount参数传递什么值。为了实现最佳的性能,只需要调整这些数字,直到对性能结果满意为止。作为一个指导原则,保护对进程的堆栈进行访问的关键代码段使用的循环次数是4000次。

15.技巧
1. 每个共享资源使用一个CRITICAL_SECTION变量如果应用程序中拥有若干个互不相干的数据结构,应该为每个数据结构创建一个CRITICAL_SECTION变量。这比只有单个CRITICAL_SECTION结构来保护对所有共享资源的访问要好
2. 同时访问多个资源有时需要同时访问两个资源。必须始终按照完全相同的顺序请求对资源的访问
eg.
DWORD WINAPI ThreadFunc(PVOID pvParam)
{
EnterCriticalSection(&g_csNums);
EnterCriticalSection(&g_csChars);

for(int x=0;x<100;x++)
g_nNums[x] = g_cChars[x];

LeaveCriticalSection(&g_csChars);
LeaveCriticalSection(&g_csNums);
return(0);
}

假定下面这个函数的进程中的另一个线程也要求访问这两个数组:
//err
DWORD WINAPI OtherThreadFunc(PVOID pvParam)
{
EnterCriticalSection(&g_csChars);
EnterCriticalSection(&g_csNums);

for(int x=0;x<100;x++)
g_nNums[x] = g_cChars[x];

LeaveCriticalSection(&g_csNums);
LeaveCriticalSection(&g_csChars);
return(0);
}
在上面这个函数中我只是切换了对EnterCriticalSection和LeaveCriticalSection函数的调用顺序。但是,由于这两个函数是按上面这种方式编写的,因此可能产生一个死锁状态。假定ThreadFunc开始执行,并且获得了g_csNums关键代码段的所有权,那么执行OtherThreadFunc函数的线程就被赋予一定的CPU时间,并可获得g_csChars关键代码段的所有权。这时就出现了一个死锁状态。当ThreadFunc或OtherThreadFunc中的任何一个函数试图继续执行时,这两个函数都无法取得对它需要的另一个关键代码段的所有权。
为了解决这个问题,必须始终按照完全相同的顺序请求对资源的访问。注意,当调用LeaveCriticalSection函数时,按照什么顺序访问资源是没有关系的,因为该函数决不会使线程进入等待状态。
//ok
DWORD WINAPI OtherThreadFunc(PVOID pvParam)
{
EnterCriticalSection(&g_csNums);
EnterCriticalSection(&g_csChars);

for(int x=0;x<100;x++)
g_nNums[x] = g_cChars[x];

LeaveCriticalSection(&g_csChars);
LeaveCriticalSection(&g_csNums);
return(0);
}
3.不要长时间运行关键代码段
当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行性能。

 

009线程与内核对象的同步
16.虽然用户方式的线程同步机制具有速度快的优点,但是它也有其局限性。对于许多应用程
序来说,这种机制是不适用的。例如,互锁函数家族只能在单值上运行,根本无法使线程进入
等待状态。可以使用关键代码段使线程进入等待状态,但是只能用这些代码段对单个进程中的
线程实施同步。还有,使用关键代码段时,很容易陷入死锁状态,因为在等待进入关键代码段
时无法设定超时值。内核对象机制的适应性
远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。当调用本
章中提到的任何新函数时,调用线程必须从用户方式转为内核方式。这个转换需要很大的代价:
往返一次需要占用x86平台上的大约1000个CPU周期,当然,这还不包括执行内核方式代码,
即实现线程调用的函数的代码所需的时间。
SetKMode这个函数用于在两种模式间切换,改变模式只需修改一些标志,所以在选择同步机制上应该优先考虑运行在用户模式的同步解决办法。
17.
DWORD dw = WaitForSingleObject(hProcess,5000);
//第一个参数hObject标识一个能够支持被通知/未通知的内核对象
//INFINITE表示调用线程愿意永远等待下去(无限时间量),直到该进程终止运行已经定义为0xFFFFFFFF(或-1)
//注意,不能为dwMillisecond传递0.如果传递了0.WaitForSingleObject函数将总是立即返回.
switch(dw)
{
case WAIT_OBJECT_0://线程等待的对象变为已通知状态
 break;
case WAIT_TIMEOUT://设置的超时已经到期
 break;
case WAIT_FAILED://如果将一个错误的值(如一个无效句柄)传递给WaitForSingleObject,那么返回值将是WAIT_FAILED(若要了解详细信息,可调用GetLastError)
 break;
}
18.
WaitForMultipleObjects与WaitForSingleObject函数很相似,区别在于它允许调用线程同时查看若干个内核对象的已通知状态

DWORD WaitForMultipleObjects(
DWORD dwCount,   //要查看的内核对象的数量(1~MAXIMUM_WAIT_OBJECTS(在windows头文件中定义64))
CONST HANDLE * phObjects, //phObjects参数是指向内核对象句柄的数组的指针
BOOL fWaitAll,   //如果为该参数传递TRUE,那么在所有对象变为已通知状态之前,该函数将不允许调用线程运行
DWORD dwMilliseconds);

如果为fWaitAll参数传递TRUE,同时所有对象均变为已通知状态,那么返回值是WAIT_OBJECT_0
如果为fWaitAll传递FALSE,那么一旦任何一个对象变为已通知状态,该函数便返回.在这种情况下,你可能想要知道哪个对象变为已通知状态.返回值是WAIT_OBJECT_0与(WAIT_OBJECT_0+dwCount-1)之间的一个值。换句话说,如果返回值不是WAIT_TIMEOUT,也不是WAIT_FAILED,那么应该从返回值中减去WAIT_OBJECT_0。产生的数字是作为第二个参数传递给WaitForMultipleObjects的句柄数组中的索引。该索引说明哪个对象变为已通知状态

19.
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa,
BOOL fManualReset, //TRUE:人工重置的事件  FALSE:自动重置的事件
BOOL fInitialState, //TRUE:初始化为已通知状态 FALSE:初始化为未通知状态
PCTSTR pszName);

DuplicateHandle/OPenEvent  在参数pszName中设定与调用CreateEvent时设定的名字相匹配的名字
当不再需要事件内核对象时,应该调用CloseHandle函数
当调用SetEvent时,可以将事件改为已通知状态
当调用ResetEvent函数时,可以将该事件改为未通知状态


当这个进程启动时,它创建一个人工重置的未通知状态的事件,并且将句柄保存在一个全
局变量中。这使得该进程中的其他线程能够非常容易地访问同一个事件对象。现在3个线程已
经产生。这些线程要等待文件的内容读入内存,然后每个线程都要访问它的数据。一个线程进
行单词计数,另一个线程运行拼写检查器,第三个线程运行语法检查器。这3个线程函数的代
码的开始部分都相同,每个函数都调用WaitForSingleObject,这将使线程暂停运行,直到文件
的内容由主线程读入内存为止。
一旦主线程将数据准备好,它就调用SetEvent,给事件发出通知信号。这时,系统就使所
有这3个辅助线程进入可调度状态,它们都获得了CPU时间,并且可以访问内存块。注意,这3
个线程都以只读方式访问内存。这就是所有3个线程能够同时运行的唯一原因。还要注意,如
何计算机上配有多个CPU,那么所有3个线程都能够真正地同时运行,从而可以在很短的时间
内完成大量的操作。

如果你使用自动重置的事件而不是人工重置的事件,那么应用程序的行为特性就有很大的
差别。当主线程调用SetEvent之后,系统只允许一个辅助线程变成可调度状态。同样,也无法
保证系统将使哪个线程变为可调度状态。其余两个辅助线程将继续等待。
已经变为可调度状态的线程拥有对内存块的独占访问权。让我们重新编写线程的函数,使
得每个函数在返回前调用SetEvent函数(就像WinMain函数所做的那样)。
当线程完成它对数据的专门传递时,它就调用SetEvent函数,该函数允许系统使得两个正
在等待的线程中的一个成为可调度线程。同样,我们不知道系统将选择哪个线程作为可调度线
程,但是该线程将进行它自己的对内存块的专门传递。当该线程完成操作时,它也将调用
SetEvent函数,使第三个即最后一个线程进行它自己的对内存块的传递。注意,当使用自动重
置事件时,如果每个辅助线程均以读/写方式访问内存块,那么就不会产生任何问题,这些线
程将不再被要求将数据视为只读数据。这个例子清楚地展示出使用人工重置事件与自动重置事
件之间的差别。


PulseEvent函数使得事件变为已通知状态,然后立即又变为未通知状态,这就像在调用
SetEvent后又立即调用ResetEvent函数一样。如果在人工重置的事件上调用PulseEvent函数,那
么在发出该事件时,等待该事件的任何一个线程或所有线程将变为可调度线程。如果在自动重
置事件上调用PulseEvent函数,那么只有一个等待该事件的线程变为可调度线程。如果在发出
事件时没有任何线程在等待该事件,那么将不起任何作用。PulseEvent函数并不非常有用。


20.
等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象.它们通常
用来在某个时间执行某个操作。
若要创建等待定时器,只需要调用CreateWaitableTimer函数:
HANDLE CreateWaitableTimer(
PSECURITY_ATTRIBUTES psa,
BOOL fManualReset,
PCTSTR pszName);
当然,进程可以获得它自己的与进程相关的现有等待定时器的句柄,方法是调用OpenWaitableTimer函数.
HANDLE OpenWaitableTimer(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);

 

等待定时器对象总是在未通知状态中创建。必须调用SetWaitableTimer函数来告诉定时器你想在何时让它成为已通知状态
BOOL SetWaitableTimer(
HANDLE hTimer,//要设置的定时器
const LARGE_INTEGER *pDueTime,//指明定时器何时应该报时 只需要在pDueTime参数中传递一个负值。传递的值必须是以100ns为间隔。由于我们通常并不以100ns的间隔来思考问题,因此我们要说明一下100ns的具体概念:1s=1000ms=1000000μs=1000000000ns。
LONG lPeriod,//用于指明此后定时器应该间隔多长时间报时一次
PTIMERAPCROUTINE pfnCompletionRoutine,
PVOID pvArgToCompletionRoutinue,
BOOL fResume);
SetWaitableTimer希望传递给它的时间始终都采用世界协调时(UTC)的时间。调用LocalFileTimeToFileTime函数,就可以很容易地进行时间的转换。
编译器能够确保LARGE_INTEGER结构总是从64位的边界开始,因此要进行的正确操作(也就是所有时间都能保证起作用的操作)是将FILETIME的成员拷贝到LARGE_INTEGER的成员中,然后将LARGE_INTEGER的地址传递给SetWaitableTimer。

CancelWaitableTimer函数...

 

 

原创粉丝点击