鼠眼再看Linux調度器(1)

一、回顧。    

       上次鼠眼初看Linux調度器時已有一年有餘的光景了。這一年多的時間裏,Linux內核中許多地方發生了重要的變化,比如引進了KVM等。相對而言,任務調度這部分變動算是非常小了:其中比較顯著的就是增加了優先級繼承支持。但若僅有這些變動的話,從量上還不足以撐起這樣一篇文章。

        在LKMLLinux Kernel Mail List)上,前陣子有過幾個回合關於任務調度的熱烈討論:CFS vs RSDL/SDCFS,即“完全公平調度”,這種方法徹底以時間和系統負載作爲參照,完全拋棄了原有調度方案中的雙優先級數組、甚至時間片的概念。RSDL/SD是我們上次介紹的Staircase調度的演化版本。我們以介紹CFS的工作過程爲主,不對兩者妄加評論。兩者都在快速發展過程中,尤其是CFS,在4月中旬發佈出第一個版本後,經過許多大俠的助力,已日趨穩定,截止到5月中旬它就有十多個版本的迭代了,當然,這也是Linux開發模型中的儘早發佈特點所導致的一個必然現象。

        如果功利一點,從實用的角度上看,只分析學習已經納入正式內核的代碼是最經濟的,因爲它最有可能是爲工作所用。但是,如果從學習積累的角度上看,更早地參與到某項技術本身的開發過程中,或者退一步說只是關注某項新技術的從幼小到成熟的成長過程,將更有利於我們徹底掌握它。我以爲CFSRSDL都是值得我們關注的目標。但本文只關注CFS的實現,因爲看起來它比RDSL/SD更有前途一些。

        我想有必要重申一下爲什麼是“鼠眼看X”?我的本意是文章中不會有太深的技術內容,儘量使“知其所以然/閱讀難度”的比值大些。再有,在簡化代碼時我只保留與主功能邏輯直接相關的部分,但是我只刪代碼而不修改原有代碼,這樣既有利於抓住核心環節縮減篇幅,又不妨礙有興趣的讀者順着這些線索親自“咀嚼”代碼。

     OK,這次“再看”的文章,內容包括2.6.21.1內核中任務調度的實時擴展,模塊化調度功能的支持,和關於CFS v15/2.6.21.1上調度的一些探討。

        在切入正題之前,容俺先向各位說聲抱歉:上次<<鼠眼看Linux調度器>>內有處錯誤:“linuxthreads線程庫實現的是N1模型”。實際上,它實現的也是NN的線程模型,我也是後來分析了C庫這部分代碼才知道的,這又一次充分證明了“道聽途說”不可信,實踐才能出真知呀!

二、實時擴展。

    “溫故以知新”,我們先簡單複習一下以前Linux調度器內部是如何工作的:Linux在調度任務時主要依靠兩個優先級參數:1、靜態優先級。“靜態”得名於內核從不主動修改它,對於普通任務它被初始化爲優先級中值120(最小100,最大140),只有通過系統調用纔會修改它。內核在計算時間片、判斷任務的交互性、計算動態優先級時都要直接或間接地使用到它,可以將它看成任務的“本性”;2、動態優先級。Linux任務是不能直接接觸到這種優先級的,內核根據任務的平均休眠時間判斷其交互特徵進而計算出動態優先級。交互性強的任務會得到更高的優先級。特別提醒一下:此前內核也是支持一定的實時特徵的,主要支持設施有三個:SCHED_FIFOSCHED_RR調度策略和搶佔式調度。

        首先,現在任務默認的時間片輪轉調度和優先級調度混合的調度策略,已經從“SCHED_OTHER”更名爲“SCHED_NORMAL”,但沒有策略本身沒有發生什麼本質變化。呵呵,它總算有了一個名正言順的名份。“靜態優先級”的處理也沒有發生什麼變化。它的處理流程幾乎和我們上次介紹的一模一樣,但在動態優先級的處理上有了一些改進,主要是源於一種叫做“優先級繼承(PI)協議”的機制。

        在這裏詳細討論“優先級繼承協議”的來龍去脈是不可能的,在文章最後我列出一些參考材料供有興趣的讀者深入閱讀。這裏只簡單介紹一下它的概念。試想有這樣兩個任務:低優先級任務L、和高優先級的任務H,而且兩個任務都需要訪問一塊共享的臨界區(資源)。正常情況下,任務H是要比任務L優先執行的。爲了保持臨界區內數據的完整性,需要在兩者之間進行同步,最常用的手段就是使用信號量。通俗地說,臨界區它就好像一間單人密室,同時只允許有一個人身處其中,而進去的人會關上門。這樣外人只有在裏面沒有人時纔有可能打開門進入密室。再假定任務L首先成功拿到了信號量,如果任務L在釋放信號量之前由於某種原因進入休眠狀態,而此時任務H若試圖獲取信號量,就只能也休眠過去等待任務L釋放信號量了。這樣,就形成了一條畸形的依賴鏈:高優先級的任務H通過信號量等候低優先級任務L的運行一段時間後纔有可能繼續。再試想,如果有更多的任務和信號量參與到其中這將是一個何等糟糕的情形。事實上,“依賴鏈”有可能演變成爲“依賴樹”、“依賴圖”,甚至形成“依賴環”。這種現象有個文縐縐的學名,“優先級反轉”。“優先級反轉”當然不是世界末日,問題的核心在於臨界區(資源)訪問的排它性。基本的解決方案有四種:臨界區的無鎖訪問算法,臨界區的不可搶佔訪問協議、優先級繼承協議、優先級頂置(Priority Ceiling)協議:

        臨界區的無鎖訪問算法。這種方法迴避了資源訪問的排它性,乾脆就把資源同步機制給跳過去了。但很遺憾,它只能用在有限的特殊情況下,即使是這樣它還有實現複雜等限制。如此看來,無鎖訪問肯定不是“瑞士軍刀”了。

        臨界區的不可搶佔訪問協議,它可能是最簡單的資源控制協議了。如果是這種方法,上面的任務L在獲得信號量之後,釋放信號量之前的時間間隔裏是不能休眠的。這樣,也就不會給任務H中間試圖取得信號量機會了。本質上,這樣的訪問就是原子性的了。這個方法雖然簡單的,但是也有致命的缺陷--打擊面過寬:此時它也剝奪了與該信號量無關的其他所有任務的運行機會,這可不是一個好消息,並且,也不適用於多處理器的情況,這在連PC計算也日趨並行化的今天就更是與現實格格不入了。

        優先級繼承協議,這是四個解決方案最爲複雜的一種,應用於以上例子,在任務L獲取信號量再因故休眠之後,如果任務H也試圖獲取相同的信號量,任務H仍然也要休眠。但不同的是任務L的動態優先級需要調整爲任務H的動態優先級。這樣,任務L就會以“高姿態”更快地再次得到處理器資源,完成對該資源的訪問,從而給任務H快速復甦的可能。當任務H不再等待任務L所佔有的資源時,任務L還要恢復原始的優先級。乍一聽起來這似乎並不困難,是不是?但是不要高興的太早,試想象一下有多個任務參與進來的情形,更復雜地,再加入多個信號量時的情形呢?你可能已經猜到了,優先級繼承過程必須是可傳遞的!稍後回過頭來再次分析優先級繼承時,你就知道了,這恐怕也不是一個什麼好消息~

        優先級頂置協議,有時也稱“迴避阻塞(Avoidance Blocking)”,本質上這是一種將臨界區(資源)優先級化的方法。在具體實現時,往往優先級化的是代表資源出現的某種同步機制,例如信號量。某任務在獲得該資源後,它的優先級就提升到該資源的優先級上。它與“優先級繼承協議”的最根本的區別在於,它不是貪心的,作爲一個副作用,它的效果可能差於“優先級繼承協議”--可能延長引起不必要的休眠時間。但是,它可以做到“優先級繼承協議”所不能的:避免死鎖。

  OK,回到“優先級繼承協議”的討論上。我們想想如果自己實現優先級繼承協議需要哪些工作?嗯,肯定需要在信號量上加一個等待隊列,它保存有等待獲取該信號量的任務們。我們在繼承和恢復優先級時是一個臺階式的調整過程:“當權”的資源佔用者總是以等待者中的最高優先級運行,下一屆“當權者”就以剩餘等待者的最高優先級運行。嗯,看起來,我們更需要一個按優先級排好序,而不是按休眠時間排序的鏈表。這個特徵還有一個推論:實現“優先級繼承協議”不單單是調度算法自身的問題,還需要相應資源同步機制的配合。這樣,內核中的新同步工具rt_mutex就出場了。而rt_mutex的成員wait_list,就是我們所需的按優先級排序的等待隊列。

        但是隻有上述信號量上的一個鏈表還不夠。想像這種情況:一個任務同時持有多個信號量的情形。此時信號量持有者的正確行爲肯定是使用各信號量中優先級最高的等待者的優先級運行。這樣,就形成了等待者跨信號量競爭最高優先級的局面。雖然這仍然是一個優先級排序的問題,但這個鏈表應該設置在任務結構上了。我們有兩個選擇:是鏈接信號量,還是直接鏈接每個信號量的最高優先級等待者。Linux選擇了後者,這便是task_struct(它代表一個Linux任務)中新成員pi_waiters的出處了。

        鏈表wait_listpi_waiters,它們的共性都是按優先級排序。內核爲此抽象出了一個新的數據結構:plistplist的實現還是非常直接的,它的API也故意地設計成與Linux雙鏈表相似,比如檢查鏈表是否爲空的API名字就是plist_head_empty()。這裏不再詳細解釋它的實現,如果你想看看它的實現,下面是幾點可能有用的提示:與標準Linux雙鏈表不同,plist是區分鏈表頭和一般鏈表結點的;第二,plist其實是兩個雙鏈表的組合,目的是爲了在遍歷優先級時減少處理重複優先級所浪費的時間;最後,優先級是按從高至低排序的。

     OK,雖然有些線性表實現的操作我們可以做到O(logn)時間複雜度,但設計者們在這裏採用了非常直接的實現方案,它們仍是O(n)時間複雜度。上面說到“優先級繼承”有一個“傳遞性”的特徵,若有大量任務參與到“優先級反轉”,就有可能會造成嚴重的性能問題。爲此,內核在調整優先級鏈的時候,施加了一個實現限制:最大優先級鏈長度(運行時可配置,默認爲1024);再有,經過精心設計,內核在處理優先級繼承時最多隻會同時使用兩把內核(自旋)鎖。

        無論是什麼時候,數據結構始終都是程序的靈魂。我們還是從task_struct結構有關成員說起吧:

     task_struct->prio

     task_struct->static_prio

        它們分別是任務的動態優先級和靜態優先級。

        粗略地說,Linux上有140個優先級可供任務使用,數值越小的優先級,優先級越高。100以下的優先級被用於實時任務,即使用調度策略SCHED_FIFOSCHED_RR的任務。這樣,它們始終會比普通策略(SCHED_NORMALSCHED_BATCH)要優先一些。

        動態優先級在數值可能不同於靜態優先級,可能的原因有:

    1、內核根據任務的平均休眠時間判斷其交互特徵進而得出動態優先級。這個工作是由recalc_task_prio()函數完成的。

    2、由於優先級繼承的原因,它們通過rt_mutex阻塞了更高優先級的任務而得到提升,但只適用於優先級值小於100的實時任務。優先級繼承過程本身是由__rt_mutex_adjust_prio()完成。

    task_struct->normal_prio

        我們已經知道:“優先級繼承協議”可能會臨時提升任務的優先級。但提升完後應該立即返回到其應有的優先級上。這個成員保存的正是這個“應有的優先級”,即,沒有優先級繼承影響時的優先級,我們姑且稱之爲“常規動態優先級”。

    task_struct->rt_priority


        對於實時任務(具有策略SCHED_FIFOSCHED_RR),即使是動態優先級,內核也是不作主動調整的。具體的調整方案完全由系統調用sched_setscheduler()控制,rt_priority保存的就是這個系統調用設置的優先級參數,它與實時任務的動態優先級成線性對應關係。其取值範圍是099

    OK,該是看看代碼的時間了,沿用我們上次看代碼的方式:

staticint effective_prio(struct task_struct*p)
{
1>    p->normal_prio= normal_prio(p);
2>    if (!rt_prio(p->prio))
        return p->normal_prio;
3>    return p->prio;
}

  1. 調用normal_prio(),計算常規動態優先級。

  2. rt_prio()其實就是檢查任務的優先級是不是小於100。如果不滿足這個檢查條件,就表明這個任務不是實時任務,直接返回剛纔計算好的常規動態優先級,也即,如果不是實時任務就不能利用“優先級繼承協議”。

  3. 如果是實時任務,就是直接返回其真正的動態優先級p->prio。不作任何修改。那麼這個優先級又是怎麼設置的呢?答案是要麼由上述的sched_setscheduler()調整,要麼就是通過優先級繼承協議調整的。

staticinline int normal_prio(struct task_struct*p)
{
    int prio;

1>    if (has_rt_policy(p))
        prio = MAX_RT_PRIO-1 – p->rt_priority;
    else
2>        prio = __normal_prio(p);
    return prio;
}


    1. has_rt_policy(p),是檢查任務p是不是實時任務的另一種方法。如果是,就按99 – p->rt_priority的公式計算動態優先級:rt_priority值越大,實時任務的優先級越高。
       2. 否則,就調用__normal_prio()計算真正的常規動態優先級。

staticinline int __normal_prio(struct task_struct*p)
{
    int bonus, prio;

    bonus = CURRENT_BONUS(p)- MAX_BONUS / 2;

    prio = p->static_prio- bonus;
    if (prio < MAX_RT_PRIO)
        prio = MAX_RT_PRIO;
    if (prio > MAX_PRIO-1)
        prio = MAX_PRIO-1;
    return prio;
}

        上次一同看過Linux調度器的朋友,可能對這個函數有些眼熟。對了,它與以前的effective_prio()不差分毫。爲了敘述上的完整性,我再簡單敘述一下此中原委。上文提到這樣兩個線索:內核會根據任務的平均休眠時間判斷得出動態優先級;在計算動態優先級時內核還要使用靜態優先級作爲依據。這裏計算得出的bonus變量值,保存的就是根據平均休眠時間得出的動態優先級與靜態優先級之間的差值,其值在-5+5之間。prio,就是真正的常規動態優先級了。函數返回之前兩個條件語句做了一些必要的邊界值檢查。


staticvoid __rt_mutex_adjust_prio(struct task_struct*task)
{
1>    int prio = rt_mutex_getprio(task);

2>    if (task->prio!= prio)
        rt_mutex_setprio(task, prio);
}

        在內核需要對一個任務進行優先級繼承調整時,就會調用rt_mutex_adjust_prio(),但是它只是__rt_mutex_adjust_prio()的一個wrapper,真正的工作是由後者完成的。那麼,內核都在哪些情況下調用了rt_mutex_adjust_prio()呢?只有兩種情況,對rt_mutex進行加鎖和解鎖時。我們可以知道,一個高優先級任務在對rt_mutex加鎖而不成進入休眠時,它可能會導致該rt_mutex的持有者任務的優先級提升。反之,rt_mutex的持有者任務在釋放它時,也可能因爲它被提升過優先級的緣故而需要恢復原優先級。所以,rt_mutex_adjust_prio()的工作其實應該有兩種:要麼優先級提升(boosting),要麼降級(unboosting)。再來看代碼:

  1. rt_mutex_getprio()即返回適合任務的優先級:若有優先級繼承發生,就是返回提升過的優先級。否則,返回“常規動態優先級”。

  2. 如果這個優先級與現有優先級不同,就設置調用rt_mutex_setprio()新優先級。再次注意,這裏只對兩者進行了不等比較,而沒有進行大小比較。


int rt_mutex_getprio(struct task_struct*task)
{
1>    if (likely(!task_has_pi_waiters(task)))
        return task->normal_prio;

2>    return min(task_top_pi_waiter(task)->pi_list_entry.prio,
         task->normal_prio);
}

  1. 函數task_has_pi_waiters(task)的實現很直接,就是“return !plist_head_empty(&task->pi_waiters);”。即判斷是否有其他任務等待task所持有的rt_mutex。如果沒有,那就根本不存在優先級繼承的可能,便直接返回“常規動態優先級”了。

  2. 理解這個返回語句需要一些背景,但幾乎所有需要的知識點我們都涉及到了。首先,內核管理優先級時是數值越小,優先級越高,因而這裏的min()調用,實際上是找出哪個優先級更高些。task_top_pi_waiter(task)的內部實現其實就是取等待隊列task->pi_waiters中第一個任務。也就是持有最高優先級的等待任務。因爲這個等待隊列是按優先級從高到底排好順序的,我們只需要得到第一個任務就足夠了。所以,這條語句實際上是在比較可能的繼承優先級和“常規動態優先級”,我們當然不希望優先級繼承還會使rt_mutex持有任務的優先級削弱。所以,只返回較高(數值較小)的那個。

                   
void rt_mutex_setprio(struct task_struct*p, int prio)
{
    struct prio_array *array;
    unsigned long flags;
    struct rq *rq;
    int oldprio;

1>    rq = task_rq_lock(p,&flags);

    oldprio = p->prio;
2>    array = p->array;
    if (array)
        dequeue_task(p, array);
    p->prio = prio;

    if (array){
       
/*
         * If changing to an RT priority then queue it
         * in the active array!
         */

3>        if (rt_task(p))
            array = rq->active;
        enqueue_task(p, array);
       
/*
         * Reschedule if we are currently running on this runqueue and
         * our priority decreased, or if we are not currently running on
         * this runqueue and our priority is higher than the current's
         */

4>        if (task_running(rq, p)){
            if (p->prio> oldprio)
                resched_task(rq->curr);
        } else if (TASK_PREEMPTS_CURR(p, rq))
            resched_task(rq->curr);
    }
    task_rq_unlock(rq,&flags);
}

        嚴格地說,這個函數與rt_mutex關係並不大,其它任何原因想修改任務的優先級了,都可以使用這個函數。通過分析rt_mutex_setprio()可以窺探出Linux是如何組織任務的,爲稍後將要介紹CFS調度作一些鋪墊。我摘出四個關鍵點:

  1. linux上,每個處理器(也包括超線程意義上的邏輯處理器)都對應到一個運行隊列。這麼安排既有性能上的考慮,也有功能上的原因:性能上,如果系統內所有的處理器都共享一個運行隊列,看似簡單卻有極大的性能隱患。因爲極有可能多個處理器需要同時或幾乎同時在運行隊列上插入/刪除任務,此時,爲了保證運行隊列的完整性,就必須有某種全局同步機制強迫這些操作串行化,例如自旋鎖。顯而易見,這不是個好主意。功能上,分開對待每個處理器,在實現負載均衡時也非常自然,也利於進一步抽象。每個struct rq表示的就是一個運行隊列/處理器。不過,由於支持搶佔的原因,即使是每個處理器對應一個運行隊列,仍然需要有同步機制保護其操作的一致性,但它所影響的卻只限於一個處理器了。task_rq_lock()的作用就是獲取指定任務所在的運行隊列的訪問權。在整個函數結束時,還調用了task_rq_unlock()來歸還訪問權。

  2. dequeue_task()將任務從運行隊列上摘掉。之所以要先摘掉,是因爲Linux上每種任務優先級都有一個小運行隊列。所以,改變了任務的動態優先級就應該將它移動到其它小運行隊列中去。概念上,rq其實是對應了這樣一套小運行隊列。但實際情況更爲複雜,rq其實是包括了兩套這樣的小運行隊列。任務結構task_struct->array就保存了這個任務處哪套小運行隊列上。

  3. 上述兩套子運行隊列,一個叫做活動隊列(rq->active);另一個叫做過期(expired)隊列。調度器只會從活動隊列中摘取任務,並且適時切換兩者。經過這個分支的處理,enqueue_task()就優先將實時任務放入到活動隊列,從而縮短它的實際等待時間。這樣,運行隊列實際分三層:處理器運行隊列->活動/過期隊列->每優先級運行隊列。

  4. 其實這條分支語句之上的註釋已經將道理寫的很明白了,我簡單地翻譯一下:如果這個任務正在這個運行隊列上運轉,並且我們是在削弱它的優先級的話,就給其他可能的任務機會佔用處理器(即所謂的reschedule);如果這個任務的優先級高於運行隊列上當前任務,就讓其讓出處理器。

        文已過半,我想大家大概應該清楚了“優先級繼承協議”對Linux調度器的影響。但爲什麼這個章節叫做“實時擴展”,而不是直接了當的“優先級繼承協議的Linux實現”呢?首先,文不對題,因爲本文的側重介紹Linux調度器的,並沒有介紹優先級繼承協議實現細節。細心的讀者也一定已經注意到,優先級繼承這個功能只能在所謂的實時任務內使用(參見對effective_prio()的解釋)。實際上rt_mutex中的rt正是realtime(實時)的縮寫。那爲什麼控制“優先級反轉”現象對於實時任務又如此重要呢?雖然還沒有一個準確的定義刻劃“實時”概念,但它絕不是說程序只要運行得越快就越好,甚至在有些情況下,過快地反應速度反而不是我們所期待的。既不能快,更不能慢的響應速度,我們實際上就是在要求有可預測的“響應時間”。一個實際系統內有各種各樣的因素干擾能夠可預測性,“優先級反轉”尤其會使其更加惡化。所以,“優先級繼承協議”在各種實時系統得到了廣泛實現。最後,即使是有了“優先級繼承協議”這樣強悍的功能,標準的Linux仍然還不能提供公認的“硬實時特徵”,現在似乎也只有RTAIRT-Linux可以提供了這樣的特性。RTAI也是一個穩步發展地開源軟件,實現了EDFRMS實時調度算法,它和其底層支持軟件Adeos也都很有意思,非常值得研究學習。

三、模塊化調度。

        在軟件工程上,“模塊化”是實現“信息隱藏”的重要手段,已經獲得廣泛應用,不過它在調度器上可算是半個少見的例外。就是否和如何在Linux內核中加入模塊化調度功能的支持,在社區內是個爭論已久的話題。支持派的代表意見是提供這樣的功能可以給用戶選擇它們所需的個性化調度器,提高系統的靈活性。反對派的意見是內核應該提供一個“十全十美”的內核,不應該將責任推卸到用戶身上。退一步講就是在如何加入這個功能上,社區內也是有分歧的,是提供運行時模塊化功能還是僅編譯開發時呢?這兩種方法現在都有實現。

        我們只簡單介紹一下與CFS補丁密切相關的“調度類”功能,Ingo實現了兩個“調度類”:一個sched_fair,一個sched_rt。前者實現了我們就要介紹的CFS(包括調度策略SCHED_NORMALSCHED_BATCH),後者實現了軟實時調度(包括策略SCHED_RRSCHED_FIFO)。這兩個調度類靜態(編譯時)嵌入到調度核心代碼裏,有了它們以後,我們就不再需要在調度核心裏摻雜具體的策略方面的內容了,但我們仍不能在運行時插入、刪除調度類。因而從功能上看“調度類”的更多的是簡化調度代碼,並沒有給最終用戶帶來多大的“實惠”。

        我們只掃描一下“調度類”的數據結構,不再詳細解釋它的實現細節了:

struct sched_class{
    void (*enqueue_task)(struct rq *rq, struct task_struct*p,
             int wakeup, u64 now);
    void (*dequeue_task)(struct rq *rq, struct task_struct*p,
             int sleep, u64 now);
    struct task_struct * (*pick_next_task)(struct rq *rq, u64 now);
    void (*task_tick)(struct rq *rq, struct task_struct*p);
    /* ...... */
};   

        我們只選取了四個有代表性功能的結構成員:

     enqueue_task()/dequeue_task()

        這兩個方法用於將任務插入至運行隊列,或者從運行隊列中刪除任務。如果我們要在一個調度類內實現現有的調度方法,那麼這兩個函數的工作就是操作優先級數組。注意,這裏的方法定義並沒有對“運行隊列”本身做任何假定。調度類可以用任何它需要的方法實現運行級別,直觀上看,一個線性數據結構就夠了,但實際上CFS選擇了紅黑樹。

    pick_next_task()

        當調度核心需要從運行隊列中取出一個任務佔用處理器時,就調用這個函數。調用時機有兩個:一種是出於某種原因當前任務要讓出處理器時,調度核心需要讓另一個任務佔用處理器;還有一種情況是支持處理器熱插拔時,把要離線處理器上的任務全部遷移到可用處理器上時。

    task_tick()

        這個函數在每次定時器中斷時調用。在現有調度實現中,這個功能扮演了重要的角色,包括更新時間片,對任務交互性的一些啓發式處理。

    “調度類”的實現並沒有多少難解之處,它爲不同的調度方法抽象出一個系列虛擬方法,調度只要填上相應的方法實現就OK了。整個處理流程也仍是我們以前介紹過的套路:以定時器中斷和以運行隊列爲中心。

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