初始多執行緒

初始多執行緒

一、基本概念

1.1 應用程式

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

1.2 進程

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

image-20221004001922323

進程的特點:

  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;它們在時間上是不可能發生重疊的。

image-20221004002625501

2.1.2 並行

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

image-20221004002744435

2.2 多執行緒

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

image-20221005215001689

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

所謂多執行緒,即一個進程中擁有多個執行緒(≥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。

運行結果如下:

image-20221004150207736

上述程式碼中,我們通過在 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;
}

運行結果:

image-20221004151246605

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;
}

運行結果如下:

image-20221005212343247

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

但如果在「第 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 錯誤。

參考資料

Tags: