執行緒同步 16:同步屏障

多個執行緒之間有的跑得快而有的跑得慢。 本來這件事情可能也不是什麼問題,但是有的時候會遇到不同執行緒的工作之間存在依賴關聯性, 這時該怎麼辦呢? 本篇就要來介紹一個相當簡單的執行緒同步工具:同步屏障。

「同步屏障」(Barrier),或者有些地方可能會翻譯為「同步柵欄」,是一個用來讓執行緒等待彼此的工具。 就好像是學校組織爬山郊遊的時候,總是有的小傢伙跑的飛快,一下子就衝到看不見人, 而有的小孩子慢吞吞的,總是落在最後面。 於是管理的老師們通常就會選擇幾個定點,比如說哪些涼亭或隘口等,在那兒把守著。 當有小朋友抵達的時候就讓他們在那裡停下來休息等待,等待後面的人陸續抵達跟上。 當清點人數確認全班同學都到達以後,才放手讓小朋友們繼續往後面走; 然後到了下一個老師把守的地方又再一次重複同樣的事情。 而這就是同步屏障的運作機制。

同步屏障在初始化的時候就需要指定需同步協作的執行緒數量。 後續當執行緒執行到某個位置,需要等候其它執行緒的時候,就呼叫同步屏障的 wait() 函式, 然後執行緒就會停在那裡休眠等待。 其它執行緒的執行流程抵達設計的位置的時候也同樣呼叫 wait() 並進入休眠等待, 直到所有的執行緒都呼叫了 wait() 之後,同步屏障就會一次喚醒全部的 wait() 呼叫。 這時所有的執行緒就會在同一時間一起醒過來並接緒往下執行。 其實同步屏障的機制和信號量有些相似,其內部都會維護一個數值; 信號量是每次呼叫 wait() 都會將數值減一,而同步屏障則是每次呼叫 wait() 都會將數值加一 (這是邏輯上的行為,未必是實做上的運作機制); 信號量在數值為零時需要等待,而同步屏障則在數值達到設定的目標值之前需要等待。

同步屏障的使用方式就是這樣單純。 但若文字的敘述讓人感到饒口的話,那麼接下來就用一個範例來演示一下同步屏障的用法。

範例

首先我建立了幾個執行緒去執行下面這個函式, 這函式的作用就是執行 4 次特定的工作。 當然作為示範範例,這裡並沒有真正去做什麼事情,而是以一段睡眠代表執行那件工作所消耗的時間, 並且在工作開始和結束的時候分別輸出訊息,告訴我們什麼時候它開始工作了,又什麼時候工作結束了。 此外為了模擬不同工作的複雜程度導致完成的時間有快有慢這件事, 於是我建立了一個延時表格,並在每一次呼叫模擬工作的休眠時,以亂數隨機選擇其中的一個時間來進行休眠。 因此從下面程式碼中的表格資料可知,每一次的工作最短只需 200 毫秒完成,而最長則需 2 秒鐘完成。

void* ThrdFunc(struct WorkData *data)
{
    static const unsigned spendtimes[] =
    {
        200,
        400,
        800,
        1200,
        1600,
        2000,
    };

    static const unsigned spendtimes_num =
        sizeof(spendtimes) / sizeof(spendtimes[0]);

    for(int i = 0; i < 4; ++i)
    {
        printf("%s: Start working\n", data->label);
        SleepMs(spendtimes[ rand() % spendtimes_num ]);
        printf("%s: Done working\n", data->label);
    }

    return NULL;
}

將上面程式編譯後執行,在我電腦上得出結果輸出如下:

Thread-0: Start working
Thread-1: Start working
Thread-2: Start working
Thread-3: Start working
Thread-0: Done working
Thread-0: Start working
Thread-3: Done working
Thread-3: Start working
Thread-3: Done working
Thread-3: Start working
Thread-2: Done working
Thread-2: Start working
Thread-2: Done working
Thread-2: Start working
Thread-1: Done working
Thread-1: Start working
Thread-1: Done working
Thread-1: Start working
Thread-0: Done working
Thread-0: Start working
Thread-3: Done working
Thread-3: Start working
Thread-2: Done working
Thread-2: Start working
Thread-0: Done working
Thread-0: Start working
Thread-1: Done working
Thread-1: Start working
Thread-2: Done working
Thread-3: Done working
Thread-1: Done working
Thread-0: Done working

從上面的輸出結果可以看出來,每條執行緒、每次工作都各自有快有慢, 有的執行緒一下子霹靂啪啦做完了好幾輪,而其它執行緒還在某一輪工作當中努力著。 如果每一輪的工作存在互相的依賴性,需要拿到其它執行緒在同一輪運算的結果才能夠正確執行下一輪的計算的話, 那麼上面這樣的執行情況肯定會導致計算的失物,或者程式的崩潰! (如果執行緒之間相依的是一些系統資源的分佈與銷毀行為話)

那麼為了解決上述的問題, 我們需要在每一輪工作開始前、或完成後進行一個彼此的同步等待,然後再一起繼續往下的工作。 在這個範例裡我選擇在每一輪工作的看使前進行同步等待, 也就是在每一次準備開始進行工作之前呼叫同步屏障的 wait() 函式,如下:

void* ThrdFunc(struct WorkData *data)
{
    // 省略......

    for(int i = 0; i < 4; ++i)
    {
        pthread_barrier_wait(data->barrier);

        printf("%s: Start working\n", data->label);
        SleepMs(spendtimes[ rand() % spendtimes_num ]);
        printf("%s: Done working\n", data->label);
    }

    return NULL;
}

這樣在迴圈的每一次呼叫 wait() 的時候,執行緒就會停下來等待其它執行緒也抵達同樣的位置。 為了讓同步屏障知道總共有多少執行緒要進行同步,這樣它才能知道什麼時候大家都到齊了,並叫醒大家。 因此在初始化同步屏障的時候,就需要設定給同步屏障這個需要等待的人數, 也就是我們範例裡面執行那些工作的執行緒數量:

pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, thrd_num);

完成這些修改之後,再次執行範例程式,得到如下輸出:

Thread-0: Start working
Thread-2: Start working
Thread-1: Start working
Thread-3: Start working
Thread-0: Done working
Thread-3: Done working
Thread-1: Done working
Thread-2: Done working
Thread-0: Start working
Thread-2: Start working
Thread-3: Start working
Thread-1: Start working
Thread-1: Done working
Thread-2: Done working
Thread-3: Done working
Thread-0: Done working
Thread-1: Start working
Thread-0: Start working
Thread-2: Start working
Thread-3: Start working
Thread-0: Done working
Thread-3: Done working
Thread-2: Done working
Thread-1: Done working
Thread-0: Start working
Thread-1: Start working
Thread-2: Start working
Thread-3: Start working
Thread-1: Done working
Thread-0: Done working
Thread-3: Done working
Thread-2: Done working

以上只節錄關鍵的部份,而完整的程式碼可以從這裡下載。 從上面的輸出結果可以見到,執行緒之間確實互相進行了同步等待。 每一輪的工作雖有人快而有人慢,但先執行完的會暫停並等待還在工作中的其他人, 等到大家都完成了這一輪的工作之後,才一起開始進行下一輪的工作。

這就是同步屏障的使用方式了,相當簡單對吧?

用途

同步屏障這東西通常比較少見,比本系列所介紹過的其它所有同步工具都還少見使用, 因此我預期讀者對這東西甚至可能連聽說過都沒有, 也因此本篇稍微花費一些篇幅解釋同步屏障的使用邏輯與可能適合使用的用途。

說起來,執行緒互相等待的機制已經在本系列介紹很多了, 例如條件變量用來等待其它執行緒的喚醒通知,信號量用來等待其它執行緒增加了它的值, 乃至於互斥子用來等待其它執行緒歸還通行牌等等; 而同步屏障則是用來等待數值達到所設定的目標值為止。 在概念上乃至於邏輯上的機制來說,同步屏障與信號量相當相似,但在使用上的邏輯不太一樣。 像信號量這樣的東西通常被用來管理有限的資源,當資源不夠的時候就進行等待,當資源來到的時候喚醒; 而同步屏障則通常被用來同步彼此工作流程的執行階段,主要關注的內容是執行流程狀態而非資源狀態。

什麼時候會需要使用同步屏障呢?那就是執行緒需要互相進行等待的時候了! 如果只有我需要等待對方而對方不需要等待我,比方等待某種資源的備便,那麼通常會使用條件變量或信號量; 如果我們彼此都可能需要等待對方,但不介意誰搶先多做了幾次,只在乎所有權的獨占的話, 那麼通常這時候會使用互斥鎖(含它的變體); 因此只有在執行緒互相都需要等待對方,並且介意偷跑行為的時候, 也就是確實需要在流程上進行同步的時候,這時就是同步屏障的合適使用時機了。

那麼具體有哪些情況可能會需要使用到同步屏障呢? 比方說有一個複雜的有限元分析(如果看不懂有限元是啥東西的話其實不影響理解,就是某種類型的演算法)工作, 整個需要被解算的資料約有 100 萬個格點。 有限元分析通常使用的是疊代類型的演算法,簡單說就是一次算不出來,需要重複計算好幾次,並且會愈算愈準, 只要反覆計算足夠多次,也就是疊代足夠多次,那麼結果就會相當準確。 為了加速計算分析,於是我們可能將 100 萬個格點分割為 10 個部份, 分別由 10 條執行緒(假設電腦有 10 個 CPU 核心)各自進行計算。 那麼這時就遇到了本篇範例所表現的一樣問題了。 不同的格點分區計算工作有快有慢,可能是因為格點分割的不夠平均,或者某些條件的格點計算就是比較久, 又或者可能是電腦同時在執行的其它任務工作瓜分了運算資源,使得某幾條執行緒速度比較慢等等。 不管原因為何,不同的格點分區計算的速度可能不一樣, 可能導致有些地方已經在進行第 1000 次疊代而有些地方可能才到第 100 次疊代, 這肯定會導致計算結果的誤差。 因此這時候就可以使用同步屏障,讓所有執行緒的疊代工作彼此互相等待,先完成的等待後完成的, 當所有執行緒都完成了這一輪的疊代之後才一起進行下一輪的疊代計算。

總結

本篇介紹了同步屏障這項執行緒同步工具,雖然簡單但卻少見,是個相當奇妙的存在。

一晃眼這個系列已經推出了十幾篇文章,回首一望累積的文章列表有種莫名的感觸。 同步屏障已是這個系列介紹的最後一個執行緒同步工具,而本系列也即將進入尾聲。 下一篇,也就是本系列的最後一篇文,將要來介紹一種特別的變數,就是執行緒的本地存儲變數。

上一篇:「執行緒同步 15:Select」
下一篇:「執行緒同步 17:本地存儲」