【操作系統】線程同步、線程互斥、原子操作

線程互斥

引入

來看一段多線程的代碼,這是一個經典的賣火車票例子,西安火車站現在剩餘 10 張到北京西的票,有 3 個售票窗口在買票:

// 銷售火車票
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10;

// 每個窗口都執行的售票操作,假設每個窗口每次只賣出 1 張
void* SellTicket(void*);

int main()
{
    // 有 3 個售票窗口在售票
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, NULL, SellTicket, "窗口1");
    pthread_create(&tid2, NULL, SellTicket, "窗口2");
    pthread_create(&tid3, NULL, SellTicket, "窗口3");
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    pthread_join(tid3, NULL);

    return 0;
}

void*
SellTicket(void* arg)
{
    char* id = (char*) arg;
    while (1)
    {
        if (ticket > 0) 
        {
            sleep(1);  // 售票員小姐姐操作一下
            --ticket;
            printf("%s 售出 1 張, 剩餘 %d 張\n", id, ticket);
        }
        else
        {
            printf("票售罄了!\n");
            break;  // 關閉售票窗口
        }
    }
}

代碼開起來是沒什麼毛病,沒毛病走兩步?
編譯 gcc 3-銷售火車票.c -lpthread
運行 ./a.out
在這裏插入圖片描述
看到結果就驚了,爲什麼票已經沒了其他窗口還在賣?
罪魁禍首就是線程的搶佔式執行

原因

把大象裝冰箱需要幾步?
打開冰箱門、把大象塞進去、關上冰箱門。

那 --ticket 需要幾步?
在馮諾依曼體系結構下:
把 ticket 讀取到寄存器、電路啪啪啪轉換將寄存器的值 -1、把值放回 ticket 對應的內存中。

既然線程是搶佔式調度的,那麼就有可能出現下面的情況:
假設 ticket 現在有 10 張。
線程 1:ticket -> 寄存器【ticket:10,寄存器:10】
【線程 1 的 CPU 時間片到保存現場,切換到線程2執行】
線程 2:ticket -> 寄存器【ticket:10,寄存器:10】
線程 2:寄存器值 -1 【ticket:10,寄存器:9】
線程 2:寄存器 -> ticket 【ticket:9,寄存器:9】
【線程 2 執行完,切換到線程1執行,恢復現場】
線程 1:寄存器值 -1 【ticket:10,寄存器:10】
線程 1:寄存器 -> ticket 【ticket:9,寄存器:9】
最終!!!兩個窗口共售出 2 張票,但是 ticket 是 9!!!

解決

先來了解一些概念:
臨界資源:多線程執行流共享的資源就叫做臨界資源。比如上面的 ticket 就是一個臨界資源。
臨界區:每個線程內部,訪問臨界資源的代碼,就叫做臨界區。比如上面的 --ticket
原子性:不會被任何調度機制打斷的操作,該操作只有兩態,要麼完成,要麼未完成。

線程互斥

互斥:任何時刻,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,通常對臨界資源起保護作用。

當線程 1 在做 “把大象裝冰箱” 的三步操作的時候,不讓其他的線程搶去線程 1 的執行權。專業點說就是當代碼進入臨界區執行時,不允許其他線程進入該臨界區。
要做到互斥那麼我們就需要一個東西來標識當前是否有線程在使用臨界資源。這個東西就叫做互斥量 mutex(互斥鎖)。

來看看修改後的代碼:

// 銷售火車票
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 10;
pthread_mutex_t mutex;

void* SellTicket(void*);

int main()
{
    pthread_mutex_init(&mutex, NULL);

    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, NULL, SellTicket, "窗口1");
    pthread_create(&tid2, NULL, SellTicket, "窗口2");
    pthread_create(&tid3, NULL, SellTicket, "窗口3");
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    pthread_join(tid3, NULL);

    pthread_mutex_destroy(&mutex);
    return 0;
}

void* SellTicket(void* arg)
{
    char* id = (char*) arg;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0) 
        {
            sleep(1);
            --ticket;
            printf("%s 售出 1 張, 剩餘 %d 張\n", id, ticket);
            pthread_mutex_unlock(&mutex);
            sched_yield();  // 測試:放棄 CPU 執行權
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            printf("票售罄了!\n");
            break;
        }
    }
}

當一個線程進入臨界區後加鎖,出臨界區後解鎖。
其他線程在執行到這裏的時候如果鎖被用了,那就等待。

在這裏插入圖片描述

互斥鎖 mutex 是一種 掛起等待鎖,一旦有一個進程上了鎖,其他進程獲取鎖失敗,就會掛起(進入操作系統的等待隊列中)。
當鎖被釋放後並且被操作系統調度,才能繼續執行!
互斥鎖能夠保證線程安全,但是最終程序的效率受到影響,除此之外還有可能出現更嚴重的問題 死鎖
那麼還需要注意一點就是 mutex 的上鎖解鎖狀態也必須是原子操作。

爲了實現互斥鎖操作,大多數體系結構都提供了swap或exchange指令,該指令的作用是把寄存器和內存單 元的數據相交換,由於只有一條指令,保證了原子性,即使是多處理器平臺,訪問內存的 總線週期也有先後, 一個處理器上的交換指令執行時另一個處理器的交換指令只能等待總線週期。

其他類型鎖:
悲觀鎖: 在每次取數據時,總是擔心數據會被其他線程修改,所以會在取數據前先加鎖(讀鎖,寫鎖, 行鎖等),當其他線程想要訪問數據時,被阻塞掛起。
樂觀鎖: 每次取數據時候,總是樂觀的認爲數據不會被其他線程修改,因此不上鎖。但是在更新數據 前,會判斷其他數據在更新前有沒有對數據進行修改。主要採用兩種方式:版本號機制和CAS操作。
CAS操作: 當需要更新數據時,判斷當前內存值和之前取得的值是否相等。如果相等則用新值更新。若 不等則失敗,失敗則重試,一般是一個自旋的過程,即不斷重試。
自旋鎖,公平鎖,非公平鎖?

線程同步

同步:同步控制着線程之間的執行順序,不讓他們搶佔式執行。
在保證數據安全的前提下,讓線程能夠按照某種特定的順序訪問臨界資源,從而有效避免飢餓問題,叫做同步。

舉個生活中的栗子:
在籃球比賽中,有傳球、扣籃兩個動作,假設傳球和扣籃是兩個人完成,那麼就需要有個先後順序,先傳球,再扣籃。
假設傳球的耗時 789789ms,扣籃耗時 123123ms。

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

// 傳球動作
void* ThreadEntry1(void* args) {
    (void) args;
    while (1) {
        printf("傳球\n");
        usleep(789789);
    }
    return NULL;
}

// 扣籃動作
void* ThreadEntry2(void* args) {
    (void)args;
    while (1) {
        printf("-扣籃\n");
        usleep(123123);
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, ThreadEntry1, NULL);
    pthread_create(&tid2, NULL, ThreadEntry2, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    return 0;
}

跑兩步:
可以看到,沒拿到球就扣籃了,這樣的情況就需要控制一下順序,首先你得拿到球,然後才能扣籃。
在這裏插入圖片描述
我們給上面的代碼加上控制:

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

// 互斥鎖
pthread_mutex_t mutex;
// 條件變量
pthread_cond_t cond;

// 傳球動作
void* ThreadEntry1(void* args) {
    (void) args;
    while (1) {
        printf("傳球\n");
        // 傳球過去了,通知一下
        pthread_cond_signal(&cond);
        usleep(788789);
    }
    return NULL;
}

// 扣籃動作
void* ThreadEntry2(void* args) {
    (void)args;
    while (1) {
        // 首先得等待球傳過來
        // 一直等到球傳過來
        pthread_cond_wait(&cond, &mutex);
        printf("-扣籃\n");
        usleep(123123);
    }
    return NULL;
}

int main() {
    // 初始化 cond 和 mutex
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, ThreadEntry1, NULL);
    pthread_create(&tid2, NULL, ThreadEntry2, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    return 0;
}

跑一跑:
在這裏插入圖片描述
注意 ThreadEntry2 執行到 pthread_cond_wait() 時候會做三個動作:
1、先釋放鎖;
2、等待 cond 條件就緒;
3、重新獲取鎖,執行後面的操作。
其中的 1、2 操作必須是原子性的,否則可能錯過其他線程通知消息,導致還在這裏傻等。
大部分情況下,條件變量得和互斥鎖一起使用。

爲什麼 pthread_cond_wait 需要互斥量?
條件等待是線程間同步的一種手段,如果只有一個線程,條件不滿足,一直等下去都不會滿足,所以必 須要有一個線程通過某些操作,改變共享變量,使原先不滿足的條件變得滿足,並且友好的通知等待在 條件變量上的線程。 條件不會無緣無故的突然變得滿足了,必然會牽扯到共享數據的變化。所以一定要用互斥鎖來保護。沒 有互斥鎖就無法安全的獲取和修改共享數據。

  1. 比如兩個線程都要訪問一個共享資源,那麼這個共享資源是不是就需要加鎖。
  2. 如果等待的函數先獲取了鎖,那麼另一個發信號的線程需要獲取鎖怎麼辦,那就得需要收信號的線程在wait函數的時候釋放鎖,等待發信號的線程訪問完臨界資源之後發信號。
  3. 如果,在等待函數前先釋放鎖,那麼同時發信號的線程發送了信號。那麼還沒來得及進入等待函數信號已經錯過了,那這不就會一直等待嘛。
  4. 所以就需要這個解鎖和等待的動作是原子的,所以這個函數就需要這個互斥量。然後再函數內部,程序設計者會用一些原子的指令來完成這兩個操作。

EOF

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