多線程間的通信和同步

最近看了很多關於網絡編程和多線程的書,爲了以後查看相關內容方便,整理了幾本書的精華形成這篇博文,希望能幫助觀看這篇博文的讀者。

目錄

一、什麼是多線程?

二、爲什麼要創建線程

三、線程之間如何通信

四、線程安全

五、線程的同步

(一)互斥量mutex

(二)臨界區 critical section

(三)信號量 semaphore

(四)事件 event


一、什麼是多線程?

再說多線程之前引入多進程的概念。那什麼是進程?在 Windows 系統中,我們每開一個應用程序系統就爲其開闢一個進程,就比如我們打開一個 Word 文檔就是一個進程,如果再此基礎上按 control + N 在新建個 Word 文檔這就開了兩個進程。

其中每個進程的內存空間都有保存全局變量的“數據區”、像 malloc / new 等函數的動態分配提供空間的堆(Heap)、函數運行時使用的棧(Stack)構成。每個進程都擁有這樣的獨立空間,多個進程的內存結構可以參考下圖。

但如果以獲得多個代碼執行流爲主要目的,就不行該這樣分離內存結構,而只需要分離棧區域。這樣可以有如下優點:

  • 上下文切換時(這裏指進程間的切換)不需要切換數據區和堆
  • 可以利用數據區和堆交換數據

實現以上目地的方法就是多線程,就像我們打開一個 Word 文檔,在裏面同時編輯 sheet1,sheet2 一樣,每一個 sheet 就是一個線程。線程爲了保持多條代碼執行流而隔離開了棧區域,因此具有如下圖的結構:

二、爲什麼要創建線程

通過上面的講解我們知道了,多線程是能夠在一個應用程序中進行多個任務。比如我們要打印 1000 頁的Word,如果只有一個線程,那麼我們在打印結束前是不可以對 Word 進行操作的,而且打印1000 頁要耗費很多時間。但是,實際並不是如此,我們在打印的時候依然可以對 Word 進行編輯操作,這就是多線程的一種應用,處理耗時程序。同樣的應用還用在數據採集當中。等

三、線程之間如何通信

在 Windows 系統中線程之間的通信有兩種方式

  • 使用全局變量進行通信
  • 使用自定義消息進行通信

在第一部分中我們介紹了,線程的數據區和堆區域是共享的,所以我們可以聲明全局變量來進行線程之間的通信和數據交換。如果線程之間傳遞的數據比較複雜,我們可以定義一個結構,通過傳遞指向該結構的指針進行消息傳遞。接着讓線程監視這個變量,當這個變量符合一定的條件時,表示該線程終止。

使用自定義消息暫時不做解釋,是 Windows 編程中MFC 的內容,如果有讀者和我同樣學習 MFC 請在文章下面留言,我在補充相關內容。下面給出基於 Linux 系統的代碼。

//
//  main.cpp
//  thread
//
//  Created by 劉一丁 on 2019/6/15.
//  Copyright © 2019年 LYD. All rights reserved.
//  本示例用的是 Linus 系統的編程語言,如有需要 Windows 編程語言的請在博文中留言,博主在補齊。
//  本示例存在線程間通信的安全問題,即同時訪問同一存儲區變量(臨界區),解決這個問題可以用線程間的同步
//  函數功能:通過兩個線程分別計算 1-5、6-10 的和,並返回其值

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

void *thread_summation(void *arg);         //聲明線程
int sum = 0;                               //線程間通信用的全局變量

int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    int range1[] = {1, 5};
    int range2[] = {6, 10};
    
    pthread_create(&id_t1, nullptr, thread_summation, (void*)range1);    //創建線程
    pthread_create(&id_t2, nullptr, thread_summation, (void*)range2);
    
    pthread_join(id_t1, NULL);                                           //控制線程的執行流
    //調用該函數的線程將進入等待狀態,直到第一個參數 ID 的線程終止爲止。
    pthread_join(id_t2, NULL);
    
    return 0;
}

void *thread_summation(void *arg)
{
    int start = ((int*)arg)[0];
    int end = ((int*)arg)[1];
    
    while(start <= end)
    {
        sum += start;             //這裏設計到對 sum 值的訪問
        start++;
    }
    return NULL;
}

流程圖如下所示:

四、線程安全

在第三部分我已經提出了示例中存在的臨界區問題,該問題的發生是有概率的,和電腦系統配置有關,運行結果可能因機器而異。那麼怎麼產生的這個問題呢?

上述示例中兩個線程同時訪問變量 sum,這裏的訪問指的是對 sum 的值的更改。除此之外例如,對於像磁盤驅動器這樣獨佔性系統資源,由於線程可以執行進程的任何代碼段,且線程的運行是由系統調度自動完成的,具有一定的不確定性,因此就有可能出現兩個線程同時對磁盤驅動器進行操作,從而出現上面的錯誤。再比如,對於銀行系統的計算機來說,可能使用一個線程來更新其用戶數據庫,而用另外一個線程來讀取數據庫以響應儲戶需求,極有可能讀數據庫的線程讀取的是未完全更新的數據庫,因爲可能在讀的時候只有一部分數據被更新過。具體解釋涉及到 CPU 和內存管理,再此略,如果感興趣的讀者,可以自行查找相關文獻書籍。

那麼怎麼解決這個問題呢?

線程間的同步就可以解決這個問題。

五、線程的同步

使隸屬於同一進程的線程協調一致的工作就是線程間的同步。在多線程環境裏,需要對線程進行同步。常用的同步對象有臨界區(Critical Section)、互斥(Mutex)、信號量(Semaphore)和事件(event)等。用於解決線程訪問順序引發的問題。需要同步的情況可以從以下兩方面考慮:

  • 同時訪問同一內存空間時發生的情況。
  • 需要指定訪問同一內存空間的線程執行順序的情況。
支持多線程同步的同步類
類型 說明
Critical Section 當在一個時間內僅有一個線程被允許修改數據或其某些其他控制資源時使用,用於保護共享資源(比如寫數據)
Mutex 當多個應用(多個進程)同時存取相應資源時使用,用於保護共享資源
Semaphore 一個應用允許同時有多個線程訪問相應資源時使用(比如讀數據),主要功能用於資源計數
event 某個線程必須等待某些事件發生後才能存取相應資源時使用,以協調線程之間的動作。

(一)互斥量mutex

下面介紹互斥量的使用方法(基於 Windows,Linux 系統下步驟也是一樣的)

  • 定義  CMutex 類的一個全局對象(以使各個線程均能訪問),如

CMutex mutex;

  • 在訪問臨界區之前,調用 mutex 類的成員 Lock()獲得臨界區

mutex.Lock();

在線程中調用該函數來使線程獲得它所請求的臨界區。如果此時沒有其他線程佔有臨界區,則調用 Lock()的線程獲得臨界區;否則,線程即將掛起,並放入到一個系統隊列中等待,直到當前擁有臨界區的線程釋放了臨界區時爲止。

  • 在本線程中訪問臨界區中的共享資源
  • 訪問臨界區完畢後,使用CMutex 的成員函數 UnLock()來釋放臨界區。

mutex.UnLock();

對於 Linux 系統來說直接給出函數使用過程

  • 聲明 mutex 全局變量
  • 創建互斥量
  • 獲得臨界區
  • 訪問共享資源
  • 釋放互斥量

下面給出 Linux 系統下上面示例的改進版,請讀者自行分析,pthread_mutex_lock();放在 while 循環裏面和 while 循環外面的區別,如果有興趣可以在博文下留言討論。

//
//  main.cpp
//  thread
//
//  Created by 劉一丁 on 2019/6/15.
//  Copyright © 2019年 LYD. All rights reserved.
//  本示例用的是 Linus 系統的編程語言,如有需要 Windows 編程語言的請在博文中留言,博主在補齊。

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

void *thread_summation(void *arg);         //聲明線程
int sum = 0;                               //線程間通信用的全局變量
pthread_mutex_t mutex;                     //聲明 mutex 變量

int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    int range1[] = {1, 5};
    int range2[] = {6, 10};
    
    pthread_mutex_init(&mutex, NULL);       //創建互斥量
    
    pthread_create(&id_t1, nullptr, thread_summation, (void*)range1);    //創建線程
    pthread_create(&id_t2, nullptr, thread_summation, (void*)range2);
    
    pthread_join(id_t1, NULL);                                           //控制線程的執行流
    //調用該函數的線程將進入等待狀態,直到第一個參數 ID 的線程終止爲止。
    pthread_join(id_t2, NULL);
    
    printf("result: %d\n", sum);
    pthread_mutex_destroy(&mutex);           //釋放互斥量
    return 0;
}

void *thread_summation(void *arg)
{
    int start = ((int*)arg)[0];
    int end = ((int*)arg)[1];
    
    while(start <= end)
    {
        pthread_mutex_lock(&mutex);          //獲得臨界區
        sum += start;                        //訪問共享資源
        start++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

//output:
//result: 55

討論:互斥量中的 Lock() 是怎麼工作的?從以下 3個方面來解釋。

1.內核對象

操作系統創建的資源(Resource)有很多種,如進程、線程、文件及剛剛介紹的互斥量和即將介紹的臨界區、信號量等。不同資源類型在“管理”方式上也有差異。例如,文件管理中應註冊並更新文件相關的數據 I/O 位置、文件的打開方式(read or write)等。如果是線程,則應註冊並維護線程 ID、線程所屬進程等信息。操作系統爲了以記錄相關信息的方式管理各種資源,在其內部生成數據塊(相當於結構體)。當然,每種資源需要維護的信息不同,所以每種資源擁有的數據塊格式也不相同。這類數據塊稱爲“內核對象”。

2.內核對象的兩種狀態

資源類型不同,內核對象也含有不同的信息。其中,應用程序實現過程中需要特別關注的信息被賦予某種“狀態”(state)。例如,線程內核對象中需要重點關注線程是否已經終止,所以終止狀態又稱“signaled 狀態”(其他線程可訪問),未終止狀態成爲“non-signaled 狀態”(其他線程不可訪問)。同時,操作系統會在進程或線程終止時,把相應的內核對象改爲 signaled 狀態。

3.互斥量內核的狀態

在基於互斥量的同步中將創建互斥量 mutex對象。與其他同步對象相同,它是進入臨界區的一把“鑰匙”。因此,爲了進入臨界區,需要得到mutex 對象這把鑰匙(Lock)。相反離開時需要上交 mutex 對象(unlock)。

互斥量被某一線程獲取時(Lock)爲 non-signaled 狀態,釋放時(unlock)進入 signaled 狀態。因此,可以使用 Lock 和 unlock 來驗證互斥量是否已經被分配。Lock函數的調用結果有如下2種。

  • 調用後進入阻塞狀態:互斥量對象已被其他線程獲取,現處於 non-signaled 狀態。
  • 調用後直接返回:其他線程未佔用互斥量對象,現處於signaled 狀態。

(二)臨界區 critical section

臨界區的使用規則和互斥量完全相同,在這裏不在討論,二者的區別也在上文表格中體現,一個用在線程之間,一個用除了線程還可以用在進程之間。

(三)信號量 semaphore

信號量的使用方法也和互斥量相同,步驟是一樣的。所以博主在這裏介紹信號量的另一種使用方法“二進制信號量”,又稱爲“控制線程順序”的同步方法。

在 Windows 系統下信號量和互斥量唯一區別的就是構造函數,其他用法一樣,下面給出 Windows 系統下 semaphore 的構造函數

1.CSemaphore(LONG lInitialCount = 1,
           LONG lMaxCount = 1,
           LPCTSTR pstrName = NULL,
           LPSECURITY_ATTRIBUTES lpsaAttributes = NULL);

→lInitialCount - 信號量對象的初始計數值,即可訪問線程數目的初始值
→lMaxCount - 信號量對象計數值的最大值,該參數決定了同一時刻可訪問由信號量保護的資源的線程最大數目

2.BOOL ReleaseSemaphone(HANDLE hSemaphone, LONG lReleaseCount, LPLONG lpPreviousCount);

→成功時返回 TRUE,失敗時返回 FALSE
→hSemaphone - 傳遞需要釋放的信號量對象
→lReleaseCount -  釋放意味着信號量值的增加,通過該參數可以指定增加的值。
                 超過最大值則不增加,返回 FALSE
→lpPreviousCount - 用於保存修改之前值的變量地址,不需要時可傳遞 NULL。

注:信號量對象的值大於 0 時成爲 signaled 狀態,爲 0 時成爲 non-signaled 狀態。
因此,調用 WaitForSingleObject 函數時,信號量大於 0 的情況纔會返回。
返回的同時將信號量值減 1,同時進入 non-signaled 狀態(當然,僅限於信號量減 1 後等於 0 的情況)。


執行 WaitForSingleObject(hSemaphone, INFINITE);時-1
執行 ReleaseSemaphone()時+1,爲 0 時阻塞

WaitForSingleSemaphone(hSemaphone, INFINITE)
//臨界區的開始
//..........
//臨界區的結束
ReleaseSemaphone(hSemaphone, 1, NULL);

在 Linux 系統下,首先給出相當於互斥量 Lock、UnLock 的函數。

#include <pthread.h>

int sem_post(sem_t * sem);   //+1
int sem_wait(sem_t * sem);   //-1,爲0 時阻塞

成功時返回 0,失敗時返回其他值。
sem - 傳遞保存信號量讀取值的變量地址,傳遞給 sem_post 時信號量增 1,傳遞給 sem_wait 時信號量減 1.

調用sem_init 函數(略)時,操作系統將創建信號量對象,此對象中記錄着“信號量值”整數。該值在調用 sem_post 函數時增 1,在調用 sem_wait函數時減 1。但信號量的值不能小於 0,因此,在信號量爲 0 的情況下調用 sem_wait 函數時,調用函數的線程將進入阻塞狀態(因爲函數未返回)。當然,此時如果有其他函數線程調用 sem_post 函數,信號量的值將變爲 1,而原本阻塞的線程可以將該信號量重新減爲 0 並跳出阻塞狀態。實際上就是通過這種特性完成臨界區的同步操作,可以通過如下形式同步臨界區(假設信號量的初始值爲 1)。

sem_wait(&sem);    //信號量變爲 0.
//臨界區的開始
//.......
//臨界區結束
sem_post(&sem);    //信號量變爲 1.

上述代碼結構中,調用 sem_wait 函數進入臨界區的線程在調用 sem_post 函數前不允許其他線程進入臨界區。信號量的值在 0 和 1 之間跳轉,這種特性就是“二進制信號量”。下面給出基於 Linux 系統的代碼示例。

//
//  main.cpp
//  thread
//
//  Created by 劉國棟 on 2019/6/15.
//  Copyright © 2019年 LGD. All rights reserved.
//  本示例用的是 Linus 系統的編程語言,如有需要 Windows 編程語言的請在博文中留言,博主在補齊。
//  本示例實現“線程 A從用戶輸入得到值後存入全局變量 num,此時線程 B 取走該值並累加。該過程共進行
//  5 次,完成後輸出總和並推出程序”

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

void * read(void * arg);
void * accu(void * arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;

 int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    sem_init(&sem_one, 0, 0);
    sem_init(&sem_two, 0, 1);
    
    pthread_create(&id_t1, NULL, read, NULL);
    pthread_create(&id_t2, NULL, accu, NULL);
    
    pthread_join(id_t1, NULL);
    pthread_join(id_t2, NULL);
    
    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

void * read(void * arg)
{
    for(int i = 0; i < 5; i++)
    {
        fputs("Input num: ", stdout);
        
        sem_wait(&sem_two);
        scanf("%d", &num);
        sem_post(&sem_one);
    }
    return NULL;
}

void * accu(void * arg)
{
    int sum = 0;
    for(int i = 0; i < 5; i++)
    {
        sem_wait(&sem_one);
        sum += sum;
        sem_post(&sem_two);
    }
    printf("Result: %d\n", sum);
    return NULL;
}

/*
運行結果:semaphore.c
root@my_linux:/tcpip# gcc semaphore.c -D_REENTRANT -o sema -lpthread
root@my_linux:/tcpip# ./sema
Input num:1
Input num:2
Input num:3
Input num:4
Input num:5
Result: 15
*/

上述代碼請讀者自行分析,有疑問可以在博文下方留言,博主會爲其解答。還請讀者特別注意分析 24-25 行 44-46、56-58 行代碼的使用方式和所達到“二進制信號量”功能的實現。

(四)事件 event

事件同步對象與前2 種同步方法相比有很大不同,區別就在於,該方式下創建對象時,可以在自動以 non-signaled 狀態運行的 auto-reset 模式和與之相反的 manual-reset 模式彙總任選其一。而事件對象的主要特點是可以創建 manual-reset 模式的對象。

在 Windows 環境下,介紹創建事件對象的函數。

#include <windows.h>

HANDLE CreateEvent(
    LPSECURITY_ATTRIBUTS lpEventAttributes, BOOL bManualReset,
    BOOL bIntialState, LPCTSTR lpName);

→成功時返回創建的事件對象句柄,失敗時返回 NULL
→lpEventAttributes  安全配置相關參數,採用默認安全時傳入 NULL
→bManualReset  傳入 TRUE 時創建 manual-reset 模式的事件對象,傳入 FALSE 時創建auto-reset 模式的事件對象。
→bIntialState  傳入 TURE 時創建 signaled 狀態的事件對象,傳入 FALSE 時創建 non-signaled 狀態的事件對象。
→lpName  用於命名事件對象。傳遞 NULL 時創建無名的事件對象

在介紹一個函數 WaitForSingleObject 函數,該函數針對單個內核對象驗證 signaled狀態。

#include <windows.h>

DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);

→成功時返回事件信息,失敗時返回 WAIT——FAILED
→hHandle  查看狀態的內核對象句柄。
→dwMilliseconds  以 1/1000 秒爲單位指定超時,傳遞 INFINTE 時函數不會返回,直到內核對象變成 signaled 狀態。
→返回值  進入 signaled 狀態返回 WAIT_OBJECT_0,超時返回 WAIT_TIMEOUT。

上面這個函數其實就相當於 Lock 的功能,只有當要查看對象的狀態爲 signaled 時纔會有返回值(超時也會返回),否則一直等待(阻塞)。同時該函數由於發生時間(變爲signaled狀態)返回時,有時會把相應內核對象再次改爲 non-signaled 狀態。這種可以再次進入 non-signaled 狀態的內核對象稱爲“auto-reset 模式”的內核對象,而不會自動跳轉到 non-signaled 狀態的內核對象稱爲“ manual-reset模式”的內核對象。

就如創建實現對象的初始化函數 CreateEvent 的第二個參數。傳入 TURE 時創建 manual-reset 模式的事件對象,此時即使 WaitForSingleObject 函數返回也不會回到 non-signaled 狀態。因此,需要通過下面兩個函數明確更改對象狀態。

#include <windows.h>

BOOL ResetEvent(HANDLE hEvent);  //to the non-signaled
BOOL SetEvent(HANDLE hEvent);    //to the signaled

→成功時返回 TURE,失敗時返回 FALSE

所以,傳遞事件對象句柄並希望改爲 non-signaled狀態時,應調用 ResetEvent 函數。如果希望改爲signaled 狀態,則可以調用 SetEvent 函數。下面給出基於 Windows 的示例

//
//  main.cpp
//  thread
//
//  Created by 劉國棟 on 2019/6/19.
//  Copyright © 2019年 LGD. All rights reserved.
//
//  示例中的兩個線程同時等待輸入字符串

#include <stdio.h>
#include <windows.h>
#include <process.h>
#define STR_LEN 100

unsigned WINAPI NumberOfA(void *arg);
unsigned WINAPI NumberOfOthers(void *arg);

static char str[STR_LRN];
static HANDLE hEvent;

int main(int argc, const char *srgv[])
{
    HANDLE hThread1, hThread2;
    hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
    hThread1 = (HANDLE)_beginthreadex(NULL, 0, NumberOfA, NULL, 0, NULL);
    hThread2 = (HANDLE)_beginthreadex(NULL, 0, NumberOfOthers, NULL, 0, NULL);
    
    fputs("input string: ", stdout);
    fgets(str, STR_LEN, stdin);
    SetEvent(hEvent);
    
    WaitForSingleObjevt(hThread1, INFINITE);
    WaitForSingleObjevt(hThread2, INFINITE);
    ResetEvent(hEvent);
    CloseHandle(hEvent);
    return 0;
}

unsigned WINAPI NumberOfA(void *arg)
{
    int i, cnt = 0;
    WaitForSingleObjevt(hEvent, INFINITE);
    for(i = 0; str[i] != 0; i++)
        if(str[i] == 'A')
            cnt++;
    printf("Num of A: %d \n", cnt);
    return 0;
}

unsigned WINAPI NumberOfOthers(void *arg)
{
    int i, cnt = 0;
    WaitForSingleObjevt(hEvent, INFINITE);
    for(i = 0; str[i] != 0; i++)
        if(str[i] != 'A')
            cnt++;
    printf("Num of Others: %d \n", cnt);
    return 0;
}

//output
//Input string: ABCDABC
//Num of A: 2
//Num of others: 5

→第 24 行:以 non-signaled 狀態創建manual-reset 模式的事件對象。

→第 25、26 行:創建兩個線程,NumOfA  and  NumOfOthers 同時開始執行,分別執行到 42 行 53 行進入阻塞狀態。

→第 30 行:讀入字符串後將事件對象改爲signaled 狀態。第 42、53 行中正在等待的2個線程將擺脫等待狀態,開始執行。2 個線程之所以可以同時擺脫等待狀態是因爲事件對象仍處於signaled 狀態。

→第 32、33 行:注意此處的 WaitForSingleObject 傳遞的句柄是線程的句柄,只有等到線程返回時,該線程的句柄纔會由 non-signaled 狀態編程 signaled 狀態,WaitForSingleObject 纔會返回,否則處於阻塞狀態。

→第 34 行:雖然在本例子中該語句沒太大必要,但還是把事件對象的狀態改爲 non-signaled。如果不進行明確更改,對象將繼續停留在 signaled 狀態。

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