五. 線程的同步

雖然多線程能給我們帶來好處,但是也有不少問題需要解決。例如,對於像磁盤驅動器這樣獨佔性系統資源,由於線程可以執行進程的任何代碼段,且線程的運行是由系統調度自動完成的,具有一定的不確定性,因此就有可能出現兩個線程同時對磁盤驅動器進行操作,從而出現操作錯誤;又例如,對於銀行系統的計算機來說,可能使用一個線程來更新其用戶數據庫,而用另外一個線程來讀取數據庫以響應儲戶的需要,極有可能讀數據庫的線程讀取的是未完全更新的數據庫,因爲可能在讀的時候只有一部分數據被更新過。

  使隸屬於同一進程的各線程協調一致地工作稱爲線程的同步。MFC提供了多種同步對象,下面我們只介紹最常用的四種:

  • 臨界區(CCriticalSection)
  • 事件(CEvent)
  • 互斥量(CMutex)
  • 信號量(CSemaphore)
     

通過這些類,我們可以比較容易地做到線程同步。 爲了能更好的應用這些同步類,MFC提供了兩個同步仿問類SingleLock,CMultiLock。以我的理解,臨界區和互斥量多用於資源仿問的代碼控制,能很好的支持SingleLock。事件多用於通知機制,大多和WaitForSingleObject結合使用,中間可以夾雜SingleLock。信號量和CMultiLock結合用於多線程仿問某些資源。

 

A、使用 CCriticalSection 類

  當多個線程訪問一個獨佔性共享資源時,可以使用“臨界區”對象。任一時刻只有一個線程可以擁有臨界區對象,擁有臨界區的線程可以訪問被保護起來的資源或代碼段,其他希望進入臨界區的線程將被掛起等待,直到擁有臨界區的線程放棄臨界區時爲止,這樣就保證了不會在同一時刻出現多個線程訪問共享資源。

CCriticalSection類的用法非常簡單,步驟如下:
 

  1. 定義CCriticalSection類的一個全局對象(以使各個線程均能訪問),如CCriticalSection critical_section;
  2. 在訪問需要保護的資源或代碼之前,調用CCriticalSection類的成員Lock()獲得臨界區對象:
    critical_section.Lock();
    
    在線程中調用該函數來使線程獲得它所請求的臨界區。如果此時沒有其它線程佔有臨界區對象,則調用Lock()的線程獲得臨界區;否則,線程將被掛起,並放入到一個系統隊列中等待,直到當前擁有臨界區的線程釋放了臨界區時爲止。
  3. 訪問臨界區完畢後,使用CCriticalSection的成員函數Unlock()來釋放臨界區:
    critical_section.Unlock();
    
    再通俗一點講,就是線程A執行到critical_section.Lock();語句時,如果其它線程(B)正在執行critical_section.Lock();語句後且critical_section. Unlock();語句前的語句時,線程A就會等待,直到線程B執行完critical_section. Unlock();語句,線程A纔會繼續執行。

下面再通過一個實例進行演示說明。


例程8 MultiThread8

  1. 建立一個基於對話框的工程MultiThread8,在對話框IDD_MULTITHREAD8_DIALOG中加入兩個按鈕和兩個編輯框控件,兩個按鈕的ID分別爲IDC_WRITEW和IDC_WRITED,標題分別爲“寫W”和“寫D”;兩個編輯框的ID分別爲IDC_W和IDC_D,屬性都選中Read-only;
  2. 在MultiThread8Dlg.h文件中聲明兩個線程函數:
    UINT WriteW(LPVOID pParam);
    UINT WriteD(LPVOID pParam);
    
  3. 使用ClassWizard分別給IDC_W和IDC_D添加CEdit類變量m_ctrlW和m_ctrlD;
  4. 在MultiThread8Dlg.cpp文件中添加如下內容:

    爲了文件中能夠正確使用同步類,在文件開頭添加:
    #include "afxmt.h"
    
    定義臨界區和一個字符數組,爲了能夠在不同線程間使用,定義爲全局變量:
    CCriticalSection critical_section;
    char g_Array[10];
    
    添加線程函數:
    UINT WriteW(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	critical_section.Lock();
    	//鎖定臨界區,其它線程遇到critical_section.Lock();語句時要等待
    	//直至執行critical_section.Unlock();語句
    	for(int i=0;i<10;i++)
    	{
    		g_Array[i]=''W'';
    	    pEdit->SetWindowText(g_Array);
    		Sleep(1000);
    	}
    	critical_section.Unlock();
    	return 0;
    
    }
    
    UINT WriteD(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	critical_section.Lock();
    	//鎖定臨界區,其它線程遇到critical_section.Lock();語句時要等待
    	//直至執行critical_section.Unlock();語句
    	for(int i=0;i<10;i++)
    	{
    		g_Array[i]=''D'';
    	    pEdit->SetWindowText(g_Array);
    		Sleep(1000);
    	}
    	critical_section.Unlock();
    	return 0;
    
    }
  5. 分別雙擊按鈕IDC_WRITEW和IDC_WRITED,添加其響應函數:
    void CMultiThread8Dlg::OnWritew() 
    {
    	CWinThread *pWriteW=AfxBeginThread(WriteW,
    		&m_ctrlW,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteW->ResumeThread();
    }
    
    void CMultiThread8Dlg::OnWrited() 
    {
    	CWinThread *pWriteD=AfxBeginThread(WriteD,
    		&m_ctrlD,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteD->ResumeThread();
    	
    }
    由於代碼較簡單,不再詳述。編譯、運行該例程,您可以連續點擊兩個按鈕,觀察體會臨界類的作用。

B、使用 CEvent 類

  CEvent 類提供了對事件的支持。事件是一個允許一個線程在某種情況發生時,喚醒另外一個線程的同步對象。例如在某些網絡應用程序中,一個線程(記爲A)負責監聽通訊端口,另外一個線程(記爲B)負責更新用戶數據。通過使用CEvent 類,線程A可以通知線程B何時更新用戶數據。每一個CEvent 對象可以有兩種狀態:有信號狀態和無信號狀態。線程監視位於其中的CEvent 類對象的狀態,並在相應的時候採取相應的操作。
  在MFC中,CEvent 類對象有兩種類型:人工事件和自動事件。一個自動CEvent 對象在被至少一個線程釋放後會自動返回到無信號狀態;而人工事件對象獲得信號後,釋放可利用線程,但直到調用成員函數ReSetEvent()纔將其設置爲無信號狀態。在創建CEvent 類的對象時,默認創建的是自動事件。 CEvent 類的各成員函數的原型和參數說明如下:

1、CEvent(BOOL bInitiallyOwn=FALSE,
          BOOL bManualReset=FALSE,
          LPCTSTR lpszName=NULL,
          LPSECURITY_ATTRIBUTES lpsaAttribute=NULL);

 

  • bInitiallyOwn:指定事件對象初始化狀態,TRUE爲有信號,FALSE爲無信號;
  • bManualReset:指定要創建的事件是屬於人工事件還是自動事件。TRUE爲人工事件,FALSE爲自動事件;
  • 後兩個參數一般設爲NULL,在此不作過多說明。
2、BOOL CEvent::SetEvent();

  將 CEvent 類對象的狀態設置爲有信號狀態。如果事件是人工事件,則 CEvent 類對象保持爲有信號狀態,直到調用成員函數ResetEvent()將 其重新設爲無信號狀態時爲止。如果CEvent 類對象爲自動事件,則在SetEvent()將事件設置爲有信號狀態後,CEvent 類對象由系統自動重置爲無信號狀態。

如果該函數執行成功,則返回非零值,否則返回零。

3、BOOL CEvent::ResetEvent();

  該函數將事件的狀態設置爲無信號狀態,並保持該狀態直至SetEvent()被調用時爲止。由於自動事件是由系統自動重置,故自動事件不需要調用該函數。如果該函數執行成功,返回非零值,否則返回零。我們一般通過調用WaitForSingleObject函數來監視事件狀態。前面我們已經介紹了該函數。由於語言描述的原因,CEvent 類的理解確實有些難度,但您只要通過仔細玩味下面例程,多看幾遍就可理解。

例程9 MultiThread9

  1. 建立一個基於對話框的工程MultiThread9,在對話框IDD_MULTITHREAD9_DIALOG中加入一個按鈕和兩個編輯框控件,按鈕的ID爲IDC_WRITEW,標題爲“寫W”;兩個編輯框的ID分別爲IDC_W和IDC_D,屬性都選中Read-only;
  2. 在MultiThread9Dlg.h文件中聲明兩個線程函數:
    UINT WriteW(LPVOID pParam);
    UINT WriteD(LPVOID pParam);
    
  3. 使用ClassWizard分別給IDC_W和IDC_D添加CEdit類變量m_ctrlW和m_ctrlD;
  4. 在MultiThread9Dlg.cpp文件中添加如下內容:

    爲了文件中能夠正確使用同步類,在文件開頭添加

    #include "afxmt.h"
    
    定義事件對象和一個字符數組,爲了能夠在不同線程間使用,定義爲全局變量。
    CEvent eventWriteD;
    char g_Array[10];
    
    添加線程函數:
    UINT WriteW(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	for(int i=0;i<10;i++)
    	{
    		g_Array[i]=''W'';
    	    pEdit->SetWindowText(g_Array);
    		Sleep(1000);
    	}
    	eventWriteD.SetEvent();
    	return 0;
    
    }
    UINT WriteD(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	WaitForSingleObject(eventWriteD.m_hObject,INFINITE);
    	for(int i=0;i<10;i++)
    	{
    		g_Array[i]=''D'';
    	    pEdit->SetWindowText(g_Array);
    		Sleep(1000);
    	}
    	return 0;
    
    }
    
      仔細分析這兩個線程函數, 您就會正確理解CEvent 類。線程WriteD執行到 WaitForSingleObject(eventWriteD.m_hObject,INFINITE);處等待,直到事件eventWriteD爲有信號該線程才往下執行,因爲eventWriteD對象是自動事件,則當WaitForSingleObject()返回時,系統自動把eventWriteD對象重置爲無信號狀態。
  5. 雙擊按鈕IDC_WRITEW,添加其響應函數:
    void CMultiThread9Dlg::OnWritew() 
    {
    	CWinThread *pWriteW=AfxBeginThread(WriteW,
    		&m_ctrlW,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteW->ResumeThread();
    
    	CWinThread *pWriteD=AfxBeginThread(WriteD,
    		&m_ctrlD,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteD->ResumeThread();
    	
    }
    編譯並運行程序,單擊“寫‘W’”按鈕,體會事件對象的作用。

C、使用CMutex 類

  互斥對象與臨界區對象很像.互斥對象與臨界區對象的不同在於:互斥對象可以在進程間使用,而臨界區對象只能在同一進程的各線程間使用。當然,互斥對象也可以用於同一進程的各個線程間,但是在這種情況下,使用臨界區會更節省系統資源,更有效率。互斥對象包含一個使用數量,一個線程ID和一個遞歸計數器。當嵌套調用時,臨界區每次都要執行lock所有代碼,而互斥對象只使遞歸計數加1。所有嵌套多的情況下建議用互斥對象。

 

D、使用CSemaphore 類

  當需要一個計數器來限制可以使用某個(資源的)線程的數目時,可以使用“信號量”對象。CSemaphore 類的對象保存了對當前訪問某一指定資源的線程的計數值,該計數值是當前還可以使用該資源的線程的數目。如果這個計數達到了零,則所有對這個CSemaphore 類對象所控制的資源的訪問嘗試都被放入到一個隊列中等待,直到超時或計數值不爲零時爲止。一個線程被釋放已訪問了被保護的資源時(開始訪問),計數值減1;一個線程完成了對被控共享資源的訪問時,計數值增1。這個被CSemaphore 類對象所控制的資源可以同時接受訪問的最大線程數在該對象的構建函數中指定。

CSemaphore 類的構造函數原型及參數說明如下:

CSemaphore (LONG lInitialCount=1,
            LONG lMaxCount=1,
            LPCTSTR pstrName=NULL,
            LPSECURITY_ATTRIBUTES lpsaAttributes=NULL);
  • lInitialCount:信號量對象的初始計數值,即可訪問線程數目的初始值;
  • lMaxCount:信號量對象計數值的最大值,該參數決定了同一時刻可訪問由信號量保護的資源的線程最大數目;
  • 後兩個參數在同一進程中使用一般爲NULL,不作過多討論;

  在用CSemaphore 類的構造函數創建信號量對象時要同時指出允許的最大資源計數和當前可用資源計數。一般是將當前可用資源計數設置爲最大資源計數,每增加一個線程對共享資源的訪問,當前可用資源計數就會減1,只要當前可用資源計數是大於0的,就可以發出信號量信號。但是當前可用計數減小到0時,則說明當前佔用資源的線程數已經達到了所允許的最大數目,不能再允許其它線程的進入,此時的信號量信號將無法發出。線程在處理完共享資源後,應在離開的同時通過ReleaseSemaphore()函數將當前可用資源數加1。

下面給出一個簡單實例來說明 CSemaphore 類的用法。

例程10 MultiThread10

  1. 建立一個基於對話框的工程MultiThread10,在對話框IDD_MULTITHREAD10_DIALOG中加入一個按鈕和三個編輯框控件,按鈕的ID爲IDC_START,標題爲“同時寫‘A’、‘B’、‘C’”;三個編輯框的ID分別爲IDC_A、IDC_B和IDC_C,屬性都選中Read-only;
  2. 在MultiThread10Dlg.h文件中聲明兩個線程函數:
    UINT WriteA(LPVOID pParam);
    UINT WriteB(LPVOID pParam);
    UINT WriteC(LPVOID pParam); 
  3. 使用ClassWizard分別給IDC_A、IDC_B和IDC_C添加CEdit類變量m_ctrlA、m_ctrlB和m_ctrlC;
  4. 在MultiThread10Dlg.cpp文件中添加如下內容:

    爲了文件中能夠正確使用同步類,在文件開頭添加:

    #include "afxmt.h"
    
    定義信號量對象和一個字符數組,爲了能夠在不同線程間使用,定義爲全局變量:
    CSemaphore semaphoreWrite(2,2); //資源最多訪問線程2個,當前可訪問線程數2個 
    char g_Array[10]; 

    添加三個線程函數:

    UINT WriteA(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	WaitForSingleObject(semaphoreWrite.m_hObject,INFINITE);
    	CString str;
    	for(int i=0;i<10;i++)
    	{
            pEdit->GetWindowText(str);
    		g_Array[i]=''A'';
    		str=str+g_Array[i];
    	    pEdit->SetWindowText(str);
    		Sleep(1000);
    	}
    	ReleaseSemaphore(semaphoreWrite.m_hObject,1,NULL);
    	return 0;
    
    }
    UINT WriteB(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	WaitForSingleObject(semaphoreWrite.m_hObject,INFINITE);
    	CString str;
    	for(int i=0;i<10;i++)
    	{
    
            pEdit->GetWindowText(str);
    		g_Array[i]=''B'';
    		str=str+g_Array[i];
    	    pEdit->SetWindowText(str);
    		Sleep(1000);
    	}
    	ReleaseSemaphore(semaphoreWrite.m_hObject,1,NULL);
    	return 0;
    
    }
    UINT WriteC(LPVOID pParam)
    {
    	CEdit *pEdit=(CEdit*)pParam;
    	pEdit->SetWindowText("");
    	WaitForSingleObject(semaphoreWrite.m_hObject,INFINITE);
    	for(int i=0;i<10;i++)
    	{
    		g_Array[i]=''C'';
    	    pEdit->SetWindowText(g_Array);
    		Sleep(1000);
    	}
    	ReleaseSemaphore(semaphoreWrite.m_hObject,1,NULL);
    	return 0;
    
    }
    
    這三個線程函數不再多說。在信號量對象有信號的狀態下,線程執行到WaitForSingleObject語句處繼續執行,同時可用線程數減1;若線程執行到WaitForSingleObject語句時信號量對象無信號,線程就在這裏等待,直到信號量對象有信號線程才往下執行。
  5. 雙擊按鈕IDC_START,添加其響應函數:
    void CMultiThread10Dlg::OnStart() 
    {
    	CWinThread *pWriteA=AfxBeginThread(WriteA,
    		&m_ctrlA,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteA->ResumeThread();
    
    	CWinThread *pWriteB=AfxBeginThread(WriteB,
    		&m_ctrlB,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteB->ResumeThread();
    
    	CWinThread *pWriteC=AfxBeginThread(WriteC,
    		&m_ctrlC,
    		THREAD_PRIORITY_NORMAL,
    		0,
    		CREATE_SUSPENDED);
    	pWriteC->ResumeThread();
    
    	
    }
    

好吧,多線程編程就介紹到這裏,希望本文能對您有所幫助。

 

增:

多線程之線程同步Mutex (功能與CriticalSection相同,保證某一時刻只有一個線程能夠訪問共享資源,但是是內核對象,所以訪問速度要比CriticalSection要慢,但是增加了等待超時的功能,使用時可以根據實際的情況選擇其一)

一 Mutex
    互斥對象(mutex)內核對象能夠確保線程擁有對單個資源的互斥訪問權。實際上互斥對象是因此而得名的。互斥對象包含一個使用數量,一個線程ID和一個遞歸計數器。
    互斥對象的行爲特性與關鍵代碼段相同,但是互斥對象屬於內核對象,而關鍵代碼段則屬於用戶方式對象。這意味着互斥對象的運行速度比關鍵代碼段要慢。但是這也意味着不同進程中的多個線程能夠訪問單個互斥對象,並且這意味着線程在等待訪問資源時可以設定一個超時值。
    ID用於標識系統中的哪個線程當前擁有互斥對象,遞歸計數器用於指明該線程擁有互斥對象的次數。
    互斥對象有許多用途,屬於最常用的內核對象之一。通常來說,它們用於保護由多個線程訪問的內存塊。如果多個線程要同時訪問內存塊,內存塊中的數據就可能遭到破壞。互斥對象能夠保證訪問內存塊的任何線程擁有對該內存塊的獨佔訪問權,這樣就能夠保證數據的完整性。
互斥對象的使用規則如下:
? 如果線程ID是0(這是個無效ID),互斥對象不被任何線程所擁有,並且發出該互斥對象的通知信號。
? 如果ID是個非0數字,那麼一個線程就擁有互斥對象,並且不發出該互斥對象的通知信號。
? 與所有其他內核對象不同, 互斥對象在操作系統中擁有特殊的代碼,允許它們違反正常的規則。
若要使用互斥對象,必須有一個進程首先調用CreateMutex,以便創建互斥對象:
HANDLECreateMutex(
   PSECURITY_ATTRIBUTES psa,
   BOOL fInitialOwner,
   PCTSTR pszName);
InitialOwner參數用於控制互斥對象的初始狀態。如果傳遞FALSE(這是通常情況下傳遞的值),那麼互斥對象的ID和遞歸計數器均被設置爲0。這意味着該互斥對象沒有被任何線程所擁有,因此要發出它的通知信號。
如果爲fInitialOwner參數傳遞TRUE,那麼該對象的線程ID被設置爲調用線程的ID,遞歸計數器被設置爲1。由於ID是個非0數字,因此該互斥對象開始時不發出通知信號。
通過調用一個等待函數,並傳遞負責保護資源的互斥對象的句柄,線程就能夠獲得對共享資源的訪問權。在內部,等待函數要檢查線程的ID,以瞭解它是否是0(互斥對象發出通知信號)。如果線程ID是0,那麼該線程ID被設置爲調用線程的ID,遞歸計數器被設置爲1,同時,調用線程保持可調度狀態。
如果等待函數發現ID不是0(不發出互斥對象的通知信號),那麼調用線程便進入等待狀態。系統將記住這個情況,並且在互斥對象的ID重新設置爲0時,將線程ID設置爲等待線程的ID,將遞歸計數器設置爲1,並且允許等待線程再次成爲可調度線程。與所有情況一樣,對互斥內核對象進行的檢查和修改都是以原子操作方式進行的。
一旦線程成功地等待到一個互斥對象,該線程就知道它已經擁有對受保護資源的獨佔訪問權。試圖訪問該資源的任何其他線程(通過等待相同的互斥對象)均被置於等待狀態中。當目前擁有對資源的訪問權的線程不再需要它的訪問權時,它必須調用ReleaseMutex函數來釋放該互斥對象:
BOOL ReleaseMutex(HANDLE hMutex);
該函數將對象的遞歸計數器遞減1。
當該對象變爲已通知狀態時,系統要查看是否有任何線程正在等待互斥對象。如果有,系統將“按公平原則”選定等待線程中的一個,爲它賦予互斥對象的所有權。當然,這意味着線程I D被設置爲選定的線程的ID,並且遞歸計數器被置爲1。如果沒有其他線程正在等待互斥對象,那麼該互斥對象將保持已通知狀態,這樣,等待互斥對象的下一個線程就立即可以得到互斥對象。

三 實例
來自msdn的實例:在線程函數中有一個循環,在每個循環的開始都取得Mutex,然後對全局或靜態操作,相當於在關鍵代碼段操作,然後在使用完以後釋放它,大家可以執行,查看結果。

#include <windows.h>
#include <stdio.h>

#define THREADCOUNT 64  //less than 64
HANDLE ghMutex;
int g_x = 0;

DWORD WINAPI WriteToDatabase(LPVOID);

void main()

{
    HANDLE aThread[THREADCOUNT];
    DWORD ThreadID;
    int i;

    // Create a mutex with no initial owner
    ghMutex = CreateMutex(
        NULL,              // default security attributes
        FALSE,             // initially not owned
        NULL);             // unnamed mutex

    if (ghMutex == NULL)
    {
        printf("CreateMutex error: %d/n", GetLastError());
        return;
    }

    // Create worker threads

    for( i=0; i < THREADCOUNT; i++ )
    {
        aThread = CreateThread(
                     NULL,       // default security attributes
                     0,          // default stack size
                     (LPTHREAD_START_ROUTINE) WriteToDatabase,
                     NULL,       // no thread function arguments
                     0,          // default creation flags
                     &ThreadID); // receive thread identifier

        if( aThread == NULL )
        {
            printf("CreateThread error: %d/n", GetLastError());
            return;
        }
    }

    // Wait for all threads to terminate

    WaitForMultipleObjects(THREADCOUNT, aThread, TRUE, INFINITE);

    // Close thread and mutex handles
    for( i=0; i < THREADCOUNT; i++ )
        CloseHandle(aThread);
    CloseHandle(ghMutex);

    printf("g_x is :%d/n",g_x);
}

DWORD WINAPI WriteToDatabase( LPVOID lpParam )
{
    DWORD dwCount=0, dwWaitResult;

    // Request ownership of mutex.

    while( dwCount < 100 )
    {
        dwWaitResult = WaitForSingleObject(
            ghMutex,    // handle to mutex
            INFINITE);  // no time-out interval

        switch (dwWaitResult)
        {
            // The thread got ownership of the mutex
            case WAIT_OBJECT_0:
                __try {
                    g_x++;
                    // TODO: Write to the database
                    printf("Thread %d writing to database/n",
                           GetCurrentThreadId());
                    dwCount++;
                }

                __finally {
                    // Release ownership of the mutex object
                    if (! ReleaseMutex(ghMutex))
                    {
                        // Deal with error.
                    }
                }
                break;

            // The thread got ownership of an abandoned mutex
            case WAIT_ABANDONED:
                return FALSE;
        }
    }
    return TRUE;
}

發佈了27 篇原創文章 · 獲贊 1 · 訪問量 7萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章