­

Pthread 並發編程(一)——深入剖析線程基本元素和狀態

Pthread 並發編程(一)——深入剖析線程基本元素和狀態

前言

在本篇文章當中講主要給大家介紹 pthread 並發編程當中關於線程的基礎概念,並且深入剖析進程的相關屬性和設置,以及線程在內存當中的布局形式,幫助大家深刻理解線程。

深入理解 pthread_create

基礎例子介紹

在深入解析 pthread_create 之前,我們先用一個簡單的例子簡單的認識一下 pthread,我們使用 pthread 創建一個線程並且打印 Hello world 字符串。


#include <stdio.h>
#include <pthread.h>

void* func(void* arg) {
  printf("Hello World from tid = %ld\n", pthread_self()); // pthread_self 返回當前調用這個函數的線程的線程 id
  return NULL;
}

int main() {

  pthread_t t; // 定義一個線程
  pthread_create(&t, NULL, func, NULL); // 創建線程並且執行函數 func 

  // wait unit thread t finished
  pthread_join(t, NULL); // 主線程等待線程 t 執行完成然後主線程才繼續往下執行

  printf("thread t has finished\n");
  return 0;
}

編譯上述程序:

clang helloworld.c -o helloworld.out -lpthread
或者
gcc helloworld.c -o helloworld.out -lpthread

在上面的代碼當中主線程(可以認為是執行主函數的線程)首先定義一個線程,然後創建線程並且執行函數 func ,當創建完成之後,主線程使用 pthread_join 阻塞自己,直到等待線程 t 執行完成之後主線程才會繼續往下執行。

我們現在仔細分析一下 pthread_create 的函數簽名,並且對他的參數進行詳細分析:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);
  • 參數 thread 是一個類型為 pthread_t 的指針對象,將這個對象會在 pthread_create 內部會被賦值為存放線程 id 的地址,在後文當中我們將使用一個例子仔細的介紹這個參數的含義。
  • 參數 attr 是一個類型為 pthread_attr_t 的指針對象,我們可以在這個對象當中設置線程的各種屬性,比如說線程取消的狀態和類別,線程使用的棧的大小以及棧的初始位置等等,在後文當中我們將詳細介紹這個屬性的使用方法,當這個屬性為 NULL 的時候,使用默認的屬性值。
  • 參數 start_routine 是一個返回類型為 void* 參數類型為 void* 的函數指針,指向線程需要執行的函數,線程執行完成這個函數之後線程就會退出。
  • 參數 arg ,傳遞給函數 start_routine 的一個參數,在上一條當中我們提到了 start_routine 有一個參數,是一個 void 類型的指針,這個參數也是一個 void 類型的指針,在後文當中我們使用一個例子說明這個參數的使用方法。

深入理解參數 thread

在下面的例子當中我們將使用 pthread_self 得到線程的 id ,並且通過保存線程 id 的地址的變量 t 得到線程的 id ,對兩個得到的結果進行比較。


#include <stdio.h>
#include <pthread.h>

void* func(void* arg) {

  printf("線程自己打印線程\tid = %ld\n", pthread_self());

  return NULL;
}

int main() {

  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  printf("主線程打印線程 t 的線程 id = %ld\n", *(long*)(&t));
  pthread_join(t, NULL);
  return 0;
}

上面程序的執行結果如下圖所示:

根據上面程序打印的結果我們可以知道,變量 pthread_t t 保存的就是線程 id 的地址, 參數 t 和線程 id 之間的關係如下所示:

在上面的代碼當中我們首先對 t 取地址,然後將其轉化為一個 long 類型的指針,然後解引用就可以得到對應地址的值了,也就是線程的ID。

深入理解參數 arg

在下面的程序當中我們定義了一個結構體用於保存一些字符出的信息,然後創建一個這個結構體的對象,將這個對象的指針作為參數傳遞給線程要執行的函數,並且在線程內部打印字符串當中的內容。


#include <stdio.h>
#include <pthread.h>
#include <malloc.h>
#include <stdlib.h>
#include <string.h>


typedef struct info {
  char s[1024]; // 保存字符信息
  int  size;    // 保存字符串的長度
}info_t;

static
void* func(void* arg) {
  info_t* in = (info_t*)arg;
  in->s[in->size] = '\0';
  printf("string in arg = %s\n", in->s);
  return NULL;
}

int main() {

  info_t* in = malloc(sizeof(info_t)); // 申請內存空間
  // 保存 HelloWorld 這個字符串 並且設置字符串的長度
  in->s[0] = 'H';
  in->s[1] = 'e';
  in->s[2] = 'l';
  in->s[3] = 'l';
  in->s[4] = 'o';
  in->s[5] = 'W';
  in->s[6] = 'o';
  in->s[7] = 'r';
  in->s[8] = 'l';
  in->s[9] = 'd';
  in->size = 10;
  pthread_t t;									// 將 in 作為參數傳遞給函數 func
  pthread_create(&t, NULL, func, (void*)in); 
  pthread_join(t, NULL);
  free(in); // 釋放內存空間
  return 0;
}

上面程序的執行結果如下所示:

可以看到函數參數已經做到了正確傳遞。

深入理解參數 attr

在深入介紹參數 attr 前,我們首先需要了解一下程序的內存布局,在64位操作系統當中程序的虛擬內存布局大致如下所示,從下往上依次為:只讀數據/代碼區、可讀可寫數據段、堆區、共享庫的映射區、程序棧區以及內核內存區域。我們程序執行的區域就是在棧區。

根據上面的虛擬內存布局示意圖,我們將其簡化一下得到單個線程的執行流和大致的內存布局如下所示(程序執行的時候有他的棧幀以及寄存器現場,圖中將寄存器也做出了標識):

程序執行的時候當我們進行函數調用的時候函數的棧幀就會從上往下生長,我們現在進行一下測試,看看程序的棧幀最大能夠達到多少。


#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int times = 1;

void* func(void* arg) {
  char s[1 << 20]; // 申請 1MB 內存空間(分配在棧空間上)
  printf("times = %d\n", times);
  times++;
  func(NULL);
  return NULL;
}

int main() {

  func(NULL);
  return 0;
}

上述程序的執行結果如下圖所示:

從上面的程序我們可以看到在第 8 次申請棧內存的時候遇到了段錯誤,因此可以判斷棧空間大小在 8MB 左右,事實上我們可以查看 linux 操作系統上,棧內存的指定大小:

事實上在 linux 操作系統當中程序的棧空間的大小默認最大為 8 MB。

現在我們來測試一下,當我們創建一個線程的時候,線程的棧的大小大概是多少:


#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int times = 1;

void* func(void* arg) {
  printf("times = %d\n", times);
  times++;
  char s[1 << 20]; // 申請 1MB 內存空間(分配在棧空間上)
  func(NULL);
  return NULL;
}

int main() {

  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_join(t, NULL);
  return 0;
}

上面的程序執行結果如下圖所示,可以看到當我們創建一個線程的時候棧的最大的大小也是 8MB。

設置線程棧空間的大小

現在如果我們有一個需求,需要的棧空間大於 8MB,我們應該怎麼辦呢?這就是我們所需要談到的 attr,這個變量是一個 pthread_attr_t 對象,這個對象的主要作用就是用於設置線程的各種屬性的,其中就包括線程的棧的大小,在下面的程序當中我們將線程的棧空間的大小設置成 24MB,並且使用程序進行測試。

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>

#define MiB * 1 << 20

int times = 0;
void* stack_overflow(void* args) {
  printf("times = %d\n", ++times);
  char s[1 << 20]; // 1 MiB
  stack_overflow(NULL);
  return NULL;
}

int main() {
  pthread_attr_t attr;
  pthread_attr_init(&attr); // 對變量 attr 進行初始化操作
  pthread_attr_setstacksize(&attr, 24 MiB); // 設置棧幀大小為 24 MiB 這裡使用了一個小的 trick 大家可以看一下 MiB 的宏定義
  pthread_t t;
  pthread_create(&t, &attr, stack_overflow, NULL);
  pthread_join(t, NULL);
  pthread_attr_destroy(&attr); // 釋放線程屬性的相關資源
  return 0;
}

上面的程序執行結果如下圖所示:

從上面程序的執行結果來看我們設置的 24 MB 的棧空間大小起到了效果,我們可以通過線程的遞歸次數可以看出來我們確實申請了那麼大的空間。在上面的程序當中我們對屬性的操作如下,這也是對屬性操作的一般流程:

  • 使用 pthread_attr_init 對屬性變量進行初始化操作。
  • 使用各種各樣的函數對屬性 attr 進行操作,比如 pthread_attr_setstacksize,這個函數的作用就是用於設置線程的棧空間的大小。
  • 使用 pthread_attr_destroy 釋放線程屬性相關的系統資源。

自己為線程的棧申請空間

在上一小節當中我們通過函數 pthread_attr_setstacksize 給棧空間設置了新的大小,並且使用程序檢查驗證了新設置的棧空間大小,在這一小節當中我們將介紹使用我們自己申請的內存空間也可以當作線程的棧使用。我們將使用兩種方法取驗證這一點:

  • 使用 malloc 函數申請內存空間,這部分空間主要在堆當中。
  • 使用 mmap 系統調用在共享庫的映射區申請內存空間。
使用 malloc 函數申請內存空間
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>

#define MiB * 1 << 20

int times = 0;
static
void* stack_overflow(void* args) {
  printf("times = %d\n", ++times);
  char s[1 << 20]; // 1 MiB
  stack_overflow(NULL);
  return NULL;
}

int main() {
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  void* stack = malloc(2 MiB); // 使用 malloc 函數申請內存空間 申請的空間大小為 2 MiB 
  pthread_t t;
  pthread_attr_setstack(&attr, stack, 2 MiB); // 使用屬性設置函數設置棧的位置 棧的最低地址為 stack 棧的大小等於 2 MiB 
  pthread_create(&t, &attr, stack_overflow, NULL);
  pthread_join(t, NULL);
  pthread_attr_destroy(&attr); // 釋放系統資源
  free(stack); // 釋放堆空間
  return 0;
}

上述程序的執行結果如下圖所示:

從上面的執行結果可以看出來我們設置的棧空間的大小為 2MB 成功了。在上面的程序當中我們主要使用 pthread_attr_setstack 函數設置棧的低地址和棧空間的大小。我們申請的內存空間內存布局大致如下圖所示:

使用 mmap 申請內存作為棧空間
#define _GNU_SOURCE
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/mman.h>

#define MiB * 1 << 20
#define STACK_SIZE 2 MiB

int times = 0;

static
void* stack_overflow(void* args) {
  printf("times = %d\n", ++times);
  char s[1 << 20]; // 1 MiB
  stack_overflow(NULL);
  return NULL;
}

int main() {
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  void* stack = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE,
                MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
  if (stack == MAP_FAILED)
      perror("mapped error:");
  pthread_t t;
  pthread_attr_setstack(&attr, stack, STACK_SIZE);
  pthread_create(&t, &attr, stack_overflow, NULL);
  pthread_join(t, NULL);
  pthread_attr_destroy(&attr);
  free(stack);
  return 0;
}

在上面的程序當中我們使用 mmap 系統調用在共享庫空間申請了一段內存空間,並且將其做為棧空間,我們在這裡就不將程序執行的結果放出來了,上面整個程序和前面的程序相差不大,只是在申請內存方面發生了變化,總體的方向是不變的。

根據前面知識的學習,我們可以知道多個線程可以共享同一個進程虛擬地址空間,我們只需要給每個線程申請一個棧空間讓線程執行起來就行,基於此我們可以知道多個線程的執行流和大致的內存布局如下圖所示:

在上圖當中不同的線程擁有不同的棧空間和每個線程自己的寄存器現場,正如上圖所示,棧空間可以是在堆區也可以是在共享庫的映射區域,只需要給線程提供棧空間即可。

深入理解線程的狀態

pthread 當中給我們提供了一個函數 pthread_cancel 可以取消一個正在執行的線程,取消正在執行的線程之後會將線程的退出狀態(返回值)設置成宏定義 PTHREAD_CANCELED 。我們使用下面的例子去理解一下線程取消的過程:

#include <stdio.h>
#include <pthread.h>
#include <assert.h>

void* task(void* arg) {

	while(1) {
    pthread_testcancel(); // 測試是否被取消執行了
  }
  return NULL;
}

int main() {

  void* res;
  pthread_t t;
  pthread_create(&t, NULL, task, NULL);
  int s = pthread_cancel(t); // 取消函數的執行
  if(s != 0)
    fprintf(stderr, "cancel failed\n");
  pthread_join(t, &res);
  assert(res == PTHREAD_CANCELED);
  return 0;
}

在上面的程序當中我們在主線程當中使用函數 pthread_cancel 函數取消線程的執行,編譯執行上面的程序是可以通過的,也就是說程序正確執行了,而且 assert 也通過了。我們先不仔細去分析上面的代碼的執行流和函數的意義。我們先需要了解一個線程的基本特性。

與線程取消執行相關的一共有兩個屬性,分別是:

  • 取消執行的狀態,線程的取消執行的狀態一共有兩個:
    • PTHREAD_CANCEL_ENABLE:這個狀態表示這個線程是可以取消的,也是線程創建時候的默認狀態。
    • PTHREAD_CANCEL_DISABLE:這個狀態表示線程是不能夠取消的,如果有一個線程發送了一個取消請求,那麼這個發送取消消息的線程將會被阻塞直到線程的取消狀態變成 PTHREAD_CANCEL_ENABLE 。
  • 取消執行的類型,取消線程執行的類型也有兩種:
    • PTHREAD_CANCEL_DEFERRED:當一個線程的取消狀態是這個的時候,線程的取消就會被延遲執行,知道線程調用一個是取消點的(cancellation point)函數,比如 sleep 和 pthread_testcancel ,所有的線程的默認取消執行的類型就是這個類型。
    • PTHREAD_CANCEL_ASYNCHRONOUS:如果線程使用的是這個取消類型那麼線程可以在任何時候被取消執行,當他接收到了一個取消信號的時候,馬上就會被取消執行,事實上這個信號的實現是使用 tgkill 這個系統調用實現的。

事實上我們很少回去使用 PTHREAD_CANCEL_ASYNCHRONOUS ,因為這樣殺死一個線程會導致線程還有很多資源沒有釋放,會給系統帶來很大的災難,比如線程使用 malloc 申請的內存空間沒有釋放,申請的鎖和信號量沒有釋放,尤其是鎖和信號量沒有釋放,很容易造成死鎖的現象。

有了以上的知識基礎我們現在可以來談一談前面的兩個函數了:

  • pthread_cancel(t) :是給線程 t 發送一個取消請求。
  • pthread_testcancel():這個函數是一個取消點,當執行這個函數的時候,程序就會取消執行。

現在我們使用默認的線程狀態和類型創建一個線程執行死循環,看看線程是否能夠被取消掉:


#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <unistd.h>

void* task(void* arg) {
  while(1) {
    
  }
  return NULL;
}

int main() {

  void* res;
  pthread_t t1;
  pthread_create(&t1, NULL, task, NULL);
  int s = pthread_cancel(t1);
  if(s != 0) // s == 0 mean call successfully
    fprintf(stderr, "cancel failed\n");
  pthread_join(t1, &res);
  assert(res == PTHREAD_CANCELED);
  return 0;
}

在上面的代碼當中我們啟動了一個線程不斷的去進行進行死循環的操作,程序的執行結果為程序不會終止,因為主線程在等待線程的結束,但是線程在進行死循環,而且線程執行死循環的時候沒有調用一個是取消點的函數,因此程序不會終止取消。

下面我們更改程序,將線程的取消類型設置為 PTHREAD_CANCEL_ASYNCHRONOUS ,在看看程序的執行結果:


#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <unistd.h>

void* task(void* arg) {
  pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);
  while(1) {
    
  }
  return NULL;
}

int main() {

  void* res;
  pthread_t t1;
  pthread_create(&t1, NULL, task, NULL);
  int s = pthread_cancel(t1);
  if(s != 0) // s == 0 mean call successfully
    fprintf(stderr, "cancel failed\n");
  pthread_join(t1, &res);
  assert(res == PTHREAD_CANCELED);
  return 0;
}

在上面的程序當中我們在線程執行的函數當中使用 pthread_setcanceltype 將線程的取消類型設置成 PTHREAD_CANCEL_ASYNCHRONOUS 這樣的話就能夠在其他線程使用 pthread_cancel 的時候就能夠立即取消線程的執行。

int pthread_setcanceltype(int type, int *oldtype)

上方是 pthread_setcanceltype 的函數簽名,在前面的使用當中我們只使用了第一個參數,第二個參數我們是設置成 NULL,第二個參數我們可以傳入一個 int 類型的指針,然後會在將線程的取消類型設置成 type 之前將前一個 type 拷貝到 oldtype 所指向的內存當中。

type: 有兩個參數:PTHREAD_CANCEL_ASYNCHRONOUS 和 PTHREAD_CANCEL_DEFERRED 。

int pthread_setcancelstate(int state, int *oldstate);

設置取消狀態的函數簽名和上一個函數簽名差不多,參數的含義也是差不多,type 表示需要設置的取消狀態,有兩個參數:PTHREAD_CANCEL_ENABLE 和 PTHREAD_CANCEL_DISABLE ,參數 oldstate 是指原來的線程的取消狀態,如果你傳入一個 int 類型的指針的話就會將原來的狀態保存到指針指向的位置。

其實關於線程的一些細節還有比較多的內容限於篇幅,在本篇文章當中主要給大家介紹這些細節。

關於棧大小程序的一個小疑惑

在上文當中我們使用了一個小程序去測試線程的棧空間的大小,並且打印函數 func 的調用次數,每一次調用的時候我們都會申請 1MB 大小的棧空間變量。現在我們看下面兩個程序,在下面兩個程序只有 func 函數有區別,而在 func 函數當中主要的區別就是:

  • 在第一個程序當中是先申請內存空間,然後再打印變量 times 的值。
  • 在第二個程序當中是先打印變量 times 的值,然後再申請內存空間。

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int times = 1;

// 先申請內存空間再打印
void* func(void* arg) {
  char s[1 << 20]; // 申請 1MB 內存空間(分配在棧空間上)
  printf("times = %d\n", times);
  times++;
  func(NULL);
  return NULL;
}

int main() {

  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_join(t, NULL);
  return 0;
}

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int times = 1;

// 先打印再申請內存空間
void* func(void* arg) {
  printf("times = %d\n", times);
  times++;
  char s[1 << 20]; // 申請 1MB 內存空間(分配在棧空間上)
  func(NULL);
  return NULL;
}

int main() {

  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_join(t, NULL);
  return 0;
}

由於上面兩個程序的輸出結果是一樣的,所以我就只放出一個程序的輸出結果了:

但是不對呀!如果是後申請內存空間的話,程序的輸出應該能夠打印 times = 8 啊,因為之前只申請了 7MB 的空間,我們打印 times = 8 的時候還沒有執行到語句 char s[1 << 20]; ,那為什麼也只打印到 7 呢?

出現上面問題的主要原因就需要看編譯器給我們編譯後的程序是如何申請內存空間的。我們將上面的函數 func 的彙編代碼展示出來:

00000000004005e0 <func>:
  4005e0:       55                      push   %rbp
  4005e1:       48 89 e5                mov    %rsp,%rbp
  4005e4:       48 81 ec 20 00 10 00    sub    $0x100020,%rsp
  4005eb:       48 8d 04 25 3c 07 40    lea    0x40073c,%rax
  4005f2:       00 
  4005f3:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  4005f7:       8b 34 25 40 10 60 00    mov    0x601040,%esi
  4005fe:       48 89 c7                mov    %rax,%rdi
  400601:       b0 00                   mov    $0x0,%al
  400603:       e8 c8 fe ff ff          callq  4004d0 <printf@plt>
  400608:       48 bf 00 00 00 00 00    movabs $0x0,%rdi
  40060f:       00 00 00 
  400612:       8b 34 25 40 10 60 00    mov    0x601040,%esi
  400619:       81 c6 01 00 00 00       add    $0x1,%esi
  40061f:       89 34 25 40 10 60 00    mov    %esi,0x601040
  400626:       89 85 ec ff ef ff       mov    %eax,-0x100014(%rbp)
  40062c:       e8 af ff ff ff          callq  4005e0 <func>
  400631:       48 bf 00 00 00 00 00    movabs $0x0,%rdi
  400638:       00 00 00 
  40063b:       48 89 85 e0 ff ef ff    mov    %rax,-0x100020(%rbp)
  400642:       48 89 f8                mov    %rdi,%rax
  400645:       48 81 c4 20 00 10 00    add    $0x100020,%rsp
  40064c:       5d                      pop    %rbp
  40064d:       c3                      retq

上面的彙編代碼是上面的程序在 x86_64 平台下得到的,我們需要注意一行彙編指令 sub $0x100020,%rsp ,這條指令的主要作用就是將棧頂往下擴展(棧是從上往下生長的)1 MB 位元組(實際上稍微比 1MB 大一點,因為還有其他操作需要一些棧空間),事實上就是給變量 s 申請 1MB 的棧空間。

好了,看到這裡就破案了,原來編譯器申請棧空間的方式是將棧頂寄存器 rsp ,往虛擬地址空間往下移動,而編譯器在函數執行剛開始的時候就申請了這麼大的空間,因此不管是先申請空間再打印,還是先打印再申請空間,在程序被編譯成彙編指令之後,函數 func 在函數剛開始就申請了對應的空間,因此才出現了都只打印到 times = 7

總結

在本篇文章當中主要給大家介紹了線程的基本元素和一些狀態,還重點介紹了各種與線程相關屬性的函數,主要使用的各種函數如下:

  • pthread_create,用與創建線程
  • pthread_attr_init,初始話線程的基本屬性。
  • pthread_attr_destroy,釋放屬性相關資源。
  • pthread_join,用於等待線程執行完成。
  • pthread_attr_setstacksize,用於設置線程執行棧的大小。
  • pthread_attr_setstack,設置線程執行棧的棧頂和棧的大小。
  • pthread_testcancel,用於檢測線程是否被取消了,是一個取消點。
  • pthread_cancel,取消一個線程的執行。

希望大家有所收穫!


更多精彩內容合集可訪問項目://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,了解更多計算機(Java、Python、計算機系統基礎、算法與數據結構)知識。

Tags: