剖析爲什麼在多核多線程程序中要慎用volatile關鍵字?

來源:http://www.parallellabs.com/2010/12/04/why-should-we-be-care-of-volatile-keyword-in-multithreaded-applications/

剖析爲什麼在多核多線程程序中要慎用volatile關鍵字?


這篇文章詳細剖析了爲什麼在多核時代進行多線程編程時需要慎用volatile關鍵字。

主要內容有:
1. C/C++中的volatile關鍵字
2. Visual Studio對C/C++中volatile關鍵字的擴展
3. Java/.NET中的volatile關鍵字
4. Memory Model(內存模型)
5. Volatile使用建議

閱讀全文>>


這篇文章詳細剖析了爲什麼在多核時代進行多線程編程時需要慎用volatile關鍵字。

主要內容有:
1. C/C++中的volatile關鍵字
2. Visual Studio對C/C++中volatile關鍵字的擴展
3. Java/.NET中的volatile關鍵字
4. Memory Model(內存模型)
5. Volatile使用建議

1. C/C++中的volatile關鍵字

1.1 傳統用途

C/C++作爲系統級語言,它們與硬件的聯繫是很緊密的。volatile的意思是“易變的”,這個關鍵字最早就是爲了針對那些“異常”的內存操作而準備的。它的效果是讓編譯器不要對這個變量的讀寫操作做任何優化,每次讀的時候都直接去該變量的內存地址中去讀,每次寫的時候都直接寫到該變量的內存地址中去,即不做任何緩存優化。它經常用在需要處理中斷的嵌入式系統中,其典型的應用有下面幾種:

a. 避免用通用寄存器對內存讀寫的優化。編譯器常做的一種優化就是:把常用變量的頻繁讀寫弄到通用寄存器中,最後不用的時候再存回內存中。但是如果某個內存地址中的值是由片外決定的(例如另一個線程或是另一個設備可能更改它),那就需要volatile關鍵字了。(感謝Kenny老師指正)
b. 硬件寄存器可能被其他設備改變的情況。例如一個嵌入式板子上的某個寄存器直接與一個測試儀器連在一起,這樣在這個寄存器的值隨時可能被那個測試儀器更改。在這種情況下如果把該值設爲volatile屬性的,那麼編譯器就會每次都直接從內存中去取這個值的最新值,而不是自作聰明的把這個值保留在緩存中而導致讀不到最新的那個被其他設備寫入的新值。
c. 同一個物理內存地址M有兩個不同的內存地址的情況。例如兩個程序同時對同一個物理地址進行讀寫,那麼編譯器就不能假設這個地址只會有一個程序訪問而做緩存優化,所以程序員在這種情況下也需要把它定義爲volatile的。

1.2 多線程程序中的錯誤用法

看到這裏,很多朋友自然會想到:恩,那麼如果是兩個線程需要同時訪問一個共享變量,爲了讓其中兩個線程每次都能讀到這個變量的最新值,我們就把它定義爲volatile的就好了嘛!我想這個就是多線程程序中volatile之所以引起那麼多爭議的最大原因。可惜的是,這個想法是錯誤的。

舉例來說,想用volatile變量來做同步(例如一個flag)?錯!爲什麼?很簡單,雖然volatile意味着每次讀和寫都是直接去內存地址中去操作,但是volatile在C/C++現有標準中即不能保證原子性(Atomicity)也不能保證順序性(Ordering),所以幾乎所有試圖用volatile來進行多線程同步的方案都是錯的。我之前一篇文章介紹了Sequential Consistency模型(後面簡稱SC),它其實就是我們印象中多線程程序應該有的執行順序。但是,SC最大的問題是性能太低了,因爲CPU/編譯器完全沒有必要嚴格按代碼規定的順序(program order)來執行每一條指令。學過體系結構的同學應該知道不管是編譯器也好CPU也好,他們最擅長做的事情就是幫你做亂序優化。在串行時代這些亂序優化對程序員來說都是透明的,封裝好了的,你不用關心它們到底給你亂序成啥樣了,因爲它們會保證優化後的程序的運行結果跟你寫程序時預期的結果是一模一樣的。但是進入多核時代之後,CPU和編譯器還會繼續做那些串行時代的優化,更重要的是這些優化還會打破你多線程程序的SC模型語義,從而使得多線程程序的實際運行結果與我們所期待的運行結果不一致!

拿X86來說,它的多核內存模型沒有嚴格執行SC,即屬於weak ordering(或者叫relax ordering?)。它唯一允許的亂序優化是可以把對不同地址的load操作提到store之前去(即把store x->load y亂序優化成load y -> store x)。而store x -> store y、load x -> load y,以及load y -> store x不允許交換執行順序。在X86這樣的內存模型下,volatile關鍵字根本就不能保證對不同volatile變量x和y的store x -> load y的操作不會被CPU亂序優化成load y -> store x。

而對多線程讀寫操作的原子性來說,諸如volatile x=1這樣的寫操作的原子性其實是由X86硬件保證的,跟volatile沒有任何關係。事實上,volatile根本不能保證對沒有內存對齊的變量(或者超出機器字長的變量)的讀寫操作的原子性。

爲了有個更直觀的理解,我們來看看CPU的亂序優化是如何讓volatile在多線程程序中顯得如此無力的。下面這個著名的Dekker算法是想用flag1/2和turn來實現兩個線程情況下的臨界區互斥訪問。這個算法關鍵就在於對flag1/2和turn的讀操作(load)是在其寫操作(store)之後的,因此這個多線程算法能保證dekker1和dekker2中對gSharedCounter++的操作是互斥的,即等於是把gSharedCounter++放到臨界區裏去了。但是,多核X86可能會對這個store->load操作做亂序優化,例如dekker1中對flag2的讀操作可能會被提到對flag1和turn的寫操作之前,這樣就會最終導致臨界區的互斥訪問失效,而gSharedCounter++也會因此產生data race從而出現錯誤的計算結果。那麼爲什麼多核CPU會對多線程程序做這樣的亂序優化呢?因爲從單線程的視角來看flag2和flag1、turn是沒有依賴關係的,所以CPU當然可以對他們進行亂序優化以便充分利用好CPU裏面的流水線(想了解更多細節請參考計算機體系結構相關書籍)。這樣的優化雖然從單線程角度來講沒有錯,但是它卻違反了我們設計這個多線程算法時所期望的那個多線程語義。(想要解決這個bug就需要自己手動添加memory barrier,或者乾脆別去實現這樣的算法,而是使用類似pthread_mutex_lock這樣的庫函數,後面我會再講到這點)

當然,對不同的CPU來說他們的內存模型是不同的。比如說,如果這個程序是在單核上以多線程的方式執行那麼它肯定不會出錯,因爲單核CPU的內存模型是符合SC的。而在例如PowerPC,ARM之類的架構上運行結果到底如何就得去翻它們的硬件手冊中內存模型是怎麼定義的了。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/*
 * Dekker's algorithm, implemented on pthreads
 *
 * To use as a test to see if/when we can make
 * memory consistency play games with us in
 * practice.
 *
 * Compile: gcc -O2 -o dekker dekker.c -lpthread
 */
 
#include <assert.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
 
#undef PRINT_PROGRESS
 
static volatile int flag1 = 0;
static volatile int flag2 = 0;
static volatile int turn  = 1;
static volatile int gSharedCounter = 0;
int gLoopCount;
int gOnePercent;
 
void dekker1( ) {
        flag1 = 1;
        turn  = 2;
        while((flag2 ==  1) && (turn == 2)) ;
        // Critical section
        gSharedCounter++;
        // Let the other task run
        flag1 = 0;
}
 
void dekker2(void) {
        flag2 = 1;
        turn = 1;
        while((flag1 ==  1) && (turn == 1)) ;
        // critical section
        gSharedCounter++;       
        // leave critical section
        flag2 = 0;
}
 
//
// Tasks, as a level of indirection
//
void *task1(void *arg) {
        int i,j;
        printf("Starting task1\n");
        // Do the dekker very many times
#ifdef PRINT_PROGRESS
    for(i=0;i<100;i++) {
      printf("[One] at %d%%\n",i);
      for(j=gOnePercent;j>0;j--) {
        dekker1();
      }
    }
#else
    // Simple basic loop
        for(i=gLoopCount;i>0;i--) {
                dekker1();
        }
#endif
 
}
 
void *task2(void *arg) {
        int i,j;
        printf("Starting task2\n");
#ifdef PRINT_PROGRESS
    for(i=0;i<100;i++) {
      printf("[Two] at %d%%\n",i);
      for(j=gOnePercent;j>0;j--) {
        dekker2();
      }
    }
#else
        for(i=gLoopCount;i>0;i--) {
                dekker2();
        }
#endif
}
 
int
main(int argc, char ** argv)
{
        int            loopCount = 0;
        pthread_t      dekker_thread_1;
        pthread_t      dekker_thread_2;
        void           * returnCode;
        int            result;
        int            expected_sum;
 
        /* Check arguments to program*/
        if(argc != 2)
        {
                fprintf(stderr, "USAGE: %s <loopcount>\n", argv[0]);
                exit(1);
        }
 
        /* Parse argument */
        loopCount   = atoi(argv[1]);    /* Don't bother with format checking */
        gLoopCount  = loopCount;
    gOnePercent = loopCount/100;
        expected_sum = 2*loopCount;
         
        /* Start the threads */
        result = pthread_create(&dekker_thread_1, NULL, task1, NULL);
        result = pthread_create(&dekker_thread_2, NULL, task2, NULL);
 
        /* Wait for the threads to end */
        result = pthread_join(dekker_thread_1,&returnCode);
        result = pthread_join(dekker_thread_2,&returnCode);
        printf("Both threads terminated\n");
 
        /* Check result */
        if( gSharedCounter != expected_sum ) {
                printf("[-] Dekker did not work, sum %d rather than %d.\n", gSharedCounter, expected_sum);
                printf("    %d missed updates due to memory consistency races.\n", (expected_sum-gSharedCounter));
                return 1;
        } else {
                printf("[+] Dekker worked.\n");
                return 0;
        }
}

2. Visual Studio對C/C++中volatile關鍵字的擴展

雖然C/C++中的volatile關鍵字沒有對ordering做任何保證,但是微軟從Visual Studio 2005開始就對volatile關鍵字添加了同步語義(保證ordering),即:對volatile變量的讀操作具有acquire語義,對volatile變量的寫操作具有release語義。Acquire和Release語義是來自data-race-free模型的概念。爲了理解這個acquire語義和release語義有什麼作用,我們來看看MSDN中的一個例子

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// volatile.cpp
// compile with: /EHsc /O2
// Output: Critical Data = 1 Success
#include <iostream>
#include <windows.h>
using namespace std;
 
volatile bool Sentinel = true;
int CriticalData = 0;
 
unsigned ThreadFunc1( void* pArguments ) {
   while (Sentinel)
      Sleep(0);   // volatile spin lock
 
   // CriticalData load guaranteed after every load of Sentinel
   cout << "Critical Data = " << CriticalData << endl;
   return 0;
}
 
unsigned  ThreadFunc2( void* pArguments ) {
   Sleep(2000);
   CriticalData++;   // guaranteed to occur before write to Sentinel
   Sentinel = false; // exit critical section
   return 0;
}
 
int main() {
   HANDLE hThread1, hThread2;
   DWORD retCode;
 
   hThread1 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc1,
      NULL, 0, NULL);
   hThread2 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc2,
      NULL, 0, NULL);
 
   if (hThread1 == NULL || hThread2 == NULL)       {
      cout << "CreateThread failed." << endl;
      return 1;
   }
 
   retCode = WaitForSingleObject(hThread1,3000);
 
   CloseHandle(hThread1);
   CloseHandle(hThread2);
 
   if (retCode == WAIT_OBJECT_0 && CriticalData == 1 )
      cout << "Success" << endl;
   else
      cout << "Failure" << endl;
}

例子中的 while (Sentinel) Sleep(0); // volatile spin lock 是對volatile變量的讀操作,它具有acquire語義,acquire語義的隱義是當前線程在對sentinel的這個讀操作之後的所有的對全局變量的訪問都必須在該操作之後執行;同理,例子中的Sentinel = false; // exit critical section 是對volatile變量的寫操作,它具有release語義,release語義的隱義是當前線程在對sentinel這個寫操作之前的所有對全局變量的訪問都必須在該操作之前執行完畢。所以ThreadFunc1()讀CriticalData時必定已經在ThreadFunc2()執行完CriticalData++之後,即CriticalData最後輸出的值必定爲1。建議大家用紙畫一下acquire/release來加深理解。一個比較形象的解釋就是把acquire當成lock,把release當成unlock,它倆組成了一個臨界區,所有臨界區外面的操作都只能往這個裏面移,但是臨界區裏面的操作都不能往外移,簡單吧?

其實這個程序就相當於用volatile變量的acquire和release語義實現了一個臨界區,在臨界區內部的代碼就是 Sleep(2000); CriticalData++; 或者更貼切點也可以看成是一對pthread_cond_wait和pthread_cond_signal。

這個volatile的acquire和release語義是VS自己的擴展,C/C++標準裏是沒有的,所以同樣的代碼用gcc編譯執行結果就可能是錯的,因爲編譯器/CPU可能做違反正確性的亂序優化。Acquire和release語義本質上就是爲了保證程序執行時memory order的正確性。但是,雖然這個VS擴展使得volatile變量能保證ordering,它還是不能保證對volatile變量讀寫的原子性。事實上,如果我們的程序是跑在X86上面的話,內存對齊了的變量的讀寫的原子性是由硬件保證的,跟volatile沒有任何關係。而像volatile g_nCnt++這樣的語句本身就不是原子操作,想要保證這個操作是原子的,就必須使用帶LOCK語義的++操作,具體請看我這篇文章

另外,VS生成的volatile變量的彙編代碼是否真的調用了memory barrier也得看具體的硬件平臺,例如x86上就不需要使用memory barrier也能保證acquire和release語義,因爲X86硬件本身就有比較強的memory模型了,但是Itanium上面VS就會生成帶memory barrier的彙編代碼。具體可以參考這篇

但是,雖然VS對volatile關鍵字加入了acquire/release語義,有一種情況還是會出錯,即我們之前看到的dekker算法的例子。這個其實蠻好理解的,因爲讀操作的acquire語義不允許在其之後的操作往前移,但是允許在其之前的操作往後移;同理,寫操作的release語義允許在其之後的操作往前移,但是不允許在其之前的操作往後移;這樣的話對一個volatile變量的讀操作(acquire)當然可以放到對另一個volatile變量的寫操作(release)之前了!Bug就是這樣產生的!下面這個程序大家拿Visual Studio跑一下就會發現bug了(我試了VS2008和VS2010,都有這個bug)。多線程編程複雜吧?希望大家還沒被弄暈,要是暈了的話也很正常,仔仔細細重新再看一遍吧:)

想解決這個Bug也很簡單,直接在dekker1和dekker2中對flag1/flag2/turn賦值操作之後都分別加入full memory barrier就可以了,即保證load一定是在store之後執行即可。具體的我就不詳述了。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <iostream>
#include <windows.h>
using namespace std;
 
static volatile int flag1 = 0;
static volatile int flag2 = 0;
static volatile int turn = 1; // must have "turn", otherwise the two threads might introduce deadlock at line 13&23 of "while..."
static int gCount = 0;
 
void dekker1() {
    flag1 = 1;
    turn = 2;
    while ((flag2 == 1) && (turn == 2));
    // critical section
    gCount++;
    flag1 = 0;  // leave critical section
}
 
void dekker2() {
    flag2 = 1;
    turn = 1;
    while ((flag1 == 1) && (turn == 1));
    // critical setion
    gCount++;
    flag2 = 0;  // leave critical section
}
 
unsigned ThreadFunc1( void* pArguments ) {
    int i;
    //cout << "Starting Thread 1" << endl;
    for (i=0;i<1000000;i++) {
        dekker1();
    }
    return 0;
}
 
unsigned  ThreadFunc2( void* pArguments ) {
    int i;
    //cout << "Starting Thread 2" << endl;
    for (i=0;i<1000000;i++) {
        dekker2();
    }
    return 0;
}
 
int main() {
    HANDLE hThread1, hThread2;
    //DWORD retCode;
 
    hThread1 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc1,
        NULL, 0, NULL);
    hThread2 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc2,
        NULL, 0, NULL);
 
    if (hThread1 == NULL || hThread2 == NULL) {
        cout << "CreateThread failed." << endl;
        return 1;
    }
 
    WaitForSingleObject(hThread1,INFINITE);
    WaitForSingleObject(hThread2,INFINITE);
    cout << gCount << endl;
 
    if (gCount == 2000000)
        cout << "Success" << endl;
    else
        cout << "Fail" << endl;
}

3. Java/.NET中的volatile關鍵字

3.1 多線程語義

Java和.NET分別有JVM和CLR這樣的虛擬機,保證多線程的語義就容易多了。說簡單點,Java和.NET中的volatile關鍵字也是限制虛擬機做優化,都具有acquire和release語義,而且由虛擬機直接保證了對volatile變量讀寫操作的原子性。 (volatile只保證可見性,不保證原子性。java中,對volatile修飾的long和double的讀寫就不是原子的 (http://java.sun.com/docs/books/jvms/second_edition/html /Threads.doc.html#22244),除此之外的基本類型和引用類型都是原子的。– 多謝liuchangit指正) 這裏需要注意的一點是,Java和.NET裏面的volatile沒有對應於我們最開始提到的C/C++中對“異常操作”用volatile修飾的傳統用法。原因很簡單,Java和.NET的虛擬機對安全性的要求比C/C++高多了,它們纔不允許不安全的“異常”訪問存在呢。

而且像JVM/.NET這樣的程序可移植性都非常好。雖然現在C++1x正在把多線程模型添加到標準中去,但是因爲C++本身的性質導致它的硬件平臺依賴性很高,可移植性不是特別好,所以在移植C/C++多線程程序時理解硬件平臺的內存模型是非常重要的一件事情,它直接決定你這個程序是否會正確執行。

至於Java和.NET中是否也存在類似VS 2005那樣的bug我沒時間去測試,道理其實是相同的,真有需要的同學自己應該能測出來。好像這篇InfoQ的文章中顯示Java運行這個dekker算法沒有問題,因爲JVM給它添加了mfence。另一個臭名昭著的例子就應該是Double-Checked Locking了。

3.2 volatile int與AtomicInteger區別

Java和.NET中這兩者還是有些區別的,主要就是後者提供了類似incrementAndGet()這樣的方法可以直接調用(保證了原子性),而如果是volatile x進行++操作則不是原子的。increaseAndGet()的實現調用了類似CAS這樣的原子指令,所以能保證原子性,同時又不會像使用synchronized關鍵字一樣損失很多性能,用來做全局計數器非常合適。

4. Memory Model(內存模型)

說了這麼多,還是順帶介紹一下Memory Model吧。就像前面說的,CPU硬件有它自己的內存模型,不同的編程語言也有它自己的內存模型。如果用一句話來介紹什麼是內存模型,我會說它就是程序員,編程語言和硬件之間的一個契約,它保證了共享的內存地址裏的值在需要的時候是可見的。下次我會專門詳細寫一篇關於它的內容。它最大的作用是取得可編程性與性能優化之間的一個平衡。

5. volatile使用建議

總的來說,volatile關鍵字有兩種用途:一個是ISO C/C++中用來處理“異常”內存行爲(此用途只保證不讓編譯器做任何優化,對多核CPU是否會進行亂序優化沒有任何約束力),另一種是在Java/.NET(包括Visual Studio添加的擴展)中用來實現高性能並行算法(此種用途通過使用memory barrier保證了CPU/編譯器的ordering,以及通過JVM或者CLR保證了對該volatile變量讀寫操作的原子性)。

一句話,volatile對多線程編程是非常危險的,使用的時候千萬要小心你的代碼在多核上到底是不是按你所想的方式執行的,特別是對現在暫時還沒有引入內存模型的C/C++程序更是如此。安全起見,大家還是用Pthreads,Java.util.concurrent,TBB等並行庫提供的lock/spinlock,conditional variable, barrier, Atomic Variable之類的同步方法來幹活的好,因爲它們的內部實現都調用了相應的memory barrier來保證memory ordering,你只要保證你的多線程程序沒有data race,那麼它們就能幫你保證你的程序是正確的(是的,Pthreads庫也是有它自己的內存模型的,只不過它的內存模型還些缺點,所以把多線程內存模型直接集成到C/C++中是更好的辦法,也是將來的趨勢,但是C++1x中將不會像Java/.NET一樣給volatile關鍵字添加acquire和release語義,而是轉而提供另一種具有同步語義的atomic variables,此爲後話)。如果你想實現更高性能的lock free算法,或是使用volatile來進行同步,那麼你就需要先把CPU和編程語言的memory model搞清楚,然後再時刻注意Atomicity和Ordering是否被保證了。(注意,用沒有acquire/release語義的volatile變量來進行同步是錯誤的,但是你仍然可以在C/C++中用volatile來修飾一個不是用來做同步(例如一個event flag)而只是被不同線程讀寫的共享變量,只不過它的新值什麼時候能被另一個線程讀到是沒有保證的,需要你自己做相應的處理)

Herb Sutter 在他的那篇volatile vs. volatile中對這兩種用法做了很仔細的區分,我把其中兩張表格鏈接貼過來供大家參考:

volatile的兩種用途
volatile兩種用途的異同

最後附上《Java Concurrency in Practice》3.1.4節中對Java語言的volatile關鍵字的使用建議(不要被英語嚇到,這些內容確實對你有用,而且還能順便幫練練英語,哈哈):

So from a memory visibility perspective, writing a volatile variable is like exiting a synchronized block and reading a volatile variable is like entering a synchronized block. However, we do not recommend relying too heavily on volatile variables for visibility; code that relies on volatile variables for visibility of arbitrary state is more fragile and harder to understand than code that uses locking.

Use volatile variables only when they simplify implementing and verifying your synchronization policy; avoid using volatile variables when veryfing correctness would require subtle reasoning about visibility. Good uses of volatile variables include ensuring the visibility of their own state, that of the object they refer to, or indicating that an important lifecycle event (such as initialization or shutdown) has occurred.

Locking can guarantee both visibility and atomicity; volatile variables can only guarantee visibility.

You can use volatile variables only when all the following criteria are met:
(1) Writes to the variable do not depend on its current value, or you can ensure that only a single thread ever updates the value;
(2) The variable does not participate in invariants with other state variables; and
(3) Locking is not required for any other reason while the variable is being accessed.

參考資料

1. 《Java Concurrency in Practice》3.1.4節
2. volatile vs. volatile(Herb Sutter對volatile的闡述,必看)
3. The “Double-Checked Locking is Broken” Declaration
4. Threading in C#
5. Volatile: Almost Useless for Multi-Threaded Programming
6. Memory Ordering in Modern Microprocessors
7. Memory Ordering @ Wikipedia
8. 內存屏障什麼的
9. The memory model of x86
10. VC 下 volatile 變量能否建立 Memory Barrier 或併發鎖
11. Sayonara volatile(Concurrent Programming on Windows作者的文章 跟我觀點幾乎一致)
12. Java 理論與實踐: 正確使用 Volatile 變量
13. Java中的Volatile關鍵字


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