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: