<meta name="source" content="lake">
前言
軟件開發(fā)中各類知識都是具有一定相關(guān)性的察绷,前端開發(fā)雖然大部分時間都是在寫頁面、做交互津辩,但是除了頁面開發(fā)之外拆撼,我們也可以掌握一些網(wǎng)絡(luò)、操作系統(tǒng)喘沿、后端闸度、數(shù)據(jù)庫等其他的知識,擴充自己的知識面蚜印。我一直傾向于多的掌握各類知識莺禁,領(lǐng)會它們之間的聯(lián)系,做一個有見識的人窄赋,然后再去深挖背后的原理和細(xì)節(jié)哟冬。在學(xué)習(xí)時先建立起完善的知識結(jié)構(gòu),然后再追本溯源忆绰,找到知識的源頭浩峡,“要****識廬山真面目,不應(yīng)身在此山中****”错敢。
今天就一起來了解下進程翰灾、線程和協(xié)程,目標(biāo)是弄明白下面幾個問題:
- 程序稚茅、進程纸淮、線程、協(xié)程是什么亚享?
- 程序運行的基本過程萎馅?
- 為什么要有進程、線程虹蒋、協(xié)程糜芳?
- 如何使用進程飒货、線程和協(xié)程?
基本概念
程序
計算機程序是指一組指示電子計算機或其他具有消息處理能力設(shè)備每一步動作的指令峭竣,通常用某種程序設(shè)計語言編寫塘辅,運行于某種目標(biāo)體系結(jié)構(gòu)上〗粤茫——《維基百科》
我們都知道程序其實就是一系列的指令扣墩,用一種計算機程序設(shè)計語言編寫,然后用編譯器或者解釋器翻譯成機器語言扛吞。指令可以分為操作和數(shù)據(jù)呻惕,對應(yīng)編程語言中的算法和數(shù)據(jù)結(jié)構(gòu)。
進程
程序相當(dāng)于是一個名詞滥比,描述了一件事如何去做亚脆,而進程是程序運行的真正實例。當(dāng)下達了運行程序的命令后盲泛,操作系統(tǒng)會把程序相關(guān)的內(nèi)容和資源加載到內(nèi)存中濒持,這些資源就是進程。進程是資源分配的基本單位寺滚。
一個進程通常包括或者說擁有下面這些資源:
- 那個程序的可執(zhí)行機器代碼的一個在存儲器的映像柑营。
- 分配到的存儲器(通常是虛擬的一個存儲器區(qū)域)。存儲器的內(nèi)容包括可執(zhí)行代碼村视、特定于進程的資料(輸入官套、輸出)、調(diào)用堆棧蚁孔、堆棧(用于保存運行時運輸中途產(chǎn)生的資料)虏杰。
- 分配給該進程的資源的操作系統(tǒng)描述符,諸如文件描述符(Unix術(shù)語)或文件句柄(Windows)勒虾、資料源和資料終端纺阔。
- 安全特性,諸如進程擁有者和進程的權(quán)限集(可以容許的操作)修然。
- 處理器狀態(tài)(內(nèi)文)笛钝,諸如寄存器內(nèi)容、物理存儲器尋址等愕宋。當(dāng)進程正在運行時玻靡,狀態(tài)通常存儲在寄存器,其他情況在存儲器中贝《谀恚——《維基百科》
虛擬內(nèi)存
進程的創(chuàng)建或者說程序的加載是由操作系統(tǒng)的加載器來完成的。內(nèi)存資源是有限的邻寿,所以程序不是一下子全部加載進內(nèi)存蝎土,而是使用了虛擬內(nèi)存视哑。操作系統(tǒng)會為程序創(chuàng)建一個連續(xù)的虛擬地址空間,內(nèi)存會被分成很多頁誊涯,然后通過頁表記錄虛擬內(nèi)存到物理內(nèi)存之間的映射挡毅,每一頁對應(yīng)物理內(nèi)存中的一塊(頁幀)。操作系統(tǒng)會記錄程序入口地址(虛擬地址)暴构, 等到程序要開始運行時跪呈,從該地址中取指令開始運行。此時進程處于就緒狀態(tài)取逾。
內(nèi)存中以字節(jié)為存儲單位耗绿,每一個字節(jié)分配一個物理地址。以 32 位 CPU 位例砾隅,可以表示 232個地址误阻,也就是 4G * 1B = 4GB
的物理空間(這也是為什么 32 位 CPU 最多支持 4G 內(nèi)存。通過 PAE 可以擴展到 64GB)琉用。虛擬內(nèi)存和物理內(nèi)存會被分為很多塊堕绩,按每塊 4KB 計算策幼,一個頁表應(yīng)該有 1M 個頁表項邑时。要想表示 1M 個頁表項,需要 20 位特姐,在 x86 中頁表項除了塊地址外還包含其他信息總共 32 位晶丘,也就是 4B,因此一個頁表的大小就是 4MB唐含。
進程在執(zhí)行時 CPU 需要將虛擬地址轉(zhuǎn)換成物理地址浅浮,轉(zhuǎn)換主要通過 MMU(內(nèi)存管理單元) 來完成。MMU 是CPU的一部分捷枯,每個處理器核心都有滚秩。每次轉(zhuǎn)換,MMU首先在 TLB(轉(zhuǎn)譯后備緩沖區(qū)淮捆,快表) 中檢查現(xiàn)有的緩存郁油。如果沒有命中,根據(jù) CR3 寄存器攀痊,Table Walk Unit 將從內(nèi)存中的頁表查詢桐腌。
進程的切換
我們都知道,程序并不完全是同時運行的苟径,而是操作系統(tǒng)按照一定的調(diào)度算法輪流讓進程獲取 CPU 時間來執(zhí)行的案站。當(dāng)一個進程的執(zhí)行時間到了就會掛起該進程,切換到另一個進程運行棘街。當(dāng)一個進程獲取到 CPU 時間開始執(zhí)行時蟆盐,進程就處于運行狀態(tài)承边。有時候,正在進行的進程由于發(fā)生某個事件而暫時無法繼續(xù)執(zhí)行時舱禽,便放棄處理機而處于暫停狀態(tài)炒刁,這種暫停狀態(tài)叫阻塞進程阻塞,此時進程處于等待狀態(tài)誊稚。
進程在切換時翔始,需要先保留當(dāng)前進程的現(xiàn)場,然后恢復(fù)另一個進程的現(xiàn)場里伯,這一過程叫做進程上下文切換城瞎。進程的上下文切換,主要可以分為兩個部分:
- 虛擬地址空間的切換疾瓮。
- 線程上下文切換(見下文)脖镀。
前面提到操作系統(tǒng)會為每個進程分配一個虛擬地址空間,CPU 執(zhí)行指令時狼电,拿到的地址都是虛擬地址蜒灰,然后 MMU 獲取物理地址。操作系統(tǒng)需要給每個進程設(shè)置虛擬地址空間肩碟,在進程調(diào)度過程中强窖,需要同時切換虛擬地址空間,否則 CPU 通過 MMU 獲取到的物理地址就不正確削祈,切換也就是把不同頁表的地址放入到 MMU 中翅溺。
關(guān)于線程的切換部分在后面線程調(diào)度的過程中再具體講述。
進程間通信
- 管道pipe:管道是一種半雙工的通信方式髓抑,數(shù)據(jù)只能單向流動咙崎,而且只能在具有親緣關(guān)系的進程間使用。進程的親緣關(guān)系通常是指父子進程關(guān)系吨拍。例如:node.js 中 fork 進程褪猛,linux 中管道符“|”。
- 命名管道FIFO:有名管道也是半雙工的通信方式羹饰,但是它允許無親緣關(guān)系進程間的通信伊滋。
- 消息隊列MessageQueue:消息隊列是由消息的鏈表,存放在內(nèi)核中并由消息隊列標(biāo)識符標(biāo)識严里。消息隊列克服了信號傳遞信息少新啼、管道只能承載無格式字節(jié)流以及緩沖區(qū)大小受限等缺點。
- 共享存儲SharedMemory:共享內(nèi)存就是映射一段能被其他進程所訪問的內(nèi)存刹碾,這段共享內(nèi)存由一個進程創(chuàng)建燥撞,但多個進程都可以訪問。共享內(nèi)存是最快的 IPC 方式,它是針對其他進程間通信方式運行效率低而專門設(shè)計的物舒。它往往與其他通信機制色洞,如信號兩,配合使用冠胯,來實現(xiàn)進程間的同步和通信火诸。
- 信號量Semaphore:信號量是一個計數(shù)器,可以用來控制多個進程對共享資源的訪問荠察。它常作為一種鎖機制置蜀,防止某進程正在訪問共享資源時,其他進程也訪問該資源悉盆。因此盯荤,主要作為進程間以及同一進程內(nèi)不同線程之間的同步手段。
- 套接字Socket:套解口也是一種進程間通信機制焕盟,與其他通信機制不同的是秋秤,它可用于不同及其間的進程通信。
- 信號 ( sinal ) : 信號是一種比較復(fù)雜的通信方式脚翘,用于通知接收進程某個事件已經(jīng)發(fā)生灼卢。
線程
線程(英語:thread)是操作系統(tǒng)能夠進行運算調(diào)度的最小單位。大部分情況下来农,它被包含在進程之中鞋真,是進程中的實際運作單位。一條線程指的是進程中一個單一順序的控制流备图,一個進程中可以并發(fā)多個線程灿巧,每條線程并行執(zhí)行不同的任務(wù)赶袄。
線程是獨立調(diào)度和分派的基本單位揽涮。——《維基百科》
程序就是一系列的指令饿肺,在程序運行的過程中 CPU 按照順序執(zhí)行指令蒋困,當(dāng)執(zhí)行某個指令所需要的的資源未就緒時,執(zhí)行就會陷入阻塞敬辣。為了提高 CPU 利用率雪标,可以將指令的執(zhí)行分為多段,讓某一段指令執(zhí)行阻塞時溉跃,可以切換到另一段流程繼續(xù)執(zhí)行村刨。這一段指令流就是線程。因此撰茎,可以說線程是程序執(zhí)行的基本單位嵌牺。
用戶線程和內(nèi)核線程
根據(jù)操作系統(tǒng)內(nèi)核是否對線程可感知,可以把線程分為內(nèi)核線程和用戶線程。
在用戶線程中逆粹,有關(guān)線程管理的所有工作都由應(yīng)用程序完成募疮,內(nèi)核意識不到線程的存在。對于系統(tǒng)內(nèi)核而言僻弹,其實就是一個單線程的進程在運行阿浓。
在內(nèi)核線程中,內(nèi)核線程建立和銷毀都是由操作系統(tǒng)負(fù)責(zé)蹋绽、通過系統(tǒng)調(diào)用完成的芭毙。線程管理的所有工作由內(nèi)核完成裳仆,應(yīng)用程序沒有進行線程管理的代碼嗦锐,只有一個到內(nèi)核級線程的編程接口。內(nèi)核為進程及其內(nèi)部的每個線程維護上下文信息皂冰,調(diào)度也是在內(nèi)核基于線程架構(gòu)的基礎(chǔ)上完成鹊奖。
用戶線程和內(nèi)核線程之間根據(jù)根據(jù)實現(xiàn)可以是一對一苛聘、多對一、多對多的關(guān)系忠聚。
線程的切換
在了解線程切換之前需要先理解一下用戶態(tài)和內(nèi)核態(tài)设哗。
在 Linux 中,按照特權(quán)等級两蟀,把進程的運行空間分為內(nèi)核空間和用戶空間网梢,分別對應(yīng)著下圖中, CPU 特權(quán)等級的 Ring 0 和 Ring 3赂毯。
- 內(nèi)核空間(Ring 0)具有最高權(quán)限战虏,可以直接訪問所有資源;
- 用戶空間(Ring 3)只能訪問受限資源党涕,不能直接訪問硬件設(shè)備烦感,必須通過系統(tǒng)調(diào)用陷入到內(nèi)核中,才能訪問這些特權(quán)資源膛堤。
進程既可以在用戶空間運行手趣,又可以在內(nèi)核空間中運行。進程在用戶空間運行時肥荔,被稱為進程的用戶態(tài)绿渣,而陷入內(nèi)核空間的時候,被稱為進程的內(nèi)核態(tài)燕耿。
當(dāng)需要訪問系統(tǒng)資源時中符,我們都需要系統(tǒng)調(diào)用來進行,也就是進入內(nèi)核態(tài)誉帅。其實就是完成某些操作的代碼放到了內(nèi)核中淀散,用戶代碼沒辦法直接訪問操作系統(tǒng)資源谭期,需要調(diào)用內(nèi)核暴露的接口來進行,相當(dāng)于一個內(nèi)核庫吧凉。
內(nèi)核線程的管理是由內(nèi)核完成的隧出,因此當(dāng)線程切換時還會涉及到用戶態(tài)到內(nèi)核態(tài)之間的切換,其實可以理解為需要執(zhí)行內(nèi)核線程調(diào)度和切換的代碼阀捅。
線上的切換就包括用戶態(tài)和內(nèi)核態(tài)的切換以及 CPU 硬件上下文的切換兩部分胀瞪。硬件上下文的切換很容易理解,就是 CPU 寄存器中數(shù)據(jù)需要緩存起來饲鄙,然后 PC(程序計數(shù)器) 切換到新的指令開始執(zhí)行凄诞,等到線程恢復(fù)運行時恢復(fù)之前緩存的數(shù)據(jù)繼續(xù)執(zhí)行指令。
線程鎖
多個線程對同一競態(tài)資源的搶奪會引發(fā)線程安全問題忍级。競態(tài)資源是對多個線程可見的共享資源帆谍,主要包括全局(非const)變量、靜態(tài)(局部)變量轴咱、堆變量汛蝙、資源文件等。
通過鎖機制朴肺,能夠保證在多核多線程環(huán)境中窖剑,在某一個時間點上,只能有一個線程進入臨界區(qū)代碼戈稿,從而保證臨界區(qū)中操作數(shù)據(jù)的一致性西土。
依據(jù)鎖的特性、鎖的設(shè)計鞍盗、鎖的狀態(tài)常見的分類如下:
- 樂觀鎖需了、悲觀鎖:樂觀鎖認(rèn)為一個線程去拿數(shù)據(jù)的時候不會有其他線程對數(shù)據(jù)進行更改,所以不會上鎖般甲,而是在更新數(shù)據(jù)的判斷是否被修改肋乍;悲觀鎖認(rèn)為一個線程去拿數(shù)據(jù)時一定會有其他線程對數(shù)據(jù)進行更改。所以一個線程在拿數(shù)據(jù)的時候都會順便加鎖欣除,這樣別的線程此時想拿這個數(shù)據(jù)就會阻塞住拭。
- 自旋鎖挪略、互斥鎖:自旋鎖的線程一直在那循環(huán)檢測鎖標(biāo)志位历帚,全程消耗 cpu,起始開銷雖然低于互斥鎖杠娱,但隨著持鎖時間加鎖開銷是線性增長挽牢。當(dāng)一個線程獲得互斥鎖后,其他線程會進入隨便狀態(tài)摊求,由操作系統(tǒng)調(diào)度喚醒并獲取鎖禽拔。
- 獨享鎖、共享鎖:字面意思。
- 公平鎖睹栖、非公平鎖:公平鎖中多個線程相互競爭時要排隊硫惕,多個線程按照申請鎖的順序來獲取鎖;非公平鎖中多個線程相互競爭時野来,先嘗試插隊恼除,插隊失敗再排隊。
協(xié)程
協(xié)程可以理解為用戶線程曼氛,工作的方式很像是線程池豁辉。協(xié)程的切換完全是在用戶空間進行,由我們自己編寫的代碼來控制舀患。協(xié)程在切換時徽级,只有 CPU 上下文的切換,相比較線程切換涉及到用戶空間和內(nèi)核空間的切換并且需要操作系統(tǒng)老大來調(diào)度聊浅,協(xié)程切換的開銷比線程切換要小得多餐抢。同時,有好必有壞低匙,協(xié)程也有如下的缺點:
- 無法利用多核資源弹澎。
- 一個協(xié)程如果阻塞會導(dǎo)致整個線程掛起。
程序執(zhí)行的基本過程
- 程序就是一堆指令和數(shù)據(jù)努咐,平時躺在硬盤里苦蒿。
- 當(dāng)開始運行該程序時,操作系統(tǒng)會通過虛擬內(nèi)存技術(shù)為程序分配虛擬的內(nèi)存空間渗稍,創(chuàng)建好頁面等各種信息佩迟,此時一個進程就起來了。此時程序代碼依舊在硬盤中竿屹。
- 當(dāng)操作系統(tǒng)通過調(diào)度輪到該進程執(zhí)行的時候报强,就把他的頁表地址放到MMU中,程序入口地址放到 PC 中拱燃。CPU 開始執(zhí)行指令秉溉。
- 此時指令還在內(nèi)存中還沒有程序的指令,會觸發(fā)缺頁中斷碗誉,然后由異常中斷程序負(fù)責(zé)從外存在中加載指令和數(shù)據(jù)到內(nèi)存中召嘶。
- 程序的指令就這樣一點點的被加載到內(nèi)存中,由 CPU 負(fù)責(zé)執(zhí)行哮缺,執(zhí)行的一系列指令就是線程弄跌。
JavaScript 中的基本使用
多進程
單個 Node.js 實例運行在單個線程中。 為了充分利用多核系統(tǒng)尝苇,有時需要啟用一組 Node.js 進程去處理負(fù)載任務(wù)铛只。
const cluster = require('cluster')
const http = require('http')
const numCPUs = require('os').cpus().length
if (cluster.isMaster) {
console.log(`主進程 ${process.pid} 正在運行`)
// 衍生工作進程埠胖。
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作進程 ${worker.process.pid} 已退出`)
})
} else {
// 工作進程可以共享任何 TCP 連接。
// 在本例子中淳玩,共享的是 HTTP 服務(wù)器直撤。
http
.createServer((req, res) => {
res.writeHead(200)
res.end('你好世界\n')
})
.listen(8000)
console.log(`工作進程 ${process.pid} 已啟動`)
}
多線程
我們都知道 JavaScript 是單線程的,既然是單線程的蜕着,在某個特定的時刻只有特定的代碼能夠被執(zhí)行谊惭,并阻塞其它的代碼。JavaScript 通過事件和回調(diào)實現(xiàn)異步任務(wù)侮东。當(dāng)所有同步任務(wù)執(zhí)行完成后圈盔,就會依次取出異步的任務(wù)開始執(zhí)行。具體可以參考JavaScript 運行機制詳解:再談Event Loop悄雅。
function foo() {
console.log('first')
setTimeout(function () {
console.log('second')
}, 5)
}
console.time()
for (let i = 0; i < 5000; i++) {
foo()
}
console.timeEnd()
Web Worker 的作用驱敲,就是為 JavaScript 創(chuàng)造多線程環(huán)境,允許主線程創(chuàng)建 Worker 線程宽闲,將一些任務(wù)分配給后者運行众眨。在主線程運行的同時,Worker 線程在后臺運行容诬,兩者互不干擾娩梨。等到 Worker 線程完成計算任務(wù),再把結(jié)果返回給主線程览徒。這樣的好處是狈定,一些計算密集型或高延遲的任務(wù),被 Worker 線程負(fù)擔(dān)了习蓬,主線程(通常負(fù)責(zé) UI 交互)就會很流暢纽什,不會被阻塞或拖慢。
協(xié)程
Generator 函數(shù)是協(xié)程在 ES6 的實現(xiàn)躲叼,最大特點就是可以交出函數(shù)的執(zhí)行權(quán)(即暫停執(zhí)行)芦缰。
function* fun(a) {
console.log('a', a)
const b = yield a + 1
console.log('b', b)
const c = yield b + 2
console.log('c', c)
return c
}
var gen = fun(1)
console.log('next', gen.next(4))
console.log('next', gen.next(5))
console.log('next', gen.next(6))
通過生成器可以使用類似用戶級線程,控制任務(wù)處理的流程枫慷。下面是生產(chǎn)者-消費者的一個簡單例子让蕾。
const BUFFER_MAX_SIZE = 10
const buffer = []
function block() {
return Math.random() < 0.1
}
function* produce() {
let count = 0
while (true) {
if (buffer.length >= BUFFER_MAX_SIZE || block()) {
yield count
count = 0
} else {
const item = Math.round(Math.random() * 100)
console.log('生產(chǎn) item:' + item)
buffer.push(item)
count++
}
}
}
function* consume() {
let count = 0
while (true) {
if (buffer.length <= 0 || block()) {
yield count
count = 0
} else {
const item = buffer.shift()
console.log('消費 item:' + item)
count++
}
}
}
function main() {
const producer = produce()
const consumr = consume()
let i = 0
while (i < 10) {
if (Math.random() > 0.5) {
console.log('開始生產(chǎn)')
console.log(`此次生產(chǎn)了 ${producer.next().value} 個`)
} else {
console.log('開始消費')
console.log(`此次消費了 ${consumr.next().value} 個`)
}
i++
}
console.log(buffer)
}
main()