⭐ 欢迎来到虫虫下载站! | 📦 资源下载 📁 资源专辑 ℹ️ 关于我们
⭐ 虫虫下载站

📄 漫谈兼容内核之二十三关于tls.txt

📁 漫谈系统内核内幕 收集得很辛苦 呵呵 大家快下在吧
💻 TXT
📖 第 1 页 / 共 3 页
字号:
漫谈兼容内核之二十三:关于TLS


毛德操

    TLS是“线程局部存储(Thread Local Storage)”的缩写,“Local”这个词有“局部”、“本地”的意思,所以也可以说是“线程本地存储”。顾名思义,这就是局部于唯一的线程、为具体线程所“私用、专用”的存储空间。这里所说的“空间”并不是“用户空间”、“系统空间”那个意义上的空间,而是指用户空间中个别变量、数组、或数据结构所占据的存储空间。
    我们知道,线程并不独立拥有用户空间,用户空间是归进程所有,为同一进程中的所有线程所共享的。所以,用户空间中的任何一个区域,只要有一个线程可以访问,那么同一进程中的所有其它的线程就都能访问。在这个意义上,整个用户空间都是(由同一进程中的)所有线程共享的,不存在只归一个线程使用的变量或数据结构。可是,一般而言,程序对变量或数据结构的访问都是按变量名访问的,经过编译/连接之后就是按地址访问,要是不知道一个变量的地址,实际上就无法正常和正确地加以访问(“地毯式”的扫描一般而言无法辨认数据的边界,所以无法正确读取其内容)。在这个意义上,则只归一个线程使用的变量或数据结构又是可能的。
    注意TLS只是对全局量和静态变量才有意义。局部量存在于具体线程的堆栈上,而每个线程都有自己的堆栈,所以局部量本来就是“局部”于具体线程的。至于通过动态分配的缓冲区,则取决于保存着缓冲区指针的变量。如果缓冲区指针是全局量,那么同一进程中的所有线程都能访问这个缓冲区;而若是局部量,则别的线程自然就不得其门而入。

    那么为什么需要有全局变量(或静态变量)的TLS呢?
    对于TLS的用途,Unix/Linux的C程序库libc中的全局变量errno是个最典型的例子。

if (open (filename, mode) < 0)
{
    error (0, errno, "%s", infile);
    . . . . . .
}

    当系统调用从内核返回用户空间时,如果EAX的值为0xfffff001至0xffffffff之间即为出错,取其负值(2的补码)就是出错代码。此时将出错代码写入一个全局量errno,以供进一步查验,并将EAX的值改成-1。这就是从Unix时代初期就定下的对于返回整数的系统调用的约定。其好处是写程序时可以略微方便一些,不用每次都在函数中定义一个局部变量err,再写成“if ((err=open(filename, mode)) < 0”、并因为系统调用出错返回的几率毕竟很小。
    在线程的概念出现之前,或者说在一个进程只含有一个线程的时代,这样安排不会有什么问题。这是因为,从启动系统调用的C库函数把出错代码写入全局量errno以后,到调用者发现返回值为-1、因而从errno获取出错代码的这一段时间中errno的值不可能改变。即使在这中间发生了中断、并且导致进程调度,从当事进程的角度看也只是时间上的短暂停滞,却不会有谁来改变errno的内容;因为全局量errno是归进程所有,别的进程不会来打扰。
    但是,有了多线程的概念和技术以后,情况就不一样了。因为同一进程中的多个线程是共享一个用户空间的,如果几个线程的程序中都引用全局量errno,那么它们在运行中实际访问的就是同一个地址。为说明问题,我们且假定线程T1和T2属于同一进程,再来考察下述的假想情景:
    1. T1先通过C库函数open()进行系统调用,但是因为所给定的文件名实际上是个目录,所以内核返回出错代码EISDIR。这个出错代码被写入了全局量errno。
    2. T1发现open()的返回值为-1,因而需要以errno当时的值、即出错代码为参数调用error();但是,在T1还没有来得及从errno读出之前就发生了中断,而且导致线程调度,调度的结果是线程T2获得运行;
    3. T2通过C库函数signal()启动另一次系统调用,但是因为使用参数不当而出错,内核返回的出错代码为EINVAL,表示参数无效。这个出错代码也被写入全局量errno,于是errno的值变成了EINVAL。
    4. T2从全局量errno读取出错代码,并依此进行相应的(正确)处理。但是errno的值仍保持为EINVAL,此后T2没有再进行系统调用。
    5. 一段时间之后,T1又被调度运行,继续其原有的处理,即从errno读出、并以errno的值为参数调用error()。但是,errno的值原来是EISDIR,而现在已变成EINVAL。

    由此可见,在多线程的环境下,对于某些应用,由多个线程共享一个同名的全局量是有问题的。
    怎么解决呢?使用局部量当然是个办法,例如改成“if ((err=open(filename, mode)) < 0”,这似乎轻而易举地就解决了问题。可是,不幸的是,这就要求改变几乎所有C库函数的调用界面,而C库函数的调用界面早已成为标准。那么另外再定义一个新的C程序库怎么样?这也很成问题,新开发的软件固然可以改用新的界面,但是这么多已经存在的代码就都需要重新加以修改了。所以,这是不太现实的。
    显然,就errno而言,比较好的解决方案是维持原有的代码和界面不变,但是让每个线程都有自己的errno,或者说都有一个专用的errno副本。这就是“线程局部存储”、即TLS。
    于是,使用TLS就是自然的选择了。实际上,现在的C库函数不是把出错代码写入全局量errno,而是通过一个函数__errno_location()获取一个地址,再把出错代码写入该地址,其意图就是让不同的线程使用不同的出错代码存储地点,这就是TLS。而errno,现在一般已经变成了一个宏定义:

#define errno (*__errno_location())

这样,原来代码中的“extern int errno;”就变成了“extern int (*__errno_location();”。应该说,这个方案还是挺巧妙的。

相比之下,Win32 API的调用方式就不同了:

Status = NtOpenFile(…);
if(!NT_SUCCESS(Status))
{
}

    当然,这里的Status是个局部变量,所以就不存在前述的问题了。这倒不是因为微软的人特别高明、早就预见到了可能发生的问题,而是因为时代不同,须知Unix的C库程序调用界面比W32 API的出现要早十多年。
    不过,尽管Windows对于系统调用的出错代码这个具体的数据没有线程局部存储的要求,这并不意味着在Windows系统中就不需要TLS。事实上,Windows也需要TLS,一些与Windows编程有关的文献、资料中对此都有叙述,这里就不重复了。

    从实现的方法和形式看,TLS有两种,一种是“静态TLS”,另一种是“动态TLS”。
    静态TLS的实现依赖于编译工具和运行库,实际上涉及编程语言(例如C语言)的语法和语义上的扩充。具体的办法是:编程时在需要作为线程局部存储的TLS变量前面加上某种前缀,让编译工具知道这是需要线程局部存储的变量,从而生成出有所不同的汇编代码。例如,在微软的VC++语言中:

__declspec(thread) DWORD something = 0;

    这里的前缀__declspec(thread)在“正宗”的C++语言中是没有的,表示全局量something是需要线程局部存储的,或者说是TLS变量。加以说明以后,在程序中可以像对待普通变量一样地读/写这个TLS变量,所以很是方便。在编译的时候,编译工具把这样的变量都分配到一个.tls段中。而连接工具则把所有.o模块的.tls段都合并在一起,形成整个EXE或DLL可执行文件的.tls段。可想而知,每创建一个线程,就要为其所在进程所涉及的所有(例如EXE和DLL)可执行映像的.tls段(如果有的话)另行分配一块空间。这样,进程中有几个线程,每个TLS变量就有几个副本。换言之,对于每个TLS变量,每个线程都有其本地的副本。
    微软的VC++是这样,GNU-C也是如此,只是具体使用的前缀在形式上有所不同。
    当然,这还只是事情的一个方面,另一个方面是程序中对这些TLS的引用。以前面的errno为例,原来直接就可以往这个变量中写,现在却要通过一个函数__errno_location()临时获取其本地副本的地址。总之,原先在连接时就把其所在的地址填写到有关的指令中,现在却要多绕一下,临时才间接地从有关的.tls段获取其本地副本的地址了。这样,程序执行的效率当然有所下降;但是只要不是大量地反复访问TLS变量,就不成为问题。
    对每一个TLS变量都生成一个类似于__errno_location()的函数未免太麻烦了,所以一般都是把同一模块中的所有TLS变量都组装在一个数据结构中,而只是把指向这个数据结构的指针作为TLS变量保存,但是每当创建一个线程时就要(通过库程序)为其分配空间并复制这个数据结构的原始副本。这样,需要访问组装在这个数据结构中的变量时就先找到属于当前线程的副本、再通过位移量在结构中找到目标变量,就好像C语言中的例如“tlsdata.errno”那样。这里的许多操作都要由库程序承担,所以不光是编译/连接的事,也需要库程序的配套。
    原理虽然简单,具体处理起来却也可能很麻烦。试想,如果应用程序在运行中要求动态装入一个带有.tls段的DLL,就得扫描已经存在的所有的线程,扩大它们已有的.tls段,并对新增加的部分进行初始化。而过一回儿若是又要卸载某个DLL,则又得对每个线程的.tls段加以调整,这就更麻烦了。
    所以,静态TLS对于程序员而言固然很是方便,但是也有缺点。实际上,在EXE程序中使用静态TLS是合适的,在DLL中使用就显出缺点来了。

    而动态TLS正好相反,动态TLS是通过一组库函数实现的,与编译没有关系。程序员必须按相应的界面通过这组函数分配、读/写、释放TLS变量,而不能直接加以读写,风格上接近于面向对象的程序设计。在Windows系统中,这组库函数是:
    TlsAlloc() —    为当前线程分配一个32位的动态TLS变量,返回一个索引号作为标识。
    TlsSetValue() —  以索引号和一个32位数值为参数,将给定的数值写入由索引号所标识的动态TLS变量中。
    TlsGetValue() —  以索引号为参数,读取(返回)由索引号所标识的动态TLS变量的值。
    TlsFree() —      以索引号为参数,为当前线程释放由索引号所标识的TLS。

    注意由TlsAlloc()分配的TLS变量一定是32位长字,可以作为整数使用,也可以作为指针使用。如果需要局部存储的是个数据结构或数组,那就要动态分配一个缓冲区,而把缓冲区指针作为TLS变量存储。这样,对于这数据结构中的成分或元素仍可像普通变量一样直接访问,因为这些数据本身并不是TLS变量。当然,这样的数据结构或数组存在于本进程公用的空间,在同一进程内所有线程的寻址范围之内,理论上这些线程都可以访问它们。但是指向这些数据的结构指针是TLS变量、是局部于具体线程的,别的线程取不到这指针,也就不能正常访问了。
    动态TLS最适合在DLL中使用。
    通常,如果一个DLL使用动态TLS,就在以DLL_PROCESS_ATTACH为参数调用其DllMain()的过程中调用TlsAlloc(),而在以DLL_PROCESS_DETACH为参数调用其DllMain()的过程中调用TlsFree()。但是当然也可以在程序中随时分配和释放。
    后面读者将会看到,静态TLS与动态TLS本质上是一样的,不同的只是形式。

    静态TLS的实现
    先看静态TLS是怎么实现的。
    前面讲过,静态TLS需要编译工具的配合。但那只是为了尽可能地为使用者提供方便,并不是非有不可,事实上通过对宏定义的预处理也可以达到目的。再说,像errno这样的变量,已经广泛存在于已有的代码中,要在编译之前都先加上TLS前缀也不太现实。所以,除使用经过扩充的编译工具和TLS前缀之外,对于errno这些变量一般是在C库里面实现线程局部存储的,而并不依赖于编译工具和TLS前缀。而明白了errno的线程局部存储是如何实现的,就可以推而广之,不必深入到编译工具例如gcc的代码中就可以理解对静态TLS的实现机理了。
    如前所述,errno可以定义为(*__errno_location ()),这很简单。但是,从哪儿获取本线程的出错代码副本的地址呢?实际上,对于一个变量而言,取其值与取其指针的值并没有什么本质的不同,对于TLS变量也是一样。所以一个圈子又绕回了原点,我们仍然面临着相同的问题:怎样让执行着相同的代码、要访问同一个变量的不同线程实际访问的是这个变量的不同副本。
    我们可以把一个进程的所有TLS变量全都组装在一个数据结构中,让每一个线程都有这个数据结构的一份副本,这并不困难,困难仍旧在于怎样让每个线程都有自己的指针,指向这个数据结构的不同副本。
    所以,静态TLS的实现问题可以归结到仅仅一个指针的线程局部存储。有了这样一个指针,其余的就好办了。
    怎么办呢?有下面这么几个思路:
    一、跟堆栈挂勾。每个线程都有自己的堆栈,如果能从堆栈上划出一小片空间,就可以用来实现局部于具体线程的存储。
    我们不妨以Linux内核中的task_struct数据结构为例来说明这个思路。在Linux内核中,形式上current是个全局的指针,总是指向当前线程的task_struct数据结构,例如current->state就是当前线程目前的状态。当然,每个线程都有自己的task_struct数据结构,里面都有state这个字段,但是所有的线程都执行同样的程序,使用同一个“指针”current。所以state以及task_struct结构中的其它字段就都相当于TLS变量,它们都组装在一个数据结构中,每个线程只要有自己版本的指针current就可以了。所以内核中task_struct数据结构的实现与用户空间TLS的实现是可以类比的,后者可以借鉴前者的实现。那么current是怎样实现的呢?看下面的代码:

#define  current  get_current()

static inline struct task_struct * get_current(void)
{
struct task_struct *current;
__asm__("andl %%esp,%0; ":"=r" (current) : "0" (~8191UL));
return current;
}

    可见,current其实是个宏操作,具体就是把当前的堆栈指针跟0xffff2000相与,把最低的13位屏蔽掉,所得到的就是当前线程的task_struct结构指针。这是因为:首先当前的堆栈一定是当前线程的(系统空间)堆栈;其次是每当创建一个线程时内核都为其分配与8KB边界对齐的两个连续页面,并把这线程的task_struct数据结构放在地址较低的那个页面中,其页面起点就是task_struct数据结构的起点,而地址较高的那个页面的顶端则就是堆栈的起点(向下伸展)。这样,不管当前的堆栈指针究竟指在什么位置上,只要将其最低的13位屏蔽掉就得到了指向当前线程的task_struct结构的指针。这里的关键在于,每个线程的task_struct结构与其堆栈是互相挂勾、互相绑定的。
    当然,使二者挂勾的办法并不止这么一种,只要挂起勾来就行。显然,在用户空间也可以使用类似的方法,即与用户空间堆栈挂勾。这是办法之一。
    二、除用户空间堆栈以外,Windows用户空间的“线程环境块”TEB也是具体线程所独有的,事实上TEB数据结构中的每一项都是TLS。所以,与TEB挂勾也是办法。
    三、另一个办法是使用段寄存器。在采用某个段寄存器加段内位移的方法寻址时,尽管位移相同,只要让不同线程有不同的段寄存器设置,就可以让形式上相同的地址(例如%fs:0x18)实际上指向不同的地方。这又是一种办法。

    其实,这几种办法实质上是一样的。因为堆栈的位置是由堆栈段寄存器和堆栈指针共同确定的;而TEB的实现本来就是依赖于段寄存器的,每个线程的TEB上实际上就是一个段。所不同的只在于是否把段寄存器加位移的寻址方式转换成线性地址。
    除此以外还有没有别的办法呢?比方说,要是通过系统调用可以获取当前线程的TID,并且可以很方便地把TID转换成一个下标,用来访问一个指针数组,从中获得本线程的TLS结构指针,那倒也是个办法。Linux现在有个系统调用gettid(),但是就目前而言所返回的就是统一编号的PID,而不是同一进程里面的线程序号,难于把它转换成数组下标。另一方面,即使这个系统调用果真能返回如0、1、2、3等TID,因而可直接用作下标,效率也还是太低(每次都得系统调用)。所以,除了上述的与堆栈挂勾、与TEB挂勾、采用段寄存器这几种方法外,别的恐怕也找不到什么更好的方法了。
    总之,TLS的实现必须与某项由具体线程所独有的、唯一的资源挂勾。堆栈、TEB就不必说了,在采用段寄存器的方案里,写入段寄存器的值就是具体线程所独有的,所以都满足这个条件。
    事实上,在libc中,前述的函数__errno_location()的定义之一为:

int *__errno_location (void)
{
  pthread_descr self = thread_self();
  return THREAD_GETMEM (self, p_errnop);
}

    说是“定义之一”,是因为libc在编译时有很多条件编译选项,选择不同的选项,其静态TLS的具体实现也就有所不同。
    关键在于thread_self()。据说人类的一个关键问题是认识自身,而这里的关键问题显然是让当前线程认识其自身。这个函数的定义之一是这样:

static inline pthread_descr thread_self (void)
{
  return (pthread_descr)((unsigned long)sp &~ (STACK_SIZE-1));
}

    显然,这就是与用户空间堆栈挂勾。

    再看动态TLS的实现,我们着重看Windows、因而ReactOS的动态TLS。
    为实现动态TLS,微软在PEB和TEB数据结构中都作了必要的安排。先看PEB数据结构中的有关定义:

typedef struct _PEB
{
    . . . . . .
    RTL_USER_PROCESS_PARAMETERS *ProcessParameters;  /* 10 */
    . . . . . .
    HANDLE                       ProcessHeap;          /* 18 */
    . . . . . .
    PRTL_BITMAP                  TlsBitmap;           /* 40 */
    ULONG                        TlsBitmapBits[2];      /* 44 */
    . . . . . .
    ULONG                        SessionId;             /* 1d4 */
} PEB, *PPEB;

    显而易见,这里的指针TlsBitmap和数组TlsBitmapBits[]就是为TLS而设的。其中的指针TlsBitmap指向一个RTL_BITMAP数据结构,其定义如下:

typedef struct _RTL_BITMAP
{
  ULONG SizeOfBitMap;
  PULONG Buffer;
} RTL_BITMAP, *PRTL_BITMAP;

    显然,其目的是要提供一个位图,而真正的位图在Buffer所指的地方,一般这就是PEB中的TlsBitmapBits[2],但是有需要时也不排斥采用别的缓冲区,因为两个32位长字只能提供64个标志位。
    PEB是进程的资源,不是线程的资源,更不成为具体线程所独有的资源。所以这两个结构成分本身不能构成TLS。如前所述,具体的TLS只能与堆栈、TEB挂勾,或者就采用段寄存器。事实上,TEB中为动态TLS的实现提供了手段。

typedef struct _TEB
{
   NT_TIB Tib;                         /* 00h */
   PVOID EnvironmentPointer;            /* 1Ch */
   . . . . . .
   PVOID ThreadLocalStoragePointer;       /* 02c */
   PPEB Peb;                           /* 30h */
   . . . . . .
   PVOID TlsSlots[0x40];                 /* E10h */
   LIST_ENTRY TlsLinks;                /* F10h */
   . . . . . .
} TEB, *PTEB;

    TlsSlots[]是个无类型指针数组,其大小为0x40、即64。这就是说,一个线程同时存在的动态TLS不能超过64项。另一个字段ThreadLocalStoragePointer用于静态TLS,后面会讲到这个指针。
    如果一项动态TLS数据的大小不超过4个字节,那么直接就可以存储在这个数组中,作为这个数组的一个元素。而若是大于4个字节的数据结构,那就需要为之动态分配存储缓冲区,而把缓冲区的地址存储在这个数组中。当然,这就大大扩充了动态TLS的容量。
    需要创建一个动态TLS变量时,应用程序先通过TlsAlloc()分配空间:

DWORD STDCALL
TlsAlloc(VOID)
{
   ULONG Index;

   RtlAcquirePebLock();
   Index = RtlFindClearBitsAndSet (NtCurrentPeb()->TlsBitmap, 1, 0);
   if (Index == (ULONG)-1)
   {
       SetLastErrorByStatus(STATUS_NO_MEMORY);
   }
   else
   {
       NtCurrentTeb()->TlsSlots[Index] = 0;
   }
   RtlReleasePebLock();
   return(Index);
}

    这里的操作很简单,先通过RtlFindClearBitsAndSet()检查所属进程的PEB中的位图,从中找到一个为0的标志位、即空闲的TLS变量,并把它设置成1,表示已经占用,然后就根据标志位所在的位置推算出相应的下标。而当前线程TEB中数组TlsSlots[]的相应元素就是所分配的动态TLS变量,这里把它初始化成0。
    关键是NtCurrentTeb()的实现,即如何找到当前线程的TEB。这个inline函数的代码为:

static __inline__ struct _TEB * NtCurrentTeb(void)
{
    struct _TEB *ret;

⌨️ 快捷键说明

复制代码 Ctrl + C
搜索代码 Ctrl + F
全屏模式 F11
切换主题 Ctrl + Shift + D
显示快捷键 ?
增大字号 Ctrl + =
减小字号 Ctrl + -