Pthread 並行程式設計(一)——深入剖析執行緒基本元素和狀態

2022-11-03 06:00:58

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,取消一個執行緒的執行。

希望大家有所收穫!


更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore

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