CreateThread 线程操作与 _beginthreadex 线程安全(Windows核心编程)
阅读原文时间:2023年07月11日阅读:1
  • 线程不同于进程,Windows 中的进程是拥有 ‘惰性’ 的,本身并不执行任何代码,而执行代码的任务转交给主线程,列如使用 CreateProcess 创建一个进程打开 Cmd 程序,实际上是这个进程的主线程去执行打开 Cmd 程序的任务,也就是说创建一个进程必然有一个主线程与之对应

  • 当然 Windows 下也可以也使用 CreateThread 创建属于当前进程或者线程的额外线程,返回值是一个线程句柄,示例程序如下图所示

    #include
    #include
    #include
    #include
    using namespace std;

    // 用于线程调用的函数
    DWORD WINAPI ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    DWORD ThreadId = NULL;
    HANDLE MyThread1 = CreateThread(
    NULL, // 第一个参数表示传入的用于设置安全标志的结构体对象
    NULL, // 第二个参数用于设置线程运行时申请多大的堆栈空间
    ThreadFun1, // 第三个参数是线程调用的函数名称
    NULL, // 第四个参数是传递给调用函数的参数
    NULL, // 第五个参数是设置如何执行线程
    &ThreadId // 第六个参数返回线程 ID
    );
    // 等待线程
    WaitForSingleObject(MyThread1, INFINITE);
    return 0;
    }

    DWORD WINAPI ThreadFun1(PVOID pvParam)
    {
    cout << "ThreadFun1 is Start" << endl;
    return 0;
    }

  • 值得注意的是 CreateThread 的第二个参数会设置线程运行时堆栈的大小(因为每一个线程都有独立的空间供线程使用),如果无需特殊设置,可以将第二个参数设置为 NULL,表示默认分配 1MB 大小的堆栈空间,如果在运行过程中超出了 1MB 大小,系统也会分配额外的空间,所以不必担心内存泄漏的问题

  • 第三个参数就是线程的调用函数,且函数的类型必须是 DWORD WINAPI ThreadFun1(PVOID pvParam),WINAPI 就等于 __stdcall,为什么一定要以这种方式调用呢,原因是 CreateThread 内部维护了一个函数指针指向了 ThreadFun1

    DWORD (__stdcall * PTHREAD_START_ROUTINE) (LPVOID pvParam)

提示:__stdcall 是函数的一种调用方式,用于规定函数调用前后如何平衡堆栈,大多数的 Windows 内核函数都会使用 __stdcall,也就是 WINAPI

  • 第五个参数表示如何执行线程,如果设置为 NULL,则立刻执行

    DWORD ThreadId = NULL;
    HANDLE ThreadId = CreateThread(NULL, NULL, ThreadFun1, NULL, NULL, &ThreadId);
  • 如果设置为 CREATE_SUSPENDED,表示先将线程挂起,之后调用 ResumeThread 函数执行,这也是挂起线程的通常做法

    DWORD ThreadId = NULL;
    HANDLE ThreadId = CreateThread(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, &ThreadId);
    ResumeThread(MyThread1);

提示:CREATE_SUSPENDED 的最终目的就是将线程的内核计数 +1,而 ResumeThread 则是将线程的内核计数 -1,这样的话就相当于线程挂起后执行。也可以多次挂起,再多次调用 ResumeThread

  • 至于为什么在最后调用 WaitForSingleObject 来等待,是因为线程不同于进程,虽然说线程有独立的空间,但是线程的所有空间都是在进程地址空间上分配的,属于相对独立而已。如果线程在 main 函数之前没有执行完那么线程的所有空间都会被回收,相当于判处线程死刑且立即执行,而用 WaitForSingleObject 的好处是保证线程能够完整的执行后再从 main 函数返回

  • 假如正在运行的线程由于 IO 操作时间过长或者由于某种未知原因导致死锁,那么可以选择将线程终止,一般不提倡这种做法,因为会导致内存泄漏,比如线程中的类构造函数动态申请了堆空间,但是由于终止了线程导致类析构函数无法执行,这样堆空间得不到及时释放,给后面的利用埋下了安全隐患

  • 终止线程非常简单,通常利用两个函数即可完成,ExitThread 和 TerminateThread 函数,两者的不同在于 ExitThread 只可以终止本线程(除了返回错误代码以外没有其他参数),而 TerminateThread 可以终止任何线程

    #include
    #include
    #include
    #include
    using namespace std;

    // 用于线程调用的函数
    DWORD WINAPI ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    DWORD ThreadId = NULL;
    HANDLE MyThread1 = CreateThread(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, &ThreadId);
    ResumeThread(MyThread1);
    // 终止线程
    TerminateThread(MyThread1, NULL);
    // 等待线程
    WaitForSingleObject(MyThread1, INFINITE);
    return 0;
    }

    DWORD WINAPI ThreadFun1(PVOID pvParam)
    {
    // 终止线程
    ExitThread(NULL); // 示例程序而已
    cout << "ThreadFun1 is Start" << endl;
    return 0;
    }

  • 所以为了保证程序发生不必要的 Bug,尽量不要使用这两个函数终止线程

  • 通常情况下查询线程是否终止可以使用 GetExitCodeThread 函数,根据返回的第二个参数即可判断,示例如下所示,其中调用了 ErrorCodeTransformation 函数,这个函数是根据错误代码打印出详细的错误信息,方便检查出程序的错误,在进行 Windows 核心编程时应该经常使用

  • 链接:https://docs.microsoft.com/zh-cn/windows/desktop/api/processthreadsapi/nf-processthreadsapi-getexitcodethread

    #include
    #include
    #include
    #include
    #include
    using namespace std;

    DWORD WINAPI ThreadFun1(PVOID pvParam);
    DWORD WINAPI ThreadFun2(PVOID pvParam);
    // 全局句柄,用于线程间共享
    HANDLE MyThread1; HANDLE MyThread2;
    // 全局变量,表示某一个线程的运行状态
    DWORD WaitFile;
    // 将错误代码打印为错误信息,这个函数非常有用
    VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);

    int main(int argc, char *argv[])
    {
    // 创建两个线程
    MyThread1 = CreateThread(NULL, NULL, ThreadFun1, NULL, NULL, NULL);
    MyThread2 = CreateThread(NULL, NULL, ThreadFun2, NULL, NULL, NULL);
    // 等待两个线程执行完毕
    HANDLE Threads[2] = { 0 };
    Threads[0] = MyThread1; Threads[1] = MyThread2;
    WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
    // 关闭句柄
    CloseHandle(MyThread1); CloseHandle(MyThread2);
    return 0;
    }

    DWORD WINAPI ThreadFun1(PVOID pvParam)
    {
    while (true)
    {
    // 每隔 200 毫秒,调用 GetExitCodeThread 显示函数运行状态
    Sleep(200);
    if (GetExitCodeThread(MyThread2, &WaitFile))
    {
    // STILL_ACTIVE 表示线程尚未终止
    if (WaitFile == STILL_ACTIVE)
    {
    cout << "程序尚未终止" << endl;
    }
    else
    {
    // 进程终止就结束 while 循环
    cout << "线程已经终止" << endl;
    break;
    }
    }
    else
    {
    // GetExitCodeThread 调用失败就打印具体错误信息
    DWORD res = GetLastError();
    ErrorCodeTransformation(res);
    }
    }
    return TRUE;
    }
    DWORD WINAPI ThreadFun2(PVOID pvParam)
    {
    // 随眠 3 秒
    Sleep(3000);
    return TRUE;
    }

    // 如果返回错误,可调用此函数打印详细错误信息
    VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode)
    {
    LPVOID lpMsgBuf; LPVOID lpDisplayBuf; DWORD dw = ErrorCode;
    // 将错误代码转换为错误信息
    FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
    NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&lpMsgBuf, 0, NULL
    );
    lpDisplayBuf = (LPVOID)LocalAlloc(LMEM_ZEROINIT, (lstrlen((LPCTSTR)lpMsgBuf) + 40) * sizeof(TCHAR));
    StringCchPrintf((LPTSTR)lpDisplayBuf, LocalSize(lpDisplayBuf), TEXT("错误代码 %d : %s"), dw, lpMsgBuf);
    // 弹窗显示错误信息
    MessageBox(NULL, (LPCTSTR)lpDisplayBuf, TEXT("Error"), MB_OK);
    LocalFree(lpMsgBuf); LocalFree(lpDisplayBuf); ExitProcess(dw);
    }

  • 运行起来的效果就像这样

  • 因为 C 运行库函数最初不是为了多线程设计的,所以在使用一些 C 运行库全局变量时应该注意任何线程都可以修改全局变量(比如 errno),在单线程情况下肯定没有问题,但是多线程就会出现混乱,比如一个线程前脚刚设置了 errno 准备查看,后脚就被另外一个线程改了

  • 这个就是 C/C++ 线程安全的由来,解决的方法就是为每个线程都分配一个独立的 C 运行库全局变量空间,当然这么复杂的工作并不需要我们来做,使用线程安全函数 _beginthreadex 就可以,真的是很方便,这个函数在内部会自动分配 C 运行库全局变量,分配完之后再调用 CreateThread 创建线程,所以以后创建线程只需要用 _beginthreadex 就足够了

  • 示例如下,包含的头文件为 process.h,除了参数类型不一样,其他的包括参数类别和参数顺序与使用 CreateThread 是一模一样的,美中不足的是将线程调用函数的返回值由 DWORD 变成了 unsigned

    #include
    #include
    #include
    using namespace std;

    unsigned WINAPI ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    // 用于接收线程的 ID
    DWORD ThreadId;
    HANDLE MyThread = (HANDLE)_beginthreadex(NULL, NULL, ThreadFun1, NULL, CREATE_SUSPENDED, (unsigned *)&ThreadId);
    ResumeThread(MyThread);

    //_endthreadex(); // 如果需要终止线程使用 _endthreadex 即可,该函数内部会释放申请的 C 运行库全局变量空间
    
    // 等待线程执行完毕
    WaitForSingleObject(MyThread, INFINITE);
    // 关闭句柄
    CloseHandle(MyThread);
    return 0;

    }

    unsigned WINAPI ThreadFun1(PVOID pvParam)
    {
    cout << "ThreadFun1 is start" << endl;
    return TRUE;
    }

  • 其实 _beginthreadex 和 _endthreadex 函数还有一个相对简单的版本叫 _beginthread 和 _endthread;区别是 _beginthread 函数不可以返回线程 ID,也不可以设置安全标志(如继承等),_endthread 不可以返回线程退出代码,总之差别很大,示例如下

    #include
    #include
    #include
    using namespace std;

    void __cdecl ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    // 用于接收线程的 ID
    DWORD ThreadId;
    HANDLE MyThread = (HANDLE)_beginthread(ThreadFun1, NULL, NULL);
    ResumeThread(MyThread);

    //_endthread(); // 如果需要终止线程使用 _endthread 即可
    
    // 等待线程执行完毕
    WaitForSingleObject(MyThread, INFINITE);
    return 0;

    }

    void __cdecl ThreadFun1(PVOID pvParam)
    {
    cout << "ThreadFun1 is start" << endl;
    }

  • 除此以外,还有一个鲜为人知的问题,就是调用完 _beginthread 之后会释放线程句柄(MyThread),也就是说创建的线程句柄在线程执行完毕之后就不可以使用了,如果再调用 CloseHandle 函数关闭句柄的话就会引发异常,如下所示

    #include
    #include
    #include
    using namespace std;

    void __cdecl ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    DWORD ThreadId;
    HANDLE MyThread = (HANDLE)_beginthread(ThreadFun1, NULL, NULL);
    ResumeThread(MyThread);

    WaitForSingleObject(MyThread, INFINITE);
    // 关闭了已经关闭了的句柄 MyThread
    CloseHandle(MyThread);
    return 0;

    }

    void __cdecl ThreadFun1(PVOID pvParam)
    {
    cout << "ThreadFun1 is start" << endl;
    }

  • 这样做的好处是方便了调用者(菜鸟们不需要再关闭句柄及使用其他复杂的操作),坏处是对线程(句柄)的控制能力降低了

  • 不论是对于进程还是线程,对其句柄的操作都非常重要,获取句柄也是家常便饭,微软为了方便获取句柄,提供了 GetCurrentProcess 和 GetCurrentThread 这两个函数来获取进程和线程的句柄(两个函数没有任何的参数),只不过获取的是伪句柄,并非正真的句柄

  • 有如下示例,GetProcessTimes 和 GetThreadTimes 的第一个参数都可以传伪句柄

    #include
    #include
    #include
    using namespace std;

    unsigned WINAPI ThreadFun1(PVOID pvParam);

    int main(int argc, char *argv[])
    {
    // 获取当前进程计时信息
    FILETIME ftCreationTime, ftExitTime, ftKernelTime, ftUserTime;
    // 这里就可以用伪句柄代替真正的句柄
    GetProcessTimes(GetCurrentProcess(), &ftCreationTime, &ftExitTime, &ftKernelTime, &ftUserTime);

    // 创建一个新的线程,查询线程计时信息
    DWORD ThreadId;
    HANDLE MyThread = (HANDLE)_beginthreadex(NULL, NULL, ThreadFun1, NULL, NULL, (unsigned *)&ThreadId);
    
    WaitForSingleObject(MyThread, INFINITE);
    CloseHandle(MyThread);
    return 0;

    }

    unsigned WINAPI ThreadFun1(PVOID pvParam)
    {
    // 获取当前线程及时信息
    FILETIME ftCreationTime, ftExitTime, ftKernelTime, ftUserTime;
    // 传递的是伪句柄
    GetThreadTimes(GetCurrentThread(), &ftCreationTime, &ftExitTime, &ftKernelTime, &ftUserTime);
    return TRUE;
    }

  • 某些函数可以传递伪句柄,但如果必须使用真正的句柄,或者某些情况下无法获得真正的句柄怎么办呢,这里可以使用 DuplicateHandle 函数来将伪句柄转换为真正的句柄;DuplicateHandle 函数真正的作用是进程间的句柄复制,句柄转换只是 DuplicateHandle 函数的一个功能而已,而且知道的人并不多

  • 示例如下

    #include
    #include
    #include
    using namespace std;

    int main(int argc, char argv[]) { HANDLE DupProcessHandle = NULL; BOOL res = DuplicateHandle( GetCurrentProcess(), // 复制句柄的进程,这里是当前进程 GetCurrentProcess(), // 复制的句柄,这里复制当前进程伪句柄 GetCurrentProcess(), // 复制到哪一个进程,这里复制到当前进程 &DupProcessHandle, // 将复制的句柄传递给一个 HANDLE 变量,如果第二个参数传递的是伪句柄,那么这个函数会把它转换成真实的句柄 0, FALSE, DUPLICATE_SAME_ACCESS ); // 由于只是把当前进程的伪句柄复制到当前进程,所以只是使用了 DupProcessHandle 函数转换伪句柄的功能,并没有用进程间复制句柄的功能 if (res) { cout << "[] 当前进程的真实句柄为: " << DupProcessHandle << endl;
    cout << "[*] 当前进程的伪造句柄为: " << GetCurrentProcess() << endl;
    }
    return(0);
    }