切换线程






4.53/5 (9投票s)
如何切换例程正在运行的线程。
目录
引言
前几天我正在构建一个 RPC 服务器时,遇到了一个有趣的与线程相关的问题。您可能知道,RPC 服务器是在您的可执行文件中实现的简单全局函数,每当远程客户端发起调用时,RPC 运行时就会调用它们。下面是一个说明其工作原理的框图。
其中一个关键问题是,RPC 运行时会从其创建/提取的线程池中任意一个线程调用您的实现。对于我正在工作的应用程序,恰好服务器公开了大约 20 个函数,它们都需要访问一个窗口,该窗口是从服务器 EXE 的主线程在屏幕上弹出的。这当然会给我们带来一个问题,因为 Win32 UI 系统坚持认为,任何对窗口的操作都必须始终从创建该窗口的线程进行。现在,鉴于我的服务器函数总是会在任意线程上被调用,我必须想办法将控制权从 RPC 线程转移到创建该窗口的线程。本文将介绍一种技术,通过利用 32 位 x86 系统上函数调用的方式,使我们能够做到这一点。
关于 UI 和工作线程的说明
在我们继续之前,我想先明确一些术语。为了不加速我不可避免地陷入 腕管综合征,并且不比绝对必要的时间更早地失去我的谋生手段,我将使用术语UI 线程来指代创建窗口的线程,并使用术语工作线程来指代所有其他线程。两者之间的主要区别在于,除了前者创建了窗口之外,它们还有消息循环。UI 线程会有一个正在运行的消息泵,通常看起来像这样:
MSG msg;
BOOL bReturn;
while( ( bReturn = GetMessage( &msg,
NULL,
0, 0 ) ) != FALSE )
{
if( bReturn == -1 )
{
// some unpleasant thing happened; handle it!
}
else
{
TranslateMessage( &msg );
DispatchMessage( &msg );
}
}
另一方面,工作线程只是执行特定的任务然后终止。
记住这种区别只是逻辑上的,这一点很有用,从操作系统(OS)和 CPU 的角度来看,所有线程都是平等的,并且受到同等对待,当然,除非其中一个线程的优先级被提高,在这种情况下,OS 调度程序会优先处理它。现在我们已经清楚了术语,让我们看看如何围绕一些线程进行有趣且有利可图的切换!
好的,那么我们如何切换线程呢?
首先,我将向您展示如何在您自己的 C++ 项目中使用头文件 tswitch.h 来切换给定例程执行的线程。但是,为了使其适用,您的项目和例程必须满足某些先决条件:
- 您的解决方案必须包含某种形式的窗口(废话!)。
- 您的例程只能接受和返回真实和/或整数类型的参数。指针也是允许的。这意味着您的函数参数和返回值必须是
int
、float
、char
或指针。这仅排除了按值传递和返回的double
、结构和联合。稍后我们将在文章中解释为什么存在此限制。 - 您的应用程序必须以 32 位 x86 体系结构为目标。不支持 x64。
- 您的例程必须使用
__stdcall
或__cdecl
函数调用约定。
假设您有一个名为 TheGreatDoofus
的函数,它正在工作线程上执行。您需要它在创建了窗口的线程上运行,该窗口的句柄可在变量 hWnd
中获得。以下是如何使用 tswitch.h 将例程的执行切换到 UI 线程。
#include "tswitch.h"
void __cdecl TheGreatDoofus( HWND hWnd,
int foo,
char *bar,
AStructure *pStruct )
{
//
// this macro expands to the code that actually does
// the thread switching
//
THREAD_SWITCH( hWnd, TheGreatDoofus, 5, ccCdecl )
//
// you can happily manipulate your window here
//
SetWindowText( hWnd,
_T( "I switched the great doofus!" ) );
}
这是宏 THREAD_SWITCH
在 tswitch.h 中的声明方式:
//
// This macro determines whether a thread switch operation is
// required by comparing the current thread ID with the ID of the
// thread that created the given window handle.
//
#define InvokeRequired(hwnd) ( GetCurrentThreadId() != \
GetWindowThreadProcessId( hwnd, NULL ) )
//
// The THREAD_SWITCH macro is used with functions that do
// not return anything. It accepts the following parameters:
//
// hwnd - handle to the window on whose thread this function
// is to execute
// fn - address of the function that is to be executed in the
// UI thread
// params - count of the parameters that this routine accepts
// conv - the calling convention used by this function; value
// must belong to the "CallingConvention" enum
//
// Here's an example:
//
// void __cdecl Doofus( HWND hwnd, int a, float b )
// {
// THREAD_SWITCH( hwnd, Doofus, 3, ccCdecl )
// }
//
#define THREAD_SWITCH(hwnd, fn, params, conv) \
if( InvokeRequired(hwnd) ) \
{ \
SendMessage( hwnd, WM_INVOKE, \
(WPARAM)new ThreadSwitchContext( (DWORD_PTR)fn, \
ThreadSwitchContext::GetEBP(), params, conv ), 0 ); \
return; \
}
稍后我将解释宏展开的代码。让我们看另一个例子,其中函数返回一个 32 位 LONG
值,并且恰好使用了 __stdcall
函数调用约定(我们将在稍后详细研究调用约定):
#include "tswitch.h"
LONG __stdcall TheGreatDoofus( HWND hWnd,
int foo,
char *bar,
AStructure *pStruct )
{
//
// this macro expands to the code that actually does
// the thread switching
//
THREAD_SWITCH_WITH_RETURN( hWnd, TheGreatDoofus,
5, ccStdcall, LONG )
//
// you can happily manipulate your window here
//
SetWindowText( hWnd,
_T( "I switched the great doofus!" ) );
return 0L;
}
参数与 THREAD_SWITCH
完全相同,只是在这种情况下,它需要一个附加参数来指定返回值的类型(上例中为 LONG
)。最后,在您的窗口过程中,您需要像这样处理一个名为 WM_INVOKE
的消息:
LRESULT CALLBACK WndProc( HWND hWnd, UINT message,
WPARM wParam, LPARAM lParam )
{
//
// process WM_INVOKE; this cannot be a case statement because
// WM_INVOKE is not a constant
//
if( message == WM_INVOKE )
{
THREAD_SWITCH_INVOKE(wParam);
}
return DefWindowProc( hWnd, message, wParam, lParam );
}
如果您使用的是 MFC,那么您需要添加一个 ON_REGISTERED_MESSAGE
消息映射条目。MFC 版本可能看起来像这样:
BEGIN_MESSAGE_MAP( CDoofusFrame, CFrameWnd )
//
// other message handlers
//
ON_REGISTERED_MESSAGE( WM_INVOKE, OnInvoke )
END_MESSAGE_MAP()
LRESULT CDoofusFrame::OnInvoke( WPARAM wParam,
LPARAM lParam )
{
THREAD_SWITCH_INVOKE( wParam );
}
就这样!难道还能更简单吗?!现在,我们来看看那些深层黑暗的实现秘密!
仅需了解一些汇编知识
但在我们深入那些深层黑暗的秘密之前,您需要了解一些 x86 汇编语言编程知识。我将在此部分介绍足够多的汇编知识,以便您能相对轻松地理解。虽然我不是汇编专家,但我会尝试分享我零散的知识。事实上,我们现在只会看四个汇编指令,因为这里只使用了这几个指令!
程序在汇编中由一系列*指令*组成。嗯,您会问,不是几乎所有语言编写的程序都是*指令序列*吗!是的,确实如此。关键区别在于,使用汇编时,大多数时候,一条指令实际上只做一件事(不像 C# 代码中的一行,它可能会在遍布全球的计算机上引发一系列事件,而您可能甚至不知道!)。使用汇编进行编程的另一个方面是,您是在*接近硬件*进行编码,可以说。您会发现自己必须直接处理 CPU 寄存器、堆栈等。
使用堆栈 - push
堆栈数据结构的观念是汇编语言编程的核心(事实上,即使使用 C 和 C++,我们也使用堆栈——所有函数参数和局部变量都在堆栈上分配;我将假设您知道它是什么)。如您所知,堆栈是*后进先出*结构。有一组汇编指令允许您以各种方式对其进行操作。要将一个项*压入*堆栈,您可以使用一个名为、嗯,push
的指令!这是一个将值 10
压入堆栈的示例。
push 10;
很简单,是吧?!在上面给出的代码中,push
是指令代码,10
是操作数。如果您使用的是 Visual C++,则可以直接将汇编嵌入到源文件中。这是一个例子:
void DoTheAssemblyJiggyWiggy()
{
//
// here's a single line of assembly
//
__asm push 20;
//
// and here's an assembly block containing
// multiple lines of assembly code
//
__asm
{
push 10;
push 20;
push 30;
}
int value = 30;
//
// look ma! i can even access local variables from
// embedded assembly!
//
__asm push value;
}
嵌入式汇编的真正绝妙之处在于,编译器允许您从汇编中引用和访问局部变量!例如,在上面的代码片段中,存储在变量 value
中的数字 30
通过从汇编中引用它而被压入堆栈。
使用 CPU 寄存器 - mov
CPU 寄存器是 CPU 可用的最快的计算机内存形式。典型 CPU 上有各种寄存器,按大小和用途区分。有四个通用寄存器,为了便于命名,称为 A、B、C 和 D!但是,有人觉得这些名称太短了,于是决定在每个名称后面加上一个*X*。因此,我们得到了 AX
、BX
、CX
和 DX
。这些寄存器都是 16 位宽,如果您愿意,甚至可以单独访问它们的低字节和高字节。例如,AH
和 AL
指的是寄存器 AX
的高低字节。您可以对 BX
、CX
和 DX
使用类似的方案。在 32 位 CPU 上,这些寄存器的尺寸变为 32 位宽。32 位版本只是在前面加上了一个*E*。因此,这四个相同的寄存器,当它们是 32 位宽时,被称为 - EAX
、EBX
、ECX
和 EDX
。
那么,如何为一个寄存器赋值呢?您可以使用 mov
指令。与 push
不同,mov
需要两个操作数。第一个是必须存储赋值的目标,第二个是要赋值的值。这里有几个例子:
void DoTheAssemblyJiggyWiggyAgain()
{
//
// assign 10 to ax
//
__asm mov ax, 10;
//
// assign 5 to bl and bh
//
__asm
{
mov bl, 5;
mov bh, 5;
}
//
// assign 0xFFFFFFFF to ecx from a C++
// variable
//
unsigned long value = 0xFFFFFFFF;
__asm mov ecx, value;
}
加法 - add
要将两个值相加,您可以使用 add
指令。与 mov
一样,add
需要两个操作数。它只是将第二个操作数的值累加到第一个操作数中。例如,以下代码行将 10
加到存储在 ECX
中的任何值上。如果 ECX
的值为 5
,那么在执行以下行之后,它将具有值 15
。
add ecx, 10;
调用函数 - call
函数调用使用 call
指令完成。这里有一个例子:
void Dooga()
{
}
void DoTheAssemblyJiggyWiggyOnceAgainPlease()
{
//
// call "Dooga"
//
__asm call Dooga;
}
就这样!这就是理解线程切换内部机制所需的全部汇编知识。您可能会顺便了解一些额外的寄存器并学习另外两个小程序指令,但除此之外,就是这些了。而且,您以为这会很难!
函数调用是如何工作的
现在,让我们深入了解 32 位 x86 系统上函数调用*真正*是如何工作的。考虑以下极其简单的 C++ 代码:
int Add( int value, char ch )
{
return (value + 10);
}
void Booga()
{
int value = 10;
value = Add( value, 'a' );
}
这是使用 DUMPBIN 为此代码片段生成的反汇编。我对输出进行了一些编辑,以便我们可以专注于重要内容。让我们尝试剖析它。
?Add@@YAHHD@Z (int __cdecl Add(int,char)):
push ebp
mov ebp,esp
mov eax,dword ptr [ebp+8]
add eax,0Ah
pop ebp
ret
?Booga@@YAXXZ (void __cdecl Booga(void)):
push ebp
mov ebp,esp
push ecx
mov dword ptr [ebp-4],0Ah
push 61h
mov eax,dword ptr [ebp-4]
push eax
call ?Add@@YAHHD@Z
add esp,8
mov dword ptr [ebp-4],eax
mov esp,ebp
pop ebp
ret
函数调用约定
第一个可能引起您注意的地方是关键字 __cdecl
,它似乎被偷偷地插入到了返回类型和函数名称之间。我指的是这个:
(int "asm">__cdecl Add(int)):
__cdecl
是 Visual C++ 编译器使用的扩展关键字(即不是 C 或 C++ 标准一部分的关键字),用于标识要使用 C 函数调用约定的函数。*函数调用约定*用于准确告诉编译器参数如何传递给函数以及函数调用完成后堆栈状态应如何恢复。我们在此回顾 Windows 系统上两个最常用的调用约定 - __cdecl
和 __stdcall
。
C 调用约定 - __cdecl
对于使用 Visual C++ 编译器的 C 和 C++ 程序,__cdecl
调用约定是默认的;也就是说,如果您没有显式指定调用约定,则编译器将使用 __cdecl
。使用此约定,函数参数以从右到左的顺序通过堆栈传递。此外,调用者应在函数调用完成后恢复堆栈状态。如果您想调用上面所示的 Add
函数,将值 10
和 'a'
传递给它,那么在汇编中执行此操作的方法如下:
push 97; // pass value for param "ch"
push 10; // pass value for param "value"
call Add; // call "Add"
add esp, 8; // restore stack state
ESP
是一个特殊用途的寄存器,CPU 用它来跟踪堆栈上的下一个可用位置。在进程可用的总地址空间中,堆栈地址从*高*地址开始,堆地址从*低*地址开始。这是描述此图的一张图片:
这意味着当您将某物*压入*堆栈时,ESP
会减去压入数据的大小。例如,如果 ESP
当前指向地址 100
,那么压入一个 4 字节整数将导致寄存器值减 4,以便在*压入*之后,ESP
的值为 96
。释放此空间只需使用汇编指令 add
将 4
加回到 ESP
即可。在上面的代码片段中,由于我们使用的是 __cdecl
调用约定,因此调用者有责任在函数调用完成后恢复堆栈状态,我们通过将 8
加到 ESP
来做到这一点。我们加 8
是因为我们在 call
指令之前将*两个* 4 字节值压入了堆栈。即使第二个参数是 1 字节的 char
,压入堆栈的值也需要是 32 位宽。
正如您可能想象的那样,使用 __cdecl
函数调用约定会导致代码膨胀,因为为了正确调整 ESP
的 add
指令必须插入到每次进行函数调用的代码流中。
"标准"调用约定 - __stdcall
与 __cdecl
类似,__stdcall
调用约定也是通过堆栈以从右到左的顺序传递参数。然而,堆栈恢复的责任在于被调用的函数。如果上面定义的 Add
函数被设置为使用 __stdcall
调用约定,那么在汇编中调用它的代码将如下所示:
push 97; // pass value for param "ch"
push 10; // pass value for param "value"
call Add; // call "Add"
正如您可能已经注意到的,我们在函数调用后没有恢复堆栈。Add
函数已经为我们完成了。
函数序言和尾声
现在我们已经了解了调用约定对进行函数调用时生成的代码的影响,接下来让我们看一下被调用函数本身内部生成的代码。我在此重新生成函数 Add
的反汇编,您可能还记得,该函数配置为使用 __cdecl
调用约定。
?Add@@YAHHD@Z (int __cdecl Add(int,char)):
push ebp ; prologue
mov ebp,esp ; prologue
mov eax,dword ptr [ebp+8]
add eax,0Ah
pop ebp ; epilogue
ret ; epilogue
文本 ?Add@@YAHHD@Z
是函数 Add
的*修饰*(name mangling)后的名称。前两行代表函数*序言*,最后两行代表函数*尾声*。之间的所有内容都是函数体。这些是由编译器自动插入到所有函数中的(除非调用约定被标记为 __naked
)。
EBP
寄存器用于存储给定函数的*基址指针*。基址指针指向堆栈上用于访问当前函数参数的位置。正如我们之前所见,调用 Add
这样的函数涉及将参数压入堆栈,然后是 call
指令。call
指令执行两项操作 - 它首先将返回地址压入堆栈,然后将控制转移到被调用的地址。因此,当您处于函数 Add
的第一条指令时,堆栈看起来是这样的:
Add
执行的第一件事是将 EBP
的当前值(其中包含调用者的基址指针)压入堆栈。它这样做是为了在函数返回时恢复调用者的基址指针。然后,它将 ESP
的当前值加载到 EBP
中,以建立当前函数的基址指针。函数序言执行后,堆栈看起来是这样的:
因此,在任何给定函数中,您都可以通过将存储在 EBP
中的地址增加 8 个字节来访问传递给它的所有参数。我们增加 8 个字节是为了跳过调用者的 EBP
和返回地址。
函数尾声执行相反的操作 - 它将调用者的基址指针值从堆栈弹出到 EBP
寄存器,并执行 ret
指令,该指令导致 CPU 将控制转移到存储在堆栈顶部的返回地址。正如您可能猜到的,pop
指令执行与 push
相反的操作 - 它读取 ESP
指向的位置上的任何内容到您作为指令操作数提供的目标,并将 4 加到 ESP
。
那么,这就是调用函数的全部内容吗?嗯,并非如此。还有一些我在这里没有介绍的调用约定(__fastcall
、__naked
、__thiscall
),因为它们与讨论的主题无关。但是,您应该能够在互联网的其他地方轻松找到更多关于它们的信息!
线程切换内部机制
现在我们已经涵盖了所有背景知识,线程切换本身就相当直接。以下是我们从工作线程执行的操作:
- 我们通过比较
GetCurrentThreadId
返回的值与GetWindowThreadProcessId
返回的值来检查当前例程是否正在窗口的线程上执行。GetWindowThreadProcessId
返回创建给定窗口的线程的 ID。在 tswitch.h 中,此检查由一个名为InvokeRequired
的宏执行。这是它的定义方式:
//
// This macro determines whether a thread switch operation is
// required by comparing the current thread ID with the ID of the
// thread that created the given window handle.
//
#define InvokeRequired(hwnd) ( GetCurrentThreadId() != \
GetWindowThreadProcessId( hwnd, NULL ) )
WM_INVOKE
的消息,并将以下信息打包在一个名为 ThreadSwitchContext
的数据结构中,通过 WPARAM
发送:- 要在 UI 线程上下文下调用的函数的地址。
- 当前例程的
EBP
值。 - 当前函数接受的参数数量。
- 以及它使用的调用约定。
这是 ThreadSwitchContext
结构的声明方式:
struct ThreadSwitchContext
{
DWORD_PTR Address; // the function which is to be called
// back from the UI thread
DWORD_PTR Ebp; // the value of the EBP register from
// the worker thread
BYTE ParamsCount; // the number of parameters that this
// routine requires
CallingConvention Conv; // calling convention used by this
// function
ThreadSwitchContext( DWORD_PTR addr, DWORD_PTR ebp,
BYTE count, CallingConvention conv ) :
Address( addr ), Ebp( ebp ),
ParamsCount( count ), Conv( conv )
{
}
... other structure members here ...
};
请注意,我们使用 SendMessage
而不是 PostMessage
向目标窗口发送 WM_INVOKE
。这一点很重要,因为 SendMessage
是一个阻塞调用,在消息得到目标窗口处理之前不会返回,这是此技术奏效的关键要求。基本上,我们需要工作线程的堆栈帧在 WM_INVOKE
完全处理之前保持不变。
在目标窗口的过程函数中,我们像这样处理 WM_INVOKE
。此实现包含在 ThreadSwitchContext::Invoke
方法中。
- 我们将
WPARAM
参数强制转换为ThreadSwitchContext
对象,并检索存储在ThreadSwitchContext::Ebp
中的工作线程的基址指针值。 - 我们将
ThreadSwitchContext::Ebp
加上8
字节以访问函数的第一个参数。 - 然后,我们启动一个循环,该循环迭代
ThreadSwitchContext::ParamsCount
次。在每次迭代中,我们按照工作线程例程参数压入该线程堆栈的顺序访问它们,并将它们压入本地线程的堆栈。以下是其外观:
//
// push all the params starting with the first
//
LPBYTE params = (LPBYTE)Ebp;
for( int i = ParamsCount - 1 ; i >= 0 ; --i )
{
DWORD_PTR param = *((PDWORD_PTR)(params + (i * sizeof(DWORD_PTR))));
__asm push param;
}
call
进行调用。//
// now call the function
//
DWORD_PTR address = Address;
__asm call address;
EAX
寄存器中提供。我们将此值保存到本地变量中。//
// save the return value
//
DWORD_PTR dwEAX;
__asm mov dwEAX, eax;
__cdecl
,那么我们就通过添加在压入参数时占用的空间来清理堆栈。//
// compute the size of the stack that's been used
// for the function parameters
//
DWORD stack = ( ParamsCount * sizeof( DWORD ) );
//
// clear the stack if the calling convention is cdecl;
// with stdcall the callee would have done this for us
//
if( Conv == ccCdecl )
{
__asm add esp, stack;
}
实际上就是这样!另一个有趣的实现细节是我们用来访问工作线程的 EBP
寄存器的机制。我想让在给定例程中启用线程切换尽可能轻松,这就是为什么我将所有复杂的细节封装在像 THREAD_SWITCH
和 THREAD_SWITCH_WITH_RETURN
这样的友好宏中。我遇到的一个问题是宏中的嵌入式汇编,因为您不能!宏中的内联汇编似乎真的会弄乱预处理器!因此,当我尝试访问本地例程的 EBP
寄存器时,这就成了一个问题,如果这个问题不存在,我会这样做:
#define THREAD_SWITCH(hwnd, fn, params, conv) \
if( InvokeRequired(hwnd) ) \
{ \
DWORD dwEBP; \
__asm mov dwEBP, ebp; \ // this messes up the preprocessor
SendMessage( hwnd, WM_INVOKE, \
(WPARAM)new ThreadSwitchContext( (DWORD_PTR)fn, \
dwEBP, params, conv ), 0 ); \
return; \
}
由于这产生了奇怪的编译错误,我决定使用像下面这样的辅助函数来访问 EBP
值并在宏中使用它:
DWORD_PTR GetEBP()
{
DWORD_PTR dwEBP;
__asm mov dwEBP, ebp;
return dwEBP;
}
如果您仔细阅读了这篇文章,您就会知道这行不通!它行不通,因为现在 GetEBP
本身就是一个函数,并且有自己的基址指针,这自然与调用者的不同。所以,我修改了 GetEBP
,使其看起来像这样:
DWORD_PTR GetEBP()
{
DWORD_PTR dwEBP;
__asm mov dwEBP, ebp;
return *( (PDWORD_PTR)dwEBP );
}
其思想是利用本地函数基址指针所指向的值是调用者的基址指针这一事实(回想一下,要访问函数参数,我们必须*跳过*此地址和返回地址)。现在,一切都很顺利,直到我进行了*发布*构建,即启用了所有编译器优化的构建。现在,突然发现我的参数在线程切换后出现了奇怪的值。结果发现,由于 GetEBP
太小,编译器将其内联了!真是兜了一圈!所以,最后,我为 GetEBP
确定了以下定义:
DWORD_PTR GetEBP()
{
DWORD_PTR dwEBP;
__asm mov dwEBP, ebp;
#ifdef _DEBUG
//
// return the caller's EBP
//
return *( (PDWORD_PTR)dwEBP );
#else
//
// "GetEBP" gets inlined in release builds so we just
// return the current EBP
//
return dwEBP;
#endif
}
注意事项
正如我在列出先决条件时提到的,此处介绍的技术不能用于按值接受结构、联合或 double
参数的函数。函数返回值也同样适用。之所以如此,是因为我们的线程切换逻辑假定函数的每个参数在堆栈上都占用 4 个字节。对于结构、联合和 double
,情况可能并非如此。
结论
所以,就是这样!您可以 在此处下载 tswitch.h,并 在此处下载演示项目。随时在本文的讨论论坛上报告错误和提供反馈,我将很乐意回复并满足您的要求!
修订历史
- 2007年9月16日:文章首次发布。