從多線程編程探討高並發實現


多線程的介紹

線程的來源,為什么會有線程?

在早期的操作系統中並沒有線程的概念,進程是能擁有資源和獨立運行的最小單位,也是程序執行的最小單位。任務調度采用的是時間片輪轉的搶占式調度方式,而進程是任務調度的最小單位,每個進程有各自獨立的一塊內存,使得各個進程之間內存地址相互隔離。
后來,隨着計算機的發展,對CPU的要求越來越高,進程之間的切換開銷較大,已經無法滿足越來越復雜的程序的要求了。於是就發明了線程,線程是程序執行中一個單一的順序控制流程,是程序執行流的最小單元,是處理器調度和分派的基本單位。一個進程可以有一個或多個線程,各個線程之間共享程序的內存空間(也就是所在進程的內存空間)。一個標准的線程由線程ID、當前指令指針(PC)、寄存器和堆棧組成。而進程由內存空間(代碼、數據、進程空間、打開的文件)和一個或多個線程組成。

線程的定義

線程(thread)是操作系統能夠進行運算調度的最小單位。它被包含在進程之中,是進程中的實際運作單位。一條線程指的是進程中一個單一順序的控制流,一個進程中可以並發多個線程,每條線程並行執行不同的任務。在Unix System V及SunOS中也被稱為輕量進程(lightweight processes),但輕量進程更多指內核線程(kernel thread),而把用戶線程(user thread)稱為線程。

操作系統中線程間任務調度方式

大部分操作系統(如Windows、Linux)的任務調度是采用時間片輪轉的搶占式調度方式,也就是說一個任務執行一小段時間后強制暫停去執行下一個任務,每個任務輪流執行。任務執行的一小段時間叫做時間片,任務正在執行時的狀態叫運行狀態,任務執行一段時間后強制暫停去執行下一個任務,被暫停的任務就處於就緒狀態等待下一個屬於它的時間片的到來。這樣每個任務都能得到執行,由於CPU的執行效率非常高,時間片非常短,在各個任務之間快速地切換,給人的感覺就是多個任務在“同時進行”。多任務運行示圖如下:
image.png

多線程與多核

前面講的線程調度是針對單個核來講的調度情況,也就是同一時間點只能執行多個線程中的某一個線程。但是在多核的情況下,多線程又增加了不同的調度方式。多線程與內核的對應關系有三種:一對一、多對一、多對多模型。

一對一模型

對於一對一模型來說,一個線程就唯一地對應一個內核。線程之間的並發就是真正的並發執行,線程之間不會互相阻塞。最大的缺點就是線程的個數受到了內核數的限制。
image.png

多對一模型

多個線程綁定到一個核。解決了一對一模型的線程數的限制,但是如果線程里某一個線程阻塞,會影響到其他線程的執行,因為這些線程會互相爭搶CPU資源。
image.png

多對多模型

多對多模型結合了一對一模型和多對一模型的優點,將多個用戶線程映射到多個內核上。 當前操作系統默認都采用的多對多模型,用於平衡多線程情況下性能和線程調度。當然,CPU密集型線程可以指定線程綁定到對應的核上,以提高線程執行的效率。
image.png

多線程帶來了哪些編程復雜性?

共享數據訪問(鎖)

訪問共享數據帶來的線程安全問題,一直是多線程編程里面最讓人頭疼的問題。為避免這種情況發生,我們要將多個線程對同一數據的訪問同步,確保線程安全。

互斥鎖

所謂同步(synchronization)就是指一個線程訪問數據時,其它線程不得對同一個數據進行訪問,即同一時刻只能有一個線程訪問該數據,當這一線程訪問結束時其它線程才能對這它進行訪問。同步最常見的方式就是使用鎖(Lock),也稱為線程鎖。鎖是一種非強制機制,每一個線程在訪問數據或資源之前,首先試圖獲取(Acquire)鎖,並在訪問結束之后釋放(Release)鎖。在鎖被占用時試圖獲取鎖,線程會進入等待狀態,直到鎖被釋放再次變為可用。

讀寫鎖

讀寫鎖(Read-Write Lock)允許多個線程同時對同一個數據進行讀操作,而只允許一個線程進行寫操作。這是因為讀操作不會改變數據的內容,是安全的;而寫操作會改變數據的內容,是不安全的。

信號量

多元信號量允許多個線程訪問同一個資源,多元信號量簡稱信號量(Semaphore),對於允許多個線程並發訪問的資源,這是一個很好的選擇。一個初始值為N的信號量允許N個線程並發訪問。
線程訪問資源時首先獲取信號量鎖,進行如下操作:

1. 將信號量的值減1; 
2. 如果信號量的值小於0,則進入等待狀態,否則繼續執行; 

訪問資源結束之后,線程釋放信號量鎖,進行如下操作:

1. 將信號量的值加1; 
2. 如果信號量的值小於1(等於0),喚醒一個等待中的線程;

粗粒度的鎖會降低效率,引起鎖的競爭。過度細粒度的鎖又會造成鎖本身占用太多的運行時間和空間,同樣會影響到效率。所以要解決鎖在效率上的影響,應從程序設計方面尋找答案。

適應並行運行的程序結構設計

多線程場景下,如果不改變程序的設計,會很難最大程度的利用到多線程的效率優勢。從設計上,盡量保證最小集的共享數據訪問,最少的細粒度鎖使用。

並行設計可以多考慮以下建議:

  • 使用基於線程的局部變量存儲線程中用到的數據(Thread Local)。
  • 使用Volatile修飾變量。對同一個 Volatile 變量的寫 / 讀訪問。
  • 分治法(Divide-and-Conquer:將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。)在多線程編程中,可以考慮將程序拆分為一個個較小的可執行任務(Task),並且這些任務之間相互獨立,然后將任務分派到各個線程去執行。

為什么線程過多會帶來性能問題?

線程的上下文交換的性能消耗相對於進程是很少的,但是在高性能場景中我們也不能忽視線程的上下文交換帶來的性能損耗。主要表現在以下幾個方面:

線程掛起和恢復,爭搶資源

系統掛起線程時,保存當前線程的注冊狀態。恢復線程時,需要恢復線程的狀態信息。這些動作都需要消耗CPU時鍾周期,並且線程信息占用高速緩存。現代處理器都非常依賴高速緩存,它們的讀取效率比內存要快100倍。但是高速緩存的容量有限,如果緩存區滿,處理器會從高速緩存里面移出數據,騰出空間供新數據使用。所以在線程很多的情況下,線程會互相移除存儲在高速緩存區的狀態,爭搶緩存區的高速緩存資源,這很明顯會影響性能。

內存抖動

虛擬內存是計算機系統內存管理的一種技術。它使得應用程序認為它擁有連續的可用的內存 (一個連續完整的地址空間),而實際上,它通常是被分隔成多個物理內存碎片, 還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數據交換。 與沒有使用虛擬內存技術的系統相比,使用這種技術的系統使得大型程序的編寫變得更容易, 對真正的物理內存(例如RAM)的使用也更有效率。

抖動在分頁存儲管理系統中,內存中只存放了那些經常使用的頁面, 而其它頁面則存放在外存中,當進程運行需要的內容不在內存時, 便啟動磁盤讀操作將所需內容調入內存,若內存中沒有空閑物理塊, 還需要將內存中的某頁面置換出去。也就是說,系統需要不斷地在內外存之間交換信息。 若在系統運行過程中,剛被淘汰出內存的頁面,過后不久又要訪問它, 需要再次將其調入。而該頁面調入內存后不久又再次被淘汰出內存,然后又要訪問它。 如此反復,使得系統把大部分時間用在了頁面的調入/換出上, 而幾乎不能完成任何有效的工作,這種現象稱為抖動。 每一個線程都需要占用部分虛擬內存來存儲它的堆棧和私有數據結構。跟高速緩存一樣,大量的線程會導致內存頁面不夠用,需要頻繁的在內存和磁盤中進行分頁置換。極端情況下,如果線程足夠多,會耗盡所有的虛擬內存。

持鎖的線程掛起

線程掛起后,其它等待該鎖的線程,必須等到持有鎖的線程在恢復運行,釋放鎖之后才能運行。如果鎖的獲取順序是先到先得,影響會更大。因為只要某一個等待線程掛起,那么所有在等待線程后面獲取鎖的所有線程都會被阻塞。很像中國的一句俗語叫“占着茅坑不拉屎”。

多線程與高並發有沒有必然的關系?

一般來說,高並發的解決方案就是多線程模型,服務器為每個客戶端請求分配一個線程,使用同步I/O,系統通過線程切換來彌補同步I/O調用的時間開銷,比如Apache就是這種策略,由於I/O一般都是耗時操作,因此這種策略很難實現高性能,但非常簡單,可以實現復雜的交互邏輯。

讓我們回到線程最初的來源,多線程是提高並發效率的手段。但是也要記住,多線程只是手段,不是目的,我們的目的是要實現高並發,有效的利用處理器的計算資源。先介紹比較典型的幾個高並發、高性能的開源組件。

如何高效的利用線程達到任務的高並發?

生活中遇到的很多場景,多是IO密集型。解決這類問題的核心思想就是減少cpu空轉的時間,增加CPU的利用率。具體有下面兩種方法:

限制活動線程的個數不超過硬件線程的個數

活動線程指Runnable狀態的線程。
Blocked狀態的線程個數不在限制內。Blocked狀態的線程都在等待外部事件觸發,比如鼠標點擊、磁盤IO操作事件,操作系統會將他們移除到調度隊列外,所以它們不會消耗cpu時間片。程序可以有很多的線程,但是只要保證活動的線程個數小於硬件線程的個數,運行效率是可以保證的。
計算密集型和IO密集型線程是要分開看待的。計算密集型線程應該永遠不被block,大部分時間都要保證runnable狀態。要有效的利用處理器資源,可以讓計算密集型的線程個數跟處理器個數匹配。而IO密集型線程大部分時間都在等待IO事件,不需要太多的線程。

基於任務的編程(協程)

線程個數跟硬件線程一致。任務調度器把對應的任務放入跟線程做一個映射,放入到相應的線程執行。有幾個明顯的優勢:

  1. 按需調度。
    線程調度器的時間片是公平的分配給各個線程的,因為它不不理解程序的業務邏輯。這就跟計划經濟樣的,極度的公平就是不公平,市場經濟這種按需分配才能提高效率。任務調度器是理解任務信息的,可以更有效的調度任務。
  2. 負載均衡。
    將程序分成一個個小的任務,讓調度器來調度,不讓所有的線程空跑,保證線程隨時有活干。有效的利用計算資源、平衡計算資源。
  3. 更易編程。
    以線程為基礎的編程,要提高效率,經常要考慮到底層的硬件線程,考慮線程調度受到的影響。但是如果基於任務來編程,只要集中注意力在任務之間的邏輯關系上,處理好任務之間的關系。調度效率可以交給調度器來管控。

單線程高並發的開源軟件介紹

Nginx

Nginx 專為性能優化而開發,性能是其最重要的考量,實現上非常注重效率 。它支持內核 Poll 模型,能經受高負載的考驗,有報告表明能支持高達 50,000 個並發連接數。
NGINX采用了異步、事件驅動的方法來處理連接。這種處理方式無需(像使用傳統架構的服務器一樣)為每個請求創建額外的專用進程或者線程,而是在一個工作進程中處理多個連接和請求。為此,NGINX工作在非阻塞的socket模式下,並使用了epoll 和 kqueue這樣有效的方法。因為滿負載進程的數量很少(通常每核CPU只有一個)而且恆定,每個進程里面也只有一個主線程,所以任務切換只消耗很少的內存,而且不會浪費CPU周期。通過NGINX本身的實例,這種方法的優點已經為眾人所知。NGINX可以非常好地處理百萬級規模的並發請求。
image.png

Node.js

Node.js采用 事件驅動 和 異步I/O 的方式,實現了一個單線程、高並發的運行時環境,而單線程就意味着同一時間只能做一件事,那么Node.js如何利用單線程來實現高並發?

多數網站的服務器端都不會做太多的計算,它們只是接收請求,交給其它服務(比如從數據庫讀取數據),然后等着結果返回再發給客戶端。因此,Node.js針對這一事實采用了單線程模型來處理,它不會為每個接入請求分配一個線程,而是用一個主線程處理所有的請求,然后對I/O操作進行異步處理,避開了創建、銷毀線程以及在線程間切換所需的開銷和復雜性。

Node.js的高並發的核心機制就是線程中循環處理事件的機制。

事件循環處理方式

Node.js 在主線程中維護了一個事件隊列。

  1. 當接收到請求后,就將請求作為一個事件放入該隊列中,然后繼續接收其他請求。
  2. 當主線程空閑時(沒有請求接入時),就開始循環事件隊列,檢查隊列中是否有要處理的事件。

    • 如果是非I/O任務,就親自處理,並通過回調函數返回到上層調用;
    • 如果是I/O任務,就從線程池中拿出一個線程來執行這個事件,並指定回調函數,然后繼續循環隊列中的其他事件。當線程中的I/O任務完成后,就執行指定的回調函數,並把這個完成的事件放到事件隊列的尾部,等待事件循環,當主線程再次循環到該事件時,就直接處理並返回給上層調用。

這個過程就叫事件循環(Event Loop),如下圖所示:
image.png


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM