C++服務器開發精髓
第一章 必知必會
1.1 RAII
先分配資源,再操作,任意一步出錯需要回收資源。
避免冗余代碼方式:
- goto語句(不推薦)
- do...while(0)循環(現有代碼中大量存在)
- RAII(推薦)
在構造函數中申請資源,在析構中釋放。對於多線程中鎖的獲取與釋放,可充分利用器特性,避免每次返回都需要釋放鎖,避免冗余代碼。c++11中可用std::lock_guard。
熟練使用RAII能讓代碼更簡潔,也能有效的避免內存泄漏和死鎖問題。
1.2 pimpl
Point to Implementation,保持對外接口不變,又不暴露成員變量和私有函數。
優點:
- 核心數據被隱藏,對使用者透明,提高安全性。
- 接口與實現分離。整個類的對外聲明不變,但是其內部聲明可以改變。增刪改Impl的成員和方法,保持其引用類的頭文件不變。
- 降低編譯依賴。頭文件被轉移到cpp文件中,精簡頭文件,對於其他需要該頭文件的文件來說,可以加快編譯速度。
c++11引入了智能指針對象,可以使用std::unique_ptr對象來管理上述用於隱藏具體實現的指針。
#include<memory>
class A
{
A();
~A();
private:
struct Impl;
std::unique_ptr<Impl> m_pImpl;
};
//c++11
A::A()
{
m_pImpl.reset(new Impl());
}
//c++14
A::A():m_pImpl(std::make_unique<Impl>())
{
}
/*用了智能指針來管理內存后就不需要再在析構函數中顯示的釋放內存了*/
1.3 新特性
c++11是c++發展史上的重大更新,改進了c++98/03的眾多問題並引入了很多新的語言特性。
- 廢棄了不實用的語法和庫,std::auto_ptr 新增了很多關鍵字和其他語法final,=default,=delete.
- 從語言本身增加對操作系統功能的支持,不需要再寫大量與平台相關的代碼。(線程庫,時間庫)
1.4 統一的類成員初始化語法與std::initialzer_list<T>
假設類A有一個類型為int數組的成員變量,在c++98/03中在構造函數初始化中我們要這樣寫。對於字符數組可能就要使用strcpy或者memcpy了。
class A
{
public:
A()
{
//當數組是局部變量時可以批量初始化 a = {2,4};但是在構造函數中卻不行。
a[0] = 2;
a[1] = 4;
}
public:
int a[2];
};
//新語法中也能進行批量初始化了
A::A():a{2,4}
{
}
c++11新引入了對象std::initialize_list<T>,用來實現可接受多個自定義類型的{}語法,接受自定義類型T,使用需要包含頭文件#include<initialze_list>
class A
{
A(std::initialize_list<int> integers)
{
m_vectMeng.insert(m_vectMeng.begin(),integers.begin(),integers.end());
}
public:
vector<int> m_vectMeng;
};
void main()
{
A a{1,2,3,4};
}
initialize_list<T> 還提供了三個成員函數
size_type size() const;
const T* begin() const;
const T* end() const;
1.5 c++17注解標簽(attributes)
c++98/03中,不同編譯器使用不同的注解為代碼添加額外的說明。c++11起同意制定了常用注解標簽。
[[attribute]] types/functions/enums/etc
c++98/03中,枚舉時不限定作用域,即作用域中不能再出現與枚舉中相同的變量名。
enum Color
{
black,
white
};
//c++98/03中報錯
bool black = true;
//c++11中的限定作用域的枚舉
enum class Color
{
black,
white
};
bool white = true;//編譯成功
- [[noreturn]] void terminate(); 告訴編譯器函數沒有返回值,一般在調用系統函數時使用
- [[deprecated]] void Fun(); 告訴使用者該函數已經被棄用,有的編譯器會生成警告,有的直接報錯。[[ deprecated("using FunX instead") ]] void Fun();給出具體的警告信息。
- [[faltthrough]] case語句中,case后沒有break編譯器會給出警告,如果是有意為之,可用此消除編譯器的警告。
- [[nodiscard]] 不能忽略函數的返回值,如果忽略會給出警告。
- [[maybe_unused]] 對於一些聲明的但是沒有使用的語句,編譯器會給出警告,使用它可以消除這個警告。(好像沒卵用啊。。。。)
1.6 final,override,=default,=delete
-
final:用於修飾一個類,寫在類名后,表明這個類不能夠再被繼承。
-
override:父類加了virtual的方法能被子類重寫,子類加不加virtual關鍵字都行。這會帶來兩個弊端
- 不能直觀的看出是不是重寫的父類的方法。
- 子類不小心寫錯了需要重寫的函數簽名(參數,返回值),改方法會變成一個獨立的方法,而編譯器不會發現。
加了override關鍵字編譯器會檢查改方法是重寫的父類的方法,如果函數簽名錯誤在編譯期間會給出相應的錯誤。
-
=default:如果沒有顯示的給出構造函數、析構函數、拷貝構造和操作符重載=,在使用時編譯器會自動生成默認的無參函數或者在鏈接時報錯,用=default標記的函數編譯器會給出默認實現。通常用來簡化構造函數中沒有實際初始化代碼的寫法------特別是在.h和.cpp文件分離時。
-
=delete:禁止編譯器自動生成構造函數、析構函數、拷貝構造函數和操作符重載函數。實際工程中如果明確不需要這4個函數,為了防止編譯器自動生成,直接用=delete禁止即可,還能減小可執行文件的體積。
1.7 auto關鍵字
自動推導數據類型。一般用來推導復雜模板的數據類型,減少代碼長度。
1.8 Range-based
//c++11 for循環新寫法
int arr[10] = {0};
for (auto i:arr)
{
std::cout<< i <<std::endl;
}
for-each陷阱:
- for-each中的迭代器類型與數組或者集合中元素的類型一致。使用傳統的iterator時,iter是容器中元素的指針類型。
- for-each中迭代器是復雜類型的拷貝,而不是原始數據的引用。(也就是說它有局部的生命周期,跟普通的變量使用時沒有差別?)
1.9 結構化綁定
std::pair一般只能表示兩個元素,std::tuple可以放任意數量的元素,不需要一個被定義成結構體的POD。
std::tuple<int, string, char, double, int> userinfo(26,"mengziyue",'A',213.45,1995);
int age = std::get<0>(userinfo);
string name = std::get<1>(userinfo);
char level = std::get<2>(userinfo);
double salary = std::get<3>(userinfo);
int birthYear = std::get<4>(userinfo);
//怎么感覺還是結構體簡單些啊。。。。難以維護,不能見名知意。
//結構化綁定的語法
auto [a,b,c, ...] = experssion;
auto [a,b,c, ...] = { experssion };
auto [a,b,c, ...] = ( experssion );
double arr[3] = {2.3, 1, 5.6};
auto [num1,num2,num3] = arr;
struct Point
{
double x;
double y;
}
Point point1(1, 2.3);
const auto& [width,heigth] = point1;
同樣注意,這里的綁定名稱是綁定目標的一份拷貝,請用&或者const &來避免不必要的拷貝。
限制:
- 不能使用constexpr或者聲明為static。(建議使用auto它不香嗎)
- 有些編譯器不支持在Lamda表達式捕獲列表中使用結構化綁定的語法。
1.10 stl容器新增的實用方法
1.10.1原位構造與容器的emplace系列函數
在循環中對一個臨時對象進行存儲,存儲的是拷貝構造函數生成的新對象。使用emplace_back()可以用來替代原先容器的的push_back()操作,減少臨時對象的構造、復制構造和析構的開銷,只需要構造函數即可。這就是“原位構造元素(EmplaceConstructible)”。
原方法 | c++11改進方法 | 含義 |
---|---|---|
push/insert | emplace | 在容器指定位置原位構造元素 |
push_front | emplace_front | 在容器首部原位構造元素 |
push_back | emplace_back | 在容器尾部原位構造元素 |
10.1.2 std::map的try_emplace和insert_or_assign
std::try_emplace,存在則使用,不存在則創建。
class A
{
public:
void active();
}
std::map<UInt64,A*> mA;
//原始方案,也是現在項目上一直在用的。
void Click(UInt64 uuid)
{
std::map<UInt64,A*>::iterator iter= mA.find(uuid);
if (iter != mA.end())//找到了
{
iter->second->active();
}
else
{
A* pA = new A();
mA.insert(std::pair<UInt64,A*>(uuid,pA));
pA->active();
}
}
//c++17的try_emplace:檢測key是否存在,如果存在則不做任何事情。
void Click(UInt64 uuid)
{
auto [iter,bInserted] = mA.try_emplace(uuid);
if (bInserted)
{
//std::map<UInt64,A*> mA; 因為map的value是指針,try_emplace的第二個參數是支持構造一個對象,當uuid不存在而被成功插入時,會導致相應的value是空指針,這就是多進行下面一步操作的原因。
iter->second = new A();
}
iter->second->active();
}
//用shared_ptr重構
void Click(UInt64 uuid)
{
auto spA = std::make_unique<A>();
auto [iter,bInserted] = mA.try_emplace(uuid,std::move(spA));
iter->second->active();
}
/*
以上函數中,構造和析構都被多調用。這是因為無論uuid是否在map上,都會創建類A,而這個類A是用不上的,導致做了無用功。
*/
//減少構造和析構的次數
void Click(UInt64 uuid)
{
auto [iter ,bInsert] = mA.try_emplace(uuid);
if (bInsert)
{
//按需創建對象
auto spA = std::make_unique<A>();
iter->second = std::move(spA);
}
iter->second->active();
}
/*
在auto [iter ,bInsert] = mA.try_emplace(uuid)中,
第二個值是bool類型,表示操作是否成功。如果成功,返回的iter中含有數據。
PS:在容器中應該存儲指針或者智能指針。
*/
std::insert_or_assign,存在則更新,不存在則插入。
std::map<std::string,int> mapAgeTable{{"mengziyue",26},{"futiantian",26}};
//map中不存在,創建
mapAgeTable.insert_or_assign("mengqingyi",26);
//map中已經存在,更新
mapAgeTable.insert_or_assign("futiantian",18);
1.11 stl的智能指針類詳解
c++11廢棄了std::auto_ptr,取而代之的是std::unique_ptr.
1.11.1 std::auto_ptr
std::auto_ptr<int> ap1(new int(66));
std::auto_ptr<int> ap2(ap1); //拷貝構造,堆對象被轉移給ap2
std::auto_ptr<int> ap3 = ap2; //復制構造,堆對象被轉移給ap3
//在遍歷時經常會有類似賦值傳遞等操作,這樣很容易造成空指針,可能遇到意想不到的錯誤
1.11.2 std::unique_ptr
/*
std::unique_ptr:對堆內存具有唯一的持有權,該智能指針對資源的引用計數永遠是1。對象銷毀時會釋放該堆內存。鑒於std::auto_ptr的前車之鑒,std::unique_ptr禁用了復制語義,拷貝構造和復制構造均被標記為=delete。默認情況下智能指針對象析構時會釋放其指向的堆內存,但是如果有對應的其他資源要回收(套接字句柄,文件句柄等),可通過給智能指針自定義資源回收函數來釋放這些資源。
*/
std::unique_ptr<int> sp1 (new int(123));
std::unique_ptr<int> sp3 = std::make_unique<int>(123); //推薦
//通過移動構造函數轉移堆內存對象
std::unique_ptr<int> sp1(std::make_unique<int>(345));
std::unique_ptr<int> sp2(std::move(sp1));
std::unique_ptr<int> sp3 = std::move(sp2);
//自定義資源回收函數的語法規則
std::unique_ptr<T,DeletorFun>
auto deletor = [](Socket* psocket)
{
psocket->close();
//甚至可以打印日志
delete psocket;
}
std::unique_ptr<T,void(*)(Socket* psocket)> spDelete(new Socket(),deletor);
std::unique_ptr<T,decltype(deletor)> spDelete(new Socket(),deletor);
1.11.3 std::shared_ptr
/* std::unique_ptr對其持有的資源具有獨占性,而std::shared_ptr持有的資源可以在各個share_ptr之間共享,每多一個引用,資源的引用計數就會加一,對象析構時會減一,到0時將釋放所有資源。*/class A{ }std::shared_ptr<A> sp1(new A());std::cout<<sp1.use_count()<<std::endl; //sp1引用計數是1std::shared_ptr<A> sp2 = std::make_shared<A>(sp1); //sp1引用計數是2sp2.reset(); //sp1的引用計數變為1
1.11.4 std::enable_shared_from_this
/* std::enable_shared_from_this,返回包裹當前對象的std::shared_ptr對象給外部使用,有需要的類需要繼承自std::enable_shared_from_this<T>模板對象即可。*/class A:public std::enable_shared_from_this<A>{ std::shared_ptr<A> getSelf(){ return shared_from_this(); }}//使用時需要注意以下事項1.不應該共享棧對象的this指針給智能指針對象A a;std::shared_ptr<A> spA = a.getSelf(); //在此處崩潰智能指針管理的是堆對象,棧對象在函數返回時自行銷毀,因此不能通過該隊現交由智能指針對象管理。智能指針的目的就是用來管理堆對象 2.循環引用class A:public std::enable_shared_from_this<A>{ std::shared_ptr<A> getSelf(){ return shared_from_this(); } void func() { m_spSelfPtr = shared_from_this(); }public: std::shared_ptr<A> m_spSelfPtr;}int main(){ { std::shared_ptr<A> spa(new A); spa->func(); }}//一個資源的生命周期可以交給智能指針管理,但是該智能指針的生命周期不可以再交給該資源來管理。
1.11.5 std::weak_ptr
/*
std::weak_ptr是不控制資源生命周期的智能指針,是對對象的一種弱引用,只提供了最其管理資源的一個訪問手段,不能直接操作對象。引入他的目的是協助std::shared_ptr工作。
兩個std::shared_ptr相互引用會導致死鎖問題,既引用永不為0,資源永遠不釋放。std::weak_ptr的構造和析構不會改變引用計數,因此可以用來解決引用死鎖的問題。std::weak_ptr可以從std::shared_ptr或者另一個std::weak_ptr構造。可以通過std::weak_ptr的lock函數獲得std::shared_ptr
既然std::weak_ptr不管理引用資源的生命周期,改引用資源就可能在某個時刻失效,在需要引用該資源時,需要用expired方法來檢測,如果返回true說明資源已經失效。
*/
if (spa.expired())
{
return;
}
//多線程編程時,這里仍然可能有隱患,可能在此spq持有的對象正好被釋放。
std::shared_ptr<A> spa2 = std::spa.lock();
if (spa2)
{
...
}
//std::weak_ptr常被用於訂閱者模式或者觀察者模式。消息發布器只有在某個訂閱者存在的情況下才會向其發布消息,不能管理訂閱者的聲明周期。
1.12 智能指針的對象大小及注意事項
std::unique_ptr指針的大小是原始指針大小。std::shared_ptr和std::weak_ptr的大小是原始指針大小的兩倍。
//1.一旦使用了智能指針管理對象,就不應該使用原始裸指針去操作它。A* a = new A();std::shared_ptr<A> spa(a);A* nakePtr = spa.get(); //nakePtr和a指向同一個對象//2.知道在那種場合使用哪種智能指針需要智能指針管理資源的聲明周期時,當資源不需要在其他地方共享,優先考慮std::unique_ptr,反之使用std::shared_ptr。如果不需要管理資源的生命周期,則使用std::weak_ptr。//3。避免操作某個引用資源已經釋放的智能指針。std::shared_ptr<A> spa(new A());auto& spa2 = spa; //注意是引用spa.reset(); //spa2也被釋放了spa2->doSomething(); //spa2不再持有對象,行為不確定。//4.作為類成員變量,應該優先使用前置聲明。為了減少編譯依賴、加快編譯速度和減少二進制文件的大小,一般采用前置聲明的方式而不是直接包含對應類的頭文件。
第二章 工具和調試
2.1 gdb調試
1.在實際調試時,通常關閉優化,用來保證調試時的行號能和代碼完全匹配。
2.對debug版的二進制文件使用strip命令,可去除調試信息,同時減小程序體積,提高運行效率。
有如下三種方法進行調試:
- gdb filename:直接調試
- gdb attach pid:調試正在運行的程序。附加目標進程時調試器會暫停,使用continue會讓程序繼續運行。
- gdb filename corename:利用core文件進行調試
2.2 gdb常用命令詳解
1.p this:打印當前對象的地址。p *this,打印對象的各個成員變量的值。p func()打印func的執行結果。
2.p serv.port=5452 p命令還能修改變量的值。
3.ptype。輸出變量類型
4.info thread:查看線程信息。
2.3 gdb實用技巧
#打印超長字符串或者字符數組
set print element 0
#讓被調試的程序接收信號
1.singnal SIGINT
2.handle SIGINT nostop print
#函數存在,添加斷點卻無效。根據文件名和行號添加斷點
#數據斷點:數據被改變時才觸發的斷點,用watch觀察
#條件斷點:break [LineNo] if [condition]
第三章 多線程編程和資源同步
3.1線程的基本操作
void func()
{
}
//傳統方式創建
#include<pthread.h>
int pthread_create(pthread_t* thread, //通過該參數獲取線程ID
const pthread_attr* attr, //線程屬性
void*(*start_routine) (void*), //線程函數
void* args) //線程函數所需的參數
返回值:
成功:0
失敗:返回錯誤碼,常見的錯誤碼有EAGAIN、EINVAL、EPERM。
pthread_t tid;
pthread_create(&tid,NULL,func,NULL);
//std::thread創建
#include<thread>
std::thread t1(func); //t1可以當作一個對象,注意其生命周期,出了作用域將會被銷毀
//獲取線程id
pthread_t pthread_self(void);
//pstack:查看進程的線程數量和每個線程的調用堆棧情況.必須有調試符號-g和對應權限。
top -H 和pstack pid搭配能夠排查程序運行情況。
int pthread_create()
c++11提供的std::thread類對線程函數簽名沒有特殊要求,但是linux的線程函數簽名必須是指定格式。如果使用c++面向對象的方式對線程函數進行封裝,線程函數就不能是類的實例方法(普通成員函數)了,必須是類的靜態方法。
class A
{
void* threadFunc(void* arg); //類的實例方法
};
//無論是實例方法還是靜態方法,編譯器在編譯時會將其“翻譯”成全局函數,即去掉類的域限制。對於類的實例方法,為了保證類方法的功能正常,翻譯時會將類的實例對象地址(也就是this指針最為第一個參數傳給)類實例方法。翻譯后該實例方法變成了如下模樣:
void* threadFunc(A* this,void* arg);
//而此時線程函數的簽名要求是void* threadFunc(void* arg);因此,線程函數不能是類的實例方法。但是又有個問題,類的靜態成員方法不能訪問類的非靜態成員方法
//用c++11的std::thread類就沒有限制。
class A
{
public:
void Start(){ m_spThread.reset(new std::thread(&A::ThreadFunc,this,1995,2021)); };
private:
int ThreadFunc(int a, int b);
//使用智能指針包裹對象,無須手動釋放對象了
std::shared_ptr<std::thread> m_spThread;
};
//為了解決創建線程時不能使用類的實例方法,可在創建線程時將對象的地址(this指針)傳遞給線程函數,然后在線程函數中將該指針轉化為原來的類實例,再通過這個實例就可以訪問所有的類方法了。
//在線程函數中調用pthread_create創建線程時,將當前對象的this指針作為線程函數的唯一參數傳入,在線程函數中就可以通過線程函數的參數得到對象的指針了通過這個指針能夠自由訪問類的實例方法。
::pthread_create(&m_pid,NULL,&ThreadFunc,this); //ThreadFunc是類的靜態方法
//通過std::bind給線程函數綁定this指針,可以達到綁定類實例方法的目的。
class A
{
public:
void RecvThread();
std::unique_ptr<std::thread> m_spRecvThread;
}
void A::Start()
{
m_spRecvThread.reset(new std::thread(std::bind(&A::RecvThread,this)));
}
靜態成員函數只能訪問靜態成員。
類的成員函數是在編譯期間編譯器會默認在其第一個參數添加一個this指針,這個指針指向對象的地址,正因為如此,在類對象調用該類的方法時能夠正常工作,這也同時要求必須在獲取對象后才能調用類的實例方法。
類的靜態成員函數為所有類共用,它在靜態存儲區,沒有this指針,不需要實例就能訪問其它靜態成員,同樣正是因為沒有this指針,無法知道是那個對象需要調用改方法,因此靜態成員函數只能訪問靜態成員。
3.2 整型變量的原子操作
線程同步:多個線程同時操作某個資源導致沖突,引發意料之外的結果。
3.2.1 整型的變量賦值為啥不是原子的?
- 賦確定的值:這一般是原子的,立即尋址,一條匯編指令即可搞定。
- 自增/自減:不是原子的。1.變量搬到寄存器 2.寄存器自增/自減 3.寄存器搬回到內存。
- 兩個變量間賦值:數據不能直接從內存搬運,要借助寄存器,因此這個也有兩步。
c++11對整型變量的原子操作的支持
借助模板類std::atomic對常見的基本類型進行原子操作。
std::atomic<int> value = 99; //linux上無法編譯通過,std::atomic禁止了拷貝構造函數
std::atomic<int> value;
value = 99; //這里重載了operator=
3.3 Linux線程同步對象
3.3.1 Linux互斥體
通過限制多個線程同時訪問一個某段代碼。可以使用定義與pthread.h中的pthread_mutex_t表示一個互斥體對象。
//1.初始化
pthread_mutex_t myMutex = PTHREAD_MUTEX_INITIALIZER;
//若互斥體是動態分配的或者需要設置屬性
int pthread_mutex_init(pthread_mutex_t* restrict mutex,
const pthread_mutexattr_t restrict attr);
pthread_mutex_t myMutex;
pthread_mutex_init(&myMutex,NULL);
//2.銷毀
int pthread_mutex_destory(pthread_mutex_t* mutex);
//注意:1.無需銷毀使用PTHREAD_MUTEX_INITIALIZER初始話的互斥體
2.不小銷毀一個已經加鎖或者被條件變量使用的互斥體對象,此時銷毀會返回EBUSY錯誤。
//3.操作
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
//屬性
1.PTHREAD_MUTEX_NORMAL
若一個線程對普通鎖加了鎖,其他線程會阻塞在這里,直到加鎖的線程釋放了鎖。
一個線程若對普通鎖再加鎖,程序會阻塞在第二次加鎖的地方。如果通pthread_mutex_trylock拿不到鎖,程序會返回一個錯誤碼而不會阻塞。
2.PTHREAD_MUTEX_ERRORCHECK
對已經加鎖的對象再加鎖會返回死鎖,其他線程再調用pthread_mutex_lock時會阻塞在線程的調用處。
3.PTHREAD_MUTEX_RECURSIVE
允許重復加鎖,加鎖引用計數加1,解鎖引用計數減1,當引用計數為0時該鎖可被獲取。
3.3.2 Linux信號量
信號量代表一定的資源數量,可以根據當前資源的數量按需喚醒指定數量的消費者線程,消費者線程一旦獲取信號量,就會讓資源減少指定的數量,如果資源數量為0,則會掛起所有的消費者線程。當有新資源到來時,消費者線程將又會被喚醒。
//信號量常見API#include<semaphore>//pshared 0表示在同一進程中共享,1表示不同進程間可共享;value表示初始狀態下的資源數量。int sem_init(sem_t* sem,int pshared, unsigned int value);int sem_post(sem_t* sem); //資源計數加一,並解鎖該信號量對象,因sem_wait阻塞的線程會被喚醒int sem_destory(sem_t* sem);//銷毀信號量int sem_wait(sem_t* sem);//如果資源計數為0,阻塞調用線程,直到資源計數大於零時被喚醒,喚醒后資源計數減1然后立即返回。int sem_trywait(sem_t* sem);//資源計數為0時不阻塞,直接返回-1,錯誤碼被設置還曾EAGAIN,int sem_timedwait(sem_t* sem,const struct timespec* abs_timeout);//帶有等待時間的版本1.abs_timeout不能為空,否則崩潰2.abs_timeout指的是絕對時間。比如想讓函數等待5s,則要先獲取當前時間,再加5s在abs_timeout指定的時間內,資源的引用計數要大於0,否則會返回-1,錯誤碼被置成ETIMEDOUT。在使用wait系列函數時,會鎖定信號量對象。trywait和timedwait獲取失敗會直接返回-1,然后可根據錯誤碼處理。struct timespec{ time_t tc_sec; //秒 long tv_nsec; //納秒};
3.3.3 Linux條件變量
當一個共享變量被多個線程操作時,需要使用互斥鎖,有可能會導致效率問題。現在又一種機制:某個線程A在條件不滿足的情況下主動更讓出互斥體,由其他線程操作,線程A在此等待條件滿足,一旦條件滿足,線程A會被立刻喚醒。他的機制是其他線程在操作時發現條件滿足,則會向線程A發信號並且讓出互斥體。條件變量需要條件等待,但是條件等待不是條件變量的唯一功能。
pthread_mutex_lock(&mutex);
while(condition_is_false)
{
pthread_mutex_unlock(&mutex); //時間片可能被剝奪,另一個線程獲取mutex並且接着執行,原線程就會永遠阻塞等待在下一行。根本原因是釋放互斥鎖和條件變量等待喚醒不是原子操作。即解鎖和等待喚醒必須在同一原子操作中
cond_wait(&cv);
pthread_mutex_lock();
}
//使用
//初始化和銷毀
int pthread_cond_init(pthread_cond_t* cond, const pthread_cond_condattr_t* attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destory(pthread_cond_t* cond);
//等待喚醒
int pthread_cond_wait(pthread_cond_t* restrict cond,pthread_mutex_t restrict mutex);
int pthread_cond_wait(pthread_cond_t* restrict cond,pthread_mutex_t restrict mutex,const struct timespec* restrict abstime);
//被喚醒
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_boardcast(pthread_cond_t* cond);
pthread_cond_signal一次只喚醒一個線程,如果有多個線程調用了pthread_cond_wait,則哪一個線程被喚醒是隨機的。pthread_cond_boardcast以喚醒所有因調用pthread_cond_wait而等待的線程。喚醒成功返回0,失敗返回相應的錯誤碼。
重點是要明白pthread_cond_wait在條件滿足和不滿足時的兩種行為。
- pthread_cond_wait函數在阻塞時,會釋放其綁定的互斥體並阻塞線程,因此在條用該函數前應該對互斥體加鎖。
- 收到條件信號時,pthread_cond_wait會返回並對其綁定的互斥體進行加鎖。
條件變量的虛假喚醒
使用while語句時,條件變量醒來后在此判斷條件是否滿足。
while(tasks.empty())
{
pthread_cond_wait(cond);
}
if (tasks.empty())
{
pthread_cond_wait(cond);
}
操作系統喚醒pthread_cond_wait時,tasks.empty()可能仍為true。存在沒有線程發送喚醒消息但等待條件變量的線程醒來的情況,這就叫虛假喚醒。將條件(tasks.empty()的值)放在while循環中,意味着要條件和喚醒要同時滿足,程序才能繼續執行。
//TODO :為啥存在虛假喚醒?
條件變量信號丟失
如果一個條件變量信號在產生時(調用pthread_cond_signal或者pthread_cond_boardcast),沒有相關線程調用pthread_cond_wait捕獲該信號,該信號就會永久丟失,在此調用pthread_cond_wait會導致永久阻塞。在設計條件變量只會產生一次時尤其要注意。如:程序中有一批等待條件變量的線程,和一個只產生一次條件變量信號的線程,等待條件變量的線程一定要在生產條件變量的信號發出之前調用pthread_cond_wait。
3.3.4 Linux讀寫鎖
共享變量訪問的特點:多數情況下只是去讀該變量的值,少數情況下才回去寫。讀請求之間不用同步,他們之間的並發訪問時安全的,但是寫請求必須鎖住其他請求。
//讀寫鎖的初始化和銷毀#include<pthread.h>int pthread_rwlock_init(pthread_rwlock_t* rwlock,const pthread_rwlock_attr_t* attr);int pthread_rwlock_destory(pthread_rwlock_t* rwlock);pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;//請求讀int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);int pthread_rwlock_timerdlock(pthread_rwlock_t* rwlock,const struct timespec* abstime);//請求寫int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);int pthread_rwlock_trywrlock(pthreadrwlock_t* rwlock);int pthread_rwlock_timewrlock(pthread_rwlock_t* rwlock,const struct timespec* abstime);//釋放讀寫鎖int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
讀鎖用於共享模式:如果當前讀寫鎖已經被某個線程以讀模式占有,則其他線程調用pthread_rwlock_t會立刻獲得讀鎖,其它線程如果調用pthread_rwlock_wrlock則會阻塞在該處。
寫鎖用於獨占模式:如果當前讀寫鎖已經被某個線程以寫模式占有,則其他線程調用pthread_rwlock_rdlock和pthread_rwlock_wrlock時,都會陷入阻塞,直到線程釋放寫鎖。
//TODO:讀寫鎖的屬性(默認讀鎖優先)
3.4 C++/11/14/17 線程同步對象
3.4.1 std::mutex
互斥量 | 版本 | 作用 |
---|---|---|
mutex | c++11 | 基本互斥量 |
timed_mutex | c++11 | 帶有超時功能的互斥量 |
recursive_mutex | c++11 | 可重入互斥量 |
recursive_timed_mutex | c++11 | 帶有超時功能的可重入互斥量 |
shared_mutex | c++17 | 共享互斥量 |
shared_timed_mutex | c++14 | 帶有超時機制的可共享互斥量 |
該系列都有加鎖(lock)、嘗試加鎖(trylock)和解鎖的方法。有加鎖就要有解鎖,以避免死鎖。新標准提供的封裝可以代替RAII。
互斥量管理 | 版本 | 作用 |
---|---|---|
lock_guard | c++11 | 基於作用域的互斥量管理 |
unique_lock | c++11 | 更加靈活的互斥量管理 |
shared_lock | c++14 | 共享互斥量管理 |
scoped_lock | c++17 | 多互斥量避免死鎖管理 |
//以下是錯誤的寫法,互斥鎖的生存周期一定要比保護區的生命周期長。
void func()
{
std::mutex m;
std::lock_guard<std::mutex> guard(m);
//臨界資源區
}
lock再lock的行為未定義。
同一個線程對某個mutex再次加鎖,應該使用std::recursive_mutex(c++17起)。
3.4.2 std::shared_mutex
std::shared_mutex的實現是利用操作系統底層實現的讀寫鎖,也就是說在多讀少寫的情況下,要比std::mutex的操作效率要高。std::shared_mutex提供了lock方法和unlock方法分別用於獲取寫鎖和解除寫鎖,lock_shared和unlockshared獲取讀鎖和解除讀鎖。一般也稱寫鎖為排它鎖,讀鎖稱為共享鎖。
新標准中引入了與std::shared_mutex配和的兩個對象,構造時對mutex加鎖,析構時解鎖。
- std::unique_lock:加std::shared_mutex的寫鎖
- std::shared_lock:加std::shared_mutex的讀鎖。
3.4.3 std::condition_variable
c++11提供了與Linux原生的條件變量類似的std::condition_variable類,還提供了等待條件變量滿足的wait系列方法(wait,wait_for,wait_until方法)。發送條件信號時需要使用notify方法(notify_one,notify_all方法)。使用std::condition_variable對象時需要綁定1個std::unique_lock或者std::lock_guard對象。
std::condition_variable不需要再顯示的初始化和銷毀。
3.4.4 如何確保創建的線程一定能夠運行
現在只要正確的使用線程創建函數,實際編碼時對線程的返回值也不必判斷,基本可以認為線程一定會創建成功,而且線程函數可以正常運行。
3.5 多線程使用鎖的經驗
3.5.1 減少鎖的使用次數
多線程時,使用鎖一般有以下損失:
- 加鎖和解鎖操作,本身有一定開銷。
- 臨界區的代碼不能並發執行。
- 進入臨界區的次數過於頻繁,線程之間對臨界區的爭奪太激烈,若線程競爭互斥體失敗,就會陷入阻塞並讓出CPU,執行上下文切換的次數要遠遠多於不使用互斥體的次數。
所以可以嘗試替代鎖的方案,如無鎖隊列。
3.5.2 明確鎖的范圍
while(m_lst.empty()) //m_lst的判空也應該在鎖的保護范圍內。
{
std::mutex_lock(&mymutex);
m_lst.insert(tmp);
std::mutex_unlock(&mymutex);
}
3.5.3 減小鎖的粒度
減小鎖的作用的臨界區代碼范圍,臨界區代碼越少,多個線程排隊進入臨界區的時間就越短。
void TaskPool::AddTask(Task* task)
{
std::lock_guard<std::mutex> guard(m_mutexLst); //guard鎖保護m_mutexLst
std::shared_ptr<Task> spTask;
spTask.reset(task);
m_mutesLst.push_back(spTask);
m_cv.notify_one();
}
}
//減小guard鎖的使用范圍
void TaskPool::AddTask(Task* task)
{
std::shared_ptr<Task> spTask;
spTask.reset(task);
{
std::lock_guard<std::mutex> guard(m_mutexLst);
m_mutexLst.push_back(spTask);
}
m_cv.notify_one();
}
3.5.4 避免死鎖的建議
- 函數內加鎖,退出函數時一定要解鎖。可用RAII或者std::lock_guard替代。
- 線程退出時一定要釋放持有的鎖。實際開發中可能會創建一些臨時線程,線程執行完相應的任務會退出,這類線程持有了鎖,推出線程時一定要記得釋放。
- 多線程請求鎖的方向要一致,避免死鎖。假設現在又兩個鎖A和B,線程1在請求鎖A后請求了鎖B,線程2請求了鎖B后請求了鎖A,此時容易造成死鎖。所以要么都先請求鎖A,再請求鎖B;或這先請求鎖B,再請求鎖A。
- 當需要同一個線程重復請求同一個鎖時,需要明白遞增鎖引用計數,還是阻塞或者直接獲得鎖。
活鎖:多個線程使用trylock系列函數時,由於相互謙讓,導致即使某個時間段鎖可用,需要鎖的線程也拿不到鎖。因此在實際編程時,應該盡量避免過多函數使用trylock系列函數而造成活鎖。
3.6 線程局部存儲
對於一個存在多個線程的進程來說,有時需要每個線程都自己操作自己的這份數據。這類似與c++類的實例屬性,每個實例對象操作的都是自己的屬性。這樣的數據稱為線程局部存儲。
3.6.1 Linux提供的線程局部存儲
//Linux中提供了一套函數實現線程的局部存儲。int pthread_key_create(pthread_key_t* key,void (*destory)(void*));int pthread_key_delete(pthread_key_t* key);int pthread_setspecific(pthread_key_t* key,const void* value);void pthread_getspecific(pthread_key_t* key);調用成功時,會為線程局部存儲創建一個新的key,用戶通過這個key設置(pthread_setspecific)和獲取(pthread_getspecific)數據。因為進程的所有變量都可以使用返回的鍵,所以key應該是一個全局變量。
gcc編譯器提供了線程局部存儲關鍵字__thread用於定義線程局部存儲。
3.6.2 c++11的thread_local關鍵字
thread_local用來定義一個線程變量。使用線程變量時尤其要注意以下兩點:
- 對於線程變量,每一個線程都會有一個該變量的拷貝,互不影響,該局部變量一致存在,直到線程退出。
- 系統的線程局部存儲區域的內存空間並不大,盡量不要用這個空間存儲大的數據塊。如果不得不使用大的數據塊,可以將大的數據快存儲在堆內存中,再將指向堆內存的指針存儲在線程局部存儲區域。
3.7 c庫的非線程安全函數
c庫中的很多函數在編寫時還沒有多線程計數,有些直接就使用了靜態變量或者全局變量,這導致了一些函數是非線程安全的,隨這多線程編程計數的出現,很多函數有了多線程安全的替代品,如localtime_r和strtok_r函數。
3.8 線程池與隊列系統的設計
3.8.1 線程池的設計原理
線程池只是一組線程。大多數時候我們需要執行一些異步任務,這些任務的產生和執行存在於整個程序的生命周期。於其讓操作系統不斷地創建與銷毀線程,不如創建一組在整個程序生命周期內都不會退出的線程。基本要求是:當有任務需要執行時,線程能夠自動拿到任務並執行,沒有任務時這些線程處於阻塞或者休眠狀態。
既然程序生命周期內會產生很多任務,可以把這些任務都放在“隊列”中,它可以是全局變量或者鏈表。生產任務的線程是生產者,線程池中的線程是消費者。既然多個線程會同時操作這個隊列,就需要對他進行加鎖操作。除了創建線程池,還需要向隊列投遞任務,從隊列中取出任務並處理問題,還需要考慮清理線程池、退出線程池的工作任務和清理任務隊列。
3.8.2 環形隊列
如果生產者和消費者的速度差不多,可以將隊列改成環形隊列,以節省內存空間,為了追求效率,可以將一些環形隊列無鎖化,以提高效率。
3.8.3 消息中間件
由於基於生產者/消費者模型衍生的隊列系統在實際開發中很常見,以至於每個進程都需要這樣一個隊列系統。出於復用和解耦的目的,業界出現了很多獨立的隊列系統,這個隊列系統以一個獨立的進程運行,或以支持分布式的一組服務運行。這種獨立的系統稱為消息中間件。消息中間件的功能上可以進行擴展,如消費方式、主備切換、容災容錯、數據自動備份和過期數據自動清理等。
這種專門的隊列系統,生產者和消費者將最大化解耦。利用消息中間件提供的對外消息接口,生產者只需要負責生產消息,消費者只負責消費,隊列系統本身也不用官自己有多少生產者和消費者。
3.9 纖程與協程
//TODO 纖程
3.9.1 纖程
3.9.2 協程
線程是操作系統的內核對象。多線程編程時線程數過多會導致上下文頻繁切換,CPU的緩存命中率會降低,對性能會有較大的損耗。如:在高並發網絡編程時,使用一個線程服務一個socket是很不明智的做法,現在主流的做法是利用操作系統提供的基於事件的異步編程模型,用少量的線程來服務大量的網絡鏈接和IO,但是采用異步和基於事件的編程模型,會使代碼程序變復雜(邏輯割裂?),也加大了排錯的難度。
協程可以被認為是應用層模擬的線程。協程避免了線程上下文切換的額外損耗,同時具有並發運行的優點,降低了編寫並發程序的復雜度。對於高並發網絡編程,可以為每個socket都開一個協程,在兼顧性能的同時代碼邏輯也會編的清晰。
協程的概念比線程早。它是非搶占是調度,無法是西安公平的任務調度,也無法直接利用多核CPU的優勢。現在最新版本的gcc11.1貌似完全支持了協程。
協程的內部實現都是基於線程,思路是維護一組數據結構和n個線程,真正的執行者還是線程,協程執行的代碼被扔進一個待執行的隊列中,由這n個線程從隊列中拉出來執行,這就解決了線程的執行問題。協程的切換是調用了操作系統的異步IO(Golang),當異步函數返回busy或者blocking時,將現有執行序列壓棧(避免的是線程的上下文切換?)讓線程拉去另一個協程的代碼執行。
協程流行的原因是大多數業務系統都秦湘語使用異步編程來提高系統性能,但是這就將線性的程序邏輯打亂,使得程序的邏輯非常復雜,堆程序狀態的管理也變得非常困難。掌握了多線程技術,就能快速的學會協程。協程是未來?沖沖沖!!!