Pthread 並行程式設計(三)——深入理解執行緒取消機制

2022-11-18 18:00:24

Pthread 並行程式設計(三)——深入理解執行緒取消機制

基本介紹

執行緒取消機制是 pthread 給我們提供的一種用於取消執行緒執行的一種機制,這種機制是線上程內部實現的,僅僅能夠在共用記憶體的多執行緒程式當中使用。

基本使用


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


void* task(void* arg) {
  usleep(10);
  printf("step1\n");
  printf("step2\n");
  printf("step3\n");
  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;
}

上面的程式的輸出結果如下:

step1

在上面的程式當中,我們使用一個執行緒去執行函數 task,然後主執行緒會執行函數 pthread_cancel 去取消執行緒的執行,從上面程式的輸出結果我們可以知道,執行函數 task 的執行緒並沒有執行完成,只列印出了 step1 ,這說明執行緒被取消執行了。

深入分析執行緒取消機制

在上文的一個例子當中我們簡單的使用了一下執行緒取消機制,在本小節當中將深入分析執行緒的取消機制。線上程取消機制當中,如果一個執行緒被正常取消執行了,其他執行緒使用 pthread_join 去獲取執行緒的退出狀態的話,執行緒的退出狀態為 PTHREAD_CANCELED 。比如在上面的例子當中,主執行緒取消了執行緒 t1 的執行,然後使用 pthread_join 函數等待執行緒執行完成,並且使用引數 res 去獲取執行緒的退出狀態,在上面的程式碼當中我們使用 assert 語句去判斷 res 的結果是否等於 PTHREAD_CANCELED ,從程式執行的結果來看,assert 通過了,因此執行緒的退出狀態驗證正確。

我們來看一下 pthread_cancel 函數的簽名:

int pthread_cancel(pthread_t thread);

函數的返回值:

  • 0 表示函數 pthread_cancel 執行成功。
  • ESRCH 表示在系統當中沒有 thread 這個執行緒。這個宏包含在標頭檔案 <errno.h> 當中。

我們現在使用一個例子去測試一下返回值 ESRCH :

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

int main() {

  pthread_t t;
  int s = pthread_cancel(t);
  if(s == ESRCH)
    printf("No thread with the ID thread could be found.\n");
  return 0;
}

上述程式的會執行列印字串的語句,因為我們並沒有使用變數 t 去建立一個執行緒,因此執行緒沒有建立,返回對應的錯誤。

pthread_cancel 的執行

pthread_cancel 函數會傳送一個取消請求到指定的執行緒,執行緒是否響應這個執行緒取消請求取決於執行緒的取消狀態和取消型別。

兩種執行緒的取消狀態:

  • PTHREAD_CANCEL_ENABLE 執行緒預設是開啟響應取消請求,這個狀態是表示會響應其他執行緒傳送過來的取消請求,但是具體是如何響應,取決於執行緒的取消型別,預設的執行緒狀態就是這個值。

  • PTHREAD_CANCEL_DISABLE 當開啟這個選項的時候,呼叫這個方法的執行緒就不會響應其他執行緒傳送過來的取消請求。

兩種取消型別:

  • PTHREAD_CANCEL_DEFERRED 如果執行緒的取消型別是這個,那麼執行緒將會在下一次呼叫一個取消點的函數時候取消執行,取消點函數有 read, write, pread, pwrite, sleep 等函數,更多的可以網上搜尋,執行緒的預設取消型別就是這個型別。
  • PTHREAD_CANCEL_ASYNCHRONOUS 這個取消型別執行緒就會立即響應傳送過來的請求,本質上在 pthread 實現的程式碼當中是會給執行緒傳送一個訊號,然後接受取消請求的執行緒在訊號處理常式當中進行退出。

讓執行緒取消機制無效

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

void* func(void* arg)
{
  pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
  sleep(1);
  return NULL;
}

int main() {
  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_cancel(t);
  void* res;
  pthread_join(t, &res);
  if(res == PTHREAD_CANCELED)
  {
    printf("thread was canceled\n");
  }
  return 0;
}

上面的程式不會執行這句話 printf("thread was canceled\n"); 因為線上程當中設定了執行緒的狀態為不開啟執行緒取消機制,因此主執行緒傳送的取消請求無效。

在上面的程式碼當中使用的函數 pthread_setcancelstate 的函數簽名如下:

int pthread_setcancelstate(int state, int *oldstate)

其中第二個引數我們可以傳入一個 int 型別的指標,然後會將舊的狀態儲存到這個值當中。

取消點測試

在前文當中我們談到了,執行緒的取消機制是預設開啟的,但是當一個執行緒傳送取消請求之後,只有等到下一個是取消點的函數的時候,執行緒才會真正退出取消執行。

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

void* func(void* arg)
{
  // 預設是 enable  執行緒的取消機制是開啟的
  while(1);
  return NULL;
}

int main() {
  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_cancel(t);
  void* res;
  pthread_join(t, &res);
  if(res == PTHREAD_CANCELED)
  {
    printf("thread was canceled\n");
  }
  return 0;
}

如果我們去執行上面的程式碼我們會發現程式會進行迴圈,不會退出,因為雖然主執行緒給執行緒 t 傳送了一個取消請求,但是執行緒 t 一直在進行死迴圈操作,並沒有執行任何一個函數,更不用提是一個取消點函數了。

如果我們修改上面的程式碼成下面這樣,那麼執行緒就會正常執行退出:

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

void* func(void* arg)
{
  // 預設是 enable  執行緒的取消機制是開啟的
  // 執行緒的預設取消型別是 PTHREAD_CANCEL_DEFERRED
  while(1)
  {
    sleep(1);
  }
  return NULL;
}

int main() {
  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_cancel(t);
  void* res;
  pthread_join(t, &res);
  if(res == PTHREAD_CANCELED)
  {
    printf("thread was canceled\n");
  }
  return 0;
}

上面的程式碼唯一修改的地方就是線上程 t 當中的死迴圈處呼叫了 sleep 函數,而 sleep 函數是一個取消點函數,因此當主執行緒給執行緒 t 傳送一個取消請求之後,執行緒 t 就會在下一次呼叫 sleep 函數徹底取消執行,退出,並且執行緒的退出狀態為 PTHREAD_CANCELED ,因此主執行緒會執行程式碼 printf("thread was canceled\n");

非同步取消

現在我們來測試一下 PTHREAD_CANCEL_ASYNCHRONOUS 會出現什麼情況:

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

void* func(void* arg)
{
  // 預設是 enable  執行緒的取消機制是開啟的
  // 設定取消機制為非同步取消
  pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);
  while(1);
  return NULL;
}

int main() {
  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_cancel(t);
  void* res;
  pthread_join(t, &res);
  if(res == PTHREAD_CANCELED)
  {
    printf("thread was canceled\n");
  }
  return 0;
}

上面的程式是可以正確輸出字串 thread was canceled 的,因為線上程執行的函數 func 當中,我們設定了執行緒的取消機制為非同步機制,因為執行緒的預設取消型別是 PTHREAD_CANCEL_DEFERRED ,因此我們需要修改一下執行緒的預設取消型別,將其修改為 PTHREAD_CANCEL_ASYNCHRONOUS,即開始非同步取消模式。

從上面的例子當中我們就可以體會到執行緒取消的兩種型別的不同效果了。

執行緒取消的後續過程

當一個執行緒接受到其他執行緒傳送過來的一個取消請求之後,如果執行緒響應這個取消請求,即執行緒退出,那麼下面的幾件事兒將會依次發生:

  • clean-up handlers 將會倒序執行,我們在文章的後續當中將會舉具體的例子對這一點進行說明。
  • 執行緒私有資料的解構函式將會執行,如果有多個解構函式那麼執行順序不一定。
  • 執行緒終止執行,即執行緒退出。

clean-up handlers

首先我們需要了解一下什麼是 clean-up handlers。clean-up handlers 是一個或多個函數當執行緒被取消的時候這個函數將會被執行。如果沒有 clean-up handlers 函數被設定,那麼將不會呼叫。

clean-up handlers 介面

在 pthread 當中,有兩個函數與 clean-up handlers 相關:

 void pthread_cleanup_push(void (*routine)(void *), void *arg);
 void pthread_cleanup_pop(int execute);

首先我們來看一下函數 pthread_cleanup_push 的作用:這個函數是將傳進來的引數——一個函數指標 routine 放入執行緒取消的 clean-up handlers 的棧中,即將函數放到棧頂。

pthread_cleanup_pop 的作用是將 clean-up handlers 棧頂的函數彈出,如果 execute 是一個非 0 的值,那麼將會執行棧頂的函數,如果 execute == 0 ,那麼將不會執行彈出來的函數。

以下幾點是與上面兩個函數密切相關的特點:

  • 如果執行緒被取消了:

    • clean-up handlers 將會倒序依次執行,因為儲存 clean-up handlers 的是一個棧結構。

    • 執行緒私有資料的解構函式將會執行,如果有多個解構函式那麼執行順序不一定。

    • 執行緒終止執行,即執行緒退出。

  • 如果執行緒呼叫 pthread_exit 函數進行退出:

    • clean-up handlers 同樣的,將會倒序依次執行。
    • 執行緒私有資料的解構函式將會執行,如果有多個解構函式那麼執行順序不一定。
    • 執行緒終止執行,即執行緒退出。
  • 需要注意的是,如果線上程被取消或者呼叫 pthread_exit 之前,執行緒呼叫 pthread_cleanup_pop 函數彈出一些 handler 那麼這些 handler 將不會被執行,如果執行緒被取消或者呼叫 pthread_exit 退出,執行緒只會呼叫當前存在於棧中的 handler 。

  • 你可以會問為什麼 pthread 要給我提供這些機制,試想一下如果在我們的執行緒當中申請了一些資源,但是突然接收到了其他執行緒傳送過來的取消執行的請求,那麼這些資源改如何釋放呢?clean-up handlers 就給我們提供了一種機制幫助我們去釋放這些資源。

  • 如果執行緒執行的函數使用 return 語句返回,那麼 clean-up handlers 將不會被呼叫。

下面我們使用一個例子去了解上面的函數:


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

void handler1(void* arg)
{
  printf("in handler1 i1 = %d\n", *(int*)arg);
}

void handler2(void* arg)
{
  printf("in handler2 i2 = %d\n", *(int*)arg);
}

void* func(void* arg)
{
  int i1 = 1, i2 = 2;
  pthread_cleanup_push(handler1, &i1); // 函數在棧底
  pthread_cleanup_push(handler2, &i2); // 函數在棧頂

  printf("In func\n");
  pthread_cleanup_pop(0); // 棧頂的函數 因為傳入的引數等於 0 雖然棧頂的函數會被彈出 但是棧頂的函數 handler2 不會被呼叫 
  pthread_cleanup_pop(1); // 因為傳入的引數等於 0 因此棧頂的函數 handler1 會被呼叫 
  return NULL;
}

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

上面的函數的執行結果如下所示:

In func
in handler1 i1 = 1

在上面的程式當中我們首先建立了一個執行緒,讓執行緒執行函數 func,然後加入了兩個函數 handler1 和 handler2 作為 clean-up handler 。如果你使用了一個 pthread_cleanup_push 必須配套一個對應的 pthread_cleanup_pop 函數。

在函數 func 當中我們首先加入了兩個 handler 到 clean-up handler 棧當中,現在棧當中的資料結結構如下所示:

隨後我們會執行語句 pthread_cleanup_pop(0) ,因為引數 execute == 0 因此會從棧當中彈出這個函數,但是不會執行。

同樣的道理,在執行語句 pthread_cleanup_pop(1) 的時候不僅會彈出函數並且還會執行這個函數。

pthread_exit 與 clean-up handler

在前面的內容當中我們提到了,如果執行緒呼叫 pthread_exit 函數進行退出,clean-up handlers 將會倒序依次執行。我們使用下面的程式可以驗證這一點:

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

void handler1(void* arg)
{
  printf("in handler1 i1 = %d\n", *(int*)arg);
}

void handler2(void* arg)
{
  printf("in handler2 i2 = %d\n", *(int*)arg);
}


void* func(void* arg)
{
  int i1 = 1, i2 = 2;
  pthread_cleanup_push(handler1, &i1);
  pthread_cleanup_push(handler2, &i2);

  printf("In func\n");
  pthread_exit(NULL);
  pthread_cleanup_pop(0);
  pthread_cleanup_pop(1);
  return NULL;
}

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

上面的程式的輸出結果如下所示:

In func
in handler2 i2 = 2
in handler1 i1 = 1

從上面程式的輸出結果來看確實 clean-up handler 被逆序呼叫了。

pthread_cancel與 clean-up handler

在前面的內容當中我們提到了,如果執行緒呼叫 pthread_cancel 函數進行退出,clean-up handlers 將會倒序依次執行。我們使用下面的程式可以驗證這一點:


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

void handler1(void* arg)
{
  printf("in handler1 i1 = %d\n", *(int*)arg);
}

void handler2(void* arg)
{
  printf("in handler2 i2 = %d\n", *(int*)arg);
}


void* func(void* arg)
{
  int i1 = 1, i2 = 2;
  pthread_cleanup_push(handler1, &i1);
  pthread_cleanup_push(handler2, &i2);

  printf("In func\n");
  sleep(1);
  pthread_cleanup_pop(0);
  pthread_cleanup_pop(1);
  return NULL;
}

int main() 
{
  pthread_t t;
  pthread_create(&t, NULL, func, NULL);
  pthread_cancel(t);
  void* res;
  pthread_join(t, &res);
  if(res == PTHREAD_CANCELED)
  {
    printf("thread was cancelled\n");
  }
  return 0;
}

上面程式的資料結果如下所示:

In func
in handler2 i2 = 2
in handler1 i1 = 1
thread was cancelled

從上面的輸出結果來看,執行緒確實被取消了,而且 clean-up handler 確實也被逆序呼叫了。

執行緒私有資料(thread local)

在 pthread 當中給我們提供了一種機制用於設定執行緒的私有資料,我們可以通過這個機制很方便的去處理一下執行緒私有的資料和場景。與這個機制有關的主要有四個函數:

int pthread_key_create(pthread_key_t *key,void(*destructor)(void*));
int pthread_key_delete(pthread_key_t key);
int pthread_setspecific(pthread_key_t key, const void * value);
void * pthread_getspecific(pthread_key_t key);
  • pthread_key_create : 這個函數的作用主要是建立一個全域性的,所有執行緒可見的一個 key ,然後所有的執行緒可以通過這個 key 建立一個執行緒私有的資料,並且我們可以設定一個解構函式 destructor,當程式退出或者被取消的時候,如果這個解構函式不等於 NULL ,而且執行緒私有資料不等於 NULL,那麼就會被呼叫,並且將執行緒私有私有資料作為引數傳遞給解構函式。
  • pthread_key_delete : 刪除使用 pthread_key_create 建立的 key 。
  • pthread_setspecific : 通過這個函數設定對應 key 的具體的資料,傳入的引數是一個指標 value,如果我們在後續的程式碼當中想要使用這個變數的話,那麼就可以使用函數 pthread_getspecific 得到對應的指標。
  • pthread_getspecific : 得到使用 pthread_setspecific 函數當中設定的指標 value 。

我們現在使用一個具體的例子深入理解執行緒私有資料:



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

pthread_key_t key;

void key_destructor1(void* arg) 
{
  printf("arg = %d thread id = %lu\n", *(int*)arg, pthread_self());
  free(arg);
}


void thread_local() 
{
  int* q = pthread_getspecific(key);
  printf("q == %d thread id = %lu\n", *q, pthread_self());
}


void* func1(void* arg)
{
  printf("In func1\n");
  int* s = malloc(sizeof(int));
  *s = 100;
  pthread_setspecific(key, s);
  thread_local();
  printf("Out func1\n");
  return NULL;
}

void* func2(void* arg)
{
  printf("In func2\n");
  int* s = malloc(sizeof(int));
  *s = -100;
  pthread_setspecific(key, s);
  thread_local();
  printf("Out func2\n");
  return NULL;
}

int main() {
  pthread_key_create(&key, key_destructor1);
  pthread_t t1, t2;
  pthread_create(&t1, NULL, func1, NULL);
  pthread_create(&t2, NULL, func2, NULL);

  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  pthread_key_delete(key);
  return 0;
}

上面的程式的執行的一種結果如下:

In func1
In func2
q == -100 thread id = 140082109499136
Out func2
arg = -100 thread id = 140082109499136
q == 100 thread id = 140082117891840
Out func1
arg = 100 thread id = 140082117891840

在上面的程式當中我們首先定一個全域性變數 key,然後使用 pthread_key_create 函數進行建立,啟動了兩個執行緒分別執行函數 func1 和 func2 ,在兩個函數當中都建立了一個執行緒私有變數(使用函數 pthread_setspecific 進行建立),然後這兩個執行緒都呼叫了同一個函數 thread_local ,但是根據上面的輸出結果我們可以知道,雖然是兩個執行緒呼叫的函數都相同,但是不同的執行緒呼叫輸出的結果是不同的(通過觀察執行緒的 id 就可以知道了),而且結果是我們設定的執行緒區域性變數,現在我們應該能夠體會這執行緒私有資料的效果了。

在前面的內容當中我們提到了,當一個執行緒被取消的時候,第二步操作就是呼叫執行緒私有資料的解構函式。



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

pthread_key_t key;

void key_destructor1(void* arg) 
{
  printf("arg = %d thread id = %lu\n", *(int*)arg, pthread_self());
  free(arg);
}


void thread_local() 
{
  int* q = pthread_getspecific(key);
  printf("q == %d thread id = %lu\n", *q, pthread_self());
}

void* func1(void* arg)
{
  printf("In func1\n");
  int* s = malloc(sizeof(int));
  *s = 100;
  pthread_setspecific(key, s);
  thread_local();
  printf("Out func1\n");
  sleep(2);
  printf("func1 finished\n");
  return NULL;
}

void* func2(void* arg)
{
  printf("In func2\n");
  int* s = malloc(sizeof(int));
  *s = -100;
  pthread_setspecific(key, s);
  thread_local();
  printf("Out func2\n");
  sleep(2);
  printf("func2 finished\n");
  return NULL;
}


int main() {
  pthread_key_create(&key, key_destructor1);
  pthread_t t1, t2;
  pthread_create(&t1, NULL, func1, NULL);
  pthread_create(&t2, NULL, func2, NULL);
  sleep(1);
  pthread_cancel(t1);
  pthread_cancel(t2);
  void* res1, *res2;
  pthread_join(t1, &res1);
  pthread_join(t2, &res2);
  if(res1 == PTHREAD_CANCELED) 
  {
    printf("thread1 was canceled\n");
  }

  if(res2 == PTHREAD_CANCELED) 
  {
    printf("thread2 was canceled\n");
  }
  pthread_key_delete(key);
  return 0;
}

上面的程式的輸出結果如下所示:

In func1
In func2
q == 100 thread id = 139947700033280
Out func1
q == -100 thread id = 139947691640576
Out func2
arg = 100 thread id = 139947700033280
arg = -100 thread id = 139947691640576
thread1 was canceled
thread2 was canceled

這一個程式和第一個執行緒私有的範例程式不一樣的是,上面的程式在主執行緒當中取消了兩個執行緒 t1 和 t2 的執行,從上面的程式的輸出結果我們也可以產出兩個執行緒的程式碼並沒有完全執行成功,而且執行緒的退出狀態確實是 PTHREAD_CANCELED 。我們可以看到的是兩個執行緒的解構函式也被呼叫了,這就可以驗證了我們在前面提到的,當一個執行緒退出或者被取消執行的時候,執行緒的執行緒本地資料的解構函式會被呼叫,而且傳入個解構函式的引數是執行緒本地資料的指標,我們可以在解構函式當中釋放對應的資料的空間,回收記憶體。

總結

在本篇文章當中主要給大家深入介紹了執行緒取消機制的各種細節,並且使用一些測試程式去一一驗證了對應的具體現象,整個執行緒的取消機制總結起來並不複雜,具體如下:

  • 執行緒可以設定是否響應其他執行緒傳送過來的取消請求,預設是開啟響應。
  • 執行緒可以設定響應取消執行的型別,一種是非同步執行,這種狀態是通過訊號實現的,執行緒接受到訊號之後會立馬退出執行,一種是 PTHREAD_CANCEL_DEFERRED 只有在下一次遇到是取消點的函數的時候才會退出執行緒的執行。
  • 當執行緒被取消執行了,clean-up handlers 將會倒序依次執行。
  • 當執行緒被取消執行了,執行緒私有資料的解構函式也會被執行。

我們可以使用下面的流程圖來表示整個流程:(以下圖片來源於網路)

在本篇文章當中主要介紹了一些基礎了執行緒自己的特性,並且使用一些例子去驗證了這些特性,幫助我們從根本上去理解執行緒,其實執行緒涉及的東西實在太多了,在本篇文章裡面只是列舉其中的部分例子進行使用說明,在後續的文章當中我們會繼續深入的去談這些機制,比如執行緒的排程,執行緒的取消,執行緒之間的同步等等。

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

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