C++11線程、互斥量以及條件變量示例詳解
前言
C++11之前,C++語言沒有對并發(fā)編程提供語言級別的支持,這使得我們在編程寫可移植的并發(fā)程序時,存在諸多不便。現(xiàn)在C++11增加了線程以及線程相關(guān)的類,很方便地支持了并發(fā)編程,使得編寫多線程程序的可移植性得到了很大的提高
1、創(chuàng)建第一個線程
//創(chuàng)建線程需要引入頭文件thread
#include<thread>
#include<iostream>
void ThreadMain()
{
cout << "begin thread main" << endl;
}
int main()
{
//創(chuàng)建新線程t并啟動
thread t(ThreadMain);
//主線程(main線程)等待t執(zhí)行完畢
if (t.joinable()) //必不可少
{
//等待子線程退出
t.join(); //必不可少
}
return 0;
}我們都知道,對于一個單線程來說,也就main線程或者叫做主線程,所有的工作都是由main線程去完成的。而在多線程環(huán)境下,子線程可以分擔(dān)main線程的工作壓力,在多個CPU下,實現(xiàn)真正的并行操作。
在上述代碼中,可以看到main線程創(chuàng)建并啟動了一個新線程t,由新線程t去執(zhí)行ThreadMain()函數(shù),jion函數(shù)將會把main線程阻塞住,知道新線程t執(zhí)行結(jié)束,如果新線程t有返回值,返回值將會被忽略
我們可以通過函數(shù)this_thread::get_id()來判斷是t線程還是main線程執(zhí)行任務(wù)
void ThreadMain()
{
cout << "線程" << this_thread::get_id()<< ":begin thread main" << endl;
}
int main()
{
//創(chuàng)建新線程t并啟動
thread t(ThreadMain);
//主線程(main線程)等待t執(zhí)行完畢
if (t.joinable()) //必不可少
{
//等待子線程退出
cout << "線程" << this_thread::get_id() << ":正在等待" << endl;
t.join(); //必不可少
}
return 0;
}執(zhí)行結(jié)果:

2、線程對象的生命周期、等待和分離
void func()
{
cout << "do func" << endl;
}
int main()
{
thread t(func);
return 0;
}上訴代碼運行可能會拋出異常,因為線程對象t可能先于線程函數(shù)func結(jié)束,應(yīng)該保證線程對象的生命周期在線程函數(shù)func執(zhí)行完時仍然存在
為了防止線程對象的生命周期早于線程函數(shù)fun結(jié)束,可以使用線程等待join
void func()
{
while (true)
{
cout << "do work" << endl;
this_thread::sleep_for(std::chrono::seconds(1));//當(dāng)前線程睡眠1秒
}
}
int main()
{
thread t(func);
if (t.joinable())
{
t.join();//main線程阻塞
}
return 0;
}雖然使用join能有效防止程序的崩潰,但是在某些情況下,我們并不希望main線程通過join被阻塞在原地,此時可以采用detach進(jìn)行線程分離。但是需要注意:detach之后main線程就無法再和子線程發(fā)生聯(lián)系了,比如detach之后就不能再通過join來等待子線程,子線程任何執(zhí)行完我們也無法控制了
void func()
{
int count = 0;
while (count < 3)
{
cout << "do work" << endl;
count++;
this_thread::sleep_for(std::chrono::seconds(1));//當(dāng)前線程睡眠1秒
}
}
int main()
{
thread t(func);
t.detach();
this_thread::sleep_for(std::chrono::seconds(1));//當(dāng)前線程睡眠1秒
cout << "線程t分離成功" << endl;
return 0;
}執(zhí)行結(jié)果:

3、線程創(chuàng)建的多種方式
線程的創(chuàng)建和執(zhí)行,無非是給線程指定一個入口函數(shù)嘛,例如main線程的入口函數(shù)就main()函數(shù),前面編寫的子線程的入口函數(shù)是一個全局函數(shù)。除了這些之外線程的入口函數(shù)還可以是函數(shù)指針、仿函數(shù)、類的成員函數(shù)、lambda表達(dá)式等,它們都有一個共同的特點:都是可調(diào)用對象。線程的入口函數(shù)指定,可以為任意一個可調(diào)用對象。
普通函數(shù)作為線程的入口函數(shù)
void func()
{
cout << "hello world" << endl;
}
int main()
{
thread t(func);
if (t.joinable())
{
t.join();
}
return 0;
}類的成員函數(shù)作為線程的入口函數(shù)
class ThreadMain
{
public:
ThreadMain() {}
virtual ~ThreadMain(){}
void SayHello(std::string name)
{
cout << "hello " << name << endl;
}
};
int main()
{
ThreadMain obj;
thread t(&ThreadMain::SayHello, obj, "fl");
thread t1(&ThreadMain::SayHello, &obj, "fl");
t.join();
t1.join();
return 0;
}t和t1在傳遞參數(shù)時存在不同:
- t是用對象obj調(diào)用線程函數(shù)的語句,即線程函數(shù)將在obj對象的上下文中運行。這里obj是通過值傳遞給線程構(gòu)造函數(shù)的,因此在線程中使用的是對象obj的一個副本。這種方式適用于類定義在局部作用域中時,需要將其傳遞給線程的情況。
- t1是使用對象的指針&obj調(diào)用線程函數(shù)的語句,即線程函數(shù)將在對象obj的指針?biāo)赶虻纳舷挛闹羞\行。這里使用的是對象obj的指針,因此在線程中使用的是原始的obj對象。這種方式適用于類定義在全局或靜態(tài)作用域中時,需要將其傳遞給線程的情況。
如果需要在類的成員函數(shù)中,創(chuàng)建線程,以類中的另一個成員函數(shù)作為入口函數(shù),再執(zhí)行
class ThreadMain
{
public:
ThreadMain() {}
virtual ~ThreadMain(){}
void SayHello(std::string name)
{
cout << "hello " << name << endl;
}
void asycSayHello(std::string name)
{
thread t(&ThreadMain::SayHello, this, name);
if (t.joinable())
{
t.join();
}
}
};
int main()
{
ThreadMain obj;
obj.asycSayHello("fl");
return 0;
}在asycSayHello的成員函數(shù)中,如果沒有傳遞this指針,會導(dǎo)致編譯不通過

原因就是參數(shù)列表不匹配,因此需要我們顯示的傳遞this指針,表示以本對象的成員函數(shù)作為參數(shù)的入口函數(shù)
lambda表達(dá)式作為線程的入口函數(shù)
int main()
{
thread t([](int i){
cout << "test lambda i = " << i << endl;
}, 123);
if (t.joinable())
{
t.join();
}
return 0;
}執(zhí)行結(jié)果:

在類的成員函數(shù)中,以lambda表達(dá)式作為線程的入口函數(shù)
class TestLmadba
{
public:
void Start()
{
thread t([this](){
cout << "name is " << this->name << endl;
});
if (t.joinable())
{
t.join();
}
}
private:
std::string name = "fl";
};
int main()
{
TestLmadba test;
test.Start();
return 0;
}在類的成員函數(shù)中,以lambda表達(dá)式作為線程的入口函數(shù),如果需要訪問兌現(xiàn)的成員變量,也需要傳遞this指針
仿函數(shù)作為線程的入口函數(shù)
class Mybusiness
{
public:
Mybusiness(){}
virtual ~Mybusiness(){}
void operator()(void)
{
cout << "Mybusiness thread id is " << this_thread::get_id() << endl;
}
void operator()(string name)
{
cout << "name is " << name << endl;
}
};
int main()
{
Mybusiness mb;
thread t(mb);
if (t.joinable())
{
t.join();
}
thread t1(mb, "fl");
if (t1.joinable())
{
t1.join();
}
return 0;
}執(zhí)行結(jié)果:

線程t以無參的仿函數(shù)作為函數(shù)入口,而線程t1以有參的仿函數(shù)作為函數(shù)入口
函數(shù)指針作為線程的入口函數(shù)
void func()
{
cout << "thread id is " << this_thread::get_id() << endl;
}
void add(int a, int b)
{
cout << a << "+" << b << "=" << a + b << endl;
}
int main()
{
//采用C++11擴展的using來定義函數(shù)指針類型
using FuncPtr = void(*)();
using FuncPtr1 = void(*)(int, int);
//使用FuncPtr來定義函數(shù)指針變量
FuncPtr ptr = &func;
thread t(ptr);
if (t.joinable())
{
t.join();
}
FuncPtr1 ptr1 = add;
thread t1(ptr1, 1, 10);
if (t1.joinable())
{
t1.join();
}
return 0;
}
執(zhí)行結(jié)果:

function和bind作為線程的入口函數(shù)
void func(string name)
{
cout << this_thread::get_id() << ":name is " << name << endl;
}
int main()
{
function<void(string)> f(func);
thread t(f, "fl");
if (t.joinable())
{
t.join();
}
thread t1(bind(func, "fl"));
if (t1.joinable())
{
t1.join();
}
return 0;
}執(zhí)行結(jié)果:

線程不能拷貝和復(fù)制,但可以移動
//賦值操作
void func(string name)
{
cout << this_thread::get_id() << ":name is " << name << endl;
}
int main()
{
thread t1(func, "fl");
thread t2 = t1;
thread t3(t1);
return 0;
}編譯報錯:

在線程內(nèi)部,已經(jīng)將線程的賦值和拷貝操作delete掉了,所以無法調(diào)用到

//移動操作
void func(string name)
{
cout << this_thread::get_id() << ":name is " << name << endl;
}
int main()
{
thread t1(func, "fl");
thread t2(std::move(t1));
if (t1.joinable())
{
t1.join();
}
if (t2.joinable())
{
t2.join();
}
return 0;
}執(zhí)行結(jié)果:

線程被移動之后,線程對象t1將不代表任何線程了,可以通過調(diào)試觀察到

4、互斥量
當(dāng)多個線程同時訪問同一個共享資源時,如果不加以保護(hù)或者不做任何同步操作,可能出現(xiàn)數(shù)據(jù)競爭或不一致的狀態(tài),導(dǎo)致程序運行出現(xiàn)問題。
為了保證所有的線程都能夠正確地、可預(yù)測地、不產(chǎn)生沖突地訪問共享資源,C++11提供了互斥量。
互斥量是一種同步原語,是一種線程同步手段,用來保護(hù)多線程同時訪問的共享數(shù)據(jù)?;コ饬烤褪俏覀兤匠Uf的鎖
C++11中提供了4種語義的互斥量
- std::mutex:獨占的互斥量,不能遞歸
- std::timed_mutex:帶超時的獨占互斥量,不能遞歸使用
- std::recursive_mutex:遞歸互斥量,不能帶超時功能
- std::recursive_timed_mutex:帶超時的遞歸互斥量
4.1 獨占的互斥量std::mutex
這些互斥量的接口基本類似,一般用法是通過lock()方法來阻塞線程,知道獲得互斥量的所有權(quán)為止。在線程獲得互斥量并完成任務(wù)之后,就必須使用unlock()來解除對互斥量的占用,lock()和unlock()必須成對出現(xiàn)。try_lock()嘗試鎖定互斥量,如果成功則返回true,失敗則返回false,它是非阻塞的。
int num = 0;
std::mutex mtx;
void func()
{
for (int i = 0; i < 100; ++i)
{
mtx.lock();
num++;
mtx.unlock();
}
}
int main()
{
thread t1(func);
thread t2(func);
if (t1.joinable())
{
t1.join();
}
if (t2.joinable())
{
t2.join();
}
cout << num << endl;
return 0;
}執(zhí)行結(jié)果:

使用lock_guard可以簡化lock/unlock的寫法,同時也更安全,因為lock_guard在構(gòu)造時會自動鎖定互斥量,而在退出作用域后進(jìn)行析構(gòu)時自動解鎖,從而保證了互斥量的正確操作,避免忘記unlock操作,因此,盡量用lock_guard。lock_guard用到了RALL技術(shù),這種技術(shù)在類的構(gòu)造函數(shù)中分配資源,在析構(gòu)函數(shù)中釋放資源,保證資源在出了作用域之后就釋放。上面的例子使用lock_guard后會更簡介,代碼如下:
void func()
{
for (int i = 0; i < 100; ++i)
{
lock_guard<mutex> lock(mtx);
num++;
}
}一般來說,當(dāng)某個線程執(zhí)行操作完畢后,釋放鎖,然后需要等待幾十毫秒,讓其他線程也去獲取鎖資源,也去執(zhí)行操作。如果不進(jìn)行等待的話,可能當(dāng)前線程釋放鎖后,又立馬獲取了鎖資源,會導(dǎo)致其他線程出現(xiàn)饑餓。
4.2 遞歸獨占互斥量recursive_mutex
遞歸鎖允許同一線程多次獲得該互斥鎖,可以用來解決同一線程需要多次獲取互斥量時死鎖的問題。在以下代碼中,一個線程多次獲取同一個互斥量時會發(fā)生死鎖:
class Complex
{
public:
std::mutex mtx;
void SayHello()
{
lock_guard<mutex> lock(mtx);
cout << "Say Hello" << endl;
SayHi();
}
void SayHi()
{
lock_guard<mutex> lock(mtx);
cout << "say Hi" << endl;
}
};
int main()
{
Complex complex;
complex.SayHello();
return 0;
}執(zhí)行結(jié)果:

這個例子運行起來就發(fā)生了死鎖,因為在調(diào)用SayHello時獲取了互斥量,之后再調(diào)用SayHI又要獲取相同的互斥量,但是這個互斥量已經(jīng)被當(dāng)前線程獲取 ,無法釋放,這時就會產(chǎn)生死鎖,導(dǎo)致程序崩潰。
要解決這里的死鎖問題,最簡單的方法就是采用遞歸鎖:std::recursive_mutex,它允許同一個線程多次獲取互斥量
class Complex
{
public:
std::recursive_mutex mtx;//同一線程可以多次獲取同一互斥量,不會發(fā)生死鎖
void SayHello()
{
lock_guard<recursive_mutex> lock(mtx);
cout << "Say Hello" << endl;
SayHi();
}
void SayHi()
{
lock_guard<recursive_mutex> lock(mtx);
cout << "say Hi" << endl;
}
};執(zhí)行結(jié)果:

需要注意的是盡量不要使用遞歸鎖比較好,主要原因如下:
1、需要用到遞歸鎖定的多線程互斥量處理往往本身就是可以簡化的,允許遞歸互斥量很容易放縱復(fù)雜邏輯的產(chǎn)生,而非導(dǎo)致一些多線程同步引起的晦澀問題
2、遞歸鎖的效率比非遞歸鎖的效率低
3、遞歸鎖雖然允許同一個線程多次獲得同一個互斥量,但可重復(fù)的最大次數(shù)并為具體說明,一旦超過一定次數(shù),再對lock進(jìn)行調(diào)用就會拋出std::system錯誤
4.3 帶超時的互斥量std::timed_mutex和std::recursive_timed_mutex
std::timed_mutex是超時的獨占鎖,srd::recursive_timed_mutex是超時的遞歸鎖,主要用在獲取鎖時增加超時鎖等待功能,因為有時不知道獲取鎖需要多久,為了不至于一直在等待獲互斥量,就設(shè)置一個等待超時時間,在超時時間后還可做其他事。
std::timed_mutex比std::mutex多了兩個超時獲取鎖的接口:try_lock_for和try_lock_until,這兩個接口是用來設(shè)置獲取互斥量的超時時間,使用時可以用一個while循環(huán)去不斷地獲取互斥量。
std::timed_mutex mtx;
void work()
{
chrono::milliseconds timeout(100);
while (true)
{
if (mtx.try_lock_for(timeout))
{
cout << this_thread::get_id() << ": do work with the mutex" << endl;
this_thread::sleep_for(chrono::milliseconds(250));
mtx.unlock();
}
else
{
cout << this_thread::get_id() << ": do work without the mutex" << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
}
int main()
{
thread t1(work);
thread t2(work);
if (t1.joinable())
{
t1.join();
}
if (t2.joinable())
{
t2.join();
}
return 0;
}
執(zhí)行結(jié)果:

在上面的例子中,通過一個while循環(huán)不斷地去獲取超時鎖,如果超時還沒有獲取到鎖時就休眠100毫秒,再繼續(xù)獲取鎖。
相比std::timed_mutex,std::recursive_timed_mutex多了遞歸鎖的功能,允許同一個線程多次獲得互斥量。std::recursive_timed_mutex和std::recursive_mutex的用法類似,可以看作在std::recursive_mutex的基礎(chǔ)上增加了超時功能
4.4 std::lock_guard和std::unique_lock
lock_guard和unique_lock的功能完全相同,主要差別在于unique_lock更加靈活,可以自由的釋放mutex,而lock_guard需要等到生命周期結(jié)束后才能釋放。
它們的構(gòu)造函數(shù)中都有第二個參數(shù)
unique_lock:

lock_guard:


可以從源碼中看到,unique_lock的構(gòu)造函數(shù)中,第二個參數(shù)的種類有三種,分別是adopt_lock,defer_lock和try_to_lock。lock_guard的構(gòu)造函數(shù)中,第二個參數(shù)的種類只有一種,adopt_lock
這些參數(shù)的含義分別是:
adopt_lock:互斥量已經(jīng)被lock,構(gòu)造函數(shù)中無需再lock(lock_ guard與unique_lock通用)
defer_lock:互斥量稍后我會自行l(wèi)ock,不需要在構(gòu)造函數(shù)中l(wèi)ock,只初始化一個沒有加鎖的mutex
try_to_lock:主要作用是在不阻塞線程的情況下嘗試獲取鎖,如果互斥量當(dāng)前未被鎖定,則返回std::unique_lock對象,該對象擁有互斥量并且已經(jīng)被鎖定。如果互斥量當(dāng)前已經(jīng)被另一個線程鎖定,則返回一個空的std::unique_lock對象
mutex mtx;
void func()
{
//mtx.lock();//需要加鎖,否則在lock的生命周期結(jié)束后,會自動解鎖,則會導(dǎo)致程序崩潰
unique_lock<mutex> lock(mtx, std::adopt_lock);
cout << this_thread::get_id() << " do work" << endl;
}
int main()
{
thread t(func);
if (t.joinable())
{
t.join();
}
return 0;
}執(zhí)行結(jié)果:

adopt_lock就表示構(gòu)造unique_lock<mutex>時,認(rèn)為mutex已經(jīng)加過鎖了,就不會再加鎖了,它就把加鎖的權(quán)限和時機交給了我們,由我們自己控制
mutex mtx;
void func()
{
while (true)
{
unique_lock<mutex> lock(mtx, std::defer_lock);
cout << "func thread id is " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
}
}
int main()
{
thread t1(func);
thread t2(func);
if (t1.joinable())
{
t1.join();
}
if (t2.joinable())
{
t2.join();
}
return 0;
}執(zhí)行結(jié)果:

本來我們的意愿是t1和t2每個時刻只能有一個線程打印"func thread id is…",但是實際上卻發(fā)生了競爭的關(guān)系,原因就在于defer_lock在構(gòu)造unique_lock<mutex>時,認(rèn)為mutex在后面會加鎖,也就沒有加鎖,所以打印結(jié)果才發(fā)生混亂,因此需要我們手動改進(jìn)一下
void func()
{
while (true)
{
unique_lock<mutex> lock(mtx, std::defer_lock);
lock.lock();
cout << "func thread id is " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
//lock.unlock(); //可以加,也可以不加
//因為內(nèi)部有一個標(biāo)準(zhǔn)為,如果我們自己手動解鎖了,由于標(biāo)志位的改變,在調(diào)用lock的析構(gòu)函數(shù)時,就不會進(jìn)行解鎖操作
}
}執(zhí)行結(jié)果:

5、call_once/once_flag的使用
為了保證在多線程環(huán)境中某個函數(shù)僅被調(diào)用一次,比如,需要在初始化某個對象,而這個對象只能初始化一次時,就可以用std::call_once來保證函數(shù)在多線程環(huán)境中只能被調(diào)用一次。使用std::call_once時,需要一個once_flag作為call_once的入?yún)?,用法比較簡單
call_once函數(shù)模板

在使用call_once時,第一個參數(shù)是類型為once_flag的標(biāo)志位,第二個參數(shù)是一個可調(diào)用對象,第三個為可變參數(shù),表示的可調(diào)用對象中的參數(shù)
std::once_flag flag;
void do_once()
{
std::call_once(flag, [](){
cout << "call once" << endl;
});
}
int main()
{
const int ThreadSize = 5;
vector<thread> threads;
for (int i = 0; i < ThreadSize; ++i)
{
threads.emplace_back(do_once);
}
for (auto& t : threads)
{
if (t.joinable())
{
t.join();
}
}
return 0;
}執(zhí)行結(jié)果:

6、條件變量
條件變量是C++11提供的另外一種用于等待的同步機制,它能夠阻塞一個或者多個賢臣,直到收到另一個線程發(fā)出的通知或者超時,才會喚醒當(dāng)前阻塞的線程。條件變量需要和互斥量配合起來使用。C++11提供了兩種條件變量:
- condition_valuable,配合std::unique<mutex>進(jìn)行wait操作
- condition_valuable_any,和任意帶有l(wèi)ock,unlock語義的mutex搭配使用,比較靈活,但效率比condition_valuable差一些
可以看到condition_valuable_any比condition_valuable更靈活,因為它通用,對所有的鎖都適用,而condition_valuable的性能更好。我們應(yīng)該根據(jù)具體的應(yīng)用場景來選擇合適的條件變量
條件變量的使用條件如下:
- 擁有條件變量的線程獲取互斥量
- 循環(huán)檢測某個條件,如果條件不滿足,則阻塞直到條件滿足;如果條件滿足,則向下執(zhí)行
- 某個線程滿足條件執(zhí)行完畢之后調(diào)用notify_onc或者notify_all喚醒一個或者所有等待的線程
一個簡單的生產(chǎn)者消費者模型
mutex mtx;
condition_variable_any notEmpty;//沒滿的條件變量
condition_variable_any notFull;//不為空的條件變量
list<string> list_; //緩沖區(qū)
const int custom_threads_size = 3;//消費者的數(shù)量
const int produce_threads_size = 4;//生產(chǎn)者的數(shù)量
const int max_size = 10;
void produce(int i)
{
while (true)
{
lock_guard<mutex> lock(mtx);
notEmpty.wait(mtx, []{
return list_.size() != max_size;
});
stringstream ss;
ss << "生產(chǎn)者" << i << "生產(chǎn)的東西";
list_.push_back(ss.str());
notFull.notify_one();
}
}
void custome(int i)
{
while (true)
{
lock_guard<mutex> lock(mtx);
notFull.wait(mtx, []{
return !list_.empty();
});
cout << "消費者" << i << "消費了 " << list_.front() << endl;
list_.pop_front();
notEmpty.notify_one();
}
}
int main()
{
vector<std::thread> producer;
vector<std::thread> customer;
for (int i = 0; i < produce_threads_size; ++i)
{
producer.emplace_back(produce, i);
}
for (int i = 0; i < custom_threads_size; ++i)
{
customer.emplace_back(custome, i);
}
for (int i = 0; i < produce_threads_size; ++i)
{
producer[i].join();
}
for (int i = 0; i < custom_threads_size; ++i)
{
customer[i].join();
}
return 0;
}
在上述案例中,list<string> list_是一個臨界資源,無論是生產(chǎn)者生產(chǎn)數(shù)據(jù),還是消費者消費數(shù)據(jù),都要往list_中插入數(shù)據(jù)或者刪除數(shù)據(jù),為了防止出現(xiàn)數(shù)據(jù)競爭或不一致的狀態(tài),導(dǎo)致程序運行出現(xiàn)問題,因為每次操作list_時都需要進(jìn)行加鎖操作。
當(dāng)list_沒有滿的情況下,生產(chǎn)者可以生產(chǎn)數(shù)據(jù),如果滿了,則會阻塞在條件變量notFull下,需要消費者通過notify_one()隨機喚醒一個生產(chǎn)者。
當(dāng)list_不為空的情況下。消費者可以消費數(shù)據(jù),如果空了,則會阻塞在條件變量notEmpty下,需要生產(chǎn)者通過notify_one()隨機喚醒一個消費者。
到此這篇關(guān)于C++11線程、互斥量以及條件變量的文章就介紹到這了,更多相關(guān)C++11線程條件變量內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++之構(gòu)造函數(shù)默認(rèn)值設(shè)置方式
這篇文章主要介紹了C++之構(gòu)造函數(shù)默認(rèn)值設(shè)置方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-08-08

