二十、線程安全

一、線程安全

(一)概念

線程安全: 就是在多線程運行的時候,不論線程的調度順序怎樣,最終的結果都是一樣的,正確的,那麼就說這些線程是安全的。

保證線程安全需要做到:

  • 對線程同步,保證同一時刻只有一個線程訪問臨界資源。
  • 在多線程中使用線程安全的函數(可重入函數)。

下面我們看一下如何從這兩方面保證線程安全。

(二)線程安全之臨界資源

前面我們已經說過很多次臨界資源這個概念,它是在任意時間只允許一個線程訪問,如果臨界資源被多個線程訪問修改,那麼就會出現線程不安全

【1. 臨界資源被多個線程訪問導致線程不安全的例子:】

我們可以看個例子,現在我們有兩個線程,全局變量G,主線程對變量G進行累加,累加10000;函數線程對G進行累加,累加10000,最後打印G的大小。思考G應該是多少?應該有不少人覺得G是20000吧,我們寫出代碼,看運行結果:

# include<stdio.h>
# include<string.h>
# include<unistd.h>
# include<assert.h>
# include<stdlib.h>
# include<pthread.h>


int G=0;

void* fun(void* arg)
{
    int i=0;
    for(;i<10000;++i)
    {
        G++;//不是原子操作
    }

}
int main()
{
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);
    int i=0;
    for(;i<10000;++i)
    {
        G++;
    }
    pthread_join(id,NULL);
    printf("G=%d\n",G);
    exit(0);
}

在這裏插入圖片描述

可以看到運行結果,每次G的值都不一樣,在[10000,20000]這個區間內,不是唯一的,是不可控的,我們分析原因

對G變量進行++的操作不是一個原子操作,它需要三步完成:

  • 從內存中讀值
  • CPU進行+1
  • 將結果寫回內存。

這三步每一步都有可能其他線程被搶佔。我們當前有兩個線程,都要對其進行G++操作,假如現在G的值爲5,那麼就有可能出現如下圖所示的情況:
在這裏插入圖片描述
核心問題就是在一個線程操作G全局變量時,沒有一次執行完,就被其他線程搶佔,其他線程已經將G值修改了,被搶佔線程在被搶佔前獲取的值是已經過期的,但它不知道啊,所以它就繼續往下做,然後就會導致結果不正確。所以就是臨界資源G被多個線程搶佔,故我們需要讓它一次性做完G++操作,把它變爲原子操作是不可能的,但是我們可以把G臨界資源保護起來,即一個線程在對其進行操作時,不能允許其他線程再對其進行操作,我們可以通過線程同步方式來實現這個功能,我們使用互斥鎖實現一下。

【2. 用互斥鎖保護臨界資源,確保線程同步:】

我們只需要在一個線程對G進行操作之前先進行加鎖操作,此時如果還有線程試圖對臨界資源進行加鎖,就會被阻塞;操作完畢就進行解鎖操作,被阻塞的線程就可以再次嘗試加鎖操作。代碼如下:

# include<stdio.h>
# include<string.h>
# include<unistd.h>
# include<assert.h>
# include<stdlib.h>
# include<pthread.h>

pthread_mutex_t mutex;
int G=0;

void* fun(void* arg)
{
    int i=0;
    for(;i<10000;++i)
    {
        pthread_mutex_lock(&mutex);//加鎖
        G++;
        pthread_mutex_unlock(&mutex);//解鎖
    }

}
int main()
{
    pthread_mutex_init(&mutex,NULL);
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);
    int i=0;
    for(;i<10000;++i)
    {
        pthread_mutex_lock(&mutex);
        G++;
        pthread_mutex_unlock(&mutex);
    }
    pthread_join(id,NULL);
    printf("G=%d\n",G);
    exit(0);
}

在這裏插入圖片描述
可以看到,這次的結果就是我們想的20000,證明實現了線程安全,這就是通過保證臨界資源來實現線程安全的方式。

(三)線程安全之可重入函數

1. 基本概念

1. 重入: 是指同一個函數被多個線程調用,當前線程還沒有執行完,就有其他線程調用。
2. 可重入函數: 一個函數在重入的情況下不會出現任何問題或結果不一致性,則我們稱其爲可重入函數,否則反之稱爲不可重入函數。

3. 出現不可重入的原因:

  • 使用了全局變量或靜態變量,可以被所有線程共享,如malloc/free函數,就是使用了全局鏈表,導致不可重入。
  • 調用標準I/O庫函數,很多函數使用了全局的數據結構。

4. 常見不可重入函數:
在這裏插入圖片描述
所以我們在線程中使用函數時,一定要注意函數是否重入,如果多線程中使用了不可重入函數,那麼多線程可能就會出現異步性,結果無法預知,引發線程安全問題。故記住:可重入函數一定是線程安全的,但是線程安全的不一定是可重入函數。

因爲我們可以使用別的函數來替代不可重入函數,讓它安全就好了,下面是常用的替代函數,和不可重入函數名稱相似,只不過在後面加了_r,表明函數是可重入的,安全的。
在這裏插入圖片描述
5. 可重入原因:

可重入函數安全的原因:不使用全局變量和靜態變量,不讓其他線程共享,就不會出現被多個線程修改,引發線程安全問題。

2. 實例

【1. 使用不可重入函數strtok:】
兩個線程,使用字符串切割函數切割數組,主線程切割字符數組str[]=“a b c d e f g h”,打印字符;函數線程切割數字數組buff[]=“1 2 3 4 5 6 7 8”,打印數字。數組都是局部變量,線程之間不會共享。我們預想的結果是:函數線程輸出數字,主線程輸出字符。

代碼如下:

# include<stdio.h>
# include<string.h>
# include<unistd.h>
# include<assert.h>
# include<stdlib.h>
# include<pthread.h>


void* fun(void* arg)
{
    char buff[]="1 2 3 4 5 6 7 8";
    char* p=strtok(buff," ");
    while(p)
    {
        printf("fun:%s\n",p);
        p=strtok(NULL," ");//用全局變量保存每次切割的位置,導致多個線程共享
        sleep(1);
    }

}
int main()
{
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);
    char str[]="a b c d e f g h";
    char* p=strtok(str," ");
    while(p)
    {
        printf("main:%s\n",p);
        p=strtok(NULL," ");
        sleep(1);
    }
    pthread_exit(0);
}

運行結果:

在這裏插入圖片描述
可以看到和我們預想的結果不一樣,分析原因 strtok函數第一次明確指出字符串,第二次切割會接着上次切割的位置繼續切割,那麼保存這個切割的位置一定是全局變量或靜態變量。這樣就會讓多個線程共享這個全局變量,可能出現,fun線程切割後的全局變量指向b,主線程接着b切割,這樣就會導致fun線程和主線程切割出來的值和我們預想的不一樣,引發線程安全問題。歸根到底:都是因爲使用了可以共享的變量,又沒對其做處理,就會導致線程不安全。

解決這個問題,我們可以使用可重入函數,讓函數使用局部變量保存每次切割的位置即可,這樣就不會共享,其他線程不能使用,即變爲線程安全的函數,那麼切割的答案是一定的。

【2. 使用可重入函數strtok_r:】

那我們就用局部變量保存每次切割的位置即可,使用strtok_r函數,就可以保證線程安全,代碼如下:

# include<stdio.h>
# include<string.h>
# include<unistd.h>
# include<assert.h>
# include<stdlib.h>
# include<pthread.h>


void* fun(void* arg)
{
    char buff[]="1 2 3 4 5 6 7 8";
    char* q=NULL;
    char* p=strtok_r(buff," ",&q);
    while(p)
    {
        printf("fun:%s\n",p);
        p=strtok_r(NULL," ",&q);//局部變量q保存切割位置,不能共享
        sleep(1);
    }

}
int main()
{
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);
    char str[]="a b c d e f g h";
    char* q=NULL;
    char* p=strtok_r(str," ",&q);
    while(p)
    {
        printf("main:%s\n",p);
        p=strtok_r(NULL," ",&q);
        sleep(1);
    }
    pthread_exit(0);
}

在這裏插入圖片描述

這下的結果和我們預想的一樣,fun函數線程切割數字數組,主線程切割字符數組

二、線程和fork()

fork可以創建出子進程,我們在進程創建那部分說過,子進程可以獲得很多父進程的資源,如文件描述符,堆區數據等。我們現在主要討論兩個問題:fork後子進程是否可以獲得父進程中的線程?父進程被加鎖的互斥鎖在子進程中的狀態?

(一)fork後子進程線程數量

fork之後子進程中的線程數量有兩種可能:

  • fork之後子進程擁有父進程所有的線程。
  • fork之後子進程只有一個調用fork的線程。

我們可以通過代碼來測試,我們寫這樣一段代碼:

  • 函數線程中fork創建子進程,在子進程中打印進程PID。父進程中打印父進程PID。
  • 主線程循環打印父進程PID。

如果運行結果中創建的子進程打印了主線程的信息,那麼就表示子進程獲得了父進程所有的線程;如果沒有打印就表示子進程只有調用fork的進程。

# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
# include<pthread.h>

void* fun(void* arg)
{
    pid_t pid=fork();
    assert(pid!=-1);
    if(pid==0)
    {
        printf("child,my pid=%d\n",getpid());
    }
    else
    {
        printf("father,my pid=%d\n",getpid());
    }
}

int main()
{
    printf("father pid=%d\n",getpid());
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);

    sleep(1);

    int i=0;
    for(;i<5;i++)
    {
        printf("main,my pid=%d\n",getpid());
        sleep(1);
    }
}

在這裏插入圖片描述
可以看到,打印結果中,父進程打印了函數線程中父進程PID,主線程中父進程PID,提示信息;但子進程只打印出了函數線程中子進程PID,並沒有主線程運行,所以:在多線程中一個線程調用fork創建子進程,創建的子進程中只有調用fork的下稱被啓動,其他線程並沒有執行。,概括爲下圖:

在這裏插入圖片描述
父進程中有主線程和函數線程;子進程中只有函數線程。

(二)fork後子進程鎖的處理

當線程調用fork時,就爲子進程創建了整個地址空間的副本,只要子進程和父進程沒有對內存做出改動,父、子進程之間還可以共享內存頁的副本。子進程通過繼承整個地址空間的副本,也從父進程哪裏繼承了所有的互斥量,讀寫鎖,讀寫鎖,條件變量的狀態在子進程中內部只存在一個線程,是由父進程中調用fork的線程的副本構成的,如果父進程中的線程佔有鎖,子進程同樣佔有鎖,這時就出現問題了:子進程中只有一個線程,可能在父進程中其他線程中對鎖進行加鎖,子進程這時獲得的鎖狀態是加鎖狀態,如果子進程中的線程對鎖再加鎖,就會導致子進程死鎖。

我們對這個過程進行一個模擬,代碼模擬思路如下:

  • 主線程中進行互斥鎖的初始化,對互斥鎖加鎖,循環打印信息後,對互斥鎖解鎖。
  • 函數線程先睡眠1秒,保證主線程給互斥鎖加鎖,在fork創建子進程時,互斥鎖處於加鎖狀態,此時子進程對鎖進行加鎖,打印信息,解鎖;父進程同樣加鎖,打印信息,解鎖。

代碼如下:

# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
# include<pthread.h>

pthread_mutex_t mutex;
void* fun(void* arg)
{
    pid_t pid=fork();//鎖爲加鎖狀態
    assert(pid!=-1);
    if(pid==0)
    {
        printf("child,my pid=%d\n",getpid());
        pthread_mutex_lock(&mutex);//在加鎖狀態的鎖上加鎖會被阻塞,死鎖
        printf("child lock sucess\n");
        sleep(2);
        pthread_mutex_unlock(&mutex);
        printf("child unlock success\n");
    }
    else
    {
        printf("father,my pid=%d\n",getpid());
        pthread_mutex_lock(&mutex);
        printf("father lock sucess\n");
        sleep(2);
        pthread_mutex_unlock(&mutex);
        printf("father unlock success\n");
    }
}

int main()
{
    pthread_mutex_init(&mutex,NULL);
    printf("father pid=%d\n",getpid());
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);

    pthread_mutex_lock(&mutex);

    int i=0;
    for(;i<5;i++)
    {
        printf("main,my pid=%d\n",getpid());
        sleep(1);
    }
    pthread_mutex_unlock(&mutex);
    printf("main unlock success\n");
    pthread_exit(NULL);
    
}

在這裏插入圖片描述
可以看到父進程成功加鎖,打印信息,解鎖;子進程中的信息並沒有被打印出來,我們用ps命令查看進程,如下圖:
在這裏插入圖片描述

可以看到子進程現在處於死鎖狀態,原因是:在創建出來的子進程中沒有主線程,只有一個調用fork的函數線程,此時鎖是加鎖狀態,這時子進程對鎖進行加鎖操作就會被阻塞,產生死鎖,因爲鎖本來就是鎖着的。但是父進程就不會,因爲父進程中有主線程,是解鎖後加鎖。子進程沒有解鎖直接就加鎖了,就產生死鎖,進程不能結束。

那我們如何解決這個問題:我們需要清除子進程中的鎖狀態即可解決

有一個函數,它可以對鎖進行處理,函數原型爲:

# include<pthread.h>
int pthread_atfork(void(*prepare)(void),void(*parent)(void),void(*child)(void));
                       //成功返回0,失敗返回錯誤編號

參數: 3個參數都需要傳入函數地址。

  • prepare函數:是fork之前調用,對所有的鎖執行加鎖操作,保證在fork執行過程中,所有的鎖都是加鎖狀態的。
  • parent函數:fork之後父進程環境中調用,對所有的鎖解鎖。
  • child函數:fork之後,子進程環境中調用,對所有鎖解鎖。

參數是3個清理鎖的函數,實現這三個函數,寫到主線程中即可,它就會自動根據位置去清理鎖。這個函數會將fork的執行延後到所有的鎖都解鎖後,即child函數執行過後,這樣子進程就不會因爲給加鎖的鎖加鎖而死鎖。

我們實現一下這份代碼:

# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
# include<pthread.h>

pthread_mutex_t mutex;
//實現3個對應的函數
void prepare()
{
    pthread_mutex_lock(&mutex);
}
void parent()
{
    pthread_mutex_unlock(&mutex);
}
void child()
{
    pthread_mutex_unlock(&mutex);
}
void* fun(void* arg)
{
    pid_t pid=fork();//鎖爲加鎖狀態
    assert(pid!=-1);
    if(pid==0)
    {
        printf("child,my pid=%d\n",getpid());
        pthread_mutex_lock(&mutex);//在加鎖狀態的鎖上加鎖會被阻塞,死鎖
        printf("child lock sucess\n");
        sleep(2);
        pthread_mutex_unlock(&mutex);
        printf("child unlock success\n");
    }
    else
    {
        printf("father,my pid=%d\n",getpid());
        pthread_mutex_lock(&mutex);
        printf("father lock sucess\n");
        sleep(2);
        pthread_mutex_unlock(&mutex);
        printf("father unlock success\n");
    }
}

int main()
{
    pthread_mutex_init(&mutex,NULL);
    pthread_atfork(prepare,parent,child);//進行函數調用
    printf("father pid=%d\n",getpid());
    pthread_t id;
    int res=pthread_create(&id,NULL,fun,NULL);
    assert(res==0);

    pthread_mutex_lock(&mutex);

    int i=0;
    for(;i<5;i++)
    {
        printf("main,my pid=%d\n",getpid());
        sleep(1);
    }
    pthread_mutex_unlock(&mutex);
    printf("main unlock success\n");
    pthread_exit(NULL);
    
}

在這裏插入圖片描述
可以看到這次函數線程中的父進程可以正常加鎖,解鎖,子進程也可以進行加鎖,解鎖,沒有產生死鎖。

加油哦!🥧。

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