事件循環與線程 二(zz)

原文:http://blog.csdn.net/changsheng230/article/details/6153449

續上文:http://blog.csdn.net/changsheng230/archive/2010/12/27/6101232.aspx

 

由於最近工作比較忙,出了趟差,還是把這篇長文、好文翻譯出來了,以饗讀者。同時也是自己很好的消化、學習過程

Qt 線程類

Qt對線程的支持已經有很多年了(發佈於2000年九月22日的Qt2.2引入了QThread類),Qt 4.0版本的release則對其所有所支持平臺默認地是對多線程支持的。(當然你也可以關掉對線程的支持,參見這裏)。現在Qt提供了不少類用於處理線程,讓你我們首先預覽一下:

QThread

QThread 是Qt中一個對線程支持的核心的底層類。 每個線程對象代表了一個運行的線程。由於Qt的跨平臺特性,QThread成功隱藏了所有在不同操作系統裏使用線程的平臺相關性代碼。

爲了運用QThread從而讓代碼在一個線程裏運行,我們可以創建一個QThread的子類,並重載QThread::run() 方法:

 

  1. class Thread : public QThread {  
  2. protected:  
  3. void run() {  
  4. /* your thread implementation goes here */  
  5. }  
  6. };  
 

接着,我們可以使用:

  1. class Thread : public QThread {  
  2. protected:  
  3. void run() {  
  4. /* your thread implementation goes here */  
  5. }  
  6. };  
 

 


來真正的啓動一個新的線程。 請注意,Qt 4.4版本之後,QThread不再支持抽象類;現在虛函數QThread::run()實際上是簡單調用了QThread::exec(),而它啓動了線程的事件循環。(更多信息見後文)

QRunnable 和 QThreadPool

QRunnable [doc.qt.nokia.com] 是一種輕量級的、以“run and forget”方式來在另一個線程開啓任務的抽象類,爲了實現這一功能,我們所需要做的全部事情是派生QRunnable 類,並實現純虛函數方法run()

  1. class Task : public QRunnable {  
  2. public:  
  3. void run() {  
  4. /* your runnable implementation goes here */  
  5. }  
  6. };  
 

事實上,我們是使用QThreadPool 類來運行一個QRunnable 對象,它維護了一個線程池。通過調用QThreadPool::start(runnable) ,我們把一個QRunnable 放入了QThreadPool的運行隊列中;只要線程是可見得,QRunnable 將會被拾起並且在那個線程裏運行。儘管所有的Qt應用程序都有一個全局的線程池,且它是通過調用QThreadPool::globalInstance()可見得,但我們總是顯式地創建並管理一個私有的QThreadPool 實例。

請注意,QRunnable 並不是一個QObject類,它並沒有一個內置的與其他組件顯式通訊的方法。你必須使用底層的線程原語(比如收集結構的枷鎖保護隊列等)來親自編寫代碼。

QtConcurrent

QtConcurrent 是一個構建在QThreadPool之上的上層API,它用於處理最普通的並行計算模式:map[en.wikipedia.org]reduce [en.wikipedia.org], and filter [en.wikipedia.org] 。同時,QtConcurrent::run()方法提供了一種便於在另一個線程運行一個函數的方法。

不像QThread 以及QRunnable,QtConcurrent 沒有要求我們使用底層的同步原語,QtConcurrent 所有的方法會返回一個QFuture 對象,它包含了結果而且可以用來查詢線程計算的狀態(它的進度),從而暫停、繼續、取消計算。QFutureWatcher 可以用來監聽一個QFuture 進度,並且通過信號和槽與之交互(注意QFuture是一個基於數值的類,它並沒有繼承自QObject).

功能比較

/QThreadQRunnableQtConcurrent1
High level API
Job-oriented
Builtin support for pause/resume/cancel
Can run at a different priority
Can run an event loop

 

線程與QObjects

線程的事件循環

我們在上文中已經討論了事件循環,我們可能理所當然地認爲在Qt的應用程序中只有一個事件循環,但事實並不是這樣:QThread對象在它們所代表的線程中開啓了新的事件循環。因此,我們說main 事件循環是由調用main()的線程通過QCoreApplication::exec() 創建的。 它也被稱做是GUI線程,因爲它是界面相關操作唯一允許的進程。一個QThread的局部事件循環可以通過調用QThread::exec() 來開啓(它包含在run()方法的內部)

  1. class Thread : public QThread {  
  2. protected:  
  3. void run() {  
  4. /* ... initialize ... */  
  5. exec();  
  6. }  
  7. };  
 

正如我們之前所提到的,自從Qt 4.4 的QThread::run() 方法不再是一個純虛函數,它調用了QThread::exec()。就像QCoreApplication,QThread 也有QThread::quit() 和QThread::exit()來停止事件循環。

一個線程的事件循環爲駐足在該線程中的所有QObjects派發了所有事件,其中包括在這個線程中創建的所有對象,或是移植到這個線程中的對象。我們說一個QObject的線程依附性(thread affinity)是指某一個線程,該對象駐足在該線程內。我們在任何時間都可以通過調用QObject::thread()來查詢線程依附性,它適用於在QThread對象構造函數中構建的對象。

  1. class MyThread : public QThread  
  2. {  
  3. public:  
  4. MyThread()  
  5. {  
  6. otherObj = new QObject;  
  7. }     
  8. private:  
  9. QObject obj;  
  10. QObject *otherObj;  
  11. QScopedPointer<QObject> yetAnotherObj;  
  12. };  
 

如上述代碼,我們在創建了MyThread 對象後,obj, otherObj, yetAnotherObj 的線程依附性是怎麼樣的?要回答這個問題,我們必須要看一下創建他們的線程:是這個運行MyThread 構造函數的線程創建了他們。因此,這三個對象並沒有駐足在MyThread 線程,而是駐足在創建MyThread 實例的線程中。

要注意的是在QCoreApplication 對象之前創建的QObjects沒有依附於某一個線程。因此,沒有人會爲它們做事件派發處理。(換句話說,QCoreApplication 構建了代表主線程的QThread 對象)


我們可以使用線程安全的QCoreApplication::postEvent() 方法來爲某個對象分發事件。它將把事件加入到對象所駐足的線程事件隊列中。因此,除非事件對象依附的線程有一個正在運行的事件循環,否則事件不會被派發。

理解QObject和它所有的子類不是線程安全的(儘管是可重入的)非常重要;因此,除非你序列化對象內部數據所有可訪問的接口、數據,否則你不能讓多個線程同一時刻訪問相同的QObject(比如,用一個鎖來保護)。請注意,儘管你可以從另一個線程訪問對象,但是該對象此時可能正在處理它所駐足的線程事件循環派發給它的事件! 基於這種原因,你不能從另一個線程去刪除一個QObject,一定要使用QObject::deleteLater(),它會Post一個事件,目標刪除對象最終會在它所生存的線程中被刪除。(譯者注:QObject::deleteLater作用是,當控制流回到該對象所依附的線程事件循環時,該對象纔會被“本”線程中刪除)。

此外,QWidget 和它所有的子類,以及所有與GUI相關的類(即便不是基於QObject的,像QPixmap)並不是可重入的。它們必須專屬於GUI線程。

我們可以通過調用QObject::moveToThread()來改變一個QObject的依附性;它將改變這個對象以及它的孩子們的依附性。因爲QObject不是線程安全的,我們必須在對象所駐足的線程中使用此函數;也就是說,你只能將對象從它所駐足的線程中推送到其他線程中,而不能從其他線程中回來。此外,Qt要求一個QObject的孩子必須與它們的雙親駐足在同一個線程中。這意味着:

  • 你不能使用QObject::moveToThread()作用於有雙親的對象;
  • 你千萬不要在一個線程中創建對象的同時把QThread對象自己作爲它的雙親。 (譯者注:兩者不在同一個線程中):

  1. class Thread : public QThread {  
  2. void run() {  
  3. QObject obj = new QObject(this); // WRONG!!!  
  4. }  
  5. };  
 

這是因爲,QThread 對象駐足在另一個線程中,即QThread 對象它自己被創建的那個線程中。

Qt同樣要求所有的對象應該在代表該線程的QThread對象銷燬之前得以刪除;實現這一點並不難:只要我們所有的對象是在QThread::run() 方法中創建即可。(譯者注:run函數的局部變量,函數返回時得以銷燬)。

跨線程的信號與槽

接着上面討論的,我們如何應用駐足在其他線程裏的QObject方法呢?Qt提供了一種非常友好而且乾淨的解決方案:向事件隊列post一個事件,事件的處理將以調用我們所感興趣的方法爲主(當然這需要線程有一個正在運行的事件循環)。而觸發機制的實現是由moc提供的內省方法實現的(譯者注:有關內省的討論請參見我的另一篇文章Qt的內省機制剖析):因此,只有信號、槽以及被標記成Q_INVOKABLE的方法才能夠被其它線程所觸發調用。

靜態方法QMetaObject::invokeMethod() 爲我們做了如下工作:

  1. QMetaObject::invokeMethod(object, "methodName",  
  2. Qt::QueuedConnection,  
  3. Q_ARG(type1, arg1),  
  4. Q_ARG(type2, arg2));  
 

請注意,因爲上面所示的參數需要被在構建事件時進行硬拷貝,參數的自定義型別所對應的類需要提供一個共有的構造函數、析構函數以及拷貝構造函數。而且必須使用註冊Qt型別系統所提供的qRegisterMetaType() 方法來註冊這一自定義型別。

跨線程的信號槽的工作方式相類似。當我們把信號連接到一個槽的時候,QObject::connect的第五個可選輸入參數用來特化這一連接類型:

  • direct connection 是指:發起信號的線程會直接觸發其所連接的槽;
  • queued connection 是指:一個事件被派發到接收者所在的線程中,在這裏,事件循環會之後的某一時間將該事件拾起並引起槽的調用;
  • blocking queued connection 與queued connection的區別在於,發送者的線程會被阻塞,直至接收者所在線程的事件循環處理髮送者發送(入棧)的事件,當連接信號的槽被觸發後,阻塞被解除;
  • automatic connection (缺省默認參數) 是指: 如果接收者所依附的線程和當前線程是同一個線程,direct connection會被使用。否則使用queued connection。

請注意,在上述四種連接方式當中,發送對象駐足於哪一個線程並不重要!對於automatic connection,Qt會檢查觸發信號的線程,並且與接收者所駐足的線程相比較從而決定到底使用哪一種連接類型。特別要指出的是:當前的Qt文檔的聲明(4.7.1) 是錯誤的:

如果發射者和接受者在同一線程,其行爲與Direct Connection相同;,如果發射者和接受者不在同一線程,其行爲Queued Connection相同

因爲,發送者對象的線程依附性在這裏無關緊要。舉例子說明

  1. class Thread : public QThread  
  2. {  
  3. Q_OBJECT  
  4. signals:  
  5. void aSignal();  
  6. protected:  
  7. void run() {  
  8. emit aSignal();  
  9. }  
  10. };  
  11. /* ... */  
  12. Thread thread;  
  13. Object obj;  
  14. QObject::connect(&thread, SIGNAL(aSignal()), &obj, SLOT(aSlot()));  
  15. thread.start();  
 

如上述代碼,信號aSignal() 將在一個新的線程裏被髮射(由線程對象所代表);因爲它並不是Object 對象駐足的線程,所以儘管Thread對象thread與Object對象obj在同一個線程,但仍然是queued connection被使用。

譯者注:這裏作者分析的很透徹,希望讀者仔細揣摩Qt文檔的這個錯誤。 也就是說 發送者對象本身在哪一個線程對與信號槽連接類型不起任何作用,起到決定作用的是接收者對象所駐足的線程以及發射信號(該信號與接受者連接)的線程是不是在同一個線程,本例中aSignal()在新的線程中被髮射,所以採用queued connection)。

另外一個常見的錯誤如下:

  1. class Thread : public QThread  
  2. {  
  3. Q_OBJECT  
  4. slots:  
  5. void aSlot() {  
  6. /* ... */  
  7. }  
  8. protected:  
  9. void run() {  
  10. /* ... */  
  11. }  
  12. };  
  13. /* ... */  
  14. Thread thread;  
  15. Object obj;  
  16. QObject::connect(&obj, SIGNAL(aSignal()), &thread, SLOT(aSlot()));  
  17. thread.start();  
  18. obj.emitSignal();  
 

當“obj”發射了一個aSignal()信號是,哪種連接將被使用呢?你也許已經猜到了:direct connection。這是因爲Thread對象實在發射該信號的線程中生存。在aSlot()槽裏,我們可能接着去訪問線程裏的一些成員變量,然而這些成員變量可能同時正在被run()方法訪問:這可是導致完美災難的祕訣。可能你經常在論壇、博客裏面找到的解決方案是在線程的構造函數里加一個moveToThread(this)方法。

class Thread : public QThread {

Q_OBJECT

public:

Thread() {

moveToThread(this); // 錯誤

}

/* ... */

};

(譯註:moveToThread(this)

這樣做確實可以工作(因爲現在線程對象的依附性已經發生了改變),但這是一個非常不好的設計。這裏的錯誤在於我們正在誤解線程對象的目的(QThread子類):QThread對象們不是線程;他們是圍繞在新產生的線程周圍用於控制管理新線程的對象,因此,它們應該用在另一個線程(往往在它們所駐足的那一個線程)

一個比較好而且能夠得到相同結果的做法是將“工作”部分從“控制”部分剝離出來,也就是說,寫一個QObject子類並使用QObject::moveToThread()方法來改變它的線程依附性:

  1. class Worker : public QObject  
  2. {  
  3. Q_OBJECT  
  4. public slots:  
  5. void doWork() {  
  6. /* ... */  
  7. }  
  8. };  
  9. /* ... */  
  10. QThread thread;  
  11. Worker worker;  
  12. connect(obj, SIGNAL(workReady()), &worker, SLOT(doWork()));  
  13. worker.moveToThread(&thread);  
  14. thread.start();  
 

 

我應該什麼時候使用線程

當你不得不使用一個阻塞式API時

當你需要(通過信號和槽,或者是事件、回調函數)使用一個沒有提供非阻塞式API的庫或者代碼時,爲了阻止凍結事件循環的唯一可行的解決方案是開啓一個進程或者線程。由於創建一個新的進程的開銷顯然要比開啓一個線程的開銷大,後者往往是最常見的一種選擇。

這種API的一個很好的例子是地址解析 方法(只是想說我們並不準備談論蹩腳的第三方API, 地址解析方法它是每個C庫都要包含的),它負責將主機名轉化爲地址。這個過程涉及到啓動一個查詢(通常是遠程的)系統:域名系統或者叫DNS。儘管通常情況下響應會在瞬間發生,但遠程服務器可能會失敗:一些數據包可能會丟失,網絡連接可能斷開等等。簡而言之,我們也許要等待幾十秒才能得到查詢的響應。

UNIX系統可見的標準API只有阻塞式的(不僅過時的gethostbyname(3)是阻塞式的,而且更新的getservbyname(3) 以及getaddrinfo(3)也是阻塞式的)。QHostInfo [doc.qt.nokia.com],  它是一個負責處理域名查找的Qt類,該類使用了QThreadPool 從而使得查詢可以在後臺進行)(參見here [qt.gitorious.com]);如果屏蔽了多線程支持,它將切換回到阻塞式API).

另一個簡單的例子是圖像裝載和放大。QImageReader [doc.qt.nokia.com] 和QImage [doc.qt.nokia.com]僅僅提供了阻塞式方法來從一個設備讀取圖像,或者放大圖像到一個不同的分辨率。如果你正在處理一個非常大的圖像,這些處理會持續數(十)秒。

當你想擴展至多核

多線程允許你的程序利用多核系統的優勢。因爲每個線程都是被操作系統獨立調度的,因此如果你的應用運行在這樣多核機器上,調度器很可能同時在不同的處理器上運行每個線程。

例如,考慮到一個通過圖像集生成縮略圖的應用。一個_n_ threads的線程農場(也就是說,一個有着固定數量線程的線程池),在系統中可見的CPU運行一個線程(可參見QThread::idealThreadCount()),可以將縮小圖像至縮略圖的工作交付給所有的進程,從而有效地提高了並行加速比,它與處理器的數量成線性關係。(簡單的講,我們認爲CPU正成爲一個瓶頸)。

什麼時候你可能不想別人阻塞

這是一個很高級的話題,你可以忽略該小節。一個比較好的例子來自於Webkit裏使用的QNetworkAccessManager 。Webkit是一個時髦的瀏覽器引擎,也就是說,它是一組用於處理網頁的佈局和顯示的類集合。使用Webkit的Qt widget是QWebView。

QNetworkAccessManager 是一個用於處理HTTP任何請求和響應的Qt類,我們可以把它當作一個web瀏覽器的網絡引擎;所有的網絡訪問被同一個QNetworkAccessManager 以及它的QNetworkReplys 駐足的線程所處理。

儘管在網絡處理時不使用線程是一個很好的主意,它也有一個很大的缺點:如果你沒有從socket中儘快地讀取數據,內核的緩存將會被填滿,數據包可能開始丟失而且傳輸速率也將迅速下降。

Sokcet活動(即,從一個socket讀取一些數據的可見性)由Qt的事件循環管理。阻塞事件循環因此會導致傳輸性能的損失,因爲沒有人會被通知將有數據可以讀取(從而沒人會去讀數據)。

但究竟什麼會阻塞事件循環呢?令人沮喪地回答: WebKit它自己!只要有數據被接收到,WebKit便用其來佈局網頁。不幸地是,佈局處理過程相當複雜,而且開銷巨大。因此,它阻塞事件循環的一小段時間足以影響到正在進行地傳輸(寬帶連接這裏起到了作用,在短短几秒內就可填滿內核緩存)。

總結一下上述所發生的事情:

  • WebKit提出了一個請求;
  • 一些響應數據開始到達;
  • WebKit開始使用接收到的數據佈局網頁,從而阻塞了事件循環;
  • 數據被OS接受,但沒有一個正在運行的事件循環爲之派發,所以並沒有被QNetworkAccessManager sockets所讀取;
  • 內核緩存將被填滿,傳輸將變慢。

網頁的總體裝載時間因其自發引起的傳輸速率降低而變得越來越壞。

諾基亞的工程師正在試驗一個支持多線程的QNetworkAccessManager來解決這個問題。請注意因爲QNetworkAccessManagers 和QNetworkReplys 是QObjects,他們不是線程安全的,因此你不能簡單地將他們移到另一個線程中並且繼續在你的線程中使用他們,原因在於,由於事件將被隨後線程的事件循環所派發,他們可能同時被兩個線程訪問:你自己的線程以及已經它們駐足的線程。

 

是麼時候不需要使用線程

If you think you need threads then your processes are too fat.—Rob Pike

計時器

這也許是線程濫用最壞的一種形式。如果我們不得不重複調用一個方法(比如每秒),許多人會這樣做:

  1. // 非常之錯誤  
  2. while (condition) {  
  3. doWork();  
  4. sleep(1); // this is sleep(3) from the C library  
  5. }  
 

然後他們發現這會阻塞事件循環,因此決定引入線程:

  1. // 錯誤  
  2. class Thread : public QThread {  
  3. protected:  
  4. void run() {  
  5. while (condition) {  
  6. // notice that "condition" may also need volatiness and mutex protection  
  7. // if we modify it from other threads (!)  
  8. doWork();  
  9. sleep(1); // this is QThread::sleep()  
  10. }  
  11. }  
  12. };  
 

一個更好也更簡單的獲得相同效果的方法是使用timers,即一個QTimer[doc.qt.nokia.com]對象,並設置一秒的超時時間,並讓doWork方法成爲它的槽:

  1. class Worker : public QObject  
  2. {  
  3. Q_OBJECT  
  4. public:  
  5. Worker() {  
  6. connect(&timer, SIGNAL(timeout()), this, SLOT(doWork()));  
  7. timer.start(1000);  
  8. }  
  9. private slots:  
  10. void doWork() {  
  11. /* ... */  
  12. }  
  13. private:  
  14. QTimer timer;  
  15. };  
 

所有我們需要做的就是運行一個事件循環,然後doWork()方法將會被每隔秒鐘調用一次。

網絡/狀態機

一個處理網絡操作非常之常見的設計模式如下:

  1. socket->connect(host);  
  2. socket->waitForConnected();  
  3. data = getData();  
  4. socket->write(data);  
  5. socket->waitForBytesWritten();  
  6. socket->waitForReadyRead();  
  7. socket->read(response);  
  8. reply = process(response);  
  9. socket->write(reply);  
  10. socket->waitForBytesWritten();  
  11. /* ... and so on ... */  
 

不用多說,各種各樣的waitFor*()函數阻塞了調用者使其無法返回到事件循環,UI被凍結等等。請注意上面的這段代碼並沒有考慮到錯誤處理,否則它會更加地笨重。這個設計中非常錯誤的地方是我們正在忘卻網絡編程是異步的設計,如果我們構建一個同步的處理方法,則是自己給自己找麻煩。爲了解決這個問題,許多人簡單得將這些代碼移到另一個線程中。

另一個更加抽象的例子:

  1. result = process_one_thing();  
  2. if (result->something())  
  3. process_this();  
  4. else  
  5. process_that();  
  6. wait_for_user_input();  
  7. input = read_user_input();  
  8. process_user_input(input);  
  9. /* ... */  
 

它多少反映了網絡編程相同的陷阱。

 

讓我們回過頭來從更高的角度來想一下我們這裏正在構建的代碼:我們想創造一個狀態機,用以反映某類的輸入並相對應的作某些動作。比如,上面的這段網絡代碼,我們可能想做如下這些事情:

  • 空閒→ 正在連接 (當調用connectToHost());
  • 正在連接→ 已經連接(當connected() 信號被髮射);
  • 已經連接→ 發送錄入數據 (當我們發送錄入的數據給服務器);
  • 發送錄入數據 → 錄入 (服務器響應一個ACK)
  • 發送錄入數據→ 錄入錯誤(服務器響應一個NACK)

以此類推。

現在,有很多種方式來構建狀態機(Qt甚至提供了QStateMachine[doc.qt.nokia.com]類),最簡單的方式是用一個枚舉值(及,一個整數)來記憶當前的狀態。我們可以這樣重寫以下上面的代碼:

  1. class Object : public QObject  
  2. {  
  3. Q_OBJECT  
  4. enum State {  
  5. State1, State2, State3 /* and so on */  
  6. };  
  7. State state;  
  8. public:  
  9. Object() : state(State1)  
  10. {  
  11. connect(source, SIGNAL(ready()), this, SLOT(doWork()));  
  12. }  
  13. private slots:  
  14. void doWork() {  
  15. switch (state) {  
  16. case State1:  
  17. /* ... */  
  18. state = State2;  
  19. break;  
  20. case State2:  
  21. /* ... */  
  22. state = State3;  
  23. break;  
  24. /* etc. */  
  25. }  
  26. }  
  27. };  
 

那麼“souce”對象和它的信號“ready()” 究竟是什麼? 我們想讓它們是什麼就是什麼:比如說,在這個例子中,我們可能想把我們的槽連接到socket的QAbstractSocket::connected() 以及QIODevice::readyRead() 信號中,當然,我們也可以簡單地在我們的用例中加更多的槽(比如一個槽用於處理錯誤情況,它將會被QAbstractSocket::error() 信號所通知)。這是一個真正的異步的,信號驅動的設計!

分解任務拆成不同的塊

假如我們有一個開銷很大的計算,它不能夠輕易的移到另一個線程中(或者說它根本不能被移動,舉個例子,它必須運行在GUI線程中)。如果我們能將計算拆分成小的塊,我們就能返回到事件循環,讓它來派發事件,並讓它激活處理下一個塊相應的函數。如果我們還記得queued connections是怎麼實現的,那麼會覺得這是很容易能夠做到的:一個事件派發到接收者所駐足的線程的事件循環;當事件被傳遞,相應的槽隨之被激活。

我們可以使用特化QMetaObject::invokeMethod() 的激活類型爲Qt::QueuedConnection 來得到相同的結果;這需要函數是可激活的。因此它需要一個槽或者用Q_INVOKABLE宏來標識。如果我們同時想給函數中傳入參數,他們需要使用Qt元對象類型系統裏的qRegisterMetaType()進行註冊。請看下面這段代碼:

  1. class Worker : public QObject  
  2. {  
  3. Q_OBJECT  
  4. public slots:  
  5. void startProcessing()  
  6. {  
  7. processItem(0);  
  8. }  
  9. void processItem(int index)  
  10. {  
  11. /* process items[index] ... */  
  12. if (index < numberOfItems)  
  13. QMetaObject::invokeMethod(this,  
  14. "processItem",  
  15. Qt::QueuedConnection,  
  16. Q_ARG(int, index + 1));  
  17. }  
  18. };  
 

 

因爲並沒有引入多線程,所以暫停/進行/取消這樣的計算並收集回結果變得簡單。(結束

 

原文出處:

http://developer.qt.nokia.com/wiki/ThreadsEventsQObjects

請尊重原創作品和譯文。轉載請保持文章完整性,並以超鏈接形式註明原始作者主站點地址,方便其他朋友提問和指正。 


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章