- 多線程搶票
實現多線程搶票的思路很簡單:假設有1000張票,讓5個線程去搶,直到票數為0為止。
代碼語言:c++
#include <iostream> #include <unistd.h> #include <pThread.h> <h1>define N 5</h1><p>using namespace std;</p><p>int ticket = 1000;</p><p>void<em> pthreadRun(void</em> arg) { char<em> name = static_cast<char</em>>(arg); int sum = 0; while (true) { if (ticket > 0) { usleep(2000); --ticket; cout << name << "搶到一張票,還剩" << ticket << "張票" << endl; ++sum; } else { cout << name << "共搶到" << sum << "張票" << endl; break; } } return nullptr; }</p><p>int main() { pthread_t tids[N]; char* name[N] = {"thread-1", "thread-2", "thread-3", "thread-4", "thread-5"};</p><pre class="brush:php;toolbar:false">for (int i = 0; i < N; i++) { int ret = pthread_create(&tids[i], nullptr, pthreadRun, (void*)name[i]); if (ret != 0) { cout << "pthread_create error: error_code=" << ret << endl; } } for (int i = 0; i < N; i++) { pthread_join(tids[i], nullptr); } return 0;
}
在運行上述程序時,我們會發現最終票數居然變成了負數。然而,代碼看起來并沒有明顯的錯誤。這是為什么呢?
- 原因分析 – 資源共享問題
在上面的搶票程序中,全局變量ticket是線程的共享資源。要修改ticket,需要執行以下三個步驟:
- 將ticket從內存拷貝到寄存器中。
- 在CPU內完成計算。
- 從寄存器中將結果轉移回內存。
在單線程情況下,這三個步驟似乎沒什么問題,因為計算機的速度非常快,用戶幾乎感覺不到延遲。然而,在多線程環境中,線程對共享資源的訪問會存在競爭現象。
假設有兩個線程,分別為thread1和thread2。在某個時刻,thread1準備修改ticket,將ticket拷貝到寄存器中時,thread2可能會搶占CPU,導致thread1的操作被中斷。大多數情況下,這種情況不會發生,因為CPU的計算速度非常快,通常能完成所有操作。但是在搶票程序中,由于存在休眠操作(usleep),這種情況確實發生了。
當ticket等于1時,滿足循環中的條件(ticket > 0)。假設此時thread-1在執行該操作,進入if語句后,執行休眠。但CPU可能不會立即開始休眠,而是選擇運行下一個線程,假設是thread-2。由于ticket的值還沒有被修改,仍然等于1,thread-2也滿足if條件。其他線程同樣如此。過了一段時間,thread-1醒來,開始執行ticket–操作,其他線程隨后醒來也會執行ticket–操作,最終導致票數變成負數。
即使去掉usleep,負數情況的概率也會很低,但仍然可能發生。正確的解決方案是使用鎖。
- 知識補充 – 臨界資源
在多線程場景中,像ticket這樣的可以被多個線程訪問的共享資源稱為臨界資源。涉及對臨界資源進行操作的代碼區域稱為臨界區。
代碼語言:C++
int ticket = 1000; // 臨界資源</p><p>void<em> pthreadRun(void</em> arg) { char<em> name = static_cast<char</em>>(arg); int sum = 0; while (true) { // 臨界區開始 if (ticket > 0) { usleep(2000); --ticket; cout << name << "搶到一張票,還剩" << ticket << "張票" << endl; ++sum; } else { cout << name << "共搶到" << sum << "張票" << endl; break; } // 臨界區結束 } return nullptr; }
臨界資源的本質是多線程共享資源,而臨界區是涉及共享資源操作的代碼區域。
- 知識補充 – ‘鎖’
為了安全地訪問臨界資源,必須確保在使用時的安全性,這就是鎖的作用。用生活中的例子來說,鎖就像是進入房間的鑰匙,只有持有鑰匙的人才能進入房間。
對于臨界資源也是如此,為了訪問時的安全,可以通過加鎖來實現。實現多線程間的互斥訪問,互斥鎖是解決多線程并發訪問問題的手段之一。具體操作就是:在進入臨界區之前加鎖,離開臨界區之后解鎖。
還是以前面的搶票程序為例。假設此時正在執行的線程為thread-1,當它在訪問ticket時如果進行了加鎖,在thread-1被切走后,假設此時進入的線程為thread-2,thread-2無法對ticket進行操作,因為此時鎖被thread-1持有,thread-2只能堵塞式等待鎖,直到thread-1解鎖。因此,對于thread-1來說,在加鎖環境中,只要接手了訪問臨界資源ticket的任務,要么完成,要么不完成,不會出現中間狀態。這種不會出現中間狀態,結果可預期的特性稱為原子性。也就是說,加鎖的本質是為了實現原子性。
在加鎖的同時,我們還需要注意以下幾點:
- 加鎖、解鎖是比較耗費系統資源的,會在一定程度上降低程序的運行速度。
- 加鎖后的代碼是串行執行的,勢必會影響多線程場景中的運行速度。
- 為了盡可能降低影響,加鎖粒度要盡可能地細。