Qt學習:Qt 多線程編程之敲開 QThread 類的大門

本文結構如下:

  1. 概述
  2. 優雅的開始我們的多線程編程之旅
    1. 我們該把耗時代碼放在哪裏?
    2. 再談 moveToThread()
  3. 啓動線程前的準備工作
    1. 開多少個線程比較合適?
    2. 設置棧大小
  4. 啓動線程/退出線程
    1. 啓動線程
    2. 優雅的退出線程
  5. 操作運行中的線程
    1. 獲取狀態
      1. 運行狀態
      2. 線程標識
      3. 更爲精細的事件處理
    2. 操作線程
      1. 安全退出線程必備函數:wait()
      2. 線程間的禮讓行爲
      3. 線程的中斷標誌位
  6. 爲每個線程提供獨立數據
  7. 附:所有函數

1. 概述

在閱讀本文之前,你需要了解進程和線程相關的知識,詳情參考《Qt 中的多線程技術》。

在很多文章中,人們傾向於把 QThread 當成線程的實體,區區創建一個 QThread 類對象就被認爲是開了一個新線程。當然這種討巧的看法似乎能快速的讓我們入門,但是隻要深入多線程編程領域後就會發現這種看法越來越站不住腳,甚至編寫的代碼脫離我們的控制,代碼越寫越複雜。最典型的問題就是“明明把耗時操作代碼放入了新線程,可實際仍在舊線程中運行”。造成這種情況的根源在於繼承 QThread 類,並在 run() 函數中塞入耗時操作代碼

追溯歷史,在 Qt 4.4 版本以前的 QThread 類是個抽象類,要想編寫多線程代碼唯一的做法就是繼承 QThread 類。但是之後的版本中,Qt 庫完善了線程的親和性以及信號槽機制,我們有了更爲優雅的使用線程的方式,即 QObject::moveToThread()。這也是官方推薦的做法,遺憾的是網上大部分教程沒有跟上技術的進步,依然採用 run() 這種腐朽的方式來編寫多線程程序。

2. 優雅的開始我們的多線程編程之旅

在 Qt 4.4 版本後,之所以 Qt 官方對 QThread 類進行了大刀闊斧地改革,我認爲這是想讓多線程編程更加符合 C++ 語言的「面向對象」特性。繼承的本意是擴展基類的功能,所以繼承 QThread 並把耗時操作代碼塞入 run() 函數中的做法怎麼看都感覺不倫不類。

2.1 我們該把耗時代碼放在哪裏?

暫時不考慮多線程,先思考這樣一個問題:想想我們平時會把耗時操作代碼放在哪裏?一個類中。那麼有了多線程後,難道我們要把這段代碼從類中剝離出來單獨放到某個地方嗎?顯然這是很糟糕的做法。QObject 中的 moveToThread() 函數可以在不破壞類結構的前提下依然可以在新線程中運行

假設現在我們有個 QObject 的子類 Worker,這個類有個成員函數 doSomething(),該函數中運行的代碼非常耗時。此時我要做的就是將這個類對象“移動”到新線程裏,這樣 Worker 的所有成員函數就可以在新線程中運行了。那麼如何觸發這些函數的運行呢?信號槽。在主線程裏需要有個 signal 信號來關聯並觸發 Worker 的成員函數,與此同時 Worker 類中也應該有個 signal 信號用於向外界發送運行的結果。這樣思路就清晰了,Worker 類需要有個槽函數用於執行外界的命令,還需要有個信號來向外界發送結果。如下列代碼:

// Worker.h
#ifndef WORKER_H
#define WORKER_H
#include <QObject>

class Worker : public QObject
{
  Q_OBJECT
public:
  explicit Worker(QObject *parent = nullptr);

signals:
  void resultReady(const QString &str); // 向外界發送結果

public slots:
  void on_doSomething(); // 耗時操作
};
#endif // WORKER_H

// Worker.cpp
#include "worker.h"
#include <QDebug>
#include <QThread>

Worker::Worker(QObject *parent) : QObject(parent)
{
}

void Worker::on_doSomething()
{
  qDebug() << "I'm working in thread:" << QThread::currentThreadId();
  emit resultReady("Hello");
}

在 Worker 類中,總共也就兩個成員:on_doSomething() 槽函數被外界觸發來執行耗時操作,操作的結果用 resultReady() 信號發射出去,我們這裏將“Hello”字符串作爲結果發射出去。爲了體現是在不同線程中執行的,我們在 on_doSomething() 槽函數中打印當前線程 ID。

// Controller.h
#ifndef CONTROLLER_H
#define CONTROLLER_H
#include <QObject>
#include <QThread>
#include "worker.h"

class Controller : public QObject
{
  Q_OBJECT
public:
  explicit Controller(QObject *parent = nullptr);
  ~Controller();

  void start();

signals:
  void startRunning(); // 用於觸發新線程中的耗時操作函數

public slots:
  void on_receivResult(const QString &str); // 接收新線程中的結果

private:
  QThread m_workThread;
  Worker *m_worker;
};
#endif // CONTROLLER_H

在作爲“外界”的 Controller 類中,由於要發送命令與接收結果,因此同樣是有兩個成員:startRunning() 信號用於啓動 Worker 類的耗時函數運行,on_receivResult() 槽函數用於接收新線程的運行結果。注意別和 Worker 類的兩個成員搞混了,在本例中信號對應着槽,即“外界”的信號觸發“新線程”的槽,“外界”的槽接收“新線程”的信號結果。

// Controller.cpp
#include "controller.h"
#include <QThread>
#include <QDebug>

Controller::Controller(QObject *parent) : QObject(parent)
{
  qDebug() << "Controller's thread is :" << QThread::currentThreadId();

  m_worker = new Worker();
  m_worker->moveToThread(&m_workThread);

  connect(this, &Controller::startRunning, m_worker, &Worker::on_doSomething);
  connect(&m_workThread, &QThread::finished, m_worker, &QObject::deleteLater);
  connect(m_worker, &Worker::resultReady, this, &Controller::on_receivResult);

  m_workThread.start();
}

Controller::~Controller()
{
  m_workThread.quit();
  m_workThread.wait();
}

void Controller::start()
{
  emit startRunning();
}

void Controller::on_receivResult(const QString &str)
{
  qDebug() << str;
}

在 Controller 類的實現裏,首先實例化一個 Worker 對象並把它“移動”到新線程中,然後就是在新線程啓動前將雙方的信號槽連接起來。同 Worker 類一樣,爲了體現是在不同線程中執行的,我們在構造函數中打印當前線程 ID。

// main.cpp
#include <QCoreApplication>
#include <QThread>
#include <QDebug>
#include "controller.h"

int main(int argc, char *argv[])
{
  QCoreApplication a(argc, argv);

  qDebug() << "The main threadID is :" << QThread::currentThreadId();
  Controller controller;
  controller.start();

  return a.exec();
}

在 main.cpp 中我們實例化一個 Controller 對象,並運行 start() 成員函數發射出信號來觸發 Worker 類的耗時操作函數。來看看運行結果:

從結果可以看出,Worker 類對象的成員函數是在新線程中運行的。而 Controller 對象是在主線程中被創建,因此它就隸屬於主線程。

2.2 再談 moveToThread()

“移動到新線程”是一個很形象的描述,作爲入門的認知是可以的,但是它的本質是改變線程親和性(也叫關聯性)。爲什麼要強調這一點?這是因爲如果你天真的認爲 Worker 類對象整體都移動到新線程中去了,那麼你就會本能的認爲 Worker 類對象的控制權是由新線程所屬,然而事實並不是如此。「在哪創建就屬於哪」這句話放在任何地方都是適用的。比如上一節的例子中,Worker 類對象是在 Controller 類中創建並初始化,因此該對象是屬於主線程的。而 moveToThread() 函數的作用是將槽函數在指定的線程中被調用。當然,在新線程中調用函數的前提是該線程已經啓動處於就緒狀態,所以在上一節的 Controller 構造函數中,我們把各種信號槽連接起來後就可以啓動新線程了。

使用 moveToThread() 有一些需要注意的地方,首先就是類對象不能有父對象,否則無法將該對象“移動”到新線程。如果類對象保存在棧上,自然銷燬由操作系統自動完成;如果是保存在堆上,沒有父對象的指針要想正常銷燬,需要將線程的 finished() 信號關聯到 QObject 的 deleteLater() 讓其在正確的時機被銷燬。其次是該對象一旦“移動”到新線程,那麼該對象中的計時器(如果有 QTimer 等計時器成員變量)將重新啓動。不是所有的場景都會遇到這兩種情況,但是記住這兩個行爲特徵可以避免踩坑。

3. 啓動線程前的準備工作

3.1 開多少個線程比較合適?

說“開線程”其實是不準確的,這種事兒只有操作系統才能做,我們所能做的是管理其中一個線程。無論是 QThread thread 還是 QThread *thread,創建出來的對象僅僅是作爲操作系統線程的接口,用這個接口可以對線程進行一些操作。雖然這樣說不準確,但下文我們仍以“開線程”的說法,只是爲了表述方便。作爲入門教程,能在主線程之外“開”一個線程就已經夠了,那麼講解“開”多個線程的內容實在沒有必要。本節的目的是想在叩開多線程大門的同時能向裏望一望多線程領域的世界,就當是拋磚引玉吧。

我們來思考這樣一個問題:“線程數是不是越大越好”?顯然不是,“開”一千個線程是沒有意義的。根據《Qt 中的多線程技術》中所講的,線程的切換是要消耗系統資源的,頻繁的切換線程會使性能降低。線程太少的話又不能完全發揮 CPU 的性能。一般後端服務器都會設置最大工作線程數,不同的架構師有着不同的經驗,有些業務設置爲 CPU 邏輯核心數的4倍,有的甚至達到32倍。如上圖所示,Chrome 瀏覽器運行時就開了36個線程。

    So, the minimum number of threads is equal to the number of available cores. If all tasks are computation intensive, then this is all we need. Having more threads will actually hurt in this case because cores would be context switching between threads when there is still work to do. If tasks are IO intensive, then we should have more threads.

    When a task performs an IO operation, its thread gets blocked. The processor immediately context switches to run other eligible threads. If we had only as many threads as the number of available cores, even though we have tasks to perform, they can't run because we haven't scheduled them on threads for the processors to pick up.

    If tasks spend 50 percent of the time being blocked, then the number of threads should be twice the number of available cores. If they spend less time being blocked—that is, they're computation intensive—then we should have fewer threads but no less than the number of cores. If they spend more time being blocked—that is, they're IO intensive—then we should have more threads, specifically, several multiples of the number of cores.

    So, we can compute the total number of threads we'd need as follows:
    Number of threads = Number of Available Cores / (1 - Blocking Coefficient)

Venkat Subramaniam 博士的《Programming Concurrency on the JVM》這本書中提到關於最優線程數的計算,即線程數量 = 可用核心數/(1 - 阻塞係數)。可用核心數就是所有邏輯 CPU 的總數,這可以用 QThread::idealThreadCount() 靜態函數獲取,比如雙核四線程的 CPU 的返回值就是4。但是阻塞係數比較難計算,這需要用一些性能分析工具來輔助計算。如果只是粗淺的計算下線程數,最簡單的辦法就是 CPU 核心數 * 2 + 2。更爲精細的找到最優線程數需要不斷的調整線程數量來觀察系統的負載情況。

3.2 設置棧大小

根據《Qt 中的多線程技術》所述,線程“與進程內的其他線程一起共享這片地址空間,基本上就可以利用進程所擁有的資源而無需調用新的資源”,這裏所指的資源之一就是堆棧空間。每個線程都有自己的棧,彼此獨立,由編譯器分配。一般在 Windows 的棧大小爲2M,在 Linux 下是8M。

Qt 提供了獲取以及設置棧空間大小的函數:stackSize()、setStackSize(uint stackSize)。其中 stackSize() 函數不是返回當前所在線程的棧大小,而是獲取用 stackSize() 函數手動設置的棧大小。如果是用編譯器默認的棧大小,該函數返回0,這一點需要注意。爲什麼要設置棧的大小?這是因爲有時候我們的局部變量很大(常見於數組),當超過編譯器默認大小時程序就會因爲棧溢出而報錯,這時候就需要手動設置棧大小了。

以上文「2.1 我們該把耗時代碼放在哪裏?」中的代碼爲例,在 Linux 操作系統環境下,假如我們在 on_doSomething() 函數中添加一個9M大小的數組 array,可以看出在程序運行時會由於棧溢出而導致異常退出,因爲 Linux 默認的棧空間僅爲8M。

如果我們設置了棧大小爲10m,那麼程序會正常運行,不會出現棧溢出的問題。

4. 啓動線程/退出線程

4.1 啓動線程

調用 start() 函數就可以啓動函數在新線程中運行,運行後會發出 started() 信號。

在「1.概述」中我們知道將耗時函數放入新線程有 moveToThread() 和繼承 QThread 且重新實現 run() 函數兩種方式。有這麼一種情況:此時我有 fun1() fun2() 兩個耗時函數,將 fun1() 中的代碼放入 run() 函數,而將 fun2() 以 moveToThread() 的方式也放到這個線程中。那新線程該運行哪個函數呢?其實調用 start() 函數後,新線程會優先執行 run() 中的代碼,即先執行 fun1() 函數,其次纔會運行 fun2() 函數。這種情況不常見,但瞭解這種先後順序有助於我們理解 start() 函數。

說到 run() 函數就不得不提 exec() 函數。這是個 protected 函數,因此只能在類內使用。默認 run() 函數會調用 exec() 函數,即啓用一個局部的不佔 CPU 的事件循環。爲什麼要默認啓動個事件循環呢?這是因爲沒有事件循環的話,耗時代碼只要執行完線程就會退出,頻繁的開銷線程顯然很浪費資源。因此,如果使用上述第二種“開線程”的方式,別忘了在 run() 函數中調用 exec() 函數。

4.2 優雅的退出線程

退出線程可是個技術活,不是隨隨便便就可以退出。比如我們關閉主進程的同時,裏面的線程可能還處在運行狀態,尤其線程上跑着耗時操作。這時候你可以用 terminate() 函數強制終止線程,調用該函數後所有處於等待狀態的線程都會被喚醒。該函數是異步的,也就是說調用該函數後雖然獲得了返回值,但此時線程依然可能在運行。因此,一般是在後面跟上 wait() 函數來保證線程已退出。當然強制是很暴力的行爲,有可能會造成局部變量得不到清理,或者無法解鎖互斥關係,種種行爲都是很危險的,除非必要時纔會使用該函數。

上文「4.1 啓動線程」結尾提到“默認 run() 函數會調用 exec() 函數”,耗時操作代碼執行完後,線程由於啓動了事件循環是不退出的。所以,正常的退出線程其實質是退出事件循環,即執行 exit(int returnCode = 0) 函數。返回0代表成功,其他非零值代表異常。quit() 函數等價於 exit(0)。線程退出後會發出 finished() 信號。

5. 操作運行中的線程

5.1 獲取狀態

(1)運行狀態

根據《Qt 中的多線程技術》中的「1.3 線程的生命週期」所述,線程的狀態有很多種,而往往我們只關心一個線程是運行中還是已經結束。QThread 提供了 isRunning()、isFinished() 兩個函數來判斷當前線程的運行狀態。

(2)線程標識

    Returns the thread handle of the currently executing thread.

    Warning: The handle returned by this function is used for internal purposes and should not be used in any application code.

    Note: On Windows, this function returns the DWORD (Windows-Thread ID) returned by the Win32 function GetCurrentThreadId(), not the pseudo-HANDLE (Windows-Thread HANDLE) returned by the Win32 function GetCurrentThread().

關於 currentThreadId() 函數,很多人將該函數用於輸出線程ID,這是錯誤的用法。該函數主要用於 Qt 內部,不應該出現在我們的代碼中。那爲什麼還要開放這個接口?這是因爲我們有時候想和系統線程進行交互,而不同平臺下的線程 ID 表示方式不同。因此調用該函數返回的 Qt::HANDLE 類型數據並轉化成對應平臺的線程 ID 號數據類型(例如 Windows 下是 DWORD 類型),利用這個轉化後的 ID 號就可以與系統開放出來的線程進行交互了。當然,這就破壞了移植性了。

需要注意的是,這個 Qt::HANDLE 是 ID 號而不是句柄。句柄相當於對象指針,一個線程可以被多個對象所操控,而每個線程只有一個全局線程 ID 號。正確的獲取線程 ID 做法是:調用操作系統的線程接口來獲取。以下是不同平臺下獲取線程 ID 的代碼:

#include <QCoreApplication>
#include <QDebug>

#ifdef Q_OS_LINUX
#include <pthread.h>
#endif

#ifdef Q_OS_WIN
#include <windows.h>
#endif

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

#ifdef Q_OS_LINUX
    qDebug() << pthread_self();
#endif

#ifdef Q_OS_WIN
    qDebug() << GetCurrentThreadId();
#endif

    return a.exec();
}

我們自己的程序內部可以調用 currentThread() 函數來獲取 QThread 指針,有了線程指針就可以對線程進行一些操作了。

3)更爲精細的事件處理

在《Qt 中的事件系統 - 有什麼難的呢?》一文中我們提到事件的整個運行流程,文中所提及的 QCoreApplication::processEvents() 等傳遞事件方法其實是很簡單的,但如果再深入下去就無能爲力了。Qt 提供了 QAbstractEventDispatcher 類用於更爲精細的事件處理,該類精細到可以管理 Qt 事件隊列,即接收到事件(來自操作系統或者 Qt 寫的程序)後負責發送到 QCoreApplication 或者 QApplication 實例以進行處理。而文中講的是從 QCoreApplication 接收到事件開始,再往後的事情了。

線程既然可以開啓事件循環,那麼就可以調用 eventDispatcher()、setEventDispatcher() 函數來設置和獲取事件調度對象,然後對事件進行更爲精細的操作。

除此以外,loopLevel() 函數可以獲取有多少個事件循環在線程中運行。正如下文所說,這個函數本來在 Qt 4 中被刪除了,但是對於那些想知道有多少事件循環的人來說該函數還是有用的。所以在 Qt 5 中又加了進來。

This function used to reside in QEventLoop in Qt 3 and was deprecated in Qt 4. However this is useful for those who want to know how many event loops are running within the thread so we just make it possible to get at the already available variable.

5.2 操作線程

(1)安全退出線程必備函數:wait(unsigned long time = ULONG_MAX)

在本文「4.2 優雅的退出線程」中已經提到“一般是在後面跟上 wait() 函數來保證線程已退出”,線程退出的時候不要那麼暴力,告訴操作系統要退出的線程後,給點時間(即阻塞)讓線程處理完。也可以設置超時時間 time,時間一到就強制退出線程。一般在類的析構函數中調用,正如本文開頭「2.1 我們該把耗時代碼放在哪裏?」的示例代碼那樣:

Controller::~Controller()
{
    m_workThread.quit();
    m_workThread.wait();
}

(2)線程間的禮讓行爲

這是個很有意思的話題,一般我們都希望每個線程都能最大限度的榨乾系統資源,何來禮讓之說呢?有時候我們採用多線程並不只是運行耗時代碼,而是和主 GUI 線程分開,避免主界面卡死的情況發生。那麼有些線程上跑的任務可能對實時性要求不高,這時候適當的縮短被 CPU 選中的機會可以節約出系統資源

除了調用 setPriority()、priority() 優先級相關的函數以外,QThread 類還提供了 yieldCurrentThread() 靜態函數,該函數是在通知操作系統“我這個線程不重要,優先處理其他線程吧”。當然,調用該函數後不會立馬將 CPU 計算資源交出去,而是由操作系統決定。

QThread 類還提供了 sleep()、msleep()、usleep() 這三個函數,這三個函數也是在通知操作系統“在未來 time 時間內我不參與 CPU 計算”。從我們直觀的角度看,就好像當前線程“沉睡”了一段時間。

(3)線程的中斷標誌位

Qt 爲每一個線程都設置了一個布爾變量用來標記當前線程的終端狀態,用 isInterruptionRequested() 函數來獲取,用 requestInterruption() 函數來設置中斷標記。這個標記不是給操作系統看的,而是給用戶寫的代碼中進行判斷。也就是說調用 requestInterruption() 函數並不能中斷線程,需要我們自己的代碼去判斷。這有什麼用處呢?

while (ture) {
    if (!isInterruptionRequested()) {
        // 耗時操作
        ......
    }
}

這種設計可以讓我們自助的中斷線程,而不是由操作系統強制中斷。經常我們會在新線程上運行無限循環的代碼,在代碼中加上判斷中斷標誌位可以讓我們隨時跳出循環。好處就是給了我們程序更大的靈活性

6. 爲每個線程提供獨立數據

思考這樣一個問題,如果線程本身存在全局變量,那麼修改一處後另一個線程會不會受影響?我們以一段代碼爲例:

// main.cpp
#include <QCoreApplication>
#include "workthread.h"

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    WorkThread thread1;
    WorkThread thread2;

    thread1.start();
    thread2.start();

    return a.exec();
}

// WorkThread.h
#ifndef WORKTHREAD_H
#define WORKTHREAD_H
#include <QThread>

class WorkThread : public QThread
{
public:
    WorkThread();

protected:
    virtual void run() override;
};
#endif

// WorkThread.cpp
#include "workthread.h"
#include <QDebug>
#include <QThreadStorage>

WorkThread::WorkThread()
{
}

quint64 g_value1 = 0;
void WorkThread::run()
{
    g_value1 = quint64(currentThreadId());
    qDebug() << g_value1;
}

我們繼承 QThread 類並重寫 run() 函數,函數中的全局變量 g_value1 由線程 ID 賦值。實例化出兩個線程對象並均啓動。其結果輸出如下:

可以看到兩個輸出的結果是一樣的,這並非是同一個線程輸出兩次,而是線程 thread1 對全局變量的修改影響了線程 thread2。造成這個現象的原因也很好理解,根據《Qt 中的多線程技術》中的「1.2 多核CPU」所述,“線程隸屬於某一個進程,與進程內的其他線程一起共享這片地址空間”。也就是說全局變量屬於公共資源,被所有線程所共享,只要一個線程修改了這個全局變量自然就會影響其他線程對該全局變量的訪問。

而 QThreadStorage 類爲每個線程提供了獨立的數據存儲功能,即使在線程中用到全局變量,只要存在 QThreadStorage 中,也不會影響到其他線程。我們對上面的 workthread.cpp 進行稍加修改,從結果來看,每個線程都有屬於各自的全局變量,而互不影響。如下圖所示:

需要注意的是,QThreadStorage 的析構函數並不會刪除所儲存的數據,只有線程退出纔會被刪除。

附:所有函數

  • 啓動前的準備工作
    • 構造函數:QThread(QObject *parent = nullptr)
    • 系統理想的線程數量:[static]int idealThreadCount()
    • 堆棧大小
      • 獲取:uint stackSize() const
      • 設置:void setStackSize(uint stackSize)
  • 啓動/退出
    • 啓動
      • 執行:[slots]void start(QThread::Priority priority = InheritPriority)
      • 信號:void started()
      • 事件循環:[protected]int exec()
    • 退出
      • 執行
        • void exit(int returnCode = 0)
        • [slots]void quit()
      • 信號:void finished()
      • 強制中止[不常用]
        • [slots]void terminate()
        • [static protected]void setTerminationEnabled(bool enabled = true)
  • 運行中
    • 狀態獲取
      • 是否運行
        • bool isRunning() const
        • bool isFinished() const
      • 標識:
        • 當前線程:[static]QThread * currentThread()
        • 線程ID:[static]Qt::HANDLE currentThreadId()
      • 更爲精細的事件管理
        • 獲取:QAbstractEventDispatcher * eventDispatcher() const
        • 設置:void setEventDispatcher(QAbstractEventDispatcher *eventDispatcher)
        • 循環級別:int loopLevel() const
    • 行爲
      • 阻塞:bool wait(unsigned long time = ULONG_MAX)
      • 線程之間的禮讓
        • 優先級
          • 獲取:QThread::Priority priority() const
          • 設置:void setPriority(QThread::Priority priority)
        • 切換線程:[static]void yieldCurrentThread()
        • 睡眠
          • [static]void sleep(unsigned long secs) const
          • [static]void msleep(unsigned long msecs)
          • [static]void usleep(unsigned long usecs)
      • 請求中斷
        • 判斷:bool isInterruptionRequested() const
        • 執行:void requestInterruption()
  • 其他(僅支持C++17)
    • [static]QThread * create(Function &&f, Args &&... args)
    • [static]QThread * create(Function &&f)

以上就是本文的全部內容,多線程的內容豐富多彩,受限於筆者的知識程度,有些內容無法深入討論,比如 QThreadStorage 和 C++11 關鍵字 thread_local 的區別。在未來的學習生活中,如果對多線程有了更深的感悟,筆者會回來更新本文的。

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