初始多執行緒

2022-10-06 12:01:28

初始多執行緒

一、基本概念

1.1 應用程式

以 Windows 為例,一個拓展名為 .exe 的檔案就是一個應用程式,應用程式是能夠雙擊執行的。

1.2 程序

應用程式執行起來就建立了一個程序,即程序就是執行起來的應用程式;如電腦上執行的 Edge、Typora、PotPlayer 等。

程序的特點:

  1. 一個程序至少包含一個執行緒(主執行緒,main)。
  2. 可以包含多個執行緒(主執行緒+若干子執行緒)。
  3. 所有執行緒共用程序的資源。

1.3 執行緒

1.3.1 執行緒概念

我們知道,一個程序指的是一個正在執行的應用程式;而執行緒則是執行程序中的某個具體任務,比如一段程式、一個函數等。

程序想要執行任務就需要依賴執行緒;換句話說,執行緒就是程序中的最小執行單位,並且一個程序中至少有一個執行緒(主執行緒)。

1.3.2 主執行緒

  1. 每個程序都有一個主執行緒,這個主執行緒是唯一的。
  2. 當你執行了一個應用程式產生了一個程序後,這個主執行緒就隨著這個程序默默地啟動起來了。
  3. 主執行緒的生命週期與程序的生命週期相同,它倆同時存在、同時結束,是脣齒相依的關係。
  4. 一個程序只能有一個主執行緒,就像一個專案中只能有一個 main 函數一樣。

1.4 程序和執行緒的關係

執行緒和程序之間的關係,類似於工廠和工人之間的關係:

  • 程序好比是工廠,執行緒就如同工廠中的工人。
  • 一個工廠可以容納多個工人,工廠負責為所有工人提供必要的資源(電力、產品原料、食堂、廁所等),所有工人共用這些資源。
  • 每個工人負責完成一項具體的任務,他們相互配合,共同保證整個工廠的平穩執行。

程序僅負責為各個執行緒提供所需的資源,真正執行任務的是執行緒,而不是程序。

二、多執行緒概念

提到多執行緒這裡要說兩個概念,就是序列和並行,搞清楚這個,我們才能更好地理解多執行緒。

2.1 序列和並行

2.1.1 序列

所謂序列,其實是相對於單條執行緒來執行多個任務來說的,我們就拿下載檔案來舉個例子:當我們下載多個檔案時,在序列中它是按照一定的順序去進行下載的,也就是說,必須等下載完 A 之後才能開始下載 B;它們在時間上是不可能發生重疊的。

2.1.2 並行

下載多個檔案,多個檔案同時進行下載;這裡是嚴格意義上的,在同一時刻發生的,並行在時間上是重疊的。

2.2 多執行緒

瞭解了序列和並行這兩個概念之後,我們再來說說什麼是多執行緒。舉個例子,我們開啟騰訊管家,騰訊管家本身就是一個應用程式,也就是說它就是一個程序,它裡面有很多的功能,我們可以看下圖,能查殺病毒、清理垃圾、電腦加速等眾多功能:

按照單執行緒來說,無論你想要清理垃圾、還是要病毒查殺,那麼你必須先做完其中的一件事,才能做下一件事,這裡面是有一個執行順序的。如果是多執行緒的話,我們其實在清理垃圾的時候,還可以進行查殺病毒、電腦加速等等其他的操作,這個是嚴格意義上的同一時刻發生的,沒有執行上的先後順序。

所謂多執行緒,即一個程序中擁有多個執行緒(≥2,主執行緒+若干子執行緒),執行緒之間相互共同作業、共同執行一個應用程式。

三、多執行緒程式設計

我們通常將以「多執行緒」方式編寫的程式稱為「多執行緒程式」,將編寫多執行緒程式的過程稱為「多執行緒程式設計」,將擁有多個執行緒的程序稱為「多執行緒程序」。

PS:以下程式碼是在 Linux 下執行的。

3.1 pthread_t

定義:typedef unsigned long int pthread_t;

功能:用於宣告執行緒ID,是一個執行緒識別符號。

3.2 pthread_create()

3.2.1 函數介紹

函數原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

頭 文 件:#include <pthread.h>

功能介紹:用來建立一個執行緒

引數介紹

  1. 第一個引數為指向執行緒識別符號的指標
  2. 第二個引數用來設定執行緒屬性,一般置為 NULL,表示使用預設屬性
  3. 第三個引數是執行緒執行的函數,返回值為 void *
  4. 最後一個引數是執行緒執行函數的引數

返 回 值

  • 當建立執行緒成功時,函數返回0
  • 若不為 0 則說明建立執行緒失敗,常見的錯誤返回程式碼為 EAGAIN 和 EINVAL:
    • 前者表示系統限制建立新的執行緒,例如執行緒數目過多了
    • 後者表示第二個引數代表的執行緒屬性值非法

3.2.2 牛刀小試

下面我們通過程式碼來深入理解如何建立一個子執行緒。

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

/* 執行緒執行函數 */
void *func(void *arg)
{
    int i;
    for (i = 1; i <= 10; i++) //該函數執行動作:列印 10 次 func
    {
        printf("func[%d]\n", i);
    }
    return NULL;
}

int main()
{
    printf("主執行緒開始執行\n");

    pthread_t th; //定義一個執行緒識別符號

    /* 使用預設屬性建立執行緒,該執行緒執行 func 函數 */
    if (0 != pthread_create(&th, NULL, func, NULL))
    {
        /* 輸出錯誤紀錄檔並列印相應的錯誤碼 */
        printf("fail, errno[%d, %s], \n", errno, strerror(errno));
    }

    /* 阻塞主執行緒,不然,主執行緒馬上結束,從而使建立的執行緒沒有機會開始執行就結束了 */
    sleep(2);
    
    printf("主執行緒結束執行\n");
    return 0;
}

注意:編寫 Linux 下的多執行緒程式時,需要使用標頭檔案pthread.h,連線時需要使用靜態庫 libpthread.a。

執行結果如下:

上述程式碼中,我們通過在 main 中新增 sleep 來阻塞主執行緒,以此保證子執行緒可以正常執行並終止;但通過 sleep 的方式阻塞主執行緒多少會影響程式效率,所以我們需要換一種方式來阻塞主執行緒。

3.3 pthread_join()

3.3.1 小栗子

在講解 pthread_join 前,我們先來通過一個小栗子初步體驗一下為何需要 pthread_join。

場景 1

在簡單的程式中一般只需要一個執行緒就可以搞定,也就是主執行緒:

int main()
{
    printf("主執行緒開始執行\n");

    return 0;
}

現在假設我要做一個比較耗時的工作,從一個伺服器下載一個視訊並進行處理,那麼我的程式碼會變成:

int main()
{
    printf("主執行緒開始執行\n");
    download(); // 下載視訊到本地
    process();  // 視訊處理
    
    return 0;
}

場景 2

如果我需要下載兩個視訊素材,一起在本地進行處理,也很簡單:

int main()
{
    printf("主執行緒開始執行\n");
    download1();    //下載視訊 1
    download2();    //下載視訊 2
    process();      //處理視訊 1、2

    return 0;
}

本身這麼做完全沒有問題,可是就是有點浪費時間,如果兩個視訊能夠同時下載就好了,這時候執行緒就派上了用場。

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

void *download1(void *arg)
{
    puts("子執行緒開始下載第一個視訊...");
    sleep(6);  // 耗時 6s
    puts("第一個視訊下載完成");
}
void *download2(void *arg)
{
    puts("主執行緒開始下載第二個視訊...");
    sleep(10);  // 耗時 10s
    puts("第二個視訊下載完成");

    return NULL;
}
void process()
{
    puts("開始處理兩個視訊...");
    sleep(3);  // 耗時 3s
    puts("處理完成");
}
int main()
{
    printf("主執行緒開始執行\n");

    pthread_t th;
    pthread_create(&th, NULL, download1, NULL);     // 子執行緒下載視訊 1

    download2(NULL);                                // 主執行緒下載視訊 2
    
    process();//處理視訊1、2

    return 0;
}

主執行緒叫來了 th 這個執行緒去下載「視訊 1」,自己去下載「視訊 2」;減輕了自己的工作量也縮短了時間。

通過download函數的對比,可以發現,兩個視訊同時下載肯定是「視訊 1」先下載完,這樣在主執行緒下載完「視訊 2」的時候,「視訊 1」已經準備好了,後面就可以一起進行處理,這沒什麼問題。

但是萬一「視訊 1」的下載時間比「視訊 2」的時間長呢(比如下載「視訊 2」僅需要耗費 3s 的時間)?當「視訊 2」下載完成了,但此時子執行緒 th 還沒幹完活,本地還沒有「視訊 1」,那麼接下來處理的時候肯定會有問題,或者說接下來不能直接進行處理,要等 th 幹完活後,主執行緒中的process函數才能去處理這兩個視訊。

在這種場景下就用到了pthread_join()這個函數。

3.3.2 pthread_join介紹

函數原型:int pthread_join(pthread_t thread, void **retval);

頭 文 件:#include <pthread.h>

功能介紹:用來等待一個執行緒的結束

引數介紹

  1. 第一個引數為被等待的執行緒識別符號
  2. 第二個引數為一個使用者定義的指標,它可以用來儲存被等待執行緒的返回值

返 回 值

  • On success, pthread_join() returns 0
  • On error, it returns an error number

這個函數是一個執行緒阻塞的函數,呼叫它的函數將一直等待到被等待的執行緒結束為止,當函數返回時,被等待執行緒的資源被收回。

3.3.3 呼叫 pthread_join

下面,我們通過 pthread_join 修改一下「場景 2」的程式碼:

void *download1(void *arg)
{
    puts("子執行緒開始下載第一個視訊...");
    sleep(6);  // 耗時 6s
    puts("第一個視訊下載完成");
}
void *download2(void *arg)
{
    puts("主執行緒開始下載第二個視訊...");
    sleep(3);  // 耗時 3s
    puts("第二個視訊下載完成");

    return NULL;
}
int main()
{
    printf("主執行緒開始執行\n");

    pthread_t th;
    pthread_create(&th, NULL, download1, NULL);     // 子執行緒下載視訊 1

    download2(NULL);                                // 主執行緒下載視訊 2
    
    pthread_join(th, NULL);                         // 阻塞主執行緒,直到「視訊 1」下載完成

    process();//處理視訊1、2

    return 0;
}

現在下載「視訊 1」需要 6s,下載「視訊 2」需要 3s;當「視訊 2」下載完成後要等待「視訊 1」下載完成方可一起進行處理,為了實現這個目的,我們在第 24 行加入了pthread_join()

在這個場景下,我們明確兩個事情:

Q1:誰呼叫了pthread_join函數?

  • th 這個執行緒物件呼叫了pthread_join函數,因此必須等待 th 的下載任務結束了,pthread_join()才能返回。

Q2:在哪個執行緒環境下呼叫了pthread_join函數?

  • th 是在主執行緒的環境下呼叫了pthread_join函數的,因此主執行緒要等待 th 的工作做完,否則主執行緒將一直處於阻塞狀態。

這裡不要搞混的是子執行緒 th 真正做的任務(下載「視訊 1」)是在另一個執行緒中做的;但是 th 呼叫pthread_join函數的動作是在主執行緒環境下做的。

3.3.4 獲取執行緒任務的返回值

子執行緒執行的函數在結束後可能會有返回值:

#define STRING_LEN_24 24

/* 執行緒執行函數 */
void *func(void *arg)
{
    int i;
    for (i = 1; i <= 10; i++) //該函數執行動作:列印 10 次 func
    {
        printf("func[%d]\n", i);
    }

    char *buf = (char *)malloc(STRING_LEN_24);
    strncpy(buf, "The child thread ends", STRING_LEN_24 - 1);

    return buf;
}

這種情況下,該如何處理呢?還記得pthread_join()函數的第二個引數嗎?這個引數就是用來儲存執行緒函數的返回值的:

int main()
{
    printf("主執行緒開始執行\n");

    pthread_t th;

    if (0 != pthread_create(&th, NULL, func, NULL))
    {
        printf("fail, errno[%d, %s], \n", errno, strerror(errno));
    }

    char *buf;
    pthread_join(th, (void **)&buf);
    printf("子執行緒返回值[%s]\n", buf);

    printf("主執行緒結束執行\n");
    return 0;
}

執行結果:

3.3.5 及時釋放資源

引入一個新的概念:執行緒分離(detach)和非分離(join)狀態。執行緒的分離狀態決定一個執行緒以什麼樣的方式來終止自己。

在預設情況下執行緒是非分離狀態的,這種情況下,原有的執行緒等待建立的執行緒結束。只有當pthread_join()函數返回時,建立的執行緒才算終止,才能釋放自己佔用的系統資源。也就是說,通過預設屬性建立的執行緒必須要通過呼叫pthread_join()函數來釋放執行緒資源,換句話說,非分離狀態的執行緒一定要呼叫pthread_join()函數。

對於非分離狀態的執行緒,如果不及時呼叫pthread_join()函數,則會導致資源洩露。下面就通過建立大量非分離狀態的執行緒,但不呼叫pthread_join()函數來觀察會出現什麼情況。

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

/* 執行緒函數,不作任何操作 */
void *func(void *arg)
{
    return NULL;
}

int main()
{
    int i;
    for (i = 1; ; i++)
    {
        pthread_t th;
        if (0 != pthread_create(&th, NULL, func, NULL))
        {
            printf("fail, errno[%d, %s]\n", errno, strerror(errno));
            break;
        }
        
        printf("pthread create succeed[%d]\n", i);
    }

    return 0;
}

執行結果如下:

通過執行結果可以看出,未及時釋放執行緒導致記憶體資源耗盡,進而導致執行緒建立失敗。

但如果在「第 23 行」新增pthread_join(th, NULL);程式碼,則可以避免這種情況的發生。

3.4 pthread_detach()

函數原型:int pthread_detach(pthread_t thread);

頭 文 件:#include <pthread.h>

功能介紹:從狀態上實現執行緒分離

引數介紹:執行緒識別符號

返 回 值

  • On success, pthread_detach() returns 0
  • On error, it returns an error number

在「3.3.5 及時釋放資源」時提到了兩個概念:執行緒分離狀態和執行緒非分離狀態。預設建立的執行緒為非分離狀態,那麼如何設定執行緒為分離狀態呢?有兩種方式:

  1. 呼叫pthread_detach()函數。
  2. 通過pthread_create()函數的第二個引數來設定執行緒分離。

一般情況下,執行緒終止後,其終止狀態一直保留到其它執行緒呼叫pthread_join()獲取它的狀態為止(或者程序終止被回收了)。但是執行緒也可以被置為 detach 狀態;如果執行緒被設定為了分離狀態,那麼該執行緒主動與主控執行緒斷開關係。執行緒結束後(不會產生殭屍執行緒),其退出狀態不由其他執行緒獲取,而直接自己自動釋放

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

/* 執行緒函數,不作任何操作 */
void *func(void *arg)
{
    return NULL;
}

int main()
{
    int i;
    for (i = 1; ; i++)
    {
        pthread_t th;
        if (0 != pthread_create(&th, NULL, func, NULL))
        {
            printf("fail, errno[%d, %s]\n", errno, strerror(errno));
            break;
        }
        pthread_detach(th);//使用 pthread_detach 函數實現執行緒分離
        printf("pthread create succeed[%d]\n", i);
    }

    return 0;
}

注意:不能對一個已經處於 detach 狀態的執行緒呼叫 pthread_join(),這樣的呼叫將返回 EINVAL 錯誤。

參考資料