OS中的程式、執行緒
- 程式:即處於執行期的程式,且包含其他資源,如開啟的檔案、掛起的訊號、核心內部資料、處理器狀態、核心地址空間、一個或多個執行的執行緒、資料段。
- 執行緒:程式中的活動物件,核心排程的物件不是程式而是執行緒;傳統Unix系統一個程式只包含一個執行緒。
執行緒在Linux中的實現
從Linux核心的角度來說,並沒有執行緒這個概念。Linux把所有的執行緒都當做程式來實現,核心沒有為執行緒準備特別的排程演算法和特別的資料結構。執行緒僅僅被視為一個與其他程式共享某些資源的程式。所以,在核心看來,它就是一個普通的程式。
在Windows或Solaris等作業系統的實現中,它們都提供了專門支援執行緒的機制(lightweight processes
)。
寫時拷貝
傳統的fork()系統呼叫直接把所有資源複製給新建立的程式,效率十分低下,因為拷貝的資料也許並不需要。
Linux的fork()使用寫時拷貝實現。核心此時並不複製整個程式地址空間,而是讓父程式和子程式共享一個拷貝。
只有在需要寫入的時候,資料才會被複制,在此之前,只是以只讀方式共享。這種優化可以避免拷貝大量根本就不會被使用的資料(地址空間常常包含幾十M的資料)。
因此,Linux建立程式和執行緒的區別就是共享的地址空間、檔案系統資源、檔案描述符、訊號處理程式等這些不同。
以下是StackOverflow上的一個答案:
即,在Linux
下,程式使用fork()
建立,執行緒使用pthread_create()
建立;fork()
和pthread_create()
都是通過clone()
函式實現,只是傳遞的引數不同,即共享的資源不同。(Linux
是通過NPTL
實現POSIX Thread
規範,即通過輕量級程式實現POSIX Thread
,使之前在Unix
上的庫、軟體可以平穩的遷移到Linux
上)
Java執行緒如何對映到OS執行緒
JVM在linux平臺上建立執行緒,需要使用pthread 介面。pthread是POSIX標準的一部分它定義了建立和管理執行緒的C語言介面。Linux提供了pthread的實現:
pthread_t tid;
if (pthread_create(&tid, &attr, thread_entry_point, arg_to_entrypoint))
{
fprintf(stderr, "Error creating thread\n");
return;
}
-
tid
是新建立執行緒的ID -
attr
是我們需要設定的執行緒屬性 -
thread_entry_point
是會被新建立執行緒呼叫的函式指標 -
arg_to_entrypoint
是會被傳遞給thread_entry_point
的引數
thread_entry_point
所指向的函式就是Thread物件的run方法。
無返回值執行緒和帶返回值的執行緒
- 無返回值:一種是直接繼承Thread,另一種是實現Runnable介面
- 帶返回值:通過Callable和Future實現
帶返回值的執行緒是我們在實踐中更常用的。
競態條件
當某個計算的正確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件。
最常見的競態條件型別就是“先檢查後執行”(Check-Then-Act
)操作,即通過一個可能失效的觀測結果來決定下一步的動作。
使用“”先檢查後執行“的一種常見情況就是延遲初始化:
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
}
不要這麼做。
Executor框架
使用裸執行緒的缺點
在prod
環境中,為每個任務分配一個執行緒
的方法存在嚴重的缺陷,尤其是當需要建立大量的執行緒時:
- 執行緒生命週期的開銷非常高:執行緒的建立與銷燬並不是沒有代價的。
- 資源消耗:會消耗記憶體和CPU,大量的執行緒競爭CPU資源將產生效能開銷。如果你已經擁有足夠多的執行緒使所有CPU處於忙碌狀態,那麼建立更多的執行緒反而會降低效能。
- 穩定性:可建立的執行緒的數量上存在限制,包括
JVM
的啟動引數、作業系統對執行緒的限制,如果超出這些限制,很可能會丟擲OutOfMemoryError
異常。
Executor基本原理
Executor
基於生產者-消費者模式,提交任務的操作相當於生產者,執行任務的執行緒則相當於消費者。
執行緒池的建構函式如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}
執行緒池大小
-
corePoolSize
:核心執行緒數,當執行緒池的執行緒數小於corePoolSize
,直接建立新的執行緒 - 執行緒數大於
corePoolSize
但是小於maximumPoolSize
:如果任務佇列還未滿, 則會將此任務插入到任務佇列末尾;如果此時任務佇列已滿, 則會建立新的執行緒來執行此任務。 - 執行緒數等於
maximumPoolSize
:如果任務佇列還未滿, 則會將此任務插入到任務佇列末尾;如果此時任務佇列已滿, 則會由RejectedExecutionHandler
處理。
keep-alive
-
keepAliveTime
:當我們的執行緒池中的執行緒數大於corePoolSize
時, 如果此時有執行緒處於空閒(Idle)狀態超過指定的時間(keepAliveTime
), 那麼執行緒池會將此執行緒銷燬。
工作佇列
工作佇列(WorkQueue
)是一個BlockingQueue
, 它是用於存放那些已經提交的, 但是還沒有空餘執行緒來執行的任務。
常見的工作佇列有一下幾種:
- 直接切換(
Direct handoffs
) - 無界佇列(
Unbounded queues
) - 有界佇列(
Bounded queues
)
在生產環境中,禁止使用無界佇列,因為當佇列中堆積的任務太多時,會消耗大量記憶體,最後OOM
;通常都是設定固定大小的有界佇列,當執行緒池已滿,佇列也滿的情況下,直接將新提交的任務拒絕,拋RejectedExecutionException
出來,本質上這是對服務自身的一種保護機制,當服務已經沒有資源來處理新提交的任務,因直接將其拒絕。
Java原生執行緒池在生產環境中的問題
在服務化的背景下,我們的框架一般都會整合全鏈路追蹤
的功能,用來串聯整個呼叫鏈,主要是記錄TraceId
和SpanId
;TraceId
和SpanId
一般都記錄在ThreadLocal
中,對業務方來說是透明的。
當在同一個執行緒中同步RPC呼叫的時候,不會存在問題;但如果我們使用執行緒池做客戶端非同步呼叫時,就會導致Trace
資訊的丟失,根本原因是Trace
資訊無法從主執行緒的ThreadLocal
傳遞到執行緒池的ThreadLocal
中。
對於這個痛點,阿里開源的transmittable-thread-local
解決了這個問題,實現其實不難,可以閱讀一下原始碼:
https://github.com/alibaba/transmittable-thread-local
效能與伸縮性
對效能的思考
提升效能意味著用更少的資源做更多的事情。“資源”的含義很廣,例如CPU
時鐘週期、記憶體、網路頻寬、磁碟空間等其他資源。當操作效能由於某種特定的資源而受到限制時,我們通常將該操作稱為資源密集型的操作,例如,CPU密集型、IO密集型等。
使用多執行緒理論上可以提升服務的整體效能,但與單執行緒相比,使用多執行緒會引入額外的效能開銷。包括:執行緒之間的協調(例如加鎖、觸發訊號以及記憶體同步),增加的上下文切換,執行緒的建立和銷燬,以及執行緒的排程等。如果過度地使用執行緒,其效能可能甚至比實現相同功能的序列程式更差。
從效能監視的角度來看,CPU需要儘可能保持忙碌狀態。如果程式是計算密集型的,那麼可以通過增加處理器來提升效能。但如果程式無法使CPU保持忙碌狀態,那增加更多的處理器也是無濟於事的。
可伸縮性
可伸縮性是指:當增加計算資源時(例如CPU、記憶體、儲存容量、IO頻寬),程式的吞吐量或者處理能力能響應的增加。
我們熟悉的三層模型,即程式中的表現層、業務邏輯層和持久層是彼此獨立,並且可能由不同的服務來處理,這很好地說明了提高伸縮性通常會造成效能損失。如果把表現層、業務邏輯層和持久層都融合到某個單體應用中,在負載不高的時候,其效能肯定要高於將應用程式分為多層的效能。這種單體應用避免了在不同層次之間傳遞任務時存在的網路延遲,減少了很多開銷。
然而、當單體應用達到自身處理能力的極限時,會遇到一個嚴重問題:提升它的處理能力非常困難,即無法水平擴充套件。
Amdahl定律
大多數併發程式都是由一系列的並行工作和序列工作組成的。Amdahl
定律描述的是:在增加計算資源的情況下,程式在理論上能夠實現最高加速比,這個值取決於程式中可並行元件
與序列元件
所佔的比重。假定F
是必須被序列執行的部分,那麼根據Amdahl
定律,在包含N個處理器的機器上,最高的加速比為:
當N趨近於無窮大時,最大的加速比趨近於1/F
。因此,如果程式中有50%的計算需要序列執行,那麼最高的加速比只能是2。
上下文切換
執行緒排程會導致上下文切換,而上下文切換是會產生開銷的。若是CPU密集型
程式產生大量的執行緒切換,將會降低系統的吞吐量。
UNIX
系統的vmstat
命令能夠報告上下文切換次數以及在核心中執行時間的所佔比例等資訊。如果核心佔用率較高(超過10%),那麼通常表示排程活動發生得很頻繁,這很可能是由I/O
或者鎖競爭導致的阻塞引起的。
>> vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 3235932 238256 3202776 0 0 0 11 7 4 1 0 99 0 0
cs:每秒上下文切換次數
sy:核心系統程式執行時間百分比
us:使用者程式執行時間百分比
以上。