??當我們提到多線程、并發(fā)的時候,我們就會回想起各種詭異的bug,比如各種線程安全問題甚至是應用崩潰,而且這些詭異的bug還很難復現(xiàn)。我們不禁發(fā)出了靈魂拷問 “為什么代碼測試環(huán)境運行好好的,一上線就不行了?”。 為了解決線程安全的問題,我們的先輩們在編程語言中引入了各種各樣新名詞,就拿我們熟悉的Java為例,不僅java語言自帶了synchronized、volatile、wait、notify… ,jdk中各種工具包也是層出不窮,就比如單一個Lock,就可以有很多種實現(xiàn),甚至很多人都談鎖色變。
??為什么會出現(xiàn)這種情況,我們得先從CPU和主存(RAM)的關(guān)系說起。 上個世紀80年代,PC機興起的時候,CPU的運算速度只有不到1MHz。放現(xiàn)在你桌上的計算器都可以吊打了它了。那時候就是因為CPU運算慢,它對數(shù)據(jù)存取速度的要求也不那么高,頂多也就1微秒(1000ns)取一次數(shù)據(jù),一次訪存100ns對CPU來說也算不上什么。 然而這么多年過去了,CPU一直在沿著摩爾定律的道路一路狂奔,而內(nèi)存訪問延遲的速度卻一直止步不前。(當然存儲也有非常大的發(fā)展,但主要體現(xiàn)在容量方面,而訪問延時自誕生初就沒什么變化)。
??我們來對比下CPU和內(nèi)存過去幾十年之間的發(fā)展速率:
??可以看出,在過去40年里, CPU的運算速度增量了上千倍,而內(nèi)存的訪問延時卻沒有太大的變化。 我們就拿當今最先進CPU和內(nèi)存舉例,目前商用的CPU主頻基本都是3GHz左右的(其實十多年前基本上就這個水平了),算下來CPU每做一次運算僅需0.3ns(納秒)。而當前最先進的內(nèi)存,訪問延遲是100ns左右的,中間相差300倍。如果把CPU比作一個打工人的話,那么他的工作狀態(tài)就會是干一天活然后休一年,這休息的一年里等著內(nèi)存里的數(shù)據(jù)過來(真是令人羨慕?。?/p>
??其實CPU的設計者早就意識到了這點,如果CPU真是干1休300的話,未免也太不高效了。在說具體解決方案前,我這里先額外說下內(nèi)存,很多人會好奇為什么主存(RAM)的訪問速度一直上不來? 這個準確來說其實只是DRAM內(nèi)存的速度上不了。存儲芯片的實現(xiàn)方式有兩種,分別是DRAM和SRAM,SRAM的速度其實也一直盡可能跟著CPU在跑的。那為什么不用SRAM來制造內(nèi)存?這個也很簡單,就是因為它存儲密度低而且巨貴(相對于DRAM),所以出于成本考量現(xiàn)在內(nèi)存條都是采用DRAM的技術(shù)制造的。
??SRAM容量小成本高,但速度快,DRAM容量大成本低,但速度慢。這倆能不能搭配使用,取長補短?結(jié)論是肯定的,在計算機科學里有個”局部性原理“,這個原理是計算機科學領(lǐng)域所有優(yōu)化的基石。我這里就單從數(shù)據(jù)訪問的局部性來說,某個位置的數(shù)據(jù)被訪問,那么相鄰于這個位置的數(shù)據(jù)更容易被訪問。那么利用這點,我們是不是可以把當前最可能被用到的小部分數(shù)據(jù)存儲在SRAM里,而其他的部分繼續(xù)保留在DRAM中,用很小的一塊SRAM來當DRAM的緩存,基于這個思路,于是CPU芯片里就有了Cache,CPU的設計者們覺得一層緩存不夠,那就給緩存再加一層緩存,于是大家就看到現(xiàn)在的CPU里有了所謂的什么L1 Cache、L2 Cache, L3 Cache。
??存儲示意圖如下,真實CPU如右圖(Intel I7某型號實物圖):
??多級緩存的出現(xiàn),極大程度解決了主存訪問速度和CPU運算速度的矛盾,但這種設計也帶來了一個新的問題。CPU運算時不直接和主存做數(shù)據(jù)交互,而是和L1 Cache交互,L1 cache 又是和L2 Cache交互…… 那么一定意味著同一份數(shù)據(jù)被緩存了多份,各層存儲之間的數(shù)據(jù)一致性如何保證? 如果是單線程還好,畢竟查詢同一時間只會在一個核心上運行。但當多線程需要操作同一份數(shù)據(jù)時,數(shù)據(jù)一致性的問題就凸顯出來了,如下圖,我們舉個例子。
??在上圖中3個CPU核心各自的Cache分別持有了不同的a0值(先忽略E和I標記),實際上只有Cache0里才持有正確的數(shù)值。這時候,如果CPU1或者CPU2需要拿著Cache中a0值去執(zhí)行某些操作,那結(jié)果可想而知。如果想保證程序在多線程環(huán)境下正確運行,就首先得保證Cache里的數(shù)據(jù)能在"恰當"的時間失效,并且有效的數(shù)據(jù)也能被及時回寫到主存里。
??然而CPU是不知道當前時刻下哪些數(shù)據(jù)該失效、哪些該回寫、哪些又是可以接著使用的。這個時候其實CPU的設計者也很犯難,如果數(shù)據(jù)頻繁失效,CPU每次獲取必須從主存里獲取數(shù)據(jù),CPU實際運算能力將回到幾十年前的水平。如果一直不給不失效,就會出現(xiàn)數(shù)據(jù)不一致導致的問題。于是CPU的設計者不干了:”這個問題我處理不了,我給你們提供一些可以保證數(shù)據(jù)一致性的匯編指令,你們自己去處理”。 于是大家就在intel、arm的開發(fā)手冊上看到了像xchg、lock、flush……之類的匯編指令,C/C++語言和操作系統(tǒng)的開發(fā)者將這些封裝成了volatile、atomic……以及各種系統(tǒng)調(diào)用,JVM和JDK的開發(fā)者又把這些封裝了我在文首說的那一堆關(guān)鍵詞。 于是CPU的設計者為了提升性能導致數(shù)據(jù)一致性的問題,最終還是推給了上層開發(fā)者自己去解決。
??作為上層的開發(fā)者們(比如我們)就得判斷,在多線程環(huán)境下那些數(shù)據(jù)操作必須是原子操作的,這個時候必須使用Unsafe.compareAndSwap()來操作。還有那些數(shù)據(jù)是不能被CPU Cache緩存的,這個時候就得加volatile關(guān)鍵詞。極端情況下,你可以所有的操作搞成原子操作、所有的變量都聲明成volatile,雖然這樣的確可以保證線程安全,但也會因為主存訪問延時的問題,顯著降低代碼運行的速度。這個時候局部性原理又發(fā)揮出其神奇的價值,在實際情況下,絕大多數(shù)場景都是線程安全的,我們只需要保證某些關(guān)鍵操作的線程安全性即可。舉個簡單的例子,我們在任務向多線程分發(fā)的時候,只需要保證一個任務同時只被分發(fā)給一個線程即可,而不需要保證整個任務執(zhí)行的過程都是完全線程安全的。
??作為Java開發(fā)者,Java和JDK的開發(fā)者們已經(jīng)幫我們在很多場景下封裝好了這些工具,比如我們就拿ReentrantLock實現(xiàn)一個多線程計數(shù)器的例子來看。
??其中increment() 本身不是一個線程安全的方法,如果多個線程并發(fā)去調(diào)用,仍然會出現(xiàn)count值增長不準確的問題。但在lock的加持下,我們能保證increment()方法同時只能有一個線程在執(zhí)行。想象下,如果我們把上述代碼中的counter()方法換成一些更復雜的方法,而完全不需要在方法中去考慮線程安全的問題,這不就實現(xiàn)了僅在關(guān)鍵操作上保證準確性就能保證全局的線程安全嗎!而當我們?nèi)ド罹縧ock的實現(xiàn)時,就會發(fā)現(xiàn)它底層也只是在tryAcquire中使用CAS設置了state值。
??在多線程編程中,加鎖或加同步其實是最簡單的,但是在什么時候什么地方加鎖卻是一件非常復雜的事情。你需要考慮鎖的粒度的問題,粒度太大可能影響性能,粒度過小可能導致線程安全的問題。還需要考慮到加鎖順序的問題,加鎖順序不當可能會導致死鎖。還要考慮數(shù)據(jù)同步的問題,同步的數(shù)據(jù)越多,CPU Cache帶來的性能提升也就越少……
??從上面CPU的發(fā)展變化我們可以看到,現(xiàn)代CPU的本質(zhì)其實也是一個分布式系統(tǒng),很多時候仍需要編程者手動去解決數(shù)據(jù)不一致性的問題。當然隨著編程語言的發(fā)展,這些底層相關(guān)的東西也逐漸對普通程序員變得更透明化,我們是不是可以預想,未來是不是會有一門高性能、并且完全不需要程序員關(guān)注數(shù)據(jù)一致性的編程語言出現(xiàn)?文章來源:http://www.zghlxwxcb.cn/news/detail-430545.html
??最后上面計數(shù)器代碼給大家留一個思考題: 代碼中的counter變量聲明是否需要加volatile關(guān)鍵字?文章來源地址http://www.zghlxwxcb.cn/news/detail-430545.html
到了這里,關(guān)于從CPU的視角看 多線程代碼為什么那么難寫!的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!