Pthread 並發編程(三)——深入理解線程取消機制

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 將會倒序依次執行。
  • 當線程被取消執行了,線程私有數據的析構函數也會被執行。

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

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

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

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