利用web work實現多執行緒非同步機制,打造頁面單步除錯IDE
我們已經完成了整個編譯器的開發,現在我們做一個能夠單步除錯的頁面IDE,完成本章程式碼後,我們可以實現下面如圖所示功能:
頁面IDE可以顯示每行程式碼所在的行,單擊某一行,在改行前面會出現一個紅點表示斷點,點選Parsing按鈕後,進入單步除錯模式,然後每點一次step按鈕,頁面就會執行一條語句,被執行的語句會以黃色高亮,同時左邊還有一個箭頭表明當前編譯器正在執行該語句,此時我們把滑鼠挪動到變數名上方時,會有一個popover控制元件彈出,它表明執行到當前語句時,滑鼠所在變數對應的數值,這個頁面IDE與我們平常使用的eclipse,VS等開發環境是一樣的,我們看看它如何設計。
基本原理是,主執行緒作為UI執行緒負責如上的顯示功能,同時我們啟動另一個解析執行緒去執行程式碼的編譯執行功能,解析執行緒每執行一條語句後,把當前變數資訊傳送給主UI執行緒,然後阻滯自身的執行,UI執行緒拿到解析執行緒傳送過來的資訊後,根據使用者的介面操作做進行相應的顯示,當用戶點選"step"按鈕時,主執行緒傳送一個訊息給解析執行緒,解析執行緒執行下一條語句的解析,然後把解析結果傳送給主執行緒,然後再次進入阻滯狀態,這個迴圈反覆進行,直到所有程式碼解析完畢為止。
我們先看看js執行緒在瀏覽器中的執行模式:
每個執行緒都對應一個訊息佇列,執行緒主體不斷的從佇列中取出訊息然後執行訊息所要做的操作,如果一個訊息處理太久時,就會把整個執行緒堵塞住。為了防止這種情況出現,同時又能有效處理那些計算繁重的任務,同時不因執行緒堵塞導致使用者介面出現僵死,JS2017版的標準提供了多執行緒機制,術語叫web woker,我們可以把計算量繁瑣的任務提交給web worker處理,主執行緒負責響應使用者操作,web worker處理完後把結果以訊息的方式傳遞給主執行緒。
有了多執行緒機制,JS又向c#,java這些桌面開發語言邁進一步。隨著多執行緒而來的是多執行緒的通訊和同步問題,web worker之間依然靠相互發送訊息進行通訊,訊息裡往往含有資料,但兩個執行緒一般情況下不會共享記憶體,當一個執行緒將資料傳送給另一個執行緒時,js直譯器會把資料拷貝後再發送到目標執行緒的訊息佇列上。但多執行緒開發中往往又這種需求,那就是一個執行緒阻滯自己,等待其他執行緒給它傳送一個訊號後再繼續往下執行,這就得提供程序間的訊號機制。在js2017中就提供了這種機制。它有一個專門類叫SharedArrayMemory,這個類可以定義一塊共享記憶體,兩個執行緒可以同時讀取這塊記憶體,這樣一個執行緒向記憶體寫入資料後,另一個執行緒既可以直接獲得寫入內容,同時js2017還聽過了一種原子操作類叫Atomics,它用於程序間對共享記憶體進行互斥的讀寫操作。
要實現兩個執行緒間的訊號機制,我們可以用上面兩個類來實現,假設有兩個執行緒,分別是worker1,worker2,worker1先分配一塊共享記憶體,然後將它傳送給worker2:
worker1:
var sharedMem = new SharedArrayMemory(8) //8位元組共享記憶體
woker2.postMessage(sharedMem);
var int8 = new Int8Array(sharedMem)
/*如果8位元組共享記憶體第一個位元組值為0,
那麼woker1進入阻滯狀態,第一個0表示int8陣列的下標,
第二個0表示比較值,如果int8[0] === 0,那麼執行緒就一直沉睡*/
Atomics.wait(int8, 0, 0)
/*
當woker2執行緒把共享記憶體第一個位元組的值改成0以外其他值,woker1就可以往下執行
*/
worker2:
self.onmessage => (e) {
//e.data是訊息附帶的資料,對應worker1傳送過來的共享記憶體
var int8 = new Int8Array(e.data)
//將共享記憶體第一位元組的值設定為1,下面語句執行後worker1就能成阻滯中恢復執行
Atomics.store(int8, 0, 1)
}
上面程式碼就是兩個執行緒間通過原子操作讀寫共享記憶體的程式碼,通過共享記憶體,兩個執行緒之間就能實現訊號機制。這裡有個問題是,在reactjs 中SharedArrayMemory以及Atomics兩個類智慧在web worker中使用而不能在主執行緒也就是UI執行緒中使用。由於這個原因,我們的IDE在實現時,主執行緒必須建立兩個worker執行緒。
頁面IDE的實現框架如下:
接著我們看看程式碼實現,首先我們看看如何顯示程式碼行數,紅色斷點,語句黃色高亮,以及顯示程式碼執行時的指向箭頭。首先我們看看如何實現每按一次回車就能在編輯框的最左邊自動顯示對應行號,在MonkeyCompilerEditer.js中新增如下程式碼:
constructor(props) {
....
// change 1
var ruleClass1= 'span.'+this.lineSpanNode + ':before'
var rule = 'counter-increment: line;content: counter(line);display: inline-block;'
rule += 'border-right: 1px solid #ddd;padding: 0 .5em;'
rule += 'margin-right: .5em;color: #666;'
rule += 'pointer-events:all;'
document.styleSheets[2].addRule(ruleClass1, rule);
this.bpMap = {}
this.ide = null
...
}
上面程式碼給css新增新規則,使得在控制元件前面自動新增一個偽元素,該微元素用於顯示行號,並且在輸入回車後自動增加行號,由於我們在編輯控制元件中,每次回車時都會構造一個元素將一行的內容夾在裡面,於是當該元素產生後,上面新增的css規則自動在該元素前面新增一個用於顯示行號的偽元素,於是就可以讓我們按回車時自動在編輯器左邊顯示行號。
我們看看滑鼠點選後如何產生一個紅色圓圈做斷點,相關程式碼如下:
getCaretLineNode() {
....
if (currentLineSpan !== null) {
// change 2
currentLineSpan.onclick = function(e) {
this.createBreakPoint(e.toElement)
}.bind(this)
return currentLineSpan
}
....
var spanNode = document.createElement('span')
spanNode.classList.add(this.lineSpanNode)
spanNode.classList.add(this.lineNodeClass + l)
// change 2
spanNode.dataset.lineNum = l
spanNode.onclick = function(e) {
this.createBreakPoint(e.toElement)
}.bind(this)
....
}
// change 3
setIDE(ide) {
this.ide = ide
}
createBreakPoint(elem) {
if (elem.classList.item(0) != this.lineSpanNode) {
return
}
//是否已存在斷點,是的話就取消斷點
if (elem.dataset.bp === "true") {
var bp = elem.previousSibling
bp.remove()
elem.dataset.bp = false
delete this.bpMap['' + elem.dataset.lineNum]
if (this.ide != null) {
this.ide.upddateBreakPointMap(this.bpMap)
}
return
}
//構造一個紅色圓點
elem.dataset.bp = true
this.bpMap[''+elem.dataset.lineNum] = elem.dataset.lineNum
var bp = document.createElement('span')
bp.style.height = '10px'
bp.style.width = '10px'
bp.style.backgroundColor = 'red'
bp.style.borderRadius = '50%'
bp.style.display = 'inline-block'
bp.classList.add(this.breakPointClass)
elem.parentNode.insertBefore(bp, elem.parentNode.firstChild)
if (this.ide != null) {
this.ide.updateBreakPointMap(this.bpMap)
}
}
當我們把游標放在某一行時,如果改行是新的一行,那麼最下面程式碼被呼叫,它建立一個的控制元件將改行包裹起來,同時設定它的onClick函式,以便響應滑鼠在改行上的單擊事件,一旦我們用滑鼠在指定行點選時,onClick事件觸發,並呼叫createBreakPoint來建立一個紅色斷點。createBreakPoint先判斷改行是否已經有斷點了,如果有則取消該點,如果沒有,我們則構建一個span控制元件,並在裡面繪製一個紅色的實心圓圈。其中的updateBreakPointMap用來通知IDE控制元件有新斷點產生。
接下來我們再看看如何顯示單步除錯時在左邊顯示一個箭頭:
hightlineByLine (line, hightLine) {
var lineClass = this.lineNodeClass + line
var spans = document.getElementsByClassName(lineClass)
// change 4
if (spans !== null && hightLine == true) {
var span = spans[0]
span.style.backgroundColor = 'yellow'
var arrow = document.createElement("span")
arrow.classList.add("glyphicon")
arrow.classList.add("glyphicon-circle-arrow-right")
arrow.classList.add("ArrowRight")
span.parentNode.insertBefore(arrow, span)
}
if (spans !== null && hightLine == false) {
var span = spans[0]
span.style.backgroundColor = 'white'
var arrow = document.getElementsByClassName('ArrowRight')
if (arrow !== undefined) {
arrow[0].parentNode.removeChild(arrow[0])
}
}
}
當某一行程式碼正在被執行時,我們會執行上面程式碼對改行程式碼進行高亮顯示,在給改行換成黃色背景時,我們會在行的前面新增一個控制元件,並將它的類設定為"glyphicon glyphicon-circle-arrow-right",這兩個類是bootstrp提供的,設定上就可以使得span變成一個指向右邊的箭頭。完成這些介面特色後,我們看看重頭戲,也就是如何使用多執行緒實現程式碼單步除錯,要想讓web worker在reactjs 框架裡能夠直接呼叫我們原來定義的class類,我們需要做一些比較複雜的配置,這樣webpack在整合程式碼時,才能將class定義的程式碼與web worker程式碼正確結合起來。 首先我們要下載一個reactjs控制元件,命令列如下:
npm install react-app-rewired worker-loader --save-dev
然後在reactjs工程的根目錄下建立一個檔名為config-overrides.js,然後新增如下程式碼:
module.exports = function override(config, env) {
config.module.rules.push({
test: /\.worker\.js$/,
use: { loader: 'worker-loader' }
})
return config;
}
它的作用是讓webpack在整合程式碼時,把檔名字尾為.worker.js的檔案也進行整合,整合的方式是呼叫我們前面安裝的worker-loader來進行,使用woker-loader我們才能在reactjs框架下方便的使用web worker。最後在根目錄的package.json檔案中做如下修改:
"scripts": {
......
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-scripts eject"
......
......
},
它的作用是,在我們使用npm start啟動專案時,呼叫react-app-rewired start,在專案的構建時也使用react-app-rewired build進行,這些工具能夠指導webpack如何將web worker對應的程式碼與class 類所在的模組相結合,如果沒有上面這些工作,我們是沒法在web worker的程式碼中呼叫我們用class關鍵字來實現的類的。
接著我們看看兩個web worker的實現,在src目錄下建立兩個檔案分別為channel.worker.js和eval.worker.js,第一個woker的實現如下:
import EvalWorker from './eval.worker'
self.addEventListener("message", handleMessage);
function handleMessage(event) {
console.log("channel worker receive msg :" , event.data[0])
var cmd = event.data
if (Array.isArray(event.data)) {
cmd = event.data[0]
}
switch (cmd) {
case 'code':
this.evaluator = new EvalWorker()
this.sharedMem = new SharedArrayBuffer(8)
this.evaluator.postMessage([this.sharedMem, event.data[1]])
this.name = "channelWorker"
var Iam = this
this.evaluator.addEventListener('message', function(e) {
var cmd = e.data
if (Array.isArray(e.data)) {
cmd = e.data[0]
}
if (cmd === "beforeExec") {
console.log("channel worker receive from EvalWorker, this is:",
this)
console.log('channel worker receive msg from EvalWorker', e.data[0])
Iam.postMessage([e.data[0], e.data[1]])
}
if (cmd === "finishExec") {
Iam.postMessage([e.data[0], e.data[1]])
}
})
return
case 'execNext':
console.log("channel worker receive msg execNext ")
var int32 = new Int32Array(this.sharedMem)
Atomics.store(int32, 0, 123)
Atomics.wake(int32, 0, 1)
return
default:
this.postMessage(event.data)
}
}
web worker本質上是監聽訊息然後處理訊息的執行緒。上面程式碼實現的woker使用函式handleMessage來監聽它訊息佇列中的訊息,它監聽兩個個訊息,分別是code 和 execNext,這兩個訊息是由主執行緒發過來的,當用戶在編輯框中寫完程式碼,點選"parsing"按鈕開始解析後,主執行緒將編輯框中的程式碼收集起來,然後向channel woker傳送code訊息,訊息附帶的資料就是使用者輸入的程式碼文字。
當channel worker收到code訊息後,建立eval worker,然後向他傳送要解析的程式碼文字。為何我們不直接建立eval worker來和主執行緒配合,反而是多建立一個channel worker來做中介呢?主要原因在於主執行緒無法使用SharedArrayBuffer類,它只能在woker中定義和使用,如果你在主執行緒程式碼檔案中定義,例如在MonkeyCompilerIDE.js中宣告它的話,會出現undefine錯誤。由於我們需要使用該類實現執行緒執行控制,因此我們不得不建立channel worker作為一箇中介。
execNext訊息也是由主執行緒傳送的,當用戶點選"step"按鈕時,該訊息傳送給channel worker,channel worker將共享記憶體第一個位元組設定為一個非0值,這樣就能觸發eval worker對當前程式碼進行解析。
我們再看看eval.worker.js的實現:
import MonkeyEvaluator from './MonkeyEvaluator'
import MonkeyLexer from './MonkeyLexer'
import MonkeyCompilerParser from './MonkeyCompilerParser'
self.addEventListener("message", handleMessage);
function handleMessage(event) {
console.log("evaluaotr begin to eval")
this.sharedArray = new Int32Array(event.data[0])
this.execCommand = 123
this.lexer = new MonkeyLexer(event.data[1])
this.parser = new MonkeyCompilerParser(this.lexer)
this.program = this.parser.parseProgram()
var props = {}
this.evaluator = new MonkeyEvaluator(this)
this.evaluator.eval(this.program)
}
self.waitBeforeEval = function() {
console.log("evaluator wait for exec command")
Atomics.wait(this.sharedArray,0, 0)
Atomics.store(this.sharedArray, 0)
}
self.sendExecInfo = function(msg, res) {
console.log("evaluator send exec info")
this.postMessage([msg, res])
}
eval worker建立MonkeyLexer, MonkeyCompilerParser以及MonkeyEvaluator來對程式碼進行解析,如果沒有我們前面繁瑣的配置工作,在eval.worker.js中是不能直接new 相應的類的。它還匯出兩個函式,分別是waitBeforeEval,當某行程式碼被解析前,該函式會被呼叫,Atomics.wait函式使得執行緒掛起,只有當channel worker執行緒接收到execNext,並執行Atomics.store,Atomics.wake兩個函式後,它才會被喚醒然後恢復執行。
sendExecInfo用於把當前程式碼執行後,相關變數的資訊傳送給channel worker,然後channel worker再發送給主執行緒,主執行緒拿到這些資訊後,當用戶把滑鼠挪動到某個變數上面時,我們就可以通過popover控制元件把變數資訊顯示出來。我們再看看MonkeyEvaluator的一些變化:
constructor (worker) {
this.enviroment = new Enviroment()
this.evalWorker = worker
}
//change2
setExecInfo(node) {
var props = {}
if (node != undefined) {
props['line'] = node.getLineNumber()
}
var env = {}
for (var s in this.enviroment.map) {
env[s] = this.enviroment.map[s].inspect()
}
props['env'] = env
return props
}
pauseBeforeExec(node) {
// change
var props = this.setExecInfo(node)
this.evalWorker.sendExecInfo("beforeExec", props)
this.evalWorker.waitBeforeEval()
}
eval (node) {
var props = {}
switch (node.type) {
case "program":
return this.evalProgram(node)
case "HashLiteral":
return this.evalHashLiteral(node)
case "ArrayLiteral":
// change3
this.pauseBeforeExec(node)
....
case "IndexExpression":
// change
this.pauseBeforeExec(node)
.....
case "LetStatement":
// change
this.pauseBeforeExec(node)
....
}
evalProgram (program) {
var result = null
for (var i = 0; i < program.statements.length; i++) {
result = this.eval(program.statements[i])
// change 4
var props = this.setExecInfo()
if (result.type() === result.RETURN_VALUE_OBJECT) {
this.evalWorker.sendExecInfo("finishExec", props)
return result.valueObject
}
if (result.type() === result.NULL_OBJ) {
this.evalWorker.sendExecInfo("finishExec", props)
return result
}
if (result.type === result.ERROR_OBJ) {
this.evalWorker.sendExecInfo("finishExec", props)
console.log(result.msg)
return result
}
}
//change 5
var props = this.setExecInfo()
this.evalWorker.sendExecInfo("finishExec", props)
return result
}
我們注意看,eval函式負責對程式碼進行解釋執行,但在解釋執行的每個case執行時,都會呼叫pauseBeforeExec函式,它會把當前執行的堆疊資訊傳送給channel worker,然後進入掛起狀態,也就是不會繼續往下解析執行,只有等到主執行緒傳送訊息後才會繼續,這樣主執行緒就有集合相應使用者的介面操作,例如把滑鼠移動到變數名上方時顯示資訊,主執行緒接收到資訊後就可以知道編譯器當前正在解釋執行哪條語句,然後對該語句進行高亮和顯示一個向右指向箭頭。當所有程式碼解釋執行完成後,它向主執行緒傳送一個finishExec訊息通知主執行緒程式碼執行完畢。
我們再看看主執行緒MonkeyCompilerIDE的程式碼修改:
constructor(props) {
super(props)
this.lexer = new MonkeyLexer("")
this.state = {stepEnable: false}
this.breakPointMap = null
this.channelWorker = new Worker()
}
// change 2
onLexingClick () {
this.inputInstance.setIDE(this)
this.channelWorker.postMessage(['code', this.inputInstance.getContent()])
this.channelWorker.addEventListener('message',
this.handleMsgFromChannel.bind(this))
}
handleMsgFromChannel(e) {
var cmd = e.data
if (Array.isArray(e.data)) {
cmd = e.data[0]
}
if (cmd === "beforeExec") {
console.log("receive before execBefore msg from channel worker")
this.setState({stepEnable: true})
var execInfo = e.data[1]
this.currentLine = execInfo['line']
this.currentEnviroment = execInfo['env']
this.inputInstance.hightlineByLine(execInfo['line'], true)
} else if (cmd === "finishExec") {
console.log("receive finishExec msg: ", e.data[1])
var execInfo = e.data[1]
this.currentEnviroment = execInfo['env']
alert("exec finish")
}
}
//change 3
getSymbolInfo(name) {
return this.currentEnviroment[name]
}
onContinueClick () {
this.channelWorker.postMessage("execNext")
this.setState({stepEnable: false})
this.inputInstance.hightlineByLine(this.currentLine, false)
}
getCurrentEnviroment() {
return this.currentEnviroment
}
它在初始化時就已經建立channel worker,等使用者在編輯框中輸入程式碼點選"parsing"後,它向channel worker傳送一個’code’訊息,並附帶程式碼文字,然後等待返回beforeExec和finishExec兩個訊息,當接收beforeExec訊息時,它能獲得eval woker傳過來的程式碼執行資訊,它利用這些資訊能響應使用者操作,例如在popover控制元件中顯示變數當前值等,接收到finishExec表明程式碼全部被執行完畢。完成這些程式碼後,我們能夠實現單步除錯的頁面IDE也就完成了,本節程式碼設計邏輯比較複雜,更詳細的講解和除錯演示,請參看視訊:
更多技術資訊,包括作業系統,編譯器,面試演算法,機器學習,人工智慧,請關照我的公眾號: