併發編程必須要知道的幾個基本問題

併發編程知識體系

併發編程是計算機學科重要的命題。 如何提綱挈領的掌握併發編程,搭建知識體系尤其重要。 這篇文章基於自己對於併發編程的理解和公開資料的整理,試圖撥開迷霧,從整體上介紹併發編程。

主要內容包括:

  1. 併發編程的基本概念:
  • 併發和並行的區別
  • 多線程優點
  • 多線程的三個基本問題
  1. 併發編程實踐
  • J.U.C框架
    • Excutor框架
    • Fork/Join框架
  • 併發編程兩個基本問題討論

併發編程基本概念

併發編程的產生

併發編程是伴隨計算機發展產生的。

第一代計算機使用笨重的卡片機, 工作人員將計算任務打孔的方式輸入到計算機中。 爲了提升運算速度,出現了批處理的操作系統。而後出現了分時操作操作系統。

英特爾(Intel)創始人之一戈登·摩爾提出:集成電路上可容納的晶體管數目,約每隔兩年便會增加一倍。這就是著名的摩爾定律。半導體行業大致按照摩爾定律發展了半個多世紀,對二十世紀後半葉的世界經濟增長做出了貢獻。

CPU從原來的單核逐漸增加成雙核、四核甚至更多。 CPU變的越來越快, 內存和IO的速度的增長卻很緩慢。緩存是爲了緩解CPU和內存速度之間的速度不匹配, 內存緩解了CPU和IO設備之間的速度不匹配。

現在計算機存儲結構如下圖所示:

多線程和多進程的產生就是爲了充分利用多核CPU的計算優勢, 減少IO阻塞而產生的。

併發編程的優勢

多線程程序有很多優點:

  1. 處理速度快: 多線程程序充分利用多核CPU的優勢,將計算分佈在不同的CPU核心提升計算速度。

  2. 減少IO阻塞: 網絡請求或者本地IO都是比較慢的操作, 可以利用多線程技術在IO的同時,新建一個線程進行其他操作(異步操作),減少IO阻塞對於響應速度的影響。 之前的操作系統只能新建少量的線程,因此,操作系統提供了高效的IO方式。比如多路複用IO,異步IO等。現代的操作系統,線程數量限制通常很大, 可以使用線程池技術提升處理能力。

  3. 提升界面系統響應速度:

傳統的GUI系統很多都是單線程, 通過主事件循環(Main Event Loop)處理界面組件各種事件。在處理比較耗時的事件操作時, 很容易卡住主界面。 現在的GUI系統利用多線程的技術, 使用事件分發機制(Event Dispatch Thread)代替主事件循環。 事件發生時調用對應的事件處理器。 界面系統影響更加靈敏。

  1. 簡化建模:

一個線程處理一個事情比一次處理多個事情建模更加容易。 通過多線程的框架可以把事件處理和資源調度、交替執行的操作、異步IO和資源等待隔離開來。現在的併發編程的框架和組件(Servlet,RMI等)使得建模更加簡單。

併發編程需要關注的三個問題:安全性問題、活性問題和性能問題

多線程是一把雙刃劍、帶來性能提升的同時帶來安全性、活性和性能問題。

以酒店清潔爲例子:

昆泰酒店有很多的房間需要打掃、保潔在主管的監督完成所有酒店房間的清潔工作。 公司資金有限, 只有有限的清潔工具供保潔輪流使用。

這個例子中:清掃房間就是接下來計算機要處理的計算任務。保潔相當於處理任務的線程,主管相當於線程調度器,有限的清潔工具相當於CPU資源或者其他公共資源。

那問題來了,酒店規模很小的時候,整個昆泰酒店只有1名保潔,這名保潔只需要逐個去打掃。處理速度雖然慢,但是不會出錯。清潔工具可以被這名保潔獨佔,只有一名保潔,因此也不需要額外招聘保潔主管。

隨着酒店規模變大,1名保潔不能滿足酒店的要求。公司僱傭更多的保潔,併爲這些保潔人員配置了保潔主管。

多名保潔打掃完房間之後會通知保潔主管該房間已經被打掃。清潔工具在使用完成,交給下一位保潔之前需要清掃乾淨並標記上自己的工號表示自己在使用(上下文切換)。

保潔之間協同工作和多線程系統很像。

安全性問題

保潔打掃一個房間,如果不做任何標記或者通知,別的保潔也可能進入打掃。造成了資源浪費(任務重疊執行)。更不幸的事情,新入住的房客在這個時候進入了房間看到房間一片狼藉,該作何想。

這種情況相當於公共資源訪問時的線程安全性問題。 線程安全性問題有時候只是造成資源浪費,跟多的時候會造成嚴重的錯誤。

保潔主管發現了這個問題之後,他讓保潔在進入房間門打掃之前在門口放置一個打掃中的牌子(鎖)。另外一個保潔看到這個牌子(鎖失效),就知道房間在打掃中。這個問題也就解決了。

房間的打掃狀態相當於Java語言中的鎖。 鎖不緊是能解決互斥訪問,也間接實現了線程之間的通信。

保潔主管爲了有限的工具(資源)被高效利用設計了一套資源分配的規則(JMM Java內存模型)。 規則規定了不同工具放置的位置(內存分佈)、工具使用規則(保證工具使用狀態每個人都可見)、工序優化的規則(重排序)、工作分配的規則(原子性)。

主管指定的資源分配的規則,相當於Java的內存模型。

Java內存模型包括Java內存區域劃分和內存使用規則(可見性、原子性和指令重排序)。

活性問題

由於線程資源稀缺性或者程序自身的問題和缺陷導致線程線程一直處於非runnable狀態,或者線程雖然處於runnable狀態但是其要執行的任務卻一直無法進展的現象被稱爲線程活性故障。 常見的線程活性故障包括死鎖、活鎖、飢餓。

死鎖: 保潔A手裏拿着拖把, 她需要抹布, 另外一個保潔手裏拿着抹布需要拖把。如果兩個人都互不相互謙讓的會就會造成死鎖問題。

造成死鎖需要四個條件: 互斥條件, 請求和保持條件, 不可剝奪和循環等待。 破壞四個條件任何一個,就可以解決死鎖的問題。

活鎖:並未產生線程阻塞,但是由於某種問題的存在,導致無法繼續執行的情況。

  1. 消息重試。當某個消息處理失敗的時候,一直重試,但重試由於某種原因,比如消息格式不對,導致解析失敗,而它又被重試

這種時候一般是將不可修復的錯誤不要重試,或者是重試次數限定

  1. 相互協作導致的活鎖問題。

比如兩個很有禮貌的人在同一條路上相遇,彼此給對方讓路,但是又在同一條路上遇到了。互相之間反覆的避讓下去

這種時候可以選擇一個隨機退讓,使得具備一定的隨機性

飢餓

優先級低的線程由於不停的被高優先級線程搶佔執行,導致最終無法執行。

引入公平機制可以解決飢餓問題。 比如使用ReentrantLock實現的公平鎖。

性能的問題

併發編程存在由於互斥資源爭用導致程序吞吐下降,執行變慢等性能問題。

加鎖可以解決線程資源爭用的問題,但同時帶來開銷。

synchronized鎖優化

在 JDK1.6 之後,出現了各種鎖優化技術,如輕量級鎖、偏向鎖、適應性自旋、鎖粗化、鎖消除等

synchronized關鍵字修飾的鎖對象會根據互斥資源爭用情況選擇不同的鎖的實現。

  • 當沒有鎖爭用時, 鎖變成一個偏向鎖,加鎖和解鎖無需額外的消耗。

  • 當存在鎖爭用時, 偏向鎖會升級爲輕量級鎖, 線程不會阻塞,通過自旋的方式提升程序響應速度。

  • 當更多的線程來爭用鎖資源的時候, 輕量級鎖會升級會重量級鎖, 線程會阻塞等待鎖釋放。

無鎖

當訪問共享數據時,通常是要使用同步。如果要避免使用同步,就是不提供共享數據。如果僅在單線程中訪問數據,就不需要同步,這種技術就叫做線程封閉。

實現線程封閉主要有三種方式:

  • Ad-hoc的方式:指維護線程封閉是由程序自己去實現和維護。Ad-hoc非常脆弱,因爲它沒有一種語言特性,例如可見性修飾符或局部變量,能將對象封閉到特定的線程上。

  • 棧封閉:棧封閉簡單理解就是通過局部變量來實現線程封閉,多個線程訪問對象的同一個方法,方法內部的局部變量會拷貝到每個線程的線程棧當中,只有當前線程才能訪問到,互不干擾。所以局部變量是不被多個線程所共享的。

  • ThreadLocal的方式

同時JDK也提供了AtomicXXX使用CAS方式實現線程同步。

併發編程模式

J.U.C模式

J.U.C是指java.util.concurrent包下的併發類。

J.U.C包裏的類有部分:

  1. 線程安全相關的類
  2. 線程池框架
  3. Fork/Join框架

線程安全相關的類包括如下三類:

  1. Atomic類:AtomicLong等
  2. 鎖對象:ReentrantLock,ReadWriteLock等
  3. 線程安全容器:ConcurrentHashMap,CopyOnWriteArrayList等

類圖如下所示:

ExecutorService模式

爲了提升房間清掃和人員使用的效率,主管提議使用動態人員的方式配置保潔(線程池)。 酒店每天至少有2名保潔值班(coreSize), 客人離店之後派遣2名保潔打掃。爲了提升服務體驗,如果有超過2個以上房客離店,主管將會從其他分部調派保潔。不幸的是總部規定每個酒店最多只能有10個保潔(maxSize)。如果沒有可用的房間,新來要入店的房客只能等待(Queue)。酒店資源有限,等待的隊列不能無限的長(有界隊列),否則有可能把酒店擠爆,哈哈哈,這種情況酒店就賺發了。在等待隊列也滿的時候, 需要合理策略(拒絕策略)處理。酒店前臺可選的策略可以有: 拒絕接待新客人(DiscardPolicy), 不接待新客人通知總部(AbortPolicy,引發RejectExecutionException異常), 讓客人自己打掃房間(CallerRunsPolicy), 隊尾位置讓給新來的客人(DiscardOldestPolicy)。

這就是線程池的模型。線程池模型核心關注:3個容量大小(coreSize, maxSize, queueSize)和4種拒絕策略(DiscardPolicy,AbortPolicy,CallerRunsPolicy,DiscardOldPolicy)。總部會根據酒店客流量情況安排資源池配置參數(設置coreSize,maxSize和QueueSize),這即是如何設置線程池參數的問題。

在默認ThreadPoolExecutor.AbortPolicy ,處理程序會引發運行RejectedExecutionException後排斥反應。 在ThreadPoolExecutor.CallerRunsPolicy中,調用execute本身的線程運行任務。 這提供了一個簡單的反饋控制機制,將降低新任務提交的速度。 在ThreadPoolExecutor.DiscardPolicy中 ,簡單地刪除無法執行的任務。 在ThreadPoolExecutor.DiscardOldestPolicy中

Fork/Join模式

需要打掃的房間不多的時候, ExcutorSevice的模式運行效率很好。中午12點之後,大量空閒房間需要打掃。 如果還按照線程池的模式進行任務分配, 保潔人員剛打掃完4層的房間,保潔主管打電話過來說下一步打掃1樓的房間。保潔主管發現這種模式在需要打掃房間比較多的時間(任務比較多)時效率並不高。

爲了解決這個難題, 保潔主管想到了一個辦法。給每個保潔分配一層樓(一個線程一個工作隊列),有樓層退房比較多, 有的比較少,爲了解決公平和效率的問題。保潔完成本層任務後,隨機去其他樓層幫忙(work stealing)。

保潔主管想到的這個方法就是Fork/Join模型。 Fork/Join類似ExecutorService 又有很多不同的地方。 Fork/Join可以把大任務(酒店),拆分成小任務(房間/樓層)。 Fork/Join模型和ExecutorService模型最主要兩點區別是

(1) 一個線程一個工作隊列:每個人負責一個樓層。 (2) 工作竊取:忙完自己的樓層,隨機去其他樓層幫忙。

併發編程兩個核心問題

多線程編程是實現併發編程主要途徑。 通過線程能夠大大提升系統吞吐和性能。但是,對於多線程編程要解決兩個核心問題:

  1. 線程通信問題:即線程之間如何交換數據和狀態。
  2. 線程同步問題: 如何在競態條件下保證程序正確運行。

我們可以使用等待/通知,共享變量,管道等方式進行線程通信,比如Object的wait/notify, Condition的await/notify, LockSupport的park/unpark, volatile,InheritableThreadLocal, PipeInputStream/PipeOutputStream等。

線程同步我們可以加鎖的技術(synchronized,Lock),也可以使用無鎖技術(CAS)。 由於篇幅的原因。關於線程通信和線程同步的問題,會單獨寫一篇文章去講解。

什麼是架構設計?架構設計看這篇文章就夠了

Redis爲什麼這麼快?

重磅:解讀2020年最新JVM生態報告

BIO,NIO,AIO 總結

JDK8的新特性,你知道多少?

回覆“資料”,免費獲取 一份獨家嘔心整理的技術資料! image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章