运行库与多线程

来源:互联网 发布:ipadair2怎么卸载软件 编辑:程序博客网 时间:2024/06/06 15:47

11.3  运行库与多线程

11.3.1  CRT的多线程困扰

线程的访问权限

线程的访问能力非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,然而这是很少见的情况),但实际运用中线程也拥有自己的私有存储空间,包括:

l           栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。

l           线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的尺寸。

l           寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。

从C程序员的角度来看,数据在线程之间是否私有如表11-3所示。

表11-3

线程私有

线程之间共享(进程所有)

局部变量

函数的参数

TLS数据

全局变量

堆上的数据

函数里的静态变量

程序代码,任何线程都有权利读取并执行任何代码

打开文件,A线程打开的文件可以由B线程读写

多线程运行库

现有版本的C/C++标准(特指C++03、C89、C99)对多线程可以说只字不提,因此相应的C/C++运行库也无法针对线程提供什么帮助,也就是说在运行库里不能找到关于创建、结束、同步线程的函数。对于C/C++标准库来说,线程相关的部分是不属于标准库的内容的,它跟网络、图形图像等一样,属于标准库之外的系统相关库。由于多线程在现代的程序设计中占据非常重要的地位,主流的C运行库在设计时都会考虑到多线程相关的内容。这里我们所说的“多线程相关”主要有两个方面,一方面是提供那些多线程操作的接口,比如创建线程、退出线程、设置线程优先级等函数接口;另外一方面是C运行库本身要能够在多线程的环境下正确运行。

对于第一方面,主流的CRT都会有相应的功能。比如Windows下,MSVC CRT提供了诸如_beginthread()、_endthread()等函数用于线程的创建和退出;而Linux下,glibc也提供了一个可选的线程库pthread(POSIX Thread),它提供了诸如pthread_create()、pthread_exit()等函数用于线程的创建和退出。很明显,这些函数都不属于标准的运行库,它们都是平台相关的。

对于第二个方面,C语言运行库必须支持多线程的环境,这是什么意思呢?实际上,最初CRT在设计的时候是没有考虑多线程环境的,因为当时根本没有多线程这样的概念。到后来多线程在程序中越来越普及,C/C++运行库在多线程环境下吃了不少苦头。例如:

(1)errno:在C标准库里,大多数错误代码是在函数返回之前赋值在名为errno的全局变量里的。多线程并发的时候,有可能A线程的errno的值在获取之前就被B线程给覆盖掉,从而获得错误的出错信息。

(2)strtok()等函数都会使用函数内部的局部静态变量来存储字符串的位置,不同的线程调用这个函数将会把它内部的局部静态变量弄混乱。

(3)malloc/new与free/delete:堆分配/释放函数或关键字在不加锁的情况下是线程不安全的。由于这些函数或关键字的调用十分频繁,因此在保证线程安全的时候显得十分繁琐。

(4)异常处理:在早期的C++运行库里,不同的线程抛出的异常会彼此冲突,从而造成信息丢失的情况。

(5)printf/fprintf及其他IO函数:流输出函数同样是线程不安全的,因为它们共享了同一个控制台或文件输出。不同的输出并发时,信息会混杂在一起。

(6)其他线程不安全函数:包括与信号相关的一些函数。

通常情况下,C标准库中在不进行线程安全保护的情况下自然地具有线程安全的属性的函数有(不考虑errno的因素):

(1)字符处理(ctype.h),包括isdigit、toupper等,这些函数同时还是可重入的。

(2)字符串处理函数(string.h),包括strlen、strcmp等,但其中涉及对参数中的数组进行写入的函数(如strcpy)仅在参数中的数组各不相同时可以并发。

(3)数学函数(math.h),包括sin、pow等,这些函数同时还是可重入的。

(4)字符串转整数/浮点数(stdlib.h),包括atof、atoi、atol、strtod、strtol、strtoul。

(5)获取环境变量(stdlib.h),包括getenv,这个函数同时还是可重入的。

(6)变长数组辅助函数(stdarg.h)。

(7)非局部跳转函数(setjmp.h),包括setjmp和longjmp,前提是longjmp仅跳转到本线程设置的jmpbuf上。

为了解决C标准库在多线程环境下的窘迫处境,许多编译器附带了多线程版本的运行库。在MSVC中,可以用/MT或/MTd等参数指定使用多线程运行库。

11.3.2  CRT改进

使用TLS

多线程运行库具有什么样的改进呢?首先,errno必须成为各个线程的私有成员。在glibc中,errno被定义为一个宏,如下:

#define errno (*__errno_location ())

函数__errno_location在不同的库版本下有不同的定义,在单线程版本中,它仅直接返回了全局变量errno的地址。而在多线程版本中,不同线程调用__errno_location返回的地址则各不相同。在MSVC中,errno同样是一个宏,其实现方式和glibc类似。

加锁

在多线程版本的运行库中,线程不安全的函数内部都会自动地进行加锁,包括malloc、printf等,而异常处理的错误也早早就解决了。因此使用多线程版本的运行库时,即使在malloc/new前后不进行加锁,也不会出现并发冲突。

改进函数调用方式

C语言的运行库为了支持多线程特性,必须做出一些改进。一种改进的办法就是修改所有的线程不安全的函数的参数列表,改成某种线程安全的版本。比如MSVC的CRT就提供了线程安全版本的strtok()函数:strtok_s,它们的原型如下:

char *strtok(char *strToken, const char *strDelimit );

char *strtok_s( char *strToken, const char *strDelimit, char **context);

改进后的strtok_s增加了一个参数,这个参数context是由调用者提供一个char*指针,strtok_s将每次调用后的字符串位置保存在这个指针中。而之前版本的strtok函数会将这个位置保存在一个函数内部的静态局部变量中,如果有多个线程同时调用这个函数,有可能出现冲突。与MSVC CRT类似,Glibc也提供了一个线程安全版本的strtok()叫做strtok_r()。

但是很多时候改变标准库函数的做法是不可行的。标准库之所以称之为“标准”,就是它具有一定的权威性和稳定性,不能随意更改。如果随意更改,那么所有遵循该标准的程序都需要重新进行修改,这个“标准”是不是值得遵循就有待商榷了。所以更好的做法是不改变任何标准库函数的原型,只是对标准库的实现进行一些改进,使得它能够在多线程的环境下也能够顺利运行,做到向后兼容。

11.3.3  线程局部存储实现

很多时候,开发者在编写多线程程序的时候都希望存储一些线程私有的数据。我们知道,属于每个线程私有的数据包括线程的栈和当前的寄存器,但是这两种存储都是非常不可靠的,栈会在每个函数退出和进入的时候被改变;而寄存器更是少得可怜,我们不可能拿寄存器去存储所需要的数据。假设我们要在线程中使用一个全局变量,但希望这个全局变量是线程私有的,而不是所有线程共享的,该怎么办呢?这时候就须要用到线程局部存储(TLS,Thread Local Storage)这个机制了。TLS的用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。对于GCC来说,这个关键字就是__thread,比如我们定义一个TLS的全局整型变量:

__thread int number;

对于MSVC来说,相应的关键字为__declspec(thread):

__declspec(thread) int number;

在Windows Vista和2008之前的操作系统,如果TLS的全局变量被定义在一个DLL中,并且该DLL是使用LoadLibrary()显式装载的,那么该全局变量将无法使用,如果访问该全局变量将会导致程序发生保护错误。导致这个情况的主要原因是在Windows Vista之前的操作系统下,DLL在使用LoadLibrary()装载时无法正确初始化由__declspec(thread)定义的变量,具体请参照MSDN。

一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。

Windows TLS的实现

对于Windows系统来说,正常情况下一个全局变量或静态变量会被放到“.data”或“.bss”段中,但当我们使用__declspec(thread)定义一个线程私有变量的时候,编译器会把这些变量放到PE文件的“.tls”段中。当系统启动一个新的线程时,它会从进程的堆中分配一块足够大小的空间,然后把“.tls”段中的内容复制到这块空间中,于是每个线程都有自己独立的一个“.tls”副本。所以对于用__declspec(thread)定义的同一个变量,它们在不同线程中的地址都是不一样的。

我们知道对于一个TLS变量来说,它有可能是一个C++的全局对象,那么每个线程在启动时不仅仅是复制“.tls”的内容那么简单,还需要把这些TLS对象初始化,必须逐个地调用它们的全局构造函数,而且当线程退出时,还要逐个地将它们析构,正如普通的全局对象在进程启动和退出时都要构造、析构一样。

Windows PE文件的结构中有个叫数据目录的结构,我们在第2部分已经介绍过了。它总共有16个元素,其中有一元素下标为IMAGE_DIRECT_ENTRY_TLS,这个元素中保存的地址和长度就是TLS表(IMAGE_TLS_DIRECTORY结构)的地址和长度。TLS表中保存了所有TLS变量的构造函数和析构函数的地址,Windows系统就是根据TLS表中的内容,在每次线程启动或退出时对TLS变量进行构造和析构。TLS表本身往往位于PE文件的“.rdata”段中。

另外一个问题是,既然同一个TLS变量对于每个线程来说它们的地址都不一样,那么线程是如何访问这些变量的呢?其实对于每个Windows线程来说,系统都会建立一个关于线程信息的结构,叫做线程环境块(TEB,Thread Environment Block)。这个结构里面保存的是线程的堆栈地址、线程ID等相关信息,其中有一个域是一个TLS数组,它在TEB中的偏移是0x2C。对于每个线程来说,x86的FS段寄存器所指的段就是该线程的TEB,于是要得到一个线程的TLS数组的地址就可以通过FS:[0x2C]访问到。

TEB这个结构不是公开的,它可能随着Windows版本的变化而变化,我们这里所说的TEB结构都是指在x86版的Windows XP。

这个TLS数组对于每个线程来说大小是固定的,一般有64个元素。而TLS数组的第一个元素就是指向该线程的“.tls”副本的地址。于是要得到一个TLS的变量地址的步骤为:首先通过FS:[0x2C]得到TLS数组的地址,然后根据TLS数组的地址得到“.tls”副本的地址,然后加上变量在“.tls”段中的偏移即该TLS变量在线程中的地址。下面看一个简单的例子:

__declspec(thread) int t = 1;

int main()

{

    t = 2;

    return 0;

}

经过编译以后,这段代码的汇编实现如下:

_main:

  00000000: 55                 push        ebp

  00000001: 8B EC              mov         ebp,esp

  00000003: A1 00 00 00 00     mov         eax,dword ptr [__tls_index]

  00000008: 64 8B 0D 00 00 00  mov         ecx,dword ptr fs:[__tls_array]

            00

  0000000F: 8B 14 81           mov         edx,dword ptr [ecx+eax*4]

  00000012: C7 82 00 00 00 00  mov         dword ptr _t[edx],2

            02 00 00 00

  0000001C: 33 C0              xor         eax,eax

  0000001E: 5D                 pop         ebp

  0000001F: C3                 ret

代码中有两个符号__tls_index和__tls_array,它们被定义在MSVC CRT中,对于MSVC 2008来说,它们的值分别是0和0x2C,分别表示TLS数组下的第一个元素和TLS数组在TEB中的偏移。由于这两个数值有可能随着Windows系统的变化而变化,所以它们被保存在CRT中,如果程序以DLL方式链接,那么在不同版本的Windows平台上运行就不会有问题;如果是静态链接,那么当新版的Windows更改TEB结构时而导致TLS数组在TEB中的偏移改变,程序运行就可能出错。当然出于Windows多年来的“良好表现”,这种随意更改核心数据结构的事情发生的可能性还是比较小的。

显式TLS

前面提到的使用__thread或__declspec(thread)关键字定义全局变量为TLS变量的方法往往被称为隐式TLS,即程序员无须关心TLS变量的申请、分配赋值和释放,编译器、运行库还有操作系统已经将这一切悄悄处理妥当了。在程序员看来,TLS全局变量就是线程私有的全局变量。相对于隐式TLS,还有一种叫做显式TLS的方法,这种方法是程序员须要手工申请TLS变量,并且每次访问该变量时都要调用相应的函数得到变量的地址,并且在访问完成之后需要释放该变量。在Windows平台上,系统提供了TlsAlloc()、TlsGetValue()、TlsSetValue()和TlsFree()这4个API函数用于显式TLS变量的申请、取值、赋值和释放;Linux下相对应的库函数为pthread库中的pthread_key_create()、pthread_getspecific()、pthread_setspecific()和pthread_key_delete()。

显式的TLS实现其实非常简单,我们前面提到过TEB结构中有个TLS数组。实际上显式的TLS就是使用这个数组保存TLS数据的。由于TLS数组的元素数量固定,一般是64个,于是显式TLS在实现时如果发现该数组已经被使用完了,就会额外申请4096个字节作为二级TLS数组,使得在WindowsXP下最多能拥有1088(1024+64)个显式TLS变量(当然隐式的TLS也会占用TLS数组)。相对于隐式的TLS变量,显式的TLS变量的使用十分麻烦,而且有诸多限制,显式TLS的诸多缺点已经使得它越来越不受欢迎了,我们并不推荐使用它。

Q&A: CreateThread()和_beginthread()有什么不同

       我们知道在Windows下创建一个线程的方法有两种,一种就是调用Windows API CreateThread()来创建线程;另外一种就是调用MSVC CRT的函数_beginthread()或_beginthreadex()来创建线程。相应的退出线程也有两个函数Windows API的ExitThread()和CRT的_endthread()。这两套函数都是用来创建和退出线程的,它们有什么区别呢?

       很多开发者不清楚这两者之间的关系,他们随意选一个函数来用,发现也没有什么大问题,于是就忙于解决更为紧迫的任务去了,而没有对它们进行深究。等到有一天忽然发现一个程序运行时间很长的时候会有细微的内存泄露,开发者绝对不会想到是因为这两套函数用混的结果。

       根据Windows API和MSVC CRT的关系,可以看出来_beginthread()是对CreateThread()的包装,它最终还是调用CreateThread()来创建线程。那么在_beginthread()调用CreateThread()之前做了什么呢?我们可以看一下_beginthread()的源代码,它位于CRT源代码中的thread.c。我们可以发现它在调用CreateThread()之前申请了一个叫_tiddata的结构,然后将这个结构用_initptd()函数初始化之后传递给_beginthread()自己的线程入口函数_threadstart。_threadstart首先把由_beginthread()传过来的_tiddata结构指针保存到线程的显式TLS数组,然后它调用用户的线程入口真正开始线程。在用户线程结束之后,_threadstart()函数调用_endthread()结束线程。并且_threadstart还用__try/__except将用户线程入口函数包起来,用于捕获所有未处理的信号,并且将这些信号交给CRT处理。

       所以除了信号之外,很明显CRT包装Windows API线程接口的最主要目的就是那个_tiddata。这个线程私有的结构里面保存的是什么呢?我们可以从mtdll.h中找到它的定义,它里面保存的是诸如线程ID、线程句柄、erron、strtok()的前一次调用位置、rand()函数的种子、异常处理等与CRT有关的而且是线程私有的信息。可见MSVC CRT并没有使用我们前面所说的__declspec(thread)这种方式来定义线程私有变量,从而防止库函数在多线程下失效,而是采用在堆上申请一个_tiddata结构,把线程私有变量放在结构内部,由显式TLS保存_tiddata的指针。

       了解了这些信息以后,我们应该会想到一个问题,那就是如果我们用CreateThread()创建一个线程然后调用CRT的strtok()函数,按理说应该会出错,因为strtok()所需要的_tiddata并不存在,可是我们好像从来没碰到过这样的问题。查看strtok()函数就会发现,当一开始调用_getptd()去得到线程的_tiddata结构时,这个函数如果发现线程没有申请_tiddata结构,它就会申请这个结构并且负责初始化。于是无论我们调用哪个函数创建线程,都可以安全调用所有需要_tiddata的函数,因为一旦这个结构不存在,它就会被创建出来。

       那么_tiddata在什么时候会被释放呢?ExitThread()肯定不会,因为它根本不知道有_tiddata这样一个结构存在,那么很明显是_endthread()释放的,这也正是CRT的做法。不过我们很多时候会发现,即使使用CreateThread()和ExitThread() (不调用ExitThread()直接退出线程函数的效果相同),也不会发现任何内存泄露,这又是为什么呢?经过仔细检查之后,我们发现原来密码在CRT DLL的入口函数DllMain中。我们知道,当一个进程/线程开始或退出的时候,每个DLL的DllMain都会被调用一次,于是动态链接版的CRT就有机会在DllMain中释放线程的_tiddata。可是DllMain只有当CRT是动态链接版的时候才起作用,静态链接CRT是没有DllMain的!这就是造成使用CreateThread()会导致内存泄露的一种情况,在这种情况下,_tiddata在线程结束时无法释放,造成了泄露。我们可以用下面这个小程序来测试:

#include <Windows.h>

#include <process.h>

void thread(void *a)

{

    char* r = strtok( "aaa", "b" );

    ExitThread(0); // 这个函数是否调用都无所谓

}

int main(int argc, char* argv[])

{

    while(1) {

        CreateThread(  0, 0, (LPTHREAD_START_ROUTINE)thread, 0, 0, 0 );

        Sleep( 5 );

    }

return 0;

}

       如果用动态链接的CRT (/MD,/MDd)就不会有问题,但是,如果使用静态链接CRT (/MT,/MTd),运行程序后在进程管理器中观察它就会发现内存用量不停地上升,但是如果我们把thread()函数中的ExitThread()改成_endthread()就不会有问题,因为_endthread()会将_tiddata()释放。

       这个问题可以总结为:当使用CRT时(基本上所有的程序都使用CRT),请尽量使用_beginthread()/_beginthreadex()/_endthread()/_endthreadex()这组函数来创建线程。在MFC中,还有一组类似的函数是AfxBeginThread()和AfxEndThread(),根据上面的原理类推,它是MFC层面的线程包装函数,它们会维护线程与MFC相关的结构,当我们使用MFC类库时,尽量使用它提供的线程包装函数以保证程序运行正确。

原创粉丝点击