浅谈Windows下的线程细节
  V8Yx6rAEvvHS 3天前 3 0

绪论

最近阅读了《windows核心编程》关于线程的章节,原书作者讨论得颇为深入,初读者极易被绕晕,我专门写这篇文章供初读者参考阅读。本文的最后,着重讨论了Windows线程API与c/c++运行时库的注意事项。由于本人水平有限,文章难免有纰漏,还望各位读者指正。

Windows提供的创捷与销结束程的函数

我们知道,Windows提供了CreateThread函数用于创建线程,CreateThread函数原型如下:

HANDLE CreateThread(
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes
[in] SIZE_T dwStackSize,
[in] LPTHREAD_START_ROUTINE lpStartAddress,
[in, optional] __drv_aliasesMem LPVOID lpParameter,
[in] DWORD dwCreationFlags,
[out, optional] LPDWORD lpThreadId
);

其中各参数的意义在此不多赘述。接下来我们着重讨论一下lpStartAddresslpParameter参数。

lpStartAddresslpParameter参数

lpStartAddress

指向要由线程执行的应用程序定义函数的指针。 此指针表示线程的起始地址。

lpParameeter

指向要传递给线程的变量的指针。

从字面意思上理解这两个参数是不难的,问题在于Windows是一个抢占式的多线程系统,也就是说,调用CreateThread函数的线程与CreateThread函数创建的线程是同时执行的,这会引发一些难以捕捉的异常。

考虑如下C++代码:

static void WINAPI Print(int* x)
{
    _tprintf(TEXT("Print函数开始执行 -> %d\n"),*x);
}

void father_thread()
{
    DWORD dwThreadId;
    int x = 5;
    HANDLE hThread = CreateThread(NULL, 0,
        (LPTHREAD_START_ROUTINE)Print,
        &x, 0, &dwThreadId);
    if (hThread == NULL)
    {
        _tprintf(TEXT("创建线程失败 -> %d\n"), GetLastError());
        return 0;
    }

    //注意,我把下面一行代码注释掉了,先记住这行代码
    //WaitForSingleObject(hThread, INFINITE);

    CloseHandle(hThread);

    return 0;
}

我们在main函数中创建了一个子线程,这个子线程执行Print函数的代码,Print函数的功能很简单,只是一个普通的输出一段文本。前面说了,调用CreateThread函数的线程与CreateThread函数创建的线程是同时执行的,这就可能发生这样的情况:father_thread函数的线程已经结束了,但是Print的线程还没有执行完毕。所以Print函数所访问的x的值很可能已经发生了变化。据我实验发现,执行这段代码后,控制台上只是输出了“Print”,并没有把整句文本全部输出。原因也很简单,正如前面所说,Windows是一个抢占式的多线程系统,在调用了Print函数后,father_thread函数并没有等待Print函数执行完毕,而是继续执行main的代码,这就导致还没有打印完文本整个进程就结束了。要解决这个问题也很简单,把代码中的注释语句写入代码中就可以了。

这个问题也算是一个小小的坑吧

关于结束线程

书中提到,结束线程运行有四种方式:

  • 线程函数自己返回(强烈推荐)
  • 线程调用ExitThread来结束自己
  • 其它线程调用TerminateThread结束线程
  • 线程所在的进程结束

原书作者推荐使用第一种方法结束线程,因为这样可以保证线程的资源可以被正确的释放;对于第二种方法,ExitThread函数会使线程终止运行,并清理该线程的操作系统资源,但是线程的C/C++资源不会被清理;对于第三种方法,TerminateThread函数是异步的,在TerminateThread函数返回时,它并不能保证所要结束的线程一定终止了,其它的线程有可能还需要访问要结束的线程的堆栈内存;最后一种方式相当于为每一个线程都调用了TerminateThread方法。

线程的实现细节

Windows下创建线程的细节
接下来我们仔细研究一下这张图片。首先我们可以看到调用CreateThread导致系统创建了线程内核对象,该对象最初的使用计数器为2,因为被创建的线程本身算一个,CreateThread函数返回的句柄也算一个。该线程的其它属性也被初始化。

创建线程内核对象之后,系统在包含该线程的进程中为线程分配堆栈。在堆栈中,系统从高位地址到低位地址依次将lpParameter和lpStartAddress写入堆栈中(Windows更新了形参的名称,所以图中和这里所说的不一样)。

每个线程都有自己的寄存器,称为线程的上下文(关于这部分的详细内容,可以参考操作系统相关知识)。
线程的所有寄存器都保存在一个CONTEXT的结构中。我们可以看到,指令指针寄存器指向了一个名为RtlUserThreadStart的函数,
这个函数就是线程开始执行的入口。RtlUserThreadStart函数的原型如下[^1]:

VOID RtlUserThreadStart(PTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter);

[^1];这个函数现在在Windows帮助文档里面已经找不到了,具体细节有待考究

RtlUserThreadStart函数有两个参数,是由操作系统显式写入的。当新线程执行RtlUserThreadStart函数时,
会有如下事情发生:

  • 围绕线程函数,会设置SEH,使得线程执行期间所有的异常都可以由系统默认处理
  • 系统调用线程函数,把CreateThread函数的lpParameter参数传递给线程函数
  • 线程函数返回时,RtlUserThreadStart调用ExitThread函数结束线程
  • 若线程产生了异常,RtlUserThreadStart会调用EixtProcess终止整个线程

注意以上执行过程,RtlUserThreadStart最后会调用ExitThread函数,着意味着RtlUserThreadStart函数永远不会退出,也就是说,RtlUserThreadStart函数永远不会返回。

调用CreateThread

最后重点:Windows线程API与C/C++运行时库

从_beginthreadex到_endthreadex

在之前,一个库分别有两个版本:单线程多线程。标准C/C++运行库最初不是为多线程语言程序而设计的。
为了保证C/C++多线程应用程序正常运行,必须创建一个数据结构与使用了C/C++运行库函数的线程相关联,这样在调用运行库函数时,这些函数会去查找主调线程的数据块,
避免影响其它线程。问题是,系统并不知道要在什么时候分配这种数据块,因为系统并不知道应用程序使用了C/C++运行库,也不知道我们调用的函数是线程安全的。
记住,保证线程安全是程序员的责任。

直接使用CreateThread函数创建线程,不能保证在多线程环境下线程的安全,我们通过调用_beginthreadex函数来解决这个问题。
这个函数的原型如下:

_MCRTIMP uintptr_t __cdecl _beginthreadex (
    void *security,
    unsigned stacksize,
    unsigned (__CLR_OR_STD_CALL * initialcode) (void *),
    void * argument,
    unsigned createflag,
    unsigned *thrdaddr
    );

_beginthreadex函数对CreateThread函数进行了封装,_beginthreadex函数让每个线程都有一个专用的_tiddata内存块,这个内存块保存了传递给_beginthreadex的线程函数地址等数据
通过观察书中给出的代码,我们发现在_beginethread里面,调用了CreateThread函数,并且传递给CreateThread函数的函数地址是_threadstartex,参数地址是_tiddata结构的地址。
接着,我们进入了_threadstartex中执行代码。在_threadstartex函数中又调用TlsSetValue和_callthreadstartex函数。
然后,我们跳转到_callthreadstartex函数中,在这个函数中又调用了_endthreadex函数,用来正确的处理_tiddata内存块
我们是永远不会执行_threadstartex函数的返回语句的。

线程创建的详细过程

如果我们直接调用CreateThread,当这个子线程需要_tiddata时,如果没有,则CreateThread创建一个公开的_tiddata。
这会导致子线程在调用一些函数时,会让整个进程停止运行,并且,如果不调用_endthreadex结束线程,
_tiddata内存块就不会被销毁,从而导致内存泄漏。

同时书中还建议,不要使用_endthread结束线程。_endthread结束线程之前,会调用CloseHand关闭线程句柄,
如果子线程已经结束了,但父线程中使用了子线程的句柄,就会引发问题。所以我们应该使用endthreadex结束线程。

结语

本文对Windows线程机制进行了十分基本的解释,其中的更多细节,会在以后的博文中更新,感兴趣的读者敬请关注

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 3天前 0

暂无评论