c 多線程編程01

Java 老師希望我們嘗試進行 Java 的多線程編程,也希望我們能夠去實現一下 C 語言的多線程編程。用以體會不同編程語言間的多線程編程。藉此機會,初步學習一下 C 語言的多線程編程。

第一部分主要內容如下:

  • 線程的基礎概念
  • 何時使用線程
  • 使用線程的好處?
  • 線程數量的限制?
  • 線程與進程的關係?
  • 線程的創建、終止
  • 管理線程的終止

一. 線程的基礎概念

1.1 什麼是線程?

線程是操作系統進行調度運算的最小單位,是進程中的實際運作單位。一條線程指的是進程中一個單一順序的控制流,一個進程中可以併發多個線程,每條線程並行執行不同的任務。

1.2 單線程、多線程與進程

單線程程序必須按順序執行,只有前面的處理好了,後面的纔會執行。
單線程有很多缺點,當執行某個耗時或者不能立即完成的任務時,比如:網絡通訊、複雜運動,該線程就會暫時停止對其他任務的響應和處理,造成的視覺效果就是程序的假死,也就是應用程序被卡在那裏無法繼續執行,因此在多數情況下,單線程的應用很少。

而多線程處理可以同時運行多個過程。 當然多線並不同於線程的並行,而是說有一條主線程,處理整個程序任務的主方向的鏈,而其鏈上又有許許多多的分支,就像樹枝那樣,這樣,既有了主線程去處理那些主要任務,又有了那些細小線程去處理耗時費力任務,從而讓界面看起來更加流暢。

幾乎所有的操作系統都支持同時運行多個任務,稱其爲多進程。 一個任務通常就是一個程序,每個運行中的程序就是一個進程。當一個程序運行時,內部可能包含了多個順序執行流,每個順序執行流就是一個線程。當有多個順序流時就爲多線程。

如果要形象一點比喻,那就是: 大家在食堂吃飯的經歷,尤其是學校裏面的食堂。飯堂會有學生訪問高峯,這就需要賣飯的大媽,身手矯健,要記下學生點的飯,之後一邊做飯,一邊裝飯。如果生意實在太好,可以多僱人手,一起幫着賣飯。

一個食堂大媽就是進程,記菜單,做飯,裝飯這些動作就是進程中線程。如果想要快速完成這些動作,食堂大媽就要在記菜單,做飯,裝飯之間不停的切換,這個就是多線程。如果增加人手這個就相當於計算機增加 cpu 內核,他們都可以獨立執行線程,這也是多線程。

若要總結一句就是: 進程是資源分配的最小單位,線程是 CPU 調度的最小單位。

1.3 使用多線程的好處

  • 提高資源利用率
    考慮某種情況, 如果一個應用程序需要從本地讀取和處理文件。比如,需要從磁盤中讀取一個文件需要 5s,處理一個文件需要 2 s。如果不採用多線程,處理兩個文件需要:
5s 讀取文件A
2s 處理文件A
5s 讀取文件B
2s 處理文件B
-------------
總共需要 14 s

學過操作系統的話應該很好理解,當從磁盤讀取文件的時候,大部分的 CPU 時間用於等待磁盤去讀取數據,也就是說這段時間裏,CPU 的非常空閒,竟然這樣,不如藉此時間段,讓 CPU 做的別的事情。比如,像下面,通過改變操作的順序,就能夠更好地利用 CPU 資源。

5s 讀取文件A
5s 讀取文件B + 2s 處理文件A
2s 處理文件B
-------------
總共需要 12 s

CPU 等待第一個文件被讀取完。然後開始讀取第二個文件。當第二文件在被讀取的時候,CPU 會去處理第一個文件。記住,在等待磁盤讀取文件的時候,CPU 大部分時間是空閒的。

總的說來,CPU 能夠在等待 IO 的時候做一些其他的事情。這個不一定就是磁盤 IO。它也可以是網絡的 IO,或者用戶輸入。通常情況下,網絡和磁盤的 IO 比 CPU 和內存 的IO慢的多。

這跟小學生做的數學題中關於合理安排煮水洗衣服時間的問題是一樣的。

  • 程序設計更簡單

在單線程應用程序中,如果想編寫程序手動處理上面所提到的讀取和處理的順序,就必須記錄每個文件讀取和處理的狀態。如果採取多線程編程,那麼可以啓動兩個線程,每個線程處理一個文件的讀取和操作。線程會在等待磁盤讀取文件的過程中被阻塞。在等待的時候,其他的線程能夠使用 CPU 去處理已經讀取完的文件。其結果就是,磁盤總是在繁忙地讀取不同的文件到內存中。這會帶來磁盤和CPU 利用率的提升。而且每個線程只需要記錄一個文件,因此這種方式也很容易編程實現。

  • 程序響應更快

將一個單線程應用程序變成多線程應用程序的另一個常見的目的是實現一個響應更快的應用程序。設想一個服務器應用,它在某一個端口監聽進來的請求。當一個請求到來時,它去處理這個請求,然後再返回去監聽。

服務器的流程如下所述:

while(server is active){
	listen for request
	process request
}

如果一個請求需要佔用大量的時間來處理,在這段時間內新的客戶端就無法發送請求給服務端。只有服務器在監聽的時候,請求才能被接收。另一種設計是,監聽線程把請求傳遞給工作者線程(worker thread),然後立刻返回去監聽。而工作者線程則能夠處理這個請求併發送一個回覆給客戶端。這種設計如下所述:

while(server is active){
	listen for request
	hand request to worker thread
}

這種方式,服務端線程迅速地返回去監聽。因此,更多的客戶端能夠發送請求給服務端。這個服務也變得響應更快。

桌面應用也是同樣如此。如點擊一個按鈕開始運行一個耗時的任務,這個線程既要執行任務又要更新窗口和按鈕,那麼在任務執行的過程中,這個應用程序看起來好像沒有反應一樣。相反,任務可以傳遞給工作者線程(word thread)。當工作者線程在繁忙地處理任務的時候,窗口線程可以自由地響應其他用戶的請求。當工作者線程完成任務的時候,它發送信號給窗口線程。窗口線程便可以更新應用程序窗口,並顯示任務的結果。對用戶而言,這種具有工作者線程設計的程序顯得響應速度更快。

1.4 多線程的缺點

多線程並不是線程越多越好,線程過多反而會降低系統運行效率。 如果一個程序有很多的線程,那麼其他程序的線程必然只能佔用更少的 CPU 時間;而且大量的 CPU 時間是用於線程調度的;操作系統也需要足夠的內存空間來維護每個線程的上下文信息,所以,過多的線程反而會降低系統的運行效率。

1.5 多線程的應用場景

列舉幾個多線程的應用場景:

  1. 爲不阻塞主線程(UI線程),使用其他線程處理事務
  2. 加快訪問 Web 訪問速度。主線程來專門監聽請求,子線程專門來處理請求,可以獲得大的吞吐量。
  3. 某種由於優先級很低的服務,卻又要不定時去做的,比如 JVM 的垃圾回收
  4. FTP,IDM 下載,多線程操作文件
  5. 大量數據插入數據庫,採用合適數量的線程

二. 線程的基本操作

Linux 操作系統使用符合 POSIX 線程作爲系統標準線程,該 POSIX 線程標準定義了一整套操作線程的 API。Pthread 是 POSIX threads 的簡稱,是 POSIX 的線程標準。學會創建線程是多線程編程的第一步,理解線程如何創建是進行多線程編程的關鍵。

2.1 線程的創建

一個線程的生命週期起始於它被創建的那一刻,創建線程的接口:

#include <pthread.h>
int pthread_create(pthread_t *thread, 
                   const pthread_attr_t *attr, 
				   void *(*start_routine)(void *), 
				   void *arg
				   );

函數參數:

  • 第一個參數 thread:線程ID指針,本質上是線程ID的寫入地址;
  • 第二個參數 attr:線程屬性參數,指新建線程的屬性,如線程棧的大小等;如果爲 NULL,則使用系統默認值;
  • 第三個參數 start_routine:線程運行函數地址;
  • 第四個參數 arg:運行時函數參數,傳遞給新建線程入口函數的參數。

當置第二、第四個參數爲 NULL 的時候,則創建默認線程。

函數返回值:
當線程創建成功時,函數值返回0, 錯誤則返回非0值(相關錯誤碼)。

注意:

  • 主線程,是一個進程的初試線程,其入口函數爲 main 函數。
  • 新線程的運行時機,一個線程被創建之後有可能不會被馬上執行,甚至在創建它的線程結束後還沒被執行;也可能新線程在當前線程從 pthread_create 前就已經在運行,甚至,在 pthread_create 前從當前線程返回前新線程就已經執行完畢。

2.2 線程 ID

在新線程被創建之後,便有了一個在其所在進程內(線程依附於進程而存在)唯一的標誌符,由 pthread_t 表示,稱爲線程 ID。一個線程可以調用一下接口獲取其 ID

#include <pthread.h>
pthread_t pthread_self(void);

pthread_self 直接返回調用線程的 ID。pthread_tpthreadtypes.h中的定義是 typedef unsigned long int pthread_t;

2.3 判斷給定線程的 ID 是否相等

#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);

如果兩線程 ID 相同,返回非0值; 否則返回 0;

2.4 從系統角度理解線程的創建

創建一個新的線程,從系統實現的角度看,就是創建了一個新的可調度實體;同一個進程內的線程,可以共享絕大部分進程中的資源,只有少部分信息是線程特有的,如棧和線程持有數據等。下圖假設一個進程內存在4個線程時,內存資源分配情況。
在這裏插入圖片描述
統一進程內的線程間除了棧是特有的,其他內存資源幾乎是共享的。共享意味着,多個線程可以同時修改某一內存區,且該修改對同一進程的所有線程都是可見的。

2.5 線程的終止

一個線程的終止有兩種方式:

  • 線程所在進程的終止,任意線程執行 exit 函數,都會導致進程的終止,從而導致依附於該進程的所有線程的終止;
  • 其他線程調用 pthread_cancel 請求取消該線程
2.5.1 主動終止的兩種方式
  • 在線程的入口函數中執行 return 語句,main 函數(主線程入口函數)執行return 語句會導致進程的終止,從而導致依附於該進程的所有線程的終止。

  • 線程調用 pthread_exit 函數, main 函數(主線程入口函數)調用 pthread_exit 函數,主線程終止,如果該進程內還有其他線程存在,進程會繼續存在,進程內其他線程繼續運行。

線程終止函數:

#include <pthread.h>
void pthread_exit(void *retval);
  • 線程調用 pthread_exit 函數會導致該調用線程終止,並且返回由 retval 指定的內容。
  • 注意:retval不能指向該線程的棧空間,否則可能成爲野指針!
2.5.2 理線程的終止

一個線程的終止對於另外一個線程而言是一種異步的事件,有時我們想等待某個ID的線程終止了再去執行某些操作,pthread_join函數爲我們提供了這種功能,該功能稱爲線程的連接

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

參數說明:

  • thread(輸入參數),指定我們希望等待的線程
  • retval(輸出參數),我們等待的線程終止時的返回值,就是在線程入口函數中return 的值或者調用 pthread_exit 函數的參數

返回值:
當等待的線程成功終止時,返回 0,否則返回錯誤代碼。

當線程 X 連接線程 Y 時,如果線程 Y 仍在運行,則線程 X 會阻塞直到線程 Y 終止;如果線程 Y 在被連接之前已經終止了,那麼線程 X 的連接調用會立即返回。

連接線程其實還有另外一層意義,一個線程終止後,如果沒有人對它進行連接,那麼該終止線程佔用的資源,系統將無法回收,而該終止線程也會成爲殭屍線程。因此,當我們去連接某個線程時,其實也是在告訴系統該終止線程的資源可以回收了。

注意: 對於一個已經被連接過的線程再次執行連接操作,將會導致無法預知的行爲!

2.6 線程的分離

有時我們並不在乎某個線程是不是已經終止了,我們只是希望如果某個線程終止了,系統能自動回收掉該終止線程所佔用的資源。pthread_detach 函數爲我們提供了這個功能,該功能稱爲線程的分離:

#include <pthread.h>
int pthread_detach(pthread_t thread);

參數說明:
thread(輸入參數), 指定希望執行分離操作的線程

返回值:
成功時,返回 0,否則返回錯誤代碼

默認情況下,一個線程終止了,是需要在被連接後系統才能回收其佔有的資源的。如果我們調用pthread_detach 函數去分離某個線程,那麼該線程終止後系統將自動回收其資源。

注意: 一個線程如果已經被分離了,那麼我們就無法再去連接它了

2.7 線程基本操作使用案例

下面例子展示了線程的創建、終止、連接等管理操作。

/*線程的基本操作 */
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<pthread.h>
#include<windows.h>


/* 子線程1 */
void *thread_routine1(void *arg)
{
    fprintf(stdout, "thread1: Hello world!\n");
    Sleep(1);
    return NULL;
}

/* 子線程2 */
void *thread_routine2(void *arg)
{
    fprintf(stdout, "thraed2: I'm running...\n");
    pthread_t main_thread = (pthread_t)arg;

    /* 分離自我 */
    pthread_detach(pthread_self());
    /* 判斷線程1 與線程2 是否相等 */
    if(!pthread_equal(main_thread, pthread_self())){
        fprintf(stdout, "thread2: main thread id is not equal thread2\n");
    }

    /* 等待線程終止 */
    pthread_join(main_thread, NULL);
    fprintf(stdout, "thread2: main thread exit!\n");
    fprintf(stdout, "thread2: exit!\n");
    fprintf(stdout, "thread2: process exit!\n");

    /* 子線程2 終止,進程退出 */
    pthread_exit(NULL);
}

int main(int argc, char *argv[])
{

    /* 創建子線程1 */
    pthread_t t1;
    if(pthread_create(&t1, NULL, thread_routine1, NULL)!=0)
    {
        fprintf(stderr, "create thread fail.\n");
        exit(-1);
    }

    /*等待子線程1終止*/
    pthread_join(t1, NULL);
    fprintf(stdout, "main thread: thread1 terminated!\n\n");

    /* 創建子線程2, 並將主線程id 傳給子線程2 */
    pthread_t t2;
    if(pthread_create(&t2, NULL, thread_routine2, (void *)pthread_self())!=0)
    {
        fprintf(stderr, "create thread fail.\n");
        exit(-1);
    }

    fprintf(stdout, "main thread: sleeping...\n");
    Sleep(3);

    /* 主線程使用 pthread_exit 函數終止,進程繼續存在 */
    fprintf(stdout,"main thread: exit!\n");
    pthread_exit(NULL);

    fprintf(stdout, "main thread: never reach here!\n");
    return 0;

}

查看運行結果

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章