单线程JavaScript的多线程方式:工作者线程

932 阅读13分钟

考虑到与不同浏览器API【同/异步】、DOM API的兼容,JavaScript采用单线程的设计。后来考虑到如果JavaScript函数运行太久,会阻塞事件循环,浏览器不能响应用户输入,浏览器提出了worker类来放松单线程的限制。

工作者线程:允许把主线程的工作转嫁给独立的实体,而不会改变现有的单线程模型

特点:

  • 各种工作者线程有不同的形式和功能,但都是独立于JavaScript的主执行环境,有着完全独立的全局对象,不能访问window或document对象,可以访问 web storage?
  • JavaScript线程的各方面,包括生命周期管理,代码路径、I/O都是由初始化线程时提供的脚本来控制,该脚本也可以再请求其他脚本,但一个线程总是从一个脚本源开始。
  • 工作者线程是以实际线程实现的 例如,Blink浏览器引擎实现工作者线程的workerThread就对应着底层线程
  • 工作者线程可以共享部分内存。 工作者线程能够使用SharedArrayBuffer在多个环境内共享内容。JavaScript可以使用Atomics接口实现并发控制。
  • 工作者线程不一定在同一个进程。 通常一个进程可以在内部产生多个线程。根据浏览器引擎的实现,工作者线程可能与页面属于同一进程,也可能不属于。
  • 创建工作者线程的开销很大。 工作者线程有独立的事件循环、全局对象、事件处理程序和其他JavaScript环境必需的特性。

工作者线程的沙盒

  • JavaScript环境实际上是运行在托管操作系统中的虚拟环境
  • 在浏览器中每打开一个页面,就会分配一个它的环境,每个页面都有自己的内存、事件循环、DOM等等,相当于一个沙盒。
  • 使用工作者线程,浏览器就可以在原始页面环境之外再分配一个完全独立的二级子环境。该子环境与父环境并行执行代码,不能与依赖单线程交互的API(如DOM)互操作。
  • 浏览器同时管理多个并行执行的环境,环境间提供异步消息机制通信

工作者线程的执行模型:自上而下地同步执行自己的代码(和导入的脚本和模块),之后就进入异步阶段-对事件、定时器作出响应,如果注册了message事件处理程序(意味着有收到message的可能),工作者线程就不会退出。否则工作者线程会自动安全退出。

如果工作者线程抛出了错误,依次如下传播,可以通过preventDefault停止传播:

  1. 工作者线程全局对象的error事件
  2. worker对象(父)的error事件
  3. window对象(父)的error事件

在父线程中的try/catch语句中定义工作者线程,不会捕获到错误。

WorkerGlobalScope对象(子)

  • 在网页上,window对象可以向运行在其中的脚本暴露各种全局变量
  • 在工作者线程内部的全局变量是WorkerGlobalScope的实例,通过self引用。

self上可用属性和方法是window对象上属性和方法的严格子集,其中一些属性会返回特定于工作者线程的版本。

  • navigation
  • location传递给worker构造函数的URL,在工作者线程中该对象的属性都是只读
  • performance
  • console
  • caches
  • indexedDB API
  • isSecureContext
  • origin
  • atob()
  • btoa()
  • clearInterval()
  • clearTimeout()
  • fetch()
  • setInterval()
  • setTimeout()
  • worker 工作者线程也可以创造自己的工作者线程

工作者线程的类别

  • 专用工作者线程 Web Worker 可以让脚本单独创建一个JavaScript线程,以执行委托的任务。专用工作者线程,只能被创建它的页面使用。

  • 共享工作者线程 可以让脚本单独创建一个JavaScript线程,以执行委托的任务。 共享工作者线程可以被多个不同的上下文使用,包括不同的页面。任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送消息或从中接受消息。

  • 服务工作者线程 用于拦截、重定向和修改页面发出的请求,充当网络请求的仲裁者的角色。


各种工作线程类型都有自己的全局对象,继承自WorkerGlobalScope

  1. 专用工作者DedicatedWorkerGlobalScope
  2. 共享工作者SharedWorkerGlobalScope
  3. 服务工作者ServiceWorkerGlobalScope

线程数据传输

在支持多线程模式编程中可以使用锁、互斥量、volatile变量,在上下文传输数据。 在JavaScript中有三种在上下文转移信息的方式:结构化克隆算法、可转移对象、共享数组缓冲区

结构化克隆算法

浏览器在后台实现的复制复杂JavaScript对象的算法:

当通过postMessage()与Workers之间进行数据传输、使用IndexedDB存储对象时,默认调用该算法,在目标上下文生成一个副本

结构化克隆算法支持的类型:
除了symbol以外的基本类型
Boolean object
String object
Date
RegExp lastIndex字段不会保留
Blob
File
FileList
ArrayBuffer
ArrayBufferView 所有的arrayBuffer视图,例如Int32Array.
ImageBitmap
ImageData
Array
Object 
Map
Set

注意:
ErrorFunctionDOM节点不能通过结构化克隆算法复制,将引发DATA_CLONE_ERR异常。

对象的以下参数不会保留:
RegExp对象的lastIndex字段不会保留。
属性描述符,setter和getter(以及类似元数据的功能)不会被复制。例如,如果使用属性描述符将对象标记为只读,则复制后的对象中是可读写(默认配置)。
原型链不会复制。

可转移对象

transferable objects可以把所有权从上一个上下文转移到另一个上下文,适用于不太可能在上下文间复制大量数据的情况下

可转移对象有:
ArrayBuffer
MessagePort
ImageBitmap
offscreenCanvas

如果把ArrayBuffer实例指定为可转移对象(即传入postMessage函数),那么对于缓冲区的引用就会从父上下文转移到工作者线程。

在其他类型中嵌套transferable objects,包装对象会被复制,而嵌套对象会被转移。

//main.js
const worker=new Worker('./worker.js');
const arrayBuffer=new ArrayBuffer(32);//32位缓冲区
worker.postMessage({foo:{bar:arrayBuffer}},[arrayBuffer]);//后面的是必要的吗?

//worker.js
self.onmessage=({data})=>{
    console.log(data.foo.bar.byteLength);//32
}

共享数组缓冲区

资源争用的风险,会被当作volatile易变内存

一、专用工作者线程(后台脚本) Worker

与父页面交换信息、发送网络请求、执行文件I/O、密集计算、处理数据,以及其他不适合在页面执行线程做的任务(以防页面响应迟钝)

例如:实现一个网页编辑器的代码高亮功能,每次敲击键盘都要解析代码,如果在主线程解析,就会阻止键盘输入事件的handler程序响应用户,导致输入体验变差。

//方法一:脚本文件
console.log(location.href);//https://example.com/
const worker=new Worker(location.href+'newWork.js');//更常用的应该是相对路径
//worker返回通信连接点
console.log(worker);//Worker{}
// 可选的第二个参数是 对象,设置配置
name 
type: 执行方式,chassic常规脚本 module模块
credentials: omit、same-origin、include
type==module时,指定如何获取与传输凭证数据相关的工作者线程模块脚本 omit、same-orign、include,这些选项与fetch的凭证选项相同。
type==classic,默认为omit

脚本不一定是远程资源

类文件对象Blob

//方法二:行内创建
//1.创建要执行的代码,注意反引号
//也可以使用函数序列化来实现脚本,function.toString()但是!函数体内不能含有通过闭包获得的引用
const workScript=`
self.onmessage=({data})=>console.log(data);
`;
//2.生成Blob对象:二进制数据
const workScriptBlob=new Blob([workScript]);
//3.创建对象URL
const workScriptBlobURL=URL.createObjectURL(workScriptBlob);
//4.创建worker
const worker=new Worker(workScriptBlobURL);//
//简写作
const worker=new Worker(URL.createObjectURL(new Blob([`
self.onmessage=({data})=>console.log(data);
`])));

线程通信

支持下列事件处理程序属性(对应都有两种方式实现):

onerror事件
worker.addEventListener('error',handler)
onmessage事件:监听工作者线程中的消息事件
worker.addEventListener('message',handler)
onmessageerror事件
worker.addEventListener('messageerror',handler)

Worker对象还支持这些方法

postMessage():异步向所创建线程的源窗口发送消息,信息传输涉及调用全局对象上的方法。其实还有很多通信API
terminate():没有清理的机会,立即终止工作者线程(外部

close:通知工作者线程取消事件循环中的所有任务,并阻止继续添加新任务。(消息队列没有没清理,只是不再新增。如果后面有一个setTimeout函数,不再挂载它的定时)

MessageChannel

postMessage()接收三个参数,消息、目标接收源、(可传输对象的数组)

基于该API可以在两个上下文间明确建立通信渠道。其实例有两个端口,分别代表两个通信端点,要让父页面和工作者线程通信,需要给工作者线程传递一个端口,父页面自己保留一个端口的引用来传递消息。工作者线程初始化信道,维护着对该端口的引用,并使用它代替全局对象传递消息。

//main.js
const channel=new MessageChannel();
const factorialWorker=new Worker('./worker.js');
//把端口发送到工作者线程,工作者线程负责初始化信道
factorialWorker.postMessage(null,[channel.port1]);//第一个参数就是一个附带消息,区分父页面同一个MessageChannel实例的多个工作者线程
//通过信道实际发送数据
channel.port2.onmessage=({data})=>console.log(data);
channel.port2.postMessage(5);


//worker.js
//在监听器中存储全局变量messageport
let messagePort=null;

//线程负责的事务处理
function factorial(n){
    let result=1;
    while(n){ result*=n--;}
    return result;
}

//在全局对象上添加消息处理程序
self.onmessage=({ports})=>{
    if(!messagePort){
        //初始化,只设置一次端口
        messagePort=ports[0];//
        self.onmessage=null;//不用之前postMessage的全局监听器
        //在全局对象上设置消息处理程序
        messagePort.onmessage=({data})=>{
            //收到消息后发送消息
            messagePort.postMessage('${data}!=${factorial(data)}');
        }

    }
}

使用数组语法是为了在两个上下文传递可转移对象Transferable(后面讲解)

真正用处在于让两个工作者线程之间直接通信,通过把端口传递给另一个工作者线程实现。

let messagePort=null;
let contextIndentifier=null;
function addContextAndSend(data,destination){
    //为当前工作者线程实例添加标识符
    data.push(contextIndentifier);
}

self.onmessage=({data,ports})=>{
    
    if(ports.length){//如果消息存在端口,则初始化
        //记录标识符
        contextIndentifier=data;
        //获取端口
        messagePort=ports[0]//为什么获取端口都是ports[0]?
        //添加处理程序
        messagePort.onmessage=({data})=>{
            addContextAndSend(data,self);
        }
    }else{
        addContextAndSend(data,messagePort);//每段旅程添加一个字符串,标记自己到了那里
    }
}

BroadcastChannel

相当于广播,没有端口的概念,通过名字标记即下文中load1.如果没有实体监听这个信道,广播的消息就没人处理。所以在发送前等待一秒(担心工作者线程的延迟,处理程序没有就位)

//main.js
const channel = new BroadcastChannel('load1');
channel.onmessage=({data})=>console.log(data);
setTimeout(()=> channel.postMessage('hi'),1000);
//工作者线程中设置同名即可通信
const channel = new BroadcastChannel('load1');

DedicatedWorkerGlobalScope全局对象(是工作者线程的,即子)

在WorkerGlobalScope基础上增加的属性和方法

name:可以提供给Woker构造函数的一个可选的字符串标识符
postMessage():对应的,子给父发送
close():没有清理的机会,工作者线程立即自杀(内部
importScripts():用于向工作者线程中导入任意数量的脚本

顶级脚本和工作者线程都在向同一个console对象发送消息,该对象随后将消息序列化并在浏览器控制台打印输出。注意工作者线程有不可忽略的启动延迟。

image.png

工作者线程内部加载脚本

importScript()

importScripts()可用于工作者线程内部,会加载脚本并按照加载顺序同步执行。与script标签功能类似

  • 导入外部代码,适用于还没有支持模块系统时候的worker
  • 不受同源限制
  • 在Worker内部可以使用,将worker分成不同的文件,然后使用importScripts()方法将其导入,且可以动态的导入?
WorkerGlobalScope增加新的全局importScripts(),只在工作者线程内使用

importScripts(url1, url2, url3);// 同步加载执行这些文件,其中一个网络错误就中断,后续脚本不在加载执行

没有追踪已下载文件的能力,不会解决循环依赖

子工作者线程

除了路径解析不同,创建与普通的工作者线程相同 子工作者线程的路径是相对于父工作者线程的

顶级工作者线程的脚本和子工作者线程的脚本都必须从主页相同的源加载。

工作者线程池

因为启用worker的代价很大,所以考虑始终保持固定数量的线程活动,需要时候就把任务分配给他们。标记忙碌状态

二、共享工作者线程SharedWorker

可以被多个可信任的执行上下文访问,适合开发者希望通过在多个上下文间共享线程减少计算性消耗的情形。

e.g:使用一个共享线程管理多个同源页面websocket消息的发送与接收。共享线程也可以用在同源上下文希望通过一个线程通信的情形。 区别在于

  • 专用工作者线程始终会创建新的工作者线程,而共享工作者线程只有在标识符不同的时候才会创建新实例。
  • 如果没有名称则按照URL是否指向同一脚本创建,如果有名称需要区分。
new SharedWorker('./worker.js',{name:'foo'});

同一工作者线程不同端口号

SharedWorkerGlobalScope全局对象(是工作者线程的,即子)

在WorkerGlobalScope基础上增加了一下属性和方法。

name:可以提供给Woker构造函数的一个可选的字符串标识符
close():没有清理的机会,工作者线程立即自杀(内部
importScripts():用于向工作者线程中导入任意数量的脚本

 与worker的postMessage()不同,通过port专门用来跟共享线程通信,
 onconnect:与共享线程建立新连接时的处理程序。connect事件包括MessagePort实例的ports数组,可以用于把消息发送回父上下文。
 另外SharedWorker上没有terminate()方法,父页面在共享线程端口上调用close()时,只是切断链接。只要共享线程还有链接就不会真的终止线程。

SharedWorker构造函数会隐式创建MessageChannel实例,这个实例会传给SharedWorker线程,保存在connect事件对象的ports数组中。


//main.js
for(let i=0;i<5;i++){
    new SharedWorker('./share.js');
}

//share.js
const connectPort=new set();//保证只跟踪唯一的对象实例
self.onconnect=({ports})=>{
    connectedPorts.add(ports[0]);
    console.log(connectPort.size);
}
//1 2 3 4 5

注意死端口污染

三、服务工作者线程service worker

  • 类似浏览器中的代理服务器的线程,可以拦截外出请求和缓存响应。
  • 这可以让网页在没有网络链接的情况下正常使用,因为部分或者全部页面可以从服务工作者线程缓存中提供服务。主要时充当网络请求的缓存层和启用推送通知。服务工作者线程也可以使用Notifications API、Push API、Background Sync API和Channel Messaging API.

查看 Mozilla维护的网站