初始多執行緒
初始多執行緒
一、基本概念
1.1 應用程式
以 Windows 為例,一個拓展名為 .exe 的文件就是一個應用程式,應用程式是能夠雙擊運行的。
1.2 進程
應用程式運行起來就創建了一個進程,即進程就是運行起來的應用程式;如電腦上運行的 Edge、Typora、PotPlayer 等。
進程的特點:
- 一個進程至少包含一個執行緒(主執行緒,main)。
- 可以包含多個執行緒(主執行緒+若干子執行緒)。
- 所有執行緒共享進程的資源。
1.3 執行緒
1.3.1 執行緒概念
我們知道,一個進程指的是一個正在執行的應用程式;而執行緒則是執行進程中的某個具體任務,比如一段程式、一個函數等。
進程想要執行任務就需要依賴執行緒;換句話說,執行緒就是進程中的最小執行單位,並且一個進程中至少有一個執行緒(主執行緒)。
1.3.2 主執行緒
- 每個進程都有一個主執行緒,這個主執行緒是唯一的。
- 當你運行了一個應用程式產生了一個進程後,這個主執行緒就隨著這個進程默默地啟動起來了。
- 主執行緒的生命周期與進程的生命周期相同,它倆同時存在、同時結束,是唇齒相依的關係。
- 一個進程只能有一個主執行緒,就像一個項目中只能有一個 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>
功能介紹:用來創建一個執行緒
參數介紹:
- 第一個參數為指向執行緒標識符的指針
- 第二個參數用來設置執行緒屬性,一般置為 NULL,表示使用默認屬性
- 第三個參數是執行緒執行的函數,返回值為 void *
- 最後一個參數是執行緒執行函數的參數
返 回 值:
- 當創建執行緒成功時,函數返回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>
功能介紹:用來等待一個執行緒的結束
參數介紹:
- 第一個參數為被等待的執行緒標識符
- 第二個參數為一個用戶定義的指針,它可以用來保存被等待執行緒的返回值
返 回 值:
- 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 及時釋放資源」時提到了兩個概念:執行緒分離狀態和執行緒非分離狀態。默認創建的執行緒為非分離狀態,那麼如何設置執行緒為分離狀態呢?有兩種方式:
- 調用
pthread_detach()
函數。 - 通過
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 錯誤。
參考資料
- 執行緒是什麼 (biancheng.net)
- 5個步驟,教你瞬間明白執行緒和執行緒安全_CSDN資訊的部落格-CSDN部落格
- C語言多執行緒編程(一) – 知乎 (zhihu.com)
- pthread_detach函數
- C語言多執行緒 – 小時候挺菜 – 部落格園 (cnblogs.com)
- Linux多執行緒操作pthread_t