Glibc 執行緒資源---__thread & pthread_key_t

weixin_33751566發表於2017-02-17

前言

前面寫了一篇文章《Glibc 執行緒資源分配與釋放-----執行緒棧》,其中主要講解了 glibc 在 x86_64 平臺 Linux 系統上的執行緒棧區管理。但是這並不是全部的執行緒資源,本文中我們將介紹另外兩類資源的,以 __thread 定義的變數以及 pthread_key_create 建立的鍵值對資源。我們仍然以 x86_64 Linux 平臺為例,分析的原始碼是 glibc-2.25。

__thread 變數

__thread 識別符號修飾的全域性或靜態變數是執行緒獨立的,執行緒對該變數的操作對其它執行緒來說是不可見的。然而執行緒之間共享記憶體空間的,因此要達到如些效果就需要針對該變數為每個執行緒分配變數的儲存位置。在 Glibc 中, 所有的 __thread 變數是與 pthread 關聯儲存的,通過相對於 pthread 變數地址的偏移實現對變數的定址。即是說,pthread 變數的地址是基址。

《Glibc 執行緒資源分配與釋放-----執行緒棧》中提到,pthread 是儲存線上程棧記憶體塊中的,線上程棧佈局圖中,我們省略了為 __thread 變數預留的記憶體空間。下圖說明了__thread 變數線上程棧記憶體空間中的儲存。

4713727-8f2a46f8753a3036.jpg
執行緒棧佈局

下面這段程式碼可以驗證這個結論。

#include <pthread.h>
#include <stdio.h>
#include <asm/prctl.h>
#include <sys/prctl.h>
#include <errno.h>

static int __thread var_1 = 0;
static short  __thread var_2 = 0;

void* func(void *f) {
    
    long pthread_addr = 0;
    arch_prctl(ARCH_GET_FS, &pthread_addr);
    printf("&pthread = %p, &var_1 = %p, off_var_1 = %d,  &var_2 = %p, off_var_2 = %d\n",
            pthread_addr, &var_1, (long)&var_1 - pthread_addr, 
            &var_2, (long)&var_2 - pthread_addr );
    return NULL;
}

int main() {
    pthread_t chs[3];
    int i = 0;

    for (i = 0; i < 3; ++i) {
        pthread_create(&chs[i], NULL, func, NULL);
    }
    
    for (i = 0; i < 3; ++i) {
        pthread_join(chs[i], NULL);
    }
    return 0;
}

上面這段程式碼中,比較難以理解的在於為什麼 arch_prctl 函式取得的是 pthread 的地址。在解答這個問題之間,我們需要先看一下建立執行緒的 clone 函式 以及呼叫時傳入的 flags 引數 CLONE_SETTLS。

int clone(int (*fn)(void *), void *child_stack,
                 int flags, void *arg, ...
                 /* pid_t *ptid, void *newtls, pid_t *ctid */ );

CLONE_SETTLS
(since Linux 2.5.32) The TLS (Thread Local Storage) descriptor is set to newtls. The interpretation of newtls and the resulting effect is architecture dependent. On x86, newtls is interpreted as a **struct user_desc ** (See set_thread_area(2)). On x86_64 it is the new value to be set for the %fs base register (See the ARCH_SET_FS argument to arch_prctl(2)). On architectures with a dedicated TLS register, it is the new value of that register.

這段描述說明, 在 x86_64 位系統上,clone 函式傳入的 newtls 引數會作為 fs 暫存器的基地址,並且該地址值能通過 arch_prctl 函式獲得。在《Glibc 執行緒資源分配與釋放-----執行緒棧》中我們也看到了,newtls 傳入的實參就是 pthread 變數的地址 (pd)。

if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
                    clone_flags, pd, &pd->tid, tp, &pd->tid)
            == -1))

因此,可以說明 arch_prctl 函式獲得的就是執行緒 pthread 變數的地址值。執行一次上面程式的結果(每次執行結果可能不同):

&pthread = 0x7f8325948700, &var_1 = 0x7f83259486f8, off_var_1 = -8,  &var_2 = 0x7f83259486fc, off_var_2 = -4
&pthread = 0x7f8324f47700, &var_1 = 0x7f8324f476f8, off_var_1 = -8,  &var_2 = 0x7f8324f476fc, off_var_2 = -4
&pthread = 0x7f8324546700, &var_1 = 0x7f83245466f8, off_var_1 = -8,  &var_2 = 0x7f83245466fc, off_var_2 = -4

可以看出, var_1 與 var_2 確實存入線上程 pthread 地址的下端,不同執行緒訪問的變數的地址是不相同的,但是變數相對於 pthread 地址的偏移是相同的,在本例中分別是 -8 與 -4。

鍵值對資源

另外一種建立執行緒特定資料(Tthread-specific data)的方式是通過 pthread_key_create 建立鍵值對映。每個執行緒通過鍵訪問執行緒特定的資料。glibc 中鍵集中分配管理,值分開儲存的方式提供 TSD 資料。

鍵的分配

pthread_key_create 建立的鍵事實上是一個無符號的整型數(sysdeps/x86/bits/pthreadtypes.h):

/* Keys for thread-specific data */
typedef unsigned int pthread_key_t;

glibc 定義了一個全域性陣列用於管理鍵是否已被建立,這個全域性陣列定義在 nptl/vars.c 中(如下)。每個鍵都會對應於陣列中一個 pthread_struct_t 結構體,該結構體描述了鍵是否已正被使用。由陣列定義可以看出, 一個程式中最多個通過 pthread_key_create 建立 PTHREAD_KEYS_MAX (1024)個鍵。

/* Table of the key information.  */
struct pthread_key_struct __pthread_keys[PTHREAD_KEYS_MAX]

如下 (sysdeps/nptl/internaltypes.h), pthread_key_struct 中定義一個序號值(seq)及一個用於釋放資料的“解構函式” (destr)。

/* Thread-local data handling.  */
struct pthread_key_struct
{
  /* Sequence numbers.  Even numbers indicated vacant entries.  Note
     that zero is even.  We use uintptr_t to not require padding on
     32- and 64-bit machines.  On 64-bit machines it helps to avoid
     wrapping, too.  */
  uintptr_t seq;

  /* Destructor for the data.  */
  void (*destr) (void *);
};

seq 用於判斷對應的鍵是否被建立,若 seq 是奇數則正被使用,若為偶數則未被使用。 例如,若 __pthread_keys[3].seq &1 == 0 為 True 則說明該鍵 3 沒有被建立,否則已被建立。 destr 允許應用建立鍵時定義一個釋放資源的函式。

鍵值的對映

鍵值的對映資訊是儲存在各執行緒的 pthread 結構體中的。最直接的方法是在每個 pthread 結構體中也定義一個類似於 __pthread_keys 的陣列, 該陣列中儲存 key-value 的對映關係。不過為了節約記憶體空間(大部分情況下應用只會使用很少的 key), pthread 並不是直接建立一個長度為 1024 的陣列,而是使用了兩級陣列的方式來儲存這種對映關係。先來看一下 pthread 結構體中儲存對映關係的變數:

  /* We allocate one block of references here.  This should be enough
     to avoid allocating any memory dynamically for most applications.  */
  struct pthread_key_data
  {
    /* Sequence number.  We use uintptr_t to not require padding on
       32- and 64-bit machines.  On 64-bit machines it helps to avoid
       wrapping, too.  */
    uintptr_t seq;

    /* Data pointer.  */
    void *data;
  } specific_1stblock[PTHREAD_KEY_2NDLEVEL_SIZE];

  /* Two-level array for the thread-specific data.  */
  struct pthread_key_data *specific[PTHREAD_KEY_1STLEVEL_SIZE];

在 pthread 中定義了一個結構體 pthread_key_data 儲存指向資料的指標(data)。同樣的,其中 seq 標識對應的鍵是否被建立。pthread 中定義了一個 pthread_key_data 的陣列 specific_1stblock 以及指標陣列 specific。 當鍵較少時,對映關係直接儲存到 sepcific_1stblock 中,隨著鍵的增加,再分配空間儲存到 specific 中。為了說明這個過程,我們來看一下 pthread_setspecific 函式(nptl/pthread_setspecific.c):

int
__pthread_setspecific (pthread_key_t key, const void *value)
{
  struct pthread *self;
  unsigned int idx1st;
  unsigned int idx2nd;
  struct pthread_key_data *level2;
  unsigned int seq;

  self = THREAD_SELF;

  /* Special case access to the first 2nd-level block.  This is the
     usual case.  */
  if (__glibc_likely (key < PTHREAD_KEY_2NDLEVEL_SIZE))
    {
      /* Verify the key is sane.  */
      if (KEY_UNUSED ((seq = __pthread_keys[key].seq)))
    /* Not valid.  */
    return EINVAL;

      level2 = &self->specific_1stblock[key];

      /* Remember that we stored at least one set of data.  */
      if (value != NULL)
    THREAD_SETMEM (self, specific_used, true);
    }
  else
    {
      if (key >= PTHREAD_KEYS_MAX
      || KEY_UNUSED ((seq = __pthread_keys[key].seq)))
    /* Not valid.  */
    return EINVAL;

      idx1st = key / PTHREAD_KEY_2NDLEVEL_SIZE;
      idx2nd = key % PTHREAD_KEY_2NDLEVEL_SIZE;

      /* This is the second level array.  Allocate it if necessary.  */
      level2 = THREAD_GETMEM_NC (self, specific, idx1st);
      if (level2 == NULL)
    {
      if (value == NULL)
        /* We don't have to do anything.  The value would in any case
           be NULL.  We can save the memory allocation.  */
        return 0;

      level2
        = (struct pthread_key_data *) calloc (PTHREAD_KEY_2NDLEVEL_SIZE,
                          sizeof (*level2));
      if (level2 == NULL)
        return ENOMEM;

      THREAD_SETMEM_NC (self, specific, idx1st, level2);
    }

      /* Pointer to the right array element.  */
      level2 = &level2[idx2nd];

      /* Remember that we stored at least one set of data.  */
      THREAD_SETMEM (self, specific_used, true);
    }

  /* Store the data and the sequence number so that we can recognize
     stale data.  */
  level2->seq = seq;
  level2->data = (void *) value;

  return 0;
}

從函式中可以看出,如果 key 小於第 specific_1stblock 陣列大小(PTHREA_KEY_2NDLEVEL_SIZE),則直接將 value 的地址直接儲存於 specific_1stblock[key] 處;如果 key 大於或等 PTHREA_KEY_2NDLEVEL_SIZE, 則會為指標陣列 sepcific 分配記憶體空間(如果未曾分配),並將 value 的地址位於 specific[idx1st][idx2nd] 處。其中 idx1st、idx2nd 分別是 key 除 PTHREA_KEY_2NDLEVEL_SIZE 的商與餘數。由於大部分應用使用的 key 的數量很小,所以 specific 陣列大部分指標都為 NULL。

可以看出,通過 key 訪問執行緒特定資料的步驟比 __thread 變數更為複雜一些。在 x86_64 架構上, fs 暫存器已經儲存了執行緒 pthread 的地址值,因此訪問 __thread 變數直接通過 fs 相對定址即可,只需要一條指令。而 pthread_getspecific 訪問執行緒特定資料時,需要通過 specific_1stblock 陣列來完成,其中還包括了諸多的有效性檢驗。效率上 __thread 變數的訪問應該會更高一些。但是兩者的差距有多大,需要真實實驗去測試一下。

相關文章