【多線程那些事兒】多線程的執行順序如你預期嗎?

来源:https://www.cnblogs.com/lc19890709/archive/2022/09/27/16735156.html
-Advertisement-
Play Games

前言 在使用SpringBoot的時候經常需要對客戶端傳入的參數進行合法性的校驗,校驗的方法基本上都是使用SpringBoot提供的註解,有時候遇上註解不能滿足需求的時候還需要在業務邏輯上進行判斷。這樣根本就沒有實現解耦。 使用方法 項目maven引入 <dependency> <groupId>c ...


一個簡單的例子

先來看一個多線程的例子:

graph TB begin(a)-->線程1(x = 1, m = y) begin(x = 0, y = 0)-->線程2(y = 1, n = x)

如圖所示,我們將變數x和y初始化為0,然後線上程1中執行:

	x = 1, m = y;

同時線上程2中執行:

	y = 1, n = x;

當兩個線程都執行結束以後,m和n的值分別是多少呢?

對於已經工作了n年、寫過無數次併發程式的的我們來說,這還不是小case嗎?讓我們來分析一下,大概有三種情況:

  1. 如果程式先執行了x = 1, m = y代碼段,後執行了y = 1, n = x代碼段,那麼結果是m = 0, n = 1;
  2. 如果程式先執行了y = 1, n = x代碼段,後執行了x = 1, m = y代碼段,那麼結果是m = 1, n = 0;
  3. 如果程式的執行順序先是 x = 1, y = 1, 後執行m = y, n = x, 那麼結果是m = 1, n = 1;

所以(m, n)的組合一共有3種情況,分別是(0, 1), (1, 0)和(1, 1)。

那有沒有可能程式執行結束後,(m, n)的值是(0, 0)呢?嗯...我們又仔細的回顧了一下自己的分析過程:在m和n被賦值的時候,x = 1和y = 1至少有一條語句被執行了...沒有問題,那應該就不會出現m和n都是0的情況。

詭異的輸出結果

不過人在江湖上混,還是要嚴謹一點。好在這代碼邏輯也不複雜,那就寫一段簡單的程式來驗證下吧:

#include <iostream>
#include <thread>

using namespace std;

int x = 0, y = 0, m = 0, n = 0;
int main()
{
	while (1) {
		x = y = 0;
		thread t1([&]() { x = 1; m = y; });
		thread t2([&]() { y = 1; n = x; });
		t1.join(); t2.join();

		if (m == 0 && n == 0) {
			cout << " m == 0 && n == 0 ? impossible!\n";
		}
	}
	return 0;
}

考慮到多線程的隨機性,就寫一個無限迴圈多跑一會吧,反正屏幕也不會有什麼輸出。我們信心滿滿的把程式跑了起來,但很快就發現有點不太對勁:

m和n居然真的同時為0了?不可能不可能...這難道是windows或者msvc的bug?那我們到linux上用g++編譯試一下,結果程式跑起來之後,又看到了熟悉的輸出:

這...打臉未免來得也太快了吧!

你看到的執行順序不是真的執行順序

看來這不是bug,真的是有可能出現m和n都是0的情況。可是,到底是為什麼呢?恍惚之間,我們突然想起曾經似乎在哪看過這樣一個as-if規則:

The rule that allows any and all code transformations that do not change the observable behavior of the program.

也就是說,在不影響可觀測結果的前提下,編譯器是有可能對程式的代碼進行重排,以取得更好的執行效率的。比如像這樣的代碼:

int a, b;
void test()
{
	a = b + 1;
	b = 1;
}

編譯器是完全有可能重新排列成下麵的樣子的:

int a, b;
void test()
{
	int c = b;
	b = 1;
	c += 1;
	a = c;
}

這樣,程式在實際執行過程中對a的賦值就晚於對b的賦值之後了。不過,有了前車之鑒,我們還是先驗證一下在下結論吧。我們使用gcc的-S選項,生成彙編代碼(開啟-O2優化)來看一下,編譯器生成的指令到底是什麼樣子的:

哈哈,果然如我們所料,對a的賦值被調整到對b的賦值後面了!那上面m和n同時為0也一定是因為編譯器重新排序我們的指令順序導致的!想到這裡,我們的底氣又漸漸回來了。那就生成彙編代碼看看吧:

果然不出所料,因為我們在編譯的時候開了-O3優化,賦值的順序被重排了!代碼實際的執行順序大概是下麵這個樣子:

	int t1 = y; x = 1; m = t1; //線程1
	int t2 = x; y = 1; n = t2; //線程2

這就難怪會出現m = 0, n = 0這樣的結果了。分析到這裡,我們終於有點鬆了一口氣,這多年的編程經驗可不是白來的,總算是給出了一個合理的解釋。
那我們在編譯的時候把-O3優化選項去掉,儘量讓編譯器不要進行優化,保持原來的指令執行順序,應該就可以避免m和n同時為0的結果了吧?試試,保險起見,我們還是先看一看彙編代碼吧:

跟我們的預期一致,彙編代碼保持了原來的執行順序,這回肯定沒有問題了。那就把程式跑起來吧。然而...不一會兒,熟悉的列印又出現了...

這...到底是怎麼回事?!!!

你看到的執行順序還不是真正的執行順序

如果不是編譯器重排了我們的指令順序,那還會是什麼呢?難道是CPU?!
還真是。實際上,現代CPU為了提高執行效率,大多都採用了流水線技術。例如:一個執行過程可以被分為:取指(IF),解碼(ID),執行(EX),訪存(MEM),回寫(WB)等階段。這樣,當第一條指令在執行的時候,第二條指令可以進行解碼,第三條指令可以進行取指...於是CPU被充分利用了,指令的執行效率也大大提高。一個標準的5級流水線的工作過程如下表所示(實際的CPU流水線遠比這複雜得多):

序號/時鐘周期 1 2 3 4 5 6 7 ...
1 IF ID EX MEM WB
2 IF ID EX MEM WB
3 IF ID EX MEM WB
4 IF ID EX MEM WB
5 IF ID EX MEM
6 IF ID EX

上面展示的指令流水線是完美的,然而實際情況往往沒有這麼理想。考慮這樣一種情況,假設第二條指令依賴於第一條指令的執行結果,而第一條指令恰巧又是一個比較耗時的操作,那麼整個流水線就停止了。即使第三條指令與前兩條指令完全無關,它也必須等到第一條指令執行完成,流水線繼續運轉時才能得已執行。這就浪費了CPU的執行帶寬。亂序執行(Out-Of-Order Execution)就是被用來解決這一問題的,它也是現代CPU提升執行效率的基礎技術之一。
簡單來說,亂序執行是指CPU提前分析待執行的指令,調整指令的執行順序,以期發揮更高流水線執行效率的一種技術。引入亂序執行技術以後,CPU執行指令過程大概是下麵這個樣子:

graph TB; IF(取指)-->ID(解碼)-->ICache(指令緩存區) ICache-->EX1((執行單元))-->ReOrder(執行完成順序重排緩衝區) ICache-->EX2[執行單元]-->ReOrder ICache-->EX3([執行單元])-->ReOrder ICache-->EX4[[執行單元]]-->ReOrder ReOrder-->WB(提交/寫回)

所以,上面的程式出現(m, n)結果為(0, 0)的情況,應該就是因為指令的執行順序被CPU重排了!

C++多線程記憶體模型

我們通常將讀取操作稱為load,存儲操作稱為store。對應的記憶體操作順序有以下幾種:

  1. load->load(讀讀)
  2. load->store(讀寫)
  3. store->load(寫讀)
  4. store->store(寫寫)

CPU在執行指令的時候,會根據情況對記憶體操作順序進行重新排列。也就是說,我們只要能夠讓CPU不要進行指令重排優化,那麼應該就不會出現(m, n)為(0, 0)的情況了。但具體要怎麼做呢?
實際上,在C++11之前,我們很難在語言層面做到這件事情。那時的C++甚至連線程都不支持,更別提什麼記憶體模型了。在C++98的年代,我們只能通過嵌入彙編的方式添加記憶體屏障來達到這樣的目的:

asm volatile("mfence" ::: "memory");

不過在現代C++中,要做這樣的事情就簡單多了。C++11引入了原子類型(atomic),同時規定了6種記憶體執行順序:

  1. memory_order_relaxed: 鬆散的,在保證原子性的前提下,允許進行任務的重新排序;
  2. memory_order_release: 代碼中這條語句前的所有讀寫操作, 不允許被重排到這個操作之後;
  3. memory_order_acquire: 代碼中這條語句後的所有讀寫操作,不允許被重排到這個操作之前;
  4. memory_order_consume: 代碼中這條語句後所有與這塊記憶體相關的讀寫操作,不允許被重排到這個操作之前;註意,這個類型已不建議被使用;
  5. memory_order_acq_rel: 對讀取和寫入施加acquire-release語義,無法被重排;
  6. memory_order_seq_cst: 順序一致性,如果是寫入就是release語義,如果是讀取是acquire語義,如果是讀取-寫入就是acquire-release語義;也是原子變數的預設語義。

所以,我們只需要將x和y的類型改為atmioc_int,就可以避免m和n同時為0的結果出現了。修改後的代碼如下:

#include <iostream>
#include <thread>
#include <atomic>

using namespace std;

atomic_int x(0);
atomic_int y(0);
int m = 0, n = 0;
int main()
{
        while (1) {
                x = y = 0;
                thread t1([&]() { x = 1; m = y; });
                thread t2([&]() { y = 1; n = x; });
                t1.join(); t2.join();

                if (m == 0 && n == 0) {
                        cout << " m == 0 && n == 0 ? impossible!\n";
                }
        }
        return 0;
}

現在編譯運行一下,看看結果:

已經不會再出現"impossible"的列印了。我們再來看看生成的彙編代碼:

原來編譯器已經自動幫我們插入了記憶體屏障,這樣就再也不會出現(m, n)為(0, 0)的情況了。

全文完。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • ###一、簡介 什麼是分散式爬蟲? 分散式爬蟲就是把一個爬蟲任務放到多台機器上去運行,提高爬取效率 但是每台機器運行同一套代碼,都在各自的任務和去重隊列,等於各爬各的,最終爬的數據是相同的 因此需要提供一個公共的去重隊列和公共的任務隊列,多台機器都在共用的隊列中去調度和去重,然後分別爬取 原來scr ...
  • 可視化打包 exe,這個神器絕了 auto-py-to-exe 是一個用於將Python程式打包成可執行文件的圖形化工具。本文就是主要介紹如何使用 auto-py-to-exe 完成 python 程式打包。auto-py-to-exe 基於 pyinstaller ,相比於 pyinstaller ...
  • 如何將編寫的c語言程式打包成exe可執行文件呢? 以前我們寫程式很多是在編輯器上,讓編輯起來編譯運行我們的程式。如果想將其打包成exe可執行文件該如何做? 我這裡推薦使用codeBlocks的gcc命令,因為比較簡單。像vs這種編輯器就像是一把屠龍刀。功能過於複雜,需要註意的很多。如何適用codeB ...
  • 《Go 精進之路》 讀書筆記。簡要記錄自己打五角星的部分,方便複習鞏固。目前看到p120 Go 語言遵從的設計哲學為組合 垂直組合:類型嵌入,快速讓一個類型復用其他類型已經實現的能力,實現功能的垂直擴展。 水平組合:介面實現鴨子類型。 變數名字中不要帶有類型信息 userSlice []*User ...
  • 怎麼借鑒開源代碼來打造一些自身面對的問題解決方案?也許有一些Demo來進行回答演示或許更為貼近地氣些。這裡打算寫一些玩轉源碼為主題的文字來實踐的回答,最近在看P3C的一些源碼,那就從這開始吧。 ...
  • 什麼是 Session 會話? 1、Session 就一個介面(HttpSession)。 2、Session 就是會話。它是用來維護一個客戶端和伺服器之間關聯的一種技術。 3、每個客戶端都有自己的一個 Session 會話。 4、Session 會話中,我們經常用來保存用戶登錄之後的信息。 如何創 ...
  • Spring中AOP的底層原理就是動態代理模式,所以我們在這裡對代理模式進行學習。 一、代理模式 1.什麼是代理 代理,顧名思義,就是一個人代替另一個人去做他需要做的事情。代理是一種設計模式,具體實現就是一個類代替某個類去實現功能。 我們舉一個例子: 我要租房子,我可以找房東直接租房子。同樣我可以找 ...
  • 特殊說明:第一章只包含了 初始化上下文,初始化監聽器列表,發佈springboot啟動事件 相關內容 其中一部分代碼 /** * Run the Spring application, creating and refreshing a new * {@link ApplicationContext ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...