【並發編程】並發編程中你需要知道的基礎概念



本博客系列是學習並發編程過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。

並發編程系列博客傳送門


多線程是Java編程中一塊非常重要的內容,其中涉及到很多概念。這些概念我們平時經常掛在嘴上,但是真的要讓你介紹下這些概念,你可能還真的講不清楚。這篇博客就總結下多線程編程中經常用到的概念,理解這些概念能幫助我們更好地掌握多線程編程。

進程(Process)與線程(Thread)

進程和線程是最常提到的概念了。在linux中,線程與進程最大的區別就是是否共享同一塊地址空間,而且共享同一塊地址空間的那一組線程將顯現相同的PID號。下面介紹下兩者的概念:

  • 進程是操作系統進行資源分配和調度的最小單元,可以簡單地理解為系統中運行的一個程序就是一個進程。
  • 線程是CPU調度的最小單元,是進程中的一個個執行流程。
  • 一個進程至少包含一個線程,可以包含多個線程,這些線程共享這個進程的資源(比如堆區和方法區資源)。同時每個線程都擁有獨立的運行棧和程序計數器,線程切換開銷小。
  • 多進程指的是操作系統同時運行多個程序,如當前操作系統中同時運行着QQ、IE、微信等程序。
  • 多線程指的是同一進程中同時運行多個線程,如迅雷運行時,可以開啟多個線程,同時進行多個文件的下載。

談到線程和進程,又勢必會涉及到線程號和進程號的概念。下面列舉了各個ID的概念。

  • pid: 進程ID。
  • tgid: 線程組ID,也就是線程組leader的進程ID,等於pid。
  • lwp: 線程ID。在用戶態的命令(比如ps)中常用的顯示方式。
  • tid: 線程ID,等於lwp。tid在系統提供的接口函數中更常用,比如syscall(SYS_gettid)和syscall(__NR_gettid)。

並行(Parallel)、並發(Concurrent)

  • 並發:是指多個線程任務在同一個CPU上快速地輪換執行,由於切換的速度非常快,給人的感覺就是這些線程任務是在同時進行的,但其實並發只是一種邏輯上的同時進行;
  • 並行:是指多個線程任務在不同CPU上同時進行,是真正意義上的同時執行。

下面貼上一張圖來解釋下這兩個概念:

上圖中的咖啡就可以看成是CPU,上面的只有一個咖啡機,相當於只有一個CPU。想喝咖啡的人只有等前面的人制作完咖啡才能制作自己的開發,也就是同一時間只能有一個人在制作咖啡,這是一種並發模式。下面的圖中有兩個咖啡機,相當於有兩個CPU,同一時刻可以有兩個人同時制作咖啡,是一種並行模式。

我們發現並行編程中,很重要的一個特點是系統具有多核CPU。要是系統是單核的,也就談不上什么並行編程了。

線程安全

這個概念可能是在多線程編程中提及最多的一個概念了。在面試過程中,我試着問過幾個面試者,但是幾乎沒人能將這個概念解釋的很好的。

關於這個概念,我覺得好多人都有一個誤區,包括我自己一開始也是這樣的。我一開始認為線程安全講的是某個共享變量線程安全,其實我們所說的線程安全是指某段代碼或者是某個方法是線程安全的。線程安全的准確定義應該是這樣的:

如果線程的隨機調度順序不影響某段代碼的最后執行結果,那么我們認為這段代碼是線程安全的

為了保證代碼的線程安全,Java中推出了很多好用的工具類或者關鍵字,比如volatile、synchronized、ThreadLocal、鎖、並發集合、線程池和CAS機制等。這些工具並不是在每個場景下都能滿足我們多線程編程的需求,並不是在每個場景下都有很高的效率,需要我們程序員根據具體的場景來選擇最適合的技術,這也許就是我們程序員存在的價值所在。(我一直覺得如果有一個技術能很好的解決大多數場景下的問題,那么這個領域肯定是可以做成機器自動化的。那么對於這個領域就不太需要有多少人參與了。)

一般情況下,當我們對共享變量進行並發修改時就可能會產生線程安全問題,最常見的就是多個線程對一個共享變量進行累加操作。這時就需要我們自己采取各種手段來保證代碼執行的正確性。那是不是說只有對共享變量進行並發改時才有線程安全問題呢?其實不是的。多線程只讀操作時也會發生可見性問題,這個會在后面的文章中分析到。這邊有個小建議:在分析共享變量的線程安全問題時,可以逐一用原子性問題、可見性問題和有序性問題往上套,如果這三個問題都不存在的話,那么大概率就不存在線程安全問題了。

死鎖

線程1占用了鎖A,等待鎖B,線程2占用了鎖B,等待鎖A,這種情況下就造成了死鎖(更加書面的解釋:死鎖是指兩個或兩個以上的線程在執行過程中,因爭奪資源而造成的互相等待的現象,在無外力作用的情況下,這些線程會一直相互等待而無法繼續運行下去)。在死鎖狀態下,相關的代碼將不能再提供服務。

private void deadLock() {
	  Thread t1 = new Thread(new Runnable() {
		@Override
		public void run() {
		    synchronized (lock1) {
			  try {
				Thread.currentThread().sleep(2000);
			  } catch (InterruptedException e) {
				e.printStackTrace();
			  }
			  synchronized (lock2) {
				System.out.println("1");
			  }
		    }
		}
	  });
	  Thread t2 = new Thread(new Runnable() {
		@Override
		public void run() {
		    synchronized (lock2) {
			  synchronized (lock1) {
				System.out.println("2");
			  }
		    }
		}
	  });
	  t1.start();
	  t2.start();
    }

這段代碼只是演示死鎖的場景,在現實中你可能不會寫出這樣的代碼。但是,在一些更為復雜的場景中,你可能會遇到這樣的問題,比如t1拿到鎖之后,因為一些異常情況沒有釋放鎖(死循環)。又或者是t1拿到一個數據庫鎖,釋放鎖的時候拋出了異常,沒釋放掉。

如果你懷疑代碼中有線程出現了死鎖,你可以dump線程,然后查看線程狀態有沒有Blocked的線程(java.lang.Thread.State: BLOCKED)


"Thread-2" prio=5 tid=7fc0458d1000 nid=0x116c1c000 waiting for monitor entry [116c1b000] 
    java.lang.Thread.State: BLOCKED (on object monitor) 
	 at com.ifeve.book.forkjoin.DeadLockDemo$2.run(DeadLockDemo.java:42) 
	 - waiting to lock <7fb2f3ec0> (a java.lang.String) 
	 - locked <7fb2f3ef8> (a java.lang.String) 
	 at java.lang.Thread.run(Thread.java:695)
	 
	 
"Thread-1" prio=5 tid=7fc0430f6800 nid=0x116b19000 waiting for monitor entry [116b18000] 
    java.lang.Thread.State: BLOCKED (on object monitor) 
	 at com.ifeve.book.forkjoin.DeadLockDemo$1.run(DeadLockDemo.java:31) 
	 - waiting to lock <7fb2f3ef8> (a java.lang.String) 
	 - locked <7fb2f3ec0> (a java.lang.String) 
	 at java.lang.Thread.run(Thread.java:695)

避免死鎖的幾個方式:

  • 盡量不要一個線程同時占用多個鎖;
  • 多個線程加鎖的順序保持一致,比如上面的列子中,都先加A鎖,再加B鎖,這樣就能破壞造成死鎖的環路結構。
  • 嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
  • 對於數據庫鎖,加鎖和解鎖必須在一個數據庫連接里,否則會出現解鎖失敗的情況。

飢餓

飢餓是指某一個或者多個線程因為種種原因無法獲得所需要的資源,導致一直無法執行。比如它的線程優先級可能太低,而高優先級的線程不斷搶占它需要的資源,導致低優先級線程無法工作。

在自然界中,母鳥給雛鳥喂食時很容易出現這種情況:由於雛鳥很多,食物有限,雛鳥之間的食物競爭可能非常厲害,經常搶不到食物的雛鳥有可能會被餓死。線程的飢餓非常類似這種情況。

此外,某一個線程一直占着關鍵資源不放,導致其他需要這個資源的線程無法正常執行,這種情況也是飢餓的一種。與死鎖相比,飢餓還是有可能在未來一段時間內解決的(比如,高優先級的線程已經完成任務,不再瘋狂執行)。

活鎖

活鎖是一種非常有趣的情況。不知道大家是否遇到過這么一種場景,當你要坐電梯下樓時,電梯到了,門開了,這時你正准備出去。但很不巧的是,門外一個人擋着你的去路,他想進來。於是,你很禮貌地靠左走,避讓對方。同時,對方也非常禮貌地靠右走,希望避讓你。結果,你們倆就又撞上了。於是乎,你們都意識到了問題,希望盡快避讓對方,你立即向右邊走,同時,他立即向左邊走。結果,又撞上了!不過介於人類的智能,我相信這個動作重復兩三次后,你應該可以順利解決這個問題。因為這個時候,大家都會本能地對視,進行交流,保證這種情況不再發生。

但如果這種情況發生在兩個線程之間可能就不會那么幸運了。如果線程的智力不夠,且都秉承着“謙讓”的原則,主動將資源釋放給他人使用,那么就會導致資源不斷地在兩個線程間跳動,而沒有一個線程可以同時拿到所有資源正常執行。這種情況就是活鎖。

同步(Synchronous)和異步(Asynchronous)

這邊討論的同步和異步指的是同步方法和異步方法。

同步方法是指調用這個方法后,調用方必須等到這個方法執行完成之后才能繼續往下執行。
異步方法是指調用這個方法后會立馬返回,調用方能立馬往下繼續執行。被調用的異步方法其實是由另外的線程進行執行的,如果這個異步方法有返回值的話可以通過某種通知的方式告知調用方。

實現異步方法的方式:

  • 回調函數模式:一個方法被調用后立馬返回,調用結果通過回調函數返回給調用方;
  • MQ(發布/訂閱):請求方將請求發送到MQ,請求處理方監聽MQ處理這些請求,並將請求處理結果也返回給某個MQ,調用方監聽這個Queue獲取處理結果;
  • 多線程處理模式:系統創建其他線程處理調用請求,比如Spring中的@Async注解標注的方法就是這種方法。

臨界區

涉及讀寫共享資源的代碼片段叫“臨界區”。

比如下面代碼中,1處和2處就是一個代碼臨界區。

private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }

        public synchronized   double deposit(double amount){
            balance = balance + amount; //1
            return balance;
        }

        public synchronized  double  withdraw(double amount){
            balance = balance - amount; //2
            return balance;
        }

    }

上下文切換

線程在CPU上運行之前需要CPU給這個線程分配時間片,當時間片運行完之后這個線程就會讓出CPU資源給其他的線程運行。但是線程在將CPU資源讓出之前會保存當前的任務狀態以便下次獲得CPU資源之后可以繼續往下執行。所以線程從保存當前執行狀態到再加載的過程稱為一次上下文切換。

減少上下文切換的措施

  • 無鎖並發編程。多線程競爭鎖時,會引起上下文切換,所以多線程處理數據時,可以用一些辦法來避免使用鎖,如將數據的ID按照Hash算法取模分段,不同的線程處理不同段的數據。
  • CAS算法。Java的Atomic包使用CAS算法來更新數據,而不需要加鎖。
  • 使用最少線程。避免創建不需要的線程,比如任務很少,但是創建了很多線程來處理,這樣會造成大量線程都處於等待狀態。
  • 協程:在單線程里實現多任務的調度,並在單線程里維持多個任務間的切換。

使用vmstat命令,可以觀測機器每秒上下文切換的次數


[root@xx ~]# vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  0  69708 149024  57776 601328    0    0    11   142    0    1  4  1 87  7  1    
 1  0  69708 148776  57776 601508    0    0     0     0  781 1553 37  3 58  0  2    
 0  0  69708 148776  57776 601544    0    0     0    36  372  801  7  0 92  0  1    
 0  0  69708 148776  57776 601544    0    0     0     0  315  521  0  1 99  0  0    
 0  0  69708 145500  57792 601560    0    0     0    72  355  624  3  4 92  0  1    

上面命令中,1表示每秒采集一次服務器狀態,5表示采集5次。cs那列表示每秒上下文切換的次數。

當我們調用系統函數,就要進行上下文切換,線程的切換,也要進程上下文切換,cs這個值要越小越好,太大了,要考慮調低線程或者進程的數目,例如在apache和nginx這種web服務器中,我們一般做性能測試時會進行幾千並發甚至幾萬並發的測試,選擇web服務器的進程可以由進程或者線程的峰值一直下調,壓測,直到cs到一個比較小的值,這個進程和線程數就是比較合適的值了。系統調用也是,每次調用系統函數,我們的代碼就會進入內核空間,導致上下文切換,這個是很耗資源,也要盡量避免頻繁調用系統函數。上下文切換次數過多表示你的CPU大部分浪費在上下文切換,導致CPU干正經事的時間少了,CPU沒有充分利用,是不可取的。

多線程編程的優勢和挑戰

使用並發編程的目的是讓程序運行的更快(更大限度的使用CPU資源,讓程序運行更快),但是在進行並發編程的過程也會遇到一些挑戰。

PS:多線程並發編程可以讓我們最大限度的使用系統的CPU資源,以達到讓程序運行更快的目的(不是所有情況下多線程都更快)。但是一個硬幣具有兩面性,引入多線程編程會給我們帶來其他的問題,比如說線程的上下文切換問題、共享變量的線程安全問題、線程間通信問題、線程死鎖問題和硬件資源對多線程的影響等問題。其實研究多線程並發編程就是在研究這對矛盾體,怎么在享受多線程並發編程給我們帶來便利的同時又能避開多線程帶來的坑。

參考


免責聲明!

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



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