執行緒同步 8:讀寫鎖

本篇再來介紹另一個互斥鎖的變體,是在特定應用場景下能夠改善鎖定效率的執行緒鎖: 讀寫鎖(Readers-Writer Lock)。

情境

在介紹這種執行緒鎖之前,先來看一段範例程式, 看看是什麼樣的情況與問題使得我們需要這麼樣一個解決方案?

本篇的範例假定的情況是有一個執行緒負責執行某種複雜的服務內容, 而其它的一些軟體模組(當然也是不同的執行緒)便需要經常查詢這個服務的當前狀態, 比如說服務資訊、版本、正在做的事情、完成進度……等等各種狀態內容, 以便依據這些內容來決定自己接下來該幹什麼? 於是範例內就有一條執行緒負責每隔一段時間更新一段模擬的訊息資訊內容, 然後另外 6 條執行緒每隔一段時間要去讀取這個訊息內容,分析並決定接下來要做什麼。 當然作為一個簡單的範例,把那個「分析並接下來幹什麼」的那部份省略了,只著重了讀取資訊的部份。

以下就是範例所定義的服務狀態資訊:

struct DummyInfo
{
    char description[128];
    unsigned version;
    int id;
    int level;
    bool activated;
};

然後是定時更新資訊的執行緒:

struct ServerData
{
    pthread_t thrd;
    bool go_term;

    pthread_mutex_t lock;
    struct DummyInfo stat_info;

    unsigned period;
    unsigned w_count;
    unsigned w_spend;
};

void* ServerThread(struct ServerData *data)
{
    for(; !data->go_term; SleepMs(data->period))
    {
        unsigned begin_time = GetMsTime();
        pthread_mutex_lock(&data->lock);

        // 更新記載某些狀態資訊的資料
        data->stat_info.version = 101;
        data->stat_info.id = 25;
        data->stat_info.level = ( data->stat_info.level + 1 ) % 5;
        data->stat_info.activated = !data->stat_info.activated;
        sprintf(
            data->stat_info.description,
            "A test server, level=%d",
            data->stat_info.level);

        // 為了模擬出更加容易被觀察的現象,
        // 這裡小休眠一下以放大在更新資訊時可能需要消耗的時間。
        SleepMs(100);

        pthread_mutex_unlock(&data->lock);
        unsigned end_time = GetMsTime();

        // 計算和儲存本次讀寫所消耗的時間
        data->w_count ++;
        data->w_spend += end_time - begin_time;
    }

    return NULL;
}

再然後是定時索取狀態資訊的執行緒:

struct ClientData
{
    pthread_t thrd;
    bool go_term;

    struct ServerData *server;
    struct DummyInfo stat_info;

    unsigned period;
    unsigned r_count;
    unsigned r_spend;
};

void* ClientThread(struct ClientData *data)
{
    for(; !data->go_term; SleepMs(data->period))
    {
        unsigned begin_time = GetMsTime();
        pthread_mutex_lock(&data->server->lock);

        // 從目標位置讀取狀態資料並存放到自己的記憶體位置,
        // 以待後續分析處理。
        // (當然在本範例中並不需要任何的後續處理)
        data->stat_info = data->server->stat_info;

        // 為了模擬出更加容易被觀察的現象,
        // 這裡小休眠一下以放大在讀取資訊時可能需要消耗的時間。
        SleepMs(50);

        pthread_mutex_unlock(&data->server->lock);
        unsigned end_time = GetMsTime();

        // 計算和儲存本次讀寫所消耗的時間
        data->r_count ++;
        data->r_spend += end_time - begin_time;
    }

    return NULL;
}

完整的範例程式碼可以從這裡下載, 而以下是在我的電腦上執行的結果輸出:

Write statistics: count=31, total-spend=5868, ave-spend=189
Read statistics: count=184, total-spend=17756, ave-spend=96

從輸出的統計結果可看到, 平均每次更新狀態資訊時消耗的時間為 189 毫秒,而平均每次讀取資訊則消耗 96 毫秒, 二者皆接近原先預期的兩倍。 確實互斥子的使用本身會帶來些許開銷, 但是如本篇測試中多達數十上百毫秒的消耗已遠遠超過執行緒排程及上下文切換所致的結果, 其大量的消耗其實是來自於等待其它執行緒歸還通行牌的過程。 那麼有沒有什麼方案能夠改善這樣的不利因素呢?有的,就是本篇介紹的讀寫鎖。

Note
為了模擬一些真實的情況,又不想讓範例程式變得和真實程式一樣龐大複雜, 於是我在程式碼中的資料讀寫都額外的加上了一小段休眠,以模擬對資料的讀寫更新可能產生的些許讀寫負擔; 並且為了讓其效果能夠被輕易的觀察和比較,又稍微誇張化的放大了讀寫延時。 其中在更新狀態資料時的延時為 100 毫秒,而讀取狀態資料時的延時為 50 毫秒。

讀寫鎖

在前面的測試程式執行的結果,我們發現無論是對於資料的讀或寫的整個過程都消耗了非常多的時間。 即便從前我們知道像互斥子這種鎖本身附帶一些可觀的開銷, 然而這點開銷在本次的測試結果裡面卻相形之下少到幾乎可以忽略不計! 其主要多餘出來的等待時間還是來自於等待其它執行緒解鎖的過程。 那麼究竟是什麼問題導致的呢?原來是參與讀寫的執行緒實在是多了!

如果說每個執行緒鎖定臨界區段都是在執行那些真的會衝突的東西,都是那些非得要排它的東西的話, 那麼這問題就只能夠這個樣子了,執行緒們總歸還是要老老實實的排隊等待, 而這時設計優化的目標可能就會變成是否減少不必要的讀寫機會或頻率。 但是我們這次面對的情況卻有些特殊, 雖然參與競爭的執行緒數量多了點,但是絕大部分執行的都是資料讀取的動作; 然而,讀取的動作會導致競爭嗎?讀取的行為真的有必要上鎖排隊嗎? 回顧執行緒 為什麼發生資料衝突競爭等問題 的內容,會發現問題根源來自於資料修改。 根本原因是因為資料的修改變更,才造成資料版本不一致(快取)的問題, 以及工作流程中途被插隊導致的狀態錯亂問題。 但是純讀取的操作並不修改資料啊! 誰管你想怎麼樣快取、怎麼樣調整執行順序、又怎麼樣被插隊呢? 是的,確實是如此。 那麼如果我們允許讓純粹資料讀取的行為可以同時進行而不需要排隊等待的話, 是不是就能減少許多不必要的時間等待浪費了呢? 是的,這就是使用讀寫鎖的目的。

不過讀取與讀取之間雖然不會產生干擾衝突,但是讀取與寫入之間卻會! 如果讀取和寫入能夠不被限制的同時進行的話,那麼就會讓我們有機會讀取到被更新到了一半的資料。 於是讀寫鎖的設計仍然需要將要寫入資料的執行緒與其它要讀取資料的執行緒互斥, 當然同樣也需要和其它也想要寫入資料的執行緒互斥; 至於如果都是讀取資料的執行緒,那就讓它們都一起進入臨界區段同時讀取吧!

面對所描述的這種應用情景,讀寫鎖應運而生。 讀寫鎖(Readers-Writer Lock)常也被簡寫為 RW Lock, 它基本的使用方式和邏輯與互斥子是一樣的, 只不過在上鎖的時候需要區分為以寫入者身份來上鎖,或者以讀取者身份來上鎖。 接著就讓我們來將上面範例程式的互斥子更換為讀寫鎖,首先是服務執行緒定時更新狀態資訊的執行緒:

for(; !data->go_term; SleepMs(data->period))
{
    unsigned begin_time = GetMsTime();
    pthread_rwlock_wrlock(&data->lock); // 以寫入者的身份要求上鎖

    data->stat_info.version = 101;
    data->stat_info.id = 25;
    data->stat_info.level = ( data->stat_info.level + 1 ) % 5;
    data->stat_info.activated = !data->stat_info.activated;
    sprintf(
        data->stat_info.description,
        "A test server, level=%d",
        data->stat_info.level);

    SleepMs(100);

    pthread_rwlock_unlock(&data->lock); // 解鎖
    unsigned end_time = GetMsTime();

    data->w_count ++;
    data->w_spend += end_time - begin_time;
}

然後是讀取狀態資訊的執行緒:

for(; !data->go_term; SleepMs(data->period))
{
    unsigned begin_time = GetMsTime();

    pthread_rwlock_rdlock(&data->server->lock); // 以讀取者的身份要求上鎖

    data->stat_info = data->server->stat_info;

    SleepMs(50);

    pthread_rwlock_unlock(&data->server->lock); // 解鎖
    unsigned end_time = GetMsTime();

    data->r_count ++;
    data->r_spend += end_time - begin_time;
}

完整的程式碼可以從這裡下載, 而程式的執行結果如下:

Write statistics: count=35, total-spend=4573, ave-spend=130
Read statistics: count=197, total-spend=12768, ave-spend=64

改用讀寫鎖來取代互斥子之後,明顯的能看出統計出來的結果大大優於使用互斥子的版本。 就以本篇範例來看,使用讀寫鎖的版本相比於使用互斥子的版本縮短了大約 30% 左右的等待時間。

適用場合

讀寫鎖看起來相當美好,但從這裡我又要開始來拆臺了。 讀寫鎖之所以能夠帶來相當的性能提升,是建立在適當合適的使用條件之下的, 尤其本文所設計的範例更是針對貼合此情境而設計; 然而若將讀寫鎖應用在並不匹配的場合,則可能使它實際的效能與互斥子相比並不優越, 甚至有可能更加劣化!

讀寫鎖其實是一種更加複雜的鎖, 甚至於某些讀寫鎖的底下其實就是靠著兩個互斥子來組合實現的 [1] , 也就是說讀寫鎖本身的使用操作之開銷其實是比普通的互斥子還要更高一些的。 而使用讀寫鎖之所以相比使用互斥子可以節省時間,就如同前面所解釋的一樣, 是因為它允許許多只讀取資料的執行緒可以重複上鎖、同時操作, 是因為讀取與讀取之間不需要互相排隊等待。 因此首先讀寫鎖適合的使用場景就是 很多緒共同存取資料,並且其中大多是只讀取,只有少部份需要寫入的場合。 (至於如果有人問大量讀取搭配沒有寫入?這種情況其實也沒必要上鎖了!) 而如果不是此種情況,例如在大量的寫入修改並搭配少量只讀或甚至沒有只讀而都是修改, 例如本系列前篇的訊息工廠和消費者範例那樣,那麼讀寫鎖的優勢將難以發揮, 而讀寫鎖也將形同於比普通互斥子更加複雜些的互斥子。

除了滿足上述多緒共同讀取的條件之外,還需要第二個條件, 就是對共同資料的讀寫需達到某種程度以上的開銷。 這是什麼意思呢? 舉個誇張點但其實相當常見的例子, 比方說我們需要讀取的狀態其實只是一個整數值,而不是像本篇範例那樣比較複雜的內容。 在臨界區段實際上非常小巧緊湊的情況下,上鎖和解鎖的時間非常非常短暫, 這時讀寫鎖允許多緒共同進入臨界區段的優勢並無法發揮出來。 因為讀寫鎖本身其實可算是普通互斥子的一種擴展方案,也就是說一般互斥子該有的特性它都有, 就導致其上鎖與解鎖的開銷仍在並與普通互斥子不相上下, 而在臨界區段相當短小的情況下其實這才是主要的延時來源! 也就是說對於某些很多讀取而少許寫入的場合,看起來好像適合使用讀寫鎖, 然而因為需要被保護的東西其實相當少量並且實際上飛快就能完成讀寫, 那麼這時候可能使用普通的互斥子其實也沒有什麼差別,甚至於或許使用自旋鎖反而會更加高效! 這就是為什麼本篇範例對於共同資料的讀寫那一段加入了人為的延時的原因, 因為這樣做才能夠在單純的範例程式碼裡面將讀寫鎖的優勢給凸顯出來。

除此之外還有另外一種可能,說不定其實並不一定需要上鎖! 競爭的問題其實是跟著多緒寫入而導致的, 然而在許多實際的應用中往往可能其實只有一條執行緒在更新某些資料,然後其它執行緒只讀取。 那麼在此情況下使用鎖來做保護仍是必須的嗎?那就不一定了! 對於單緒寫入、多緒讀取的情況如果仍需要做保護,那就只有兩個原因: 或者是怕讀到了才被更新到一半的資料,比如本篇的範例如果去掉保護的話就有可能發生這種現象; 或者是怕取得的資料不能即時反應當下的最新狀態, 這主要是因為負責寫入的動作可能還來不及將快取資料刷回去所導致, 而這在某些對於資料同步一致性性高度敏感的用途上可能會造成程式行為不正確對應的問題。

然而如果我們的程式並不存在上述的兩個問題呢? 比方可能相當常見的只使用一個整數來表示某種狀態, 而整數對於可能絕大部份的執行平臺上都是一步就能更新完成的; 除非像是在 32 位元 CPU 上更新一個 64 位元整數的動作才會被拆成兩個步驟分別更新。 (也許有讀者可能會問說明明在前篇敘述這些簡單動作其實需要好多 CPU 指令的,怎麼現在變成一步完成了呢? 那是因為現在討論的是單緒寫入的情境,管它為了這件事需要幾個指令呢, 反正最後將資料更新回去只有一個指令,不存在讓其它人讀到「一半新、一半舊」的問題!) 或者即便是像本篇的範例這樣需要更新一組資料結構,那不上鎖的話的確可能會發生取到被更新到一半的資訊的問題, 但假若我一個讀取者其實並不是需要分析整個資料,而其實只需要裡面的其中一個整數值呢?

所以在很多乍看可以應用讀寫鎖的地方,比如最常見的讀取一個整數值的情況, 其實壓根並不需要任何形式的鎖定; 就算我們的程式需要即時最新的數值資訊, 而不希望因為快取什麼的因素導致取得的資訊有些落後,而在我們的程式上會造成可能某些嚴重的後果, 那也只需要使用原子變數就能夠解決了,並不需要用上讀寫鎖。 從作者我的實務經驗來看,其實真正需要使用讀寫鎖的場合並不多見。 當然這並不是說讀寫鎖毫無用武之地,這最終還是要看應用場合的, 像我從前在做檔案系統驅動程式的時候用的就挺多! 這裡只是在告訴你讀寫鎖還是有它自己的適與不適條件存在, 並且在平均大多的情況下其實適合應用的地方可能並不多, 至少可能不比我們剛認識學習讀寫鎖的時候所想像的那麼好用。 總之只有在我們足夠了解它的特性之後,才能更加準確的判斷在真正適合的地方,讓它發揮應該有的效用。

總結

本篇介紹了讀寫鎖,加上前幾篇介紹的幾個鎖,正好湊齊了一組執行緒鎖。 這幾個鎖看來看去其實會發現,它們都是互斥鎖的不同變種! 無論是互斥鎖、自旋鎖、還是讀寫鎖,其實都可以概括歸類於互斥鎖大類別之下。 三種鎖的使用邏輯基本都是一樣的,上鎖、解鎖、保護中間的臨界區段, 只不過不同的所針對不同的應用場景做了優化。 也就是說自旋鎖和讀寫鎖其實都是互斥鎖針對特定場景下的效能優化變種, 如果不在乎效能最佳化的話,甚至可以就只學了個互斥鎖就到處使用,也都不影響程式的運作邏輯。 甚至於更誇張的話其實連原子變數也不用學,只要懂一個互斥鎖那也能到處走天下闖江湖, 也許做出來的東西不見得比別人高效,但並不影響能把功能給做出來。

然而如果追求好還要更好,那麼學習這些「多餘的」東西就是必不可少。 並且除了知道才什麼情況下使用什麼東西可以更進一步增進效能之外, 我認為更重要的是要知道在什麼情況下使用什麼東西沒有幫助,甚至於弊大於利導致性能倒退與其它問題。 而這些只有在足夠了解各種工具的底層邏輯的情況下才能夠掌握的游刃有餘, 真正成為幫助我們設計程式的上手工具。

上一篇:「執行緒同步 7:自旋鎖」
下一篇:「執行緒同步 9:死鎖問題」