1. 程式人生 > >設計模式與多執行緒——用命令模式來設計多執行緒架構

設計模式與多執行緒——用命令模式來設計多執行緒架構

下載原始碼

 
圖一
 
圖二

毫無疑問,多執行緒會增加寫程式碼的難度。在有併發處理的情況下,debug變得更困難,程式碼中的臨界區必須得到保護,共享的資源也得管理好。當然,通過增加程式執行的執行緒數帶來的結果是:效率可以大大提高。從一個程式設計師的角度來說,處理執行緒是很令人頭疼的——一些奇怪的現象在Debug時反而捕捉不到;有些Bug不能重現,只偶爾才會彈出錯誤提示;更糟的是,有時程式並沒有執行得更快。引入並行處理的收穫同這些比起來似乎有些不太值了。

通過將執行緒邏輯本地化並仔細的管理共享資源(這通常比表面上看起來困難得多),很多這些惱人的情形都可以避免。將命令模式與多執行緒應用結合起來,我們可以通過建立一個框架來完善多執行緒程式設計中的細節。這個框架將把大部分的多執行緒行為封裝起來,並使得我們的開發減少至只要專注於應用層的邏輯。此外,這種機制使你能以開放/封閉的方式來開發,也就是說,在你將越來越多的功能增加進來的時候,沒有一個工作執行緒的程式碼需要被修改到。

命令模式是一條這樣的原則,它能讓你“在不知道所請求的操作的任何資訊或者請求接收者的任何資訊時,能向物件發出請求”(請參考Gang of Four的設計模式書)。它的核心是:命令模式有一個基類(如下所示),裡面有一個名為Execute()的純虛擬函式,這個函式將在子類中實現,定義具體所要完成的工作。

class Command
{
public:
    virtual void Execute() = 0;
};
	  

將這個思想繫結在一個接收命令物件的多執行緒框架上,我們可以開發出一個多執行緒應用而不用糾結於複雜的多執行緒設計。這個框架遵循生產者/消費者模式,以工作執行緒為消費者。生產者產出各種的命令——都從Command這個類派生。但生產者將這些命令交給工作執行緒時,由於Command多型的特性,這些執行緒不需要知道任何的細節,只需簡單的呼叫Execute()。由於應用的邏輯在Command的子類中封裝了,這個框架能適於任何種類的工作並能在不影響已有程式碼的基礎上擴充套件。

開發一個多執行緒程式

執行緒會帶來什麼優點呢?每當效率成為一個考量的因素,多執行緒就能帶來效能上的收益(假使兩件或兩件以上的事情能同時做)。當然,除了多執行緒也會有其他選擇,即用多個程序併發地工作。然而,執行緒天生就比程序有優勢。起決定性的因素在於多執行緒程式設計的唯一最大的好處:執行緒比程序需要更少的程式以及系統的開銷。並且,由於執行緒共享程序空間,執行緒間的通訊和資源共享比程序間的要來得更直接。

Win32中建立執行緒是用CreateThread,這個函式接收一個指向執行緒函式(執行緒的入口)的指標作為引數。新建的執行緒將在這個函式中執行並隨著函式執行的終結而終結。

以下是用CreateThread建立執行緒的一個例子:

#include <windows.h>
#include <iostream>
void thread_entry ()
{
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 200 );
        std::cout << "world" << std::endl;
    }
}

int main ()
{
    HANDLE thread = CreateThread ( NULL, 0,
                                  ( LPTHREAD_START_ROUTINE ) thread_entry,
                                   NULL, 0, NULL );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 100 );
        std::cout << "hello" << std::endl;
    }
    WaitForSingleObject ( thread, INFINITE );
    return  0 ;
}
	  

從這個簡單的例子中你可以看到有兩個執行緒在跑(這從交替出現的輸出可以看出):一個是用CreateThread建立的,另一個是在main()中跑的。WaitForSingleObject函式使得呼叫者等待一個特定的執行緒結束,之後操作再繼續進行。在上面的例子中這是必須的,因為執行緒函式比main中的操作花的時間要長,如果把它扔在那裡不等它會使得那個執行緒在程式退出前沒有辦法做完它的工作。

為了從多執行緒開發得到更多的功用,你需要用mutex來保持執行緒間的同步。mutex是一個加鎖的機制,它會保護共享資源不被同時訪問。一個mutex允許一個獲得鎖的執行緒對資源有專有訪問權,直到鎖被釋放。Mutex能用於保護檔案、全域性變數、計數器或任何需要被執行緒獨佔的物件的訪問。Win32執行緒庫提供了一個mutex型別和一些函式來獲得和釋放mutex上的鎖,比如CreateMutex、WaitForSingleObject和ReleaseMutex等函式。

以下是一個用mutex變數來實現同步的例子:

#include <windows.h>
#include <iostream>
HANDLE mutex;
void thread_entry ()
{
    WaitForSingleObject ( mutex, INFINITE );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 200 );
        std::cout << "world" << std::endl;
    }
    ReleaseMutex ( mutex );
}

int main ()
{
    mutex = CreateMutex ( 0, NULL, 0 );
    HANDLE thread = CreateThread ( NULL, 0,
                                 ( LPTHREAD_START_ROUTINE ) thread_entry,
                                 NULL, 0, NULL );
    WaitForSingleObject ( mutex, INFINITE );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 100 );
        std::cout << "hello" << std::endl;
    }
    ReleaseMutex ( mutex );
    WaitForSingleObject ( thread, INFINITE );
    return 0;
}
	  

由於使用了mutex,這一次的輸出不像前一次那樣是交替出現的,一個執行緒中的輸完後才會出現另一個執行緒中的結果。這是由於一個執行緒能拿到mutex上的鎖,這樣另一個就被阻塞住了,直到mutex得到釋放。當mutex釋放後,被阻塞的執行緒才能獲取鎖,然後繼續執行。

目前的例子中,CreateThread在任何需要新執行緒的地方被呼叫,但既然執行緒的建立的確會帶來一些開銷,在初始化時就建立所有的執行緒是很常見的,然後讓它們等待適當的時機再執行。這種方式被稱為“執行緒池”。通過用執行緒池,建立執行緒的全部開銷都被安排在啟動的時候,在程式執行時就不受這部分工作的影響了。

一般實現一個執行緒池需要用到另一種同步機制——event。Event允許執行緒在空閒狀態下等待event發生而當選擇使條件成立時也不需要產生額外開銷。一個執行緒池用event來喚醒休眠的執行緒,執行任務。由於程式執行所需要的所有執行緒是在啟動時建立,這些執行緒在它們的入口函式中常會有無限迴圈。

以下是一個從佇列中讀取訊息的簡單的例子,其中用到執行緒池:

#include <iostrem>
#include <string>
#include <vector>
#include <windows.h>
HANDLE condition;
HANDLE mutex;
std::vector < std::string > queue;
void thread_entry ()
{
    for ( ;; )
    {
        WaitForSingleObject ( mutex, INFINITE );
        if ( queue.empty () )
        {
            ReleaseMutex ( mutex );
            std::cout << "thread going to sleep" >> std::endl;
            WaitForSingleObject ( condition, INFINITE );

            WaitForSingleObject ( mutex, INFINITE );
            std::cout << "thread awake" << std::endl;
        }
        if ( queue.empty () )
        {
            std::cout << "work queue is empty" << std::endl;
            ReleaseMutex ( mutex );
            continue;
        }
        std::string message = queue.back ();
        queue.pop_back ();
        ReleaseMutex ( mutex );
        std::cout << message << std::endl;
        Sleep ( 1 );
    }
}

int main ()
{
    HANDLE threads[ 3 ];
    std::string messages[ 10 ] = { "hello", "goodbye", "test", "check",
                                   "one", "two", "james", "way-way",
                                   "here", "there" };
    mutex = CreateMutex ( NULL, FALSE, NULL );
    condition = CreateEvent ( NULL, FALSE, FALSE, NULL );
    for ( int i = 3; i > 0; --i )
    {
        threads[ i - 1 ] = CreateThread ( NULL, 0,
                                         ( LPTHREAD_START_ROUTINE ) thread_entry,
                                          NULL, 0, NULL );
    }
    for ( int index = 10; index > 0; --index )
    {
        WaitForSingleObject ( mutex, INFINITE );
        queue.push_back ( messages[ index - 1 ] ); 
        SetEvent ( condition );
        ReleaseMutex ( mutex );
    }
    WaitForSingleObject ( threads[ 0 ], INFINITE );
    return 0;
}
	  

可以看到,由於沒有訊息可以處理,執行緒最初都進入了睡眠狀態。當main將訊息推入佇列後,這些執行緒就被event喚醒了,然後開始將佇列中的訊息拿出來——處理,也就是將訊息簡單的打印出來。執行緒函式中的sleep()主要是人為地要將執行時間延長,並用來顯示在一個執行緒忙碌的狀態下另一個執行緒是如何運作的。當一個執行緒完成了它的任務,它會返回到空閒的睡眠狀態,再次等待有event來喚醒它工作。

命令模式

命令模式是比較簡單的,只需幾步來實現。首先,這個模式的基礎是用一個抽象基類來實現的,這個抽象類有一個虛擬函式Execute()。子類都需要實現這個函式。命令模式的幾個已知的用途包括支援日誌功能、將請求排放在佇列中以及支援撤銷等,但重要的一點是:所有的命令物件有一樣的介面,而且它們對請求者隱藏了接收者的資訊。藉由命令類我們可以很容易的達成開放/關閉原則,因為擴充套件功能的工作將被減少到只需要增加更多的派生的命令類而不是修改已有的類來完成。

以下是命令模式的一個簡單應用:

#include <iostream>
// base command class
class Command
{
public:
    Command () {}
    ~Command () {}
    // the actual command logic resides in Execute ()
    virtual void Execute () = 0; 
};
class Command_A : public Command
{
public:
    Command_A () {}
    ~Command_A () {}
    void Execute () { std::cout << "Doing some work" << std::endl; }
};
class Command_B : public Command
{
public:
    Command_B () {}
    ~Command_B () {}
    void Execute () { std::cout << "Doing some other work" << std::endl; }
};

int main ()
{
    Command_A a;
    Command_B b;
    Command* Commands[ 2 ];
    Commands[ 0 ] = &a;
    Commands[ 1 ] = &b;
    Commands[ 0 ]->Execute ();
    Commands[ 1 ]->Execute ();
    return 0;
}
	  

命令類的多型特性使得呼叫者只需簡單的呼叫Execute()而不需要知道派生的命令類的內部情形。當然,在這簡單的例子中,派生的命令類究竟在做什麼是一部瞭然的,沒有任何神祕可言。但是,通過給這簡單的例子加上一些執行緒,一個更有用的例子就會出現了(見下節)。

將命令模式和多執行緒結合在一起

將命令模式和一個多執行緒伺服器結合起來會帶來一些好處。最重要的是:所有核心的處理邏輯被植入到命令類中。這樣,任何額外需要新增的功能都能通過增加派生的命令類來完成,而不需要觸動任何已有的程式碼。命令類的開發有可能完全獨立於多執行緒的邏輯開發,這使得你可以完全的測試並debug普通的邏輯而不需要立即就進入到多執行緒的環境中。通過將已有的程式碼隔離開我們將獲得一個巨大的優勢——你可以消除將新的bug引入到已測試併發布的程式碼中的風險。除此之外,因為執行緒邏輯可以單獨並間斷地開發,而不會將執行緒的一些呼叫散佈在程式碼中,這個邏輯就可以在不需要將整個應用重新連起來的情況下被測試、debug並被完善。這將多執行緒程式設計集中到一個地方,降低了複雜性;而且只需在一個地方調整並debug程式碼。這對任何專案來說都是極其重要的,尤其是對那些廣泛應用執行緒的專案。

請再看最後一個例子(類圖見圖一,用例見圖二):

#include <iostream>
#include <vector>
#include <windows.h>
HANDLE condition;
HANDLE mutex;
class Command;
std::vector < Command* > queue;
// base command class
class Command
{
public:
    Command () {}
    ~Command () {}
    // the actual command logic resides in Execute ()
    virtual void Execute () = 0; 
};
class Command_A : public Command
{
public:
    Command_A () {}
    ~Command_A () {}
    void Execute () { std::cout << "Doing some work" << std::endl; }
};
class Command_B : public Command
{
public:
    Command_B () {}
    ~Command_B () {}
    void Execute () { std::cout << "Doing some other work" << std::endl; }
};

void thread_entry ()
{
    for ( ;; )
    {
        WaitForSingleObject ( mutex, INFINITE );
        if ( queue.empty () )
        {
            ReleaseMutex ( mutex );
            std::cout << "thread going to sleep" << std::endl;
            WaitForSingleObject ( condition, INFINITE );
            WaitForSingleObject ( mutex, INFINITE );
            std::cout << "thread awake" << std::endl;
        }
        if ( queue.empty () )
        {
            std::cout << "work queue is empty" << std::endl;
            ReleaseMutex ( mutex );
            continue;
        }
        Command* command = queue.back ();
        queue.pop_back ();
        ReleaseMutex ( mutex );
        command->Execute ();
        delete command;
        Sleep ( 1 );
    }
}

int main ()
{
    HANDLE threads[ 10 ];
    mutex = CreateMutex ( NULL, FALSE, NULL );
    condition = CreateEvent ( NULL, FALSE, FALSE, NULL );
    for ( int i = 10; i > 0; --i )
    {
        threads[ i - 1 ] = CreateThread ( NULL, 0,
                                    ( LPTHREAD_START_ROUTINE ) thread_entry,
                                     NULL, 0, NULL );
    }
    for ( int index = 10; index > 0; --index )
    {
        WaitForSingleObject ( mutex, INFINITE );
        Command* command = index % 2 ? ( Command* ) new Command_A
                                 : ( Command* ) new Command_B;
        queue.push_back ( command ); 
        SetEvent ( condition );
        ReleaseMutex ( mutex );
    }
    WaitForSingleObject ( threads[ 0 ], INFINITE );
    return 0;
}
	  

上面的例子表明:除了呼叫它們接收到的命令物件上的Execute(),執行緒自身並不包含處理邏輯。而且,所有的執行緒共用一個入口,如此一來,幾乎所有的執行緒特定的資訊都在那個入口函式中。命令中通常包含接收者的資訊,然而,在上面的例子中,由於執行緒都以同樣的方式處理所有的命令物件,將這個功能拿出來並在main中將命令物件顯示填入佇列中可能會使程式碼的意思更清晰。如果需要特殊的執行緒就不是這麼一回事了,這樣的話,將命令物件傳給一個特定的執行緒或將命令物件推入一個特定的佇列的邏輯會被封裝在Execute()中。

為進一步拓展這裡展示的命令物件的功能,可以將命令物件想象成不相關的工作包。事實上,任何型別的需要完成的工作都能被封裝進命令類中。通過將完成某個特定任務所需要的資訊包裝在一個命令類中,框架將會處理剩下的事情。

這個處理多執行緒的框架可以讓你將多執行緒開發中存在的複雜性抽離出來。根據你的程式的具體要求,你也許或也許不需要為工作執行緒或命令類加入更多的同步機制。通過以這個結構作為開端並使用命令模式,你可以新增並刪除程式碼而不需要動到應用程式其他的部分。也許這不足以消除多執行緒程式設計的困難,但希望可以減少困難並幫你避免多執行緒程式設計中一些常見的陷阱。