天天看點

[譯] 多線程簡介:一步一步來接近多線程的世界

原文位址:A gentle introduction to multithreading

原文作者:Triangles

譯文出自:掘金翻譯計劃

本文永久連結:github.com/xitu/gold-m…

譯者:steinliber

校對者:Graywd,Endone

現代計算機已經具備了在同一時間執行多個操作的能力。在更先進的硬體和更智能的作業系統支援下,這個特征可以讓你程式的執行和響應速度變得更快。

編寫能夠利用這種特性的軟體會很有意思,但也很棘手:這需要你了解計算機背後所發生的事情。在第一節中,我将會試着簡單覆寫關于線程的知識,它是由作業系統提供能實作這種魔術的工具之一。讓我們開始吧!

程序和線程:用正确的方式來命名事物

現代作業系統可以在同一時間運作多個程式。這就是為什麼你可以在浏覽器(一個程式)閱讀這篇文章的同時還可以在播放器(另一個程式)上收聽音樂。這裡的每個程式被認為是一個正在執行的程序。作業系統知道很多軟體層面的技巧來使一個程序和其他程序一起運作,也可以利用底層硬體來實作這個目的。無論哪種方式,最終的結果就是你會感覺所有程式都正在同時運作。

在作業系統中運作程序并不是同時執行多個操作唯一的方式。每個程序其内部還可以同時運作多個子任務,這些子任務叫做線程。你可以把線程了解為程序本身的一部分。每個程序在啟動時至少會觸發一個線程,被稱為主線程。然後,根據程式/開發者的需要,可以在程序内啟動和終止額外的線程。多線程就是指在同一個程序中運作多個線程的技術。

比如說,你的播放器就可能運作了多個線程:一個線程用來渲染界面 —— 這個線程通常是主線程,另一個用于播放音樂等等。

你可以把作業系統了解為一個包含多個程序的容器,其中的每個程序都是一個包含多個線程的容器。在本文中,我将隻關注線程,但是這整個主題都很吸引人,是以值得在将來做更深入的分析。

[譯] 多線程簡介:一步一步來接近多線程的世界

圖1:作業系統可以被看作一個包含程序的盒子,程序又可以被看作包含一個或多個線程的盒子。

程序和線程之間的差別

每個程序都有屬于它自己的記憶體塊,由作業系統負責進行配置設定。在預設情況下,程序之間不能共享彼此的記憶體塊:浏覽器程式無法通路配置設定給播放器的記憶體,反之亦然。就算你運作了相同的程序執行個體(比如你啟動了浏覽器兩次),它們之間也不會共享記憶體。作業系統将每個執行個體視為一個新的程序,并配置設定其各自獨立的記憶體。是以,在一般情況下,多個程序互相之間無法共享資料,除非它們使用一些進階的技巧 —— 所謂的程序間通信。

和程序不一樣,線程共享由作業系統配置設定給其父程序的同一塊記憶體:這樣播放器的音頻引擎可以很簡單的讀取到主界面的資料,反之亦然。是以相較于程序,線程之間互相通信更加容易。除此之外,線程通常比程序更輕:它們占用的資源更少,建立的速度更快,這就是為什麼它們也被稱為輕量級程序的原因。

要讓你的程式在同一時間執行多個操作,線程是一種簡單的方式。如果沒有線程,你就需要為每個任務寫一個程式,把它們作為程序運作并通過作業系統對這些程序進行同步。相較之下,這不僅會變得更難(程序間通信比較棘手)而且速度更慢(程序比線程更重)。

綠色線程,纖程

到目前為止提到的線程都是作業系統層面的概念:一個程序想要啟動一個新線程必須通過作業系統。然而并非每個平台都原生支援線程。綠色線程,也被稱為纖程是對線程的一種模拟,使多線程程式可以在不提供線程能力的環境下工作。比如說,在虛拟機的底層作業系統并沒有對線程原生支援的情況下,它還是可以實作綠色線程。

綠色線程可以更快的建立和管理,因為對其的操作完全繞過了作業系統,但是這也有缺點。我将在下一節中談到這個話題。

“綠色線程”的名字來自于 Sun Microsystem 的綠色團隊,他們在 90 年代設計了 Java 最初 的線程庫。現在,Java 不再使用綠色線程:它們在 2000 年的時候被切換成了原生線程。其它一些像 Go,Haskell 或者 Ruby 等程式設計語言 —— 它們采用了和綠色線程相同的實作而沒有用原生線程。

線程是用來幹嘛的

為什麼一個程序應該使用多個線程?就像我之前提到的,并行處理可以極大加快速度。假設你要在電影編輯器中渲染一部電影。這個編輯器足夠智能的話,它可以将渲染操作分散到多個線程中,每個線程負責處理電影的一部分。這樣的話如果用一個線程處理該任務要一個小時,那麼使用兩個線程則需要 30 分鐘;使用 4 個線程要 15 分鐘,以此類推。

真的有那麼簡單嗎?這裡有三點需要考慮:

并不是每個程式都需要多線程。如果你的應用執行的是順序操作或者等待使用者做一些事情,多線程可能并沒有那麼好;

你不能隻是簡單在應用中增加更多的線程,來讓它運作更快:每個子任務都必須經過仔細的思考和設計進而實作并行操作;

并不能百分百保證線程将真正并行的執行操作(即同時執行):它實際上取決于程式運作的底層硬體。

最後至關重要的一點:如果你的計算機不支援在同一時間執行多個操作,作業系統就會僞裝成它們是那樣運作的。我們之後将會馬上看到這個。目前,讓我們把并發了解成我們看起來任務在同時運作,而真正的并行就是像字面上了解的那樣,任務在同一時間運作。

[譯] 多線程簡介:一步一步來接近多線程的世界

圖 2:并行是并發的子集。

是什麼使并發和并行成為可能

計算機的中央處理單元(CPU)負責運作程式的繁重工作。它由幾部分組成,其中主要的部分叫做核心:這就是實際執行計算的地方。一個核心在同一時間隻能執行一個操作。

無疑,這是核心一個主要的缺點。是以,作業系統層面提供了先進的技術使使用者能夠同時運作多個程序(或線程),特别是在圖形環境中,甚至在單核機器上。其中最重要的方式叫做搶占式多任務處理,這裡面的搶占式是指可以控制中斷正在運作的任務,切換到另一個任務,一段時間後再恢複執行之前運作任務的能力。

是以如果你的 CPU 隻有一個核心,那麼作業系統的一部分工作就是把這個單核的計算能力配置設定到多個程序或線程中,這些程序或線程會一個接一個地循環執行。這種操作會給你一種多個程式在并行運作的錯覺,如果是使用了多線程,就會覺得這個程式在同時做很多事。這滿足了并發性,但是并不是真的并行 —— 即同時運作程序的能力仍然是缺失的。

目前現代 CPU 都會有多個核心,其中每個核心同一時間執行一次獨立的操作。這意味着在多核的情況下真正的并行是可以實作的。比如說,我的 Intel Core i7 處理器有 4 個核心:它可以同時運作 4 個不同的程序和線程。

作業系統可以檢測 CPU 内部核心的數量并為其中的每一個都配置設定程序或者線程。隻要作業系統喜歡,線程可以被配置設定到其中的任何一個核心,并且這種排程對于運作的程式來講是完全透明的。另外如果所有核心都在忙的話,搶占式多任務就會參與其中進行排程。這就可以讓你能夠運作比計算機實際可用核心數量更多的程序和線程。

多線程應用跑在一個單獨的核心:這有意義嗎?

在單核機器上是不可能實作真正意義上的并行的。然而,如果你的應用可以從多線程中獲益,那在單核機器上跑多線程應用還是有意義的。這種情況下當一個程序使用多線程的時候,即使其中的一個線程在執行比較慢或者阻塞的任務,搶占式多任務機制還是可以讓應用保持運作。

比如說你正在開發一個桌面應用,它會從一個很慢的磁盤讀取一些資料。如果你隻是寫了個單線程程式,整個應用在讀取資料的時候就會失去響應一直到讀取完成:配置設定給這個唯一線程的 CPU 算力在等待磁盤喚醒的過程中被浪費。當然,作業系統還運作了除此之外的其它很多程序,但是你這個特定應用的運作将不會有任何進展。

讓我們重新用多線程的方式思考你的應用。程式的線程 A 負責磁盤通路,線程 B 負責主界面。如果線程 A 由于裝置讀取慢而卡住,線程 B 仍運作着主界面,進而讓你的應用保持響應。這是有可能的,因為有了兩個線程,作業系統就可以在它們之間切換配置設定 CPU 資源,而不會讓這個程式因為較慢的線程而卡住。

線程越多,問題越多

如我們所知,線程共享它們父程序的同一塊記憶體。這使得在同一個應用的線程間交換資料非常容易。比如:一個電影編輯器可能有一大部分的共享記憶體用于包含視訊時間線。這樣的共享記憶體被數個用于渲染電影到檔案中的工作線程讀取。它們隻需要一個指向該記憶體區域的句柄(例如指針),就可以從中讀取資料并将渲染幀輸出到磁盤。

隻要多個線程是從同一個記憶體位置讀取資料那這事情還算順利。如果它們之中的一個或多個寫資料到共享記憶體中而有其他線程正從中讀取資料的時候,麻煩就開始了。這個時候會出現兩個問題:

資料競争 —— 當寫線程修改記憶體的時候,讀線程可能這在讀這個記憶體。如果寫線程還沒有完成寫操作,讀線程将會得到損壞的資料;

競争條件 —— 讀線程應該在寫線程寫完之後才能讀記憶體。如果事情發生的順序正好相反呢?比資料競争更微妙在于,競争條件是指多個線程以不可預知的順序執行它們的工作,而實際上,我們想要這些操作按照正确的順序執行。即使對資料競争做了保護,你的程式可能還是會觸發競争條件。

線程安全的概念

如果一段代碼由多個線程同時執行,且正常工作,即沒有資料競争或競争條件,那麼就可以說它是線程安全的。你可能已經注意到一些程式庫聲明自己是線程安全的:如果你正在編寫一個多線程程式,想要確定任何第三方的函數可以跨線程使用而不會觸發并發問題,就要注意這些聲明。

資料競争的根本原因

我們知道一個 CPU 核心在同一時間隻能執行一條機器指令。這樣的指令叫做原子操作因為它是不可分割的:它不能被分解成更小的操作。希臘語單詞 “atom”(ἄτομος; atomos)就是指不能被切分了。

不可分割的屬性使原子操作本質上就是線程安全的。當一個線程在共享資料上執行原子寫時,沒有其它線程可以讀取被修改了一半的資料。相反,當一個線程在共享資料上執行原子讀時,它會讀取在某一時刻出現在記憶體中的整個值。在執行原子操作的時候其它線程不可能蒙混過關插入進來,是以就不會發生資料競争。

不幸的是,絕大部分操作都是非原子的。在一些硬體上即使是像 x = 1 這樣簡單的指派操作也可能是由多個原子機器指令組成的,這就使指派操作這個整體本身成為一個非原子操作。如果一個線程在讀取 x 值的同時另一個線程在對其進行指派就會觸發資料競争。

競争條件的根本原因

搶占式多任務機制給予了作業系統對線程管理完全的控制權:它可以根據進階排程算法來開始,停止或者暫停線程。作為開發者,你不能控制線程執行的時間或者順序。實際上,像下面這樣簡單的代碼也不能保證按照特定的順序啟動:

writer_thread.start()
reader_thread.start()           

複制代碼運作這個程式幾次,你就會注意到它每次運作的行為是如何的不同:有時寫線程先啟動,有時讀線程先啟動。如果你的程式需要在讀之前先寫,那麼肯定會遇到競争條件。

這種表現被稱為非确定性:運作結果每次都會改變而你無法預測。調試受競争條件影響的程式非常煩人,因為你不能總是以一種可控的方式來重制問題。

來教線程們相處:并發控制

資料競争和競争條件都是現實世界的問題:有些人甚至因之而死。排程多個并發線程的藝術叫做并發控制:為了處理這個問題,作業系統和程式設計語言提供了幾個解決方案。其中最重要的是:

  • 同步 —— 一種確定同一時間資源隻會被一個線程使用的方式。同步就是把代碼的特定部分标記為“受保護的”,這樣多個并發線程就不會同時執行這段代碼,避免它們把共享資料搞砸;
  • 原子操作 —— 由于作業系統提供了特殊指令,許多非原子操作(像之前的指派操作)可以變成原子操作。這樣,無論其它線程如何通路共享資料,共享資料始終保持有效狀态。
  • 不可變資料 —— 共享資料被标記為不可變的,沒有什麼可以改變它:線程隻能從中讀取,這樣就消除了根本原因。正如我們所知,隻要不修改記憶體線程就可以安全的從相同的記憶體位置讀取資料。這是函數式程式設計背後的主要理念。

在這個關于并發的小系列下一節中,我将會讨論所有這些引人入勝的主題。敬請期待!

作者:掘金翻譯計劃

連結:

https://juejin.im/post/5ca351da6fb9a05e6a08745b

來源:掘金

著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

繼續閱讀