使用Task代替ThreadPool和Thread



一:Task的優勢

ThreadPool相比Thread來說具備了很多優勢,但是ThreadPool卻又存在一些使用上的不方便。比如:

1: ThreadPool不支持線程的取消、完成、失敗通知等交互性操作;

2: ThreadPool不支持線程執行的先後次序;

以往,如果開發者要實現上述功能,需要完成很多額外的工作,現在,FCL中提供了一個功能更強大的概念:Task。Task在線程池的基礎上進行了優化,並提供了更多的API。在FCL4.0中,如果我們要編寫多線程程序,Task顯然已經優於傳統的方式。

以下是一個簡單的任務示例:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             Task t = new Task(() =>  
  4.                 {  
  5.                     Console.WriteLine("任務開始工作……");  
  6.                     //模擬工作過程  
  7.                     Thread.Sleep(5000);  
  8.                 });  
  9.             t.Start();  
  10.             t.ContinueWith((task) =>  
  11.                 {  
  12.                     Console.WriteLine("任務完成,完成時候的狀態爲:");  
  13.                     Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  14.                 });  
  15.             Console.ReadKey();  
  16.         }  
二:Task的完成狀態

任務Task有這樣一些屬性,讓我們查詢任務完成時的狀態:

1: IsCanceled,因爲被取消而完成;

2: IsCompleted,成功完成;

3: IsFaulted,因爲發生異常而完成

需要注意的是,任務並沒有提供回調事件來通知完成(像BackgroundWorker一樣),它通過啓用一個新任務的方式來完成類似的功能。ContinueWith方法可以在一個任務完成的時候發起一個新任務,這種方式天然就支持了任務的完成通知:我們可以在新任務中獲取原任務的結果值。

       下面是一個稍微複雜一點的例子,同時支持完成通知、取消、獲取任務返回值等功能:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             Task<int> t = new Task<int>(() => Add(cts.Token), cts.Token);  
  5.             t.Start();  
  6.             t.ContinueWith(TaskEnded);  
  7.             //等待按下任意一個鍵取消任務  
  8.             Console.ReadKey();  
  9.             cts.Cancel();  
  10.             Console.ReadKey();  
  11.         }  
  12.   
  13.         static void TaskEnded(Task<int> task)  
  14.         {  
  15.             Console.WriteLine("任務完成,完成時候的狀態爲:");  
  16.             Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  17.             Console.WriteLine("任務的返回值爲:{0}", task.Result);  
  18.         }  
  19.   
  20.         static int Add(CancellationToken ct)  
  21.         {  
  22.             Console.WriteLine("任務開始……");  
  23.             int result = 0;  
  24.             while (!ct.IsCancellationRequested)  
  25.             {  
  26.                 result++;  
  27.                 Thread.Sleep(1000);  
  28.             }  
  29.             return result;  
  30.         }  

在任務開始後大概3秒鐘的時候按下鍵盤,會得到如下的輸出:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=False        IsCompleted=True        IsFaulted=False  
  4. 任務的返回值爲:3  


你也許會奇怪,我們的任務是通過Cancel的方式處理,爲什麼完成的狀態IsCanceled那一欄還是False。這是因爲在工作任務中,我們對於IsCancellationRequested進行了業務邏輯上的處理,並沒有通過ThrowIfCancellationRequested方法進行處理。如果採用後者的方式,如下:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             Task<int> t = new Task<int>(() => AddCancleByThrow(cts.Token), cts.Token);  
  5.             t.Start();  
  6.             t.ContinueWith(TaskEndedByCatch);  
  7.             //等待按下任意一個鍵取消任務  
  8.             Console.ReadKey();  
  9.             cts.Cancel();  
  10.             Console.ReadKey();  
  11.         }  
  12.   
  13.         static void TaskEndedByCatch(Task<int> task)  
  14.         {  
  15.             Console.WriteLine("任務完成,完成時候的狀態爲:");  
  16.             Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  17.             try  
  18.             {  
  19.                 Console.WriteLine("任務的返回值爲:{0}", task.Result);  
  20.             }  
  21.             catch (AggregateException e)  
  22.             {  
  23.                 e.Handle((err) => err is OperationCanceledException);  
  24.             }  
  25.         }  
  26.   
  27.         static int AddCancleByThrow(CancellationToken ct)  
  28.         {  
  29.             Console.WriteLine("任務開始……");  
  30.             int result = 0;  
  31.             while (true)  
  32.             {  
  33.                 ct.ThrowIfCancellationRequested();  
  34.                 result++;  
  35.                 Thread.Sleep(1000);  
  36.             }  
  37.             return result;  
  38.         }  


那麼輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=True       IsCompleted=True        IsFaulted=False  

在任務結束求值的方法TaskEndedByCatch中,如果任務是通過ThrowIfCancellationRequested方法結束的,對任務求結果值將會拋出異常OperationCanceledException,而不是得到拋出異常前的結果值。這意味着任務是通過異常的方式被取消掉的,所以可以注意到上面代碼的輸出中,狀態IsCancled爲True。

再一次,我們注意到取消是通過異常的方式實現的,而表示任務中發生了異常的IsFaulted狀態卻還是等於False。這是因爲ThrowIfCancellationRequested是協作式取消方式類型CancellationTokenSource的一個方法,CLR進行了特殊的處理。CLR知道這一行程序開發者有意爲之的代碼,所以不把它看作是一個異常(它被理解爲取消)。要得到IsFaulted等於True的狀態,我們可以修改While循環,模擬一個異常出來:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. while (true)  
  2.             {  
  3.                 //ct.ThrowIfCancellationRequested();  
  4.                 if (result == 5)  
  5.                 {  
  6.                     throw new Exception("error");  
  7.                 }  
  8.                 result++;  
  9.                 Thread.Sleep(1000);  
  10.             }  


模擬異常後的輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=False        IsCompleted=True        IsFaulted=True  

三:任務工廠

Task還支持任務工廠的概念。任務工廠支持多個任務之間共享相同的狀態,如取消類型CancellationTokenSource就是可以被共享的。通過使用任務工廠,可以同時取消一組任務:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             //等待按下任意一個鍵取消任務  
  5.             TaskFactory taskFactory = new TaskFactory();  
  6.             Task[] tasks = new Task[]  
  7.                 {  
  8.                     taskFactory.StartNew(() => Add(cts.Token)),  
  9.                     taskFactory.StartNew(() => Add(cts.Token)),  
  10.                     taskFactory.StartNew(() => Add(cts.Token))  
  11.                 };  
  12.             //CancellationToken.None指示TasksEnded不能被取消  
  13.             taskFactory.ContinueWhenAll(tasks, TasksEnded, CancellationToken.None);  
  14.             Console.ReadKey();  
  15.             cts.Cancel();  
  16.             Console.ReadKey();  
  17.         }  
  18.           
  19.         static void TasksEnded(Task[] tasks)  
  20.         {  
  21.             Console.WriteLine("所有任務已完成!");  
  22.         }  


以上代碼輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務開始……  
  3. 任務開始……  
  4. 所有任務已完成(取消)!  


本建議演示了Task(任務)和TaskFactory(任務工廠)的使用方法。Task甚至進一步優化了後臺線程池的調度,加快了線程的處理速度。在FCL4.0時代,使用多線程,我們理應更多地使用Task。


下面說下如何正確停止線程


開發者總嘗試對自己的代碼有更多的控制。“讓那個還在工作的線程馬上停止下來”就是諸多要求中的一種。然而事與願違,這裏面至少存在兩個問題:

第一個問題是:正如線程不能立即啓動一樣,線程也並不能說停就停。無論採用何種方式通知工作線程需要停止,工作線程都會忙完手頭最緊要的活,然後在它覺得合適的時候退出。以最傳統的Thread.Abort方法爲例,如果線程當前正在執行的是一段非託管代碼,那麼CLR就不會拋出ThreadAbortException,只有當代碼繼續回到CLR中時,纔會引發ThreadAbortException。當然,即便是在CLR環境中,ThreadAbortException也不會立即引發。

其次,正確停止線程,不在於調用者採取了什麼行爲(如最開始的Thread.Abort()方法),而更多依賴於工作線程是否能主動響應調用者的停止請求。大體機制是,如果線程需要被停止,那麼線程自身就得負責開放給調用者這樣的接口:Cancled,然後線程在工作的同時,還得以某種頻率檢測Cancled標識,若檢測到Cancled,線程自己負責退出。

FCL現在爲我們提供了標準的取消模式:協作式取消(Cooperative Cancellation)。協作式取消的機制就是上文提到的機制。下面是一個最基礎的協作式取消的樣例:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. CancellationTokenSource cts = new CancellationTokenSource();  
  2.             Thread t = new Thread(() =>  
  3.                 {  
  4.                     while (true)  
  5.                     {  
  6.                         if (cts.Token.IsCancellationRequested)  
  7.                         {  
  8.                             Console.WriteLine("線程被終止!");  
  9.                             break;  
  10.                         }  
  11.                         Console.WriteLine(DateTime.Now.ToString());  
  12.                         Thread.Sleep(1000);  
  13.                     }  
  14.                 });  
  15.             t.Start();  
  16.             Console.ReadLine();  
  17.             cts.Cancel();  

調用者使用CancellationTokenSource的Cancle方法通知工作線程退出。工作線程則以大致1000毫秒的頻率一邊工作,一邊檢查是否有外界傳入進來的Cancel信號。若有這樣的信號,則負責退出。可以看到,在正確停止線程的機制中,真正起到主要作用的是線程本身。樣例中的工作代碼比較簡單,不過也足以說明問題。更復雜的計算式的工作,也應該以這樣的一種方式,妥善而正確地處理退出。

協作式取消中的關鍵類型是CancellationTokenSource。它有一個關鍵屬性Token,Token是一個名爲CancellationToken的值類型。CancellationToken繼而進一步提供了布爾值的屬性IsCancellationRequested作爲需要取消工作的標識。CancellationToken還有一個方法尤其值得注意,那就是Register方法。它負責傳遞一個Action委託,在線程停止的時候被回調,使用方法如:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. cts.Token.Register(() =>  
  2. {  
  3.     Console.WriteLine("工作線程被終止了。");  
  4. });  


本建議中的例子使用Thread進行了演示,使用ThreadPool也是一樣的模式,這裏就不再贅述。後面我們還會講到任務Task,它依賴於CancellationTokenSource和CancellationToken完成了所有的取消控制。  

一:Task的優勢

ThreadPool相比Thread來說具備了很多優勢,但是ThreadPool卻又存在一些使用上的不方便。比如:

1: ThreadPool不支持線程的取消、完成、失敗通知等交互性操作;

2: ThreadPool不支持線程執行的先後次序;

以往,如果開發者要實現上述功能,需要完成很多額外的工作,現在,FCL中提供了一個功能更強大的概念:Task。Task在線程池的基礎上進行了優化,並提供了更多的API。在FCL4.0中,如果我們要編寫多線程程序,Task顯然已經優於傳統的方式。

以下是一個簡單的任務示例:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             Task t = new Task(() =>  
  4.                 {  
  5.                     Console.WriteLine("任務開始工作……");  
  6.                     //模擬工作過程  
  7.                     Thread.Sleep(5000);  
  8.                 });  
  9.             t.Start();  
  10.             t.ContinueWith((task) =>  
  11.                 {  
  12.                     Console.WriteLine("任務完成,完成時候的狀態爲:");  
  13.                     Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  14.                 });  
  15.             Console.ReadKey();  
  16.         }  
二:Task的完成狀態

任務Task有這樣一些屬性,讓我們查詢任務完成時的狀態:

1: IsCanceled,因爲被取消而完成;

2: IsCompleted,成功完成;

3: IsFaulted,因爲發生異常而完成

需要注意的是,任務並沒有提供回調事件來通知完成(像BackgroundWorker一樣),它通過啓用一個新任務的方式來完成類似的功能。ContinueWith方法可以在一個任務完成的時候發起一個新任務,這種方式天然就支持了任務的完成通知:我們可以在新任務中獲取原任務的結果值。

       下面是一個稍微複雜一點的例子,同時支持完成通知、取消、獲取任務返回值等功能:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             Task<int> t = new Task<int>(() => Add(cts.Token), cts.Token);  
  5.             t.Start();  
  6.             t.ContinueWith(TaskEnded);  
  7.             //等待按下任意一個鍵取消任務  
  8.             Console.ReadKey();  
  9.             cts.Cancel();  
  10.             Console.ReadKey();  
  11.         }  
  12.   
  13.         static void TaskEnded(Task<int> task)  
  14.         {  
  15.             Console.WriteLine("任務完成,完成時候的狀態爲:");  
  16.             Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  17.             Console.WriteLine("任務的返回值爲:{0}", task.Result);  
  18.         }  
  19.   
  20.         static int Add(CancellationToken ct)  
  21.         {  
  22.             Console.WriteLine("任務開始……");  
  23.             int result = 0;  
  24.             while (!ct.IsCancellationRequested)  
  25.             {  
  26.                 result++;  
  27.                 Thread.Sleep(1000);  
  28.             }  
  29.             return result;  
  30.         }  

在任務開始後大概3秒鐘的時候按下鍵盤,會得到如下的輸出:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=False        IsCompleted=True        IsFaulted=False  
  4. 任務的返回值爲:3  


你也許會奇怪,我們的任務是通過Cancel的方式處理,爲什麼完成的狀態IsCanceled那一欄還是False。這是因爲在工作任務中,我們對於IsCancellationRequested進行了業務邏輯上的處理,並沒有通過ThrowIfCancellationRequested方法進行處理。如果採用後者的方式,如下:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             Task<int> t = new Task<int>(() => AddCancleByThrow(cts.Token), cts.Token);  
  5.             t.Start();  
  6.             t.ContinueWith(TaskEndedByCatch);  
  7.             //等待按下任意一個鍵取消任務  
  8.             Console.ReadKey();  
  9.             cts.Cancel();  
  10.             Console.ReadKey();  
  11.         }  
  12.   
  13.         static void TaskEndedByCatch(Task<int> task)  
  14.         {  
  15.             Console.WriteLine("任務完成,完成時候的狀態爲:");  
  16.             Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted);  
  17.             try  
  18.             {  
  19.                 Console.WriteLine("任務的返回值爲:{0}", task.Result);  
  20.             }  
  21.             catch (AggregateException e)  
  22.             {  
  23.                 e.Handle((err) => err is OperationCanceledException);  
  24.             }  
  25.         }  
  26.   
  27.         static int AddCancleByThrow(CancellationToken ct)  
  28.         {  
  29.             Console.WriteLine("任務開始……");  
  30.             int result = 0;  
  31.             while (true)  
  32.             {  
  33.                 ct.ThrowIfCancellationRequested();  
  34.                 result++;  
  35.                 Thread.Sleep(1000);  
  36.             }  
  37.             return result;  
  38.         }  


那麼輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=True       IsCompleted=True        IsFaulted=False  

在任務結束求值的方法TaskEndedByCatch中,如果任務是通過ThrowIfCancellationRequested方法結束的,對任務求結果值將會拋出異常OperationCanceledException,而不是得到拋出異常前的結果值。這意味着任務是通過異常的方式被取消掉的,所以可以注意到上面代碼的輸出中,狀態IsCancled爲True。

再一次,我們注意到取消是通過異常的方式實現的,而表示任務中發生了異常的IsFaulted狀態卻還是等於False。這是因爲ThrowIfCancellationRequested是協作式取消方式類型CancellationTokenSource的一個方法,CLR進行了特殊的處理。CLR知道這一行程序開發者有意爲之的代碼,所以不把它看作是一個異常(它被理解爲取消)。要得到IsFaulted等於True的狀態,我們可以修改While循環,模擬一個異常出來:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. while (true)  
  2.             {  
  3.                 //ct.ThrowIfCancellationRequested();  
  4.                 if (result == 5)  
  5.                 {  
  6.                     throw new Exception("error");  
  7.                 }  
  8.                 result++;  
  9.                 Thread.Sleep(1000);  
  10.             }  


模擬異常後的輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務完成,完成時候的狀態爲:  
  3. IsCanceled=False        IsCompleted=True        IsFaulted=True  

三:任務工廠

Task還支持任務工廠的概念。任務工廠支持多個任務之間共享相同的狀態,如取消類型CancellationTokenSource就是可以被共享的。通過使用任務工廠,可以同時取消一組任務:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. static void Main(string[] args)  
  2.         {  
  3.             CancellationTokenSource cts = new CancellationTokenSource();  
  4.             //等待按下任意一個鍵取消任務  
  5.             TaskFactory taskFactory = new TaskFactory();  
  6.             Task[] tasks = new Task[]  
  7.                 {  
  8.                     taskFactory.StartNew(() => Add(cts.Token)),  
  9.                     taskFactory.StartNew(() => Add(cts.Token)),  
  10.                     taskFactory.StartNew(() => Add(cts.Token))  
  11.                 };  
  12.             //CancellationToken.None指示TasksEnded不能被取消  
  13.             taskFactory.ContinueWhenAll(tasks, TasksEnded, CancellationToken.None);  
  14.             Console.ReadKey();  
  15.             cts.Cancel();  
  16.             Console.ReadKey();  
  17.         }  
  18.           
  19.         static void TasksEnded(Task[] tasks)  
  20.         {  
  21.             Console.WriteLine("所有任務已完成!");  
  22.         }  


以上代碼輸出爲:

[plain] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. 任務開始……  
  2. 任務開始……  
  3. 任務開始……  
  4. 所有任務已完成(取消)!  


本建議演示了Task(任務)和TaskFactory(任務工廠)的使用方法。Task甚至進一步優化了後臺線程池的調度,加快了線程的處理速度。在FCL4.0時代,使用多線程,我們理應更多地使用Task。


下面說下如何正確停止線程


開發者總嘗試對自己的代碼有更多的控制。“讓那個還在工作的線程馬上停止下來”就是諸多要求中的一種。然而事與願違,這裏面至少存在兩個問題:

第一個問題是:正如線程不能立即啓動一樣,線程也並不能說停就停。無論採用何種方式通知工作線程需要停止,工作線程都會忙完手頭最緊要的活,然後在它覺得合適的時候退出。以最傳統的Thread.Abort方法爲例,如果線程當前正在執行的是一段非託管代碼,那麼CLR就不會拋出ThreadAbortException,只有當代碼繼續回到CLR中時,纔會引發ThreadAbortException。當然,即便是在CLR環境中,ThreadAbortException也不會立即引發。

其次,正確停止線程,不在於調用者採取了什麼行爲(如最開始的Thread.Abort()方法),而更多依賴於工作線程是否能主動響應調用者的停止請求。大體機制是,如果線程需要被停止,那麼線程自身就得負責開放給調用者這樣的接口:Cancled,然後線程在工作的同時,還得以某種頻率檢測Cancled標識,若檢測到Cancled,線程自己負責退出。

FCL現在爲我們提供了標準的取消模式:協作式取消(Cooperative Cancellation)。協作式取消的機制就是上文提到的機制。下面是一個最基礎的協作式取消的樣例:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. CancellationTokenSource cts = new CancellationTokenSource();  
  2.             Thread t = new Thread(() =>  
  3.                 {  
  4.                     while (true)  
  5.                     {  
  6.                         if (cts.Token.IsCancellationRequested)  
  7.                         {  
  8.                             Console.WriteLine("線程被終止!");  
  9.                             break;  
  10.                         }  
  11.                         Console.WriteLine(DateTime.Now.ToString());  
  12.                         Thread.Sleep(1000);  
  13.                     }  
  14.                 });  
  15.             t.Start();  
  16.             Console.ReadLine();  
  17.             cts.Cancel();  

調用者使用CancellationTokenSource的Cancle方法通知工作線程退出。工作線程則以大致1000毫秒的頻率一邊工作,一邊檢查是否有外界傳入進來的Cancel信號。若有這樣的信號,則負責退出。可以看到,在正確停止線程的機制中,真正起到主要作用的是線程本身。樣例中的工作代碼比較簡單,不過也足以說明問題。更復雜的計算式的工作,也應該以這樣的一種方式,妥善而正確地處理退出。

協作式取消中的關鍵類型是CancellationTokenSource。它有一個關鍵屬性Token,Token是一個名爲CancellationToken的值類型。CancellationToken繼而進一步提供了布爾值的屬性IsCancellationRequested作爲需要取消工作的標識。CancellationToken還有一個方法尤其值得注意,那就是Register方法。它負責傳遞一個Action委託,在線程停止的時候被回調,使用方法如:

[csharp] view plain copy
 在CODE上查看代碼片派生到我的代碼片
  1. cts.Token.Register(() =>  
  2. {  
  3.     Console.WriteLine("工作線程被終止了。");  
  4. });  


本建議中的例子使用Thread進行了演示,使用ThreadPool也是一樣的模式,這裏就不再贅述。後面我們還會講到任務Task,它依賴於CancellationTokenSource和CancellationToken完成了所有的取消控制。  
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章