利用web work实现js多线程异步机制,打造页面单步调试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也就完成了,本节代码设计逻辑比较复杂,更详细的讲解和调试演示,请参看视频:

更详细的讲解和代码调试演示过程,请点击链接

了解更多
举报
评论 0