[單刷APUE系列]第十一章——執行緒[1]

山河永寂發表於2019-05-13

執行緒概念

在前面的章節,都是以多程式單執行緒概念來講解的,特別是早期的Unix環境,沒有引入執行緒模型,所以無所謂執行緒概念,也就是一個程式在某一時刻只能做一件事情,而多執行緒則是可以讓程式擁有多個執行緒,這樣程式就能在某一時刻做不止一件事情。執行緒的好處和缺點就不多說了,相信各位應該都有體會了。
當然,多執行緒和多處理器或者多核是無關的,多執行緒的出現是為了解決非同步和並行,即使是執行在單核心上,也能得到效能提升,例如,當IO執行緒處於阻塞狀態,其他的執行緒就能搶佔CPU,從而得到資源有效利用。
在前面的章節中,也介紹了程式記憶體空間是如何的,具體包含了那些內容,而多執行緒的引入,則將其內容擴充了。通常情況下,談論Unix環境的多執行緒就是特指pthread,一個程式在啟動的時候只有一個控制執行緒,而使用者可以通過系統提供的API建立管理執行緒,實際上,執行緒是非常輕量化的,程式的正文段、資料段等實際上都是共享的,包括了全域性記憶體啊檔案描述符啊,這些資源實際上都是共享的,也就是說,執行緒雖然建立管理銷燬很容易,但是也會導致資源搶佔的問題,執行緒主要是在核心空間中暫存器等東西需要佔用記憶體。

執行緒標識

就像程式ID一樣,執行緒也有自己的ID,叫做執行緒ID。程式ID相對於這個系統而言,而執行緒ID則是相對於程式ID而言,兩個程式的同一個執行緒ID是沒有可比性的。
在現代的Unix環境中,系統已經提供了pthread_t資料型別作為執行緒ID的儲存型別,由於不同的Unix環境的實現不同,有些是使用整形,有些是使用一個結構體,所以為了保證可移植性,我們不能直接去操作這個資料型別。

int pthread_equal(pthread_t t1, pthread_t t2);
pthread_t pthread_self(void);

一個是比較函式,一個是獲取執行緒自身的執行緒ID,當然,由於執行緒ID的資料結構不確定性,所以在除錯輸出的時候很麻煩,通常的做法就是使用第三方除錯庫,或者自己寫一個除錯函式,根據當前系統來確定是輸出結構體還是整形。

執行緒建立

前面說過,程式建立的時候一般只有一個執行緒,當需要多執行緒的時候需要開發者自行呼叫函式庫來建立管理銷燬,新的執行緒建立函式如下

int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void *), void *restrict arg);

其實原著中有一些翻譯錯誤,例如,原著中這麼寫

當 pthread_create 成功返回時,新建立執行緒的執行緒ID會被設定成 tidp 指向的記憶體單元

這句話非常讓人費解,實際上Unix手冊是這麼講的

The pthread_create() function is used to create a new thread, with attributes specified by attr, within a process.  If attr is NULL, the default attributes are used.  If the attributes specified by attr are modified later, the thread`s attributes are not affected.  Upon successful completion, pthread_create() will store the ID of the created thread in the location specified by thread.

pthread_create函式被用來建立一個新的執行緒,並且會應用attr引數指定的屬性,如果attr引數為null,則會使用預設的屬性,後續對attr引數的修改不會影響以建立執行緒的屬性。當函式成功返回的時候,pthread_create函式將會把執行緒ID儲存在thread引數的記憶體位置。這樣大家應該就明白了。

Upon its creation, the thread executes start_routine, with arg as its sole argument.  If start_routine returns, the effect is as if there was an implicit call to pthread_exit(), using the return value of start_routine as the exit status.  Note that the thread in which main() was originally invoked differs from this.  When it returns from main(), the effect is as if there was an implicit call to exit(), using the return value of main() as the exit status.

當建立後,執行緒執行start_routine引數指定的函式,並且將arg引數作為其唯一引數,如果start_routine函式返回了,就是隱含了pthread_exit()函式的呼叫,並且將start_routine函式的返回值作為退出狀態。注意,main函式中喚起的執行緒和這種方式建立的執行緒是有區別的,當main函式返回的時候,就隱含了exit()函式的呼叫,並且將main函式的返回值當做退出狀態。

執行緒終止

執行緒其實可以當做輕量級的程式,程式如果呼叫了exit_Exit或者_exit,則程式會終止,而執行緒也可以終止,單個執行緒可以使用一下三種方式退出

  1. 執行緒返回,返回值是執行緒退出碼

  2. 執行緒被同一程式的其他執行緒取消

  3. 執行緒呼叫pthread_exit

void pthread_exit(void *value_ptr);

The pthread_exit() function terminates the calling thread and makes the value value_ptr available to any successful join with the terminating thread.

從上面我們好像看到了一些新的內容,提到了successful join,其實是一個類似wait的函式。

int pthread_join(pthread_t thread, void **value_ptr);

前面提到了執行緒有三種方式結束,執行緒返回、執行緒取消、使用pthread_exit函式。如果是簡單的返回,那麼rval_ptr就會包含返回碼,如果執行緒被取消,則rval_ptr將被設定為PTHREAD_CANCELED

#include "include/apue.h"
#include <pthread.h>

void *thr_fn1(void *arg)
{
    printf("thread 1 returning
");
    return((void *)1);
}

void *thr_fn2(void *arg)
{
    printf("thread 2 exiting
");
    pthread_exit((void *)2);
}

int main(int argc, char *argv[])
{
    int err;
    pthread_t tid1, tid2;
    void *tret;
    
    err = pthread_create(&tid1, NULL, thr_fn1, NULL);
    if (err != 0)
        err_exit(err, "can`t create thread 1");
    err = pthread_create(&tid2, NULL, thr_fn2, NULL);
    if (err != 0)
        err_exit(err, "can`t create thread 2");
    err = pthread_join(tid1, &tret);
    if (err != 0)
        err_exit(err, "can`t join with thread 1");
    printf("thread 1 exit code %ld
", (long)tret);
    err = pthread_join(tid2, &tret);
    if (err != 0)
        err_exit(err, "can`t join with thread 2");
    printf("thread 2 exit code %ld
", (long)tret);
    exit(0);
}

執行後的結果如下

thread 1 returning
thread 2 exiting
thread 1 exit code 1
thread 2 exit code 2

除了能看到pthread_exit和return都是一樣的效果以外,我們還能發現一些書上沒有提到的東西。比如,執行緒退出後依舊會等待程式進行清理工作,或者我們可以類比父子程式,主執行緒建立了子執行緒,所以子執行緒需要等待父執行緒使用函式清理回收,而且pthread_join函式是一個阻塞函式,當然,實際的執行緒工作當然不是如同這樣的。
在對執行緒函式的檢視中我們可以看到,無論是引數還是返回值,都是一個無型別指標,這代表著我們可以傳遞任何的資料。但是,請記住,C語言程式設計是存在棧分配和堆分配的,如果是棧分配的變數,我們需要考慮到訪問的時候記憶體是否已經被回收了,所以,像這類的情況,基本都是使用堆分配變數手動管理記憶體的。

int pthread_cancel(pthread_t thread);

pthread_cancel函式會發起一個取消請求給thread引數指定的執行緒,目標執行緒的取消狀態和型別確定了取消過程發生的時間。當取消過程生效的時候,目標執行緒的取消清理函式將會被呼叫。當最後一個取消清理函式返回的時候,指定執行緒的資料解構函式將會被呼叫,當最後一個資料解構函式返回的時候,執行緒將會終止。
當然,pthread_cancel函式是非同步請求,所以不會等待執行緒的完全終止。最終如果使用pthread_join函式偵聽執行緒結束,實際上會得到PTHREAD_CANCELED常量。

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

就像程式退出會有程式清理函式一樣,執行緒退出也會有執行緒清理函式,從上面的函式名稱中也能猜出來實際上使用的是棧來儲存函式指標。也就是說,註冊的順序和呼叫的順序是反過來的。
pthread_cleanup_push函式將routine函式指標壓入棧頂,噹噹前執行緒退出的時候被呼叫,換言之,這個函式實際上是針對當前執行緒的行為。
pthread_cleanup_pop函式彈出當前棧頂的routine清理函式,如果execute引數為非0,將會執行這個清理函式,如果不存在清理函式,則pthread_cleanup_pop將不會做任何事情。

pthread_cleanup_push() must be paired with a corresponding pthread_cleanup_pop(3) in the same lexical scope.

pthread_cleanup_push函式需要和pthead_cleanup_pop函式在一個作用域內配對使用,原著對此給出的解釋是這兩個函式可能是以巨集定義的形式實現的。
注意:這兩個函式只會在pthraed_exit()返回的時候被呼叫,如果是執行緒函式返回,則不會呼叫。
而且,經過實際測試,蘋果系統下確實是通過巨集定義實現這兩個函式的。所以,如果在這兩個函式尚未呼叫的時候就返回的話,會導致段錯誤。根據猜想,應該是返回的時候棧被改寫了,但是清理函式仍然會繼續呼叫。
以下是我自己的程式碼

#include "include/apue.h"
#include <pthread.h>

void cleanup(void *arg)
{
    printf("cleanup: %s
", (char *)arg);
}

void *thr_fn1(void *arg)
{
    printf("thread 1 start
");
    pthread_cleanup_push(cleanup, "thread 1 first handler");
    pthread_cleanup_push(cleanup, "thread 1 second handler");
    printf("thread 1 push complete
");
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    return((void *)1);
}

void *thr_fn2(void *arg)
{
    printf("thread 2 start
");
    pthread_cleanup_push(cleanup, "thread 2 first handler");
    pthread_cleanup_push(cleanup, "thread 2 second handler");
    printf("thread 2 push complete
");
    if (arg)
        pthread_exit((void *)2);
    pthread_cleanup_pop(0);
    pthread_cleanup_pop(0);
    pthread_exit((void *)2);
}

int main(int argc, char *argv[])
{
    int err;
    pthread_t tid1, tid2;
    void *tret;
    
    err = pthread_create(&tid1, NULL, thr_fn1, (void *)1);
    if (err != 0)
        err_exit(err, "can`t create thread 1");
    err = pthread_create(&tid2, NULL, thr_fn2, (void *)1);
    if (err != 0)
        err_exit(err, "can`t create thread 2");
    err = pthread_join(tid1, &tret);
    if (err != 0)
        err_exit(err, "can`t join with thread 1");
    printf("thread 1 exit code %ld
", (long)tret);
    err = pthread_join(tid2, &tret);
    if (err != 0)
        err_exit(err, "can`t join with thread 2");
    printf("thread 2 exit code %ld
", (long)tret);
    exit(0);
}

筆者在這裡將原著的第一個執行緒的程式碼改了,令其能執行完pthread_cleanup_pop()函式以後在執行return語句,就不存在錯誤了,但是依舊不會執行清理程式碼。

~/Development/Unix » ./a.out
thread 1 start
thread 2 start
thread 1 push complete
thread 2 push complete
cleanup: thread 2 second handler
cleanup: thread 2 first handler
thread 1 exit code 1
thread 2 exit code 2

所以在開發中,如果使用了清理函式,則應當使用pthread_exit()函式返回。
我們知道,程式如果終止了,則需要父程式執行清理工作,而執行緒如果終止了,那麼執行緒的終止狀態將會儲存直到pthread_join函式的呼叫,但是如果使用pthread_detach函式將執行緒分離,則執行緒退出時候將會立刻回收儲存資源

int pthread_detach(pthread_t thread);

The pthread_detach() function is used to indicate to the implementation that storage for the thread thread can be reclaimed when the thread terminates.  If thread has not terminated, pthread_detach() will not cause it to terminate.  The effect of multiple pthread_detach() calls on the same target thread is unspecified.

pthread_detach函式被用來標識一個執行緒可以在終止後回收儲存空間,如果執行緒沒有終止,pthread_detach將不會導致執行緒終止。
實際上是這樣的,當一個執行緒建立的時候,預設是joinable的,所以就像程式一樣,如果終止了,則需要手動使用pthread_join函式偵聽返回值並且回收空間,但是在很多情況下,我們建立執行緒後,不會去管後續,所以就需要使用這個函式對其進行分離。

相關文章