├── LICENSE ├── worker.js ├── index.html ├── ES-support-main.md ├── worker-service-api.js └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 The Chromium Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | 2 | function weatherApiUrl(location) { 3 | return 'https://query.yahooapis.com/v1/public/yql?q=' + 4 | encodeURIComponent('select item.condition, wind from ' + 5 | 'weather.forecast where woeid in (select woeid from geo.places(1) ' + 6 | `where text="${location}")`) + '&format=json&env=' + 7 | encodeURIComponent('store://datatables.org/alltableswithkeys'); 8 | } 9 | 10 | // Expose a service to the main thread. 11 | self.services.register('speak', class { 12 | // We have to list what methods are exposed. 13 | static get exposed() { return ['concat']; } 14 | 15 | // The constructor can take arguments. 16 | constructor(prefix) { 17 | this.prefix_ = prefix; 18 | } 19 | 20 | concat(message) { 21 | return this.prefix_ + message; 22 | } 23 | }); 24 | 25 | self.services.register('weather', class { 26 | static get exposed() { return ['query']; } 27 | 28 | // If a method returns a Promise (ex. is async) then the system waits for it 29 | // to resolve and returns the answer. 30 | async query(location) { 31 | // Make some network requests and do something "expensive". 32 | let response = await fetch(weatherApiUrl(location)); 33 | let data = await response.json(); 34 | let channel = data.query.results.channel; 35 | return { 36 | temp: channel.item.condition.temp, 37 | text: channel.item.condition.text, 38 | wind: channel.wind.speed, 39 | }; 40 | } 41 | }); 42 | 43 | // We can also connect to services the main thread exposes to us. 44 | (async function() { 45 | let dom = await self.services.connect('dom'); 46 | // We can send multiple messages in parallel, since this is all inside one 47 | // function they'll show up in the same order on the other thread. 48 | dom.appendText('Inside the worker?'); 49 | dom.appendBox('A box!'); 50 | dom.appendBox('A styled box!', new Map([ 51 | ['color', 'red'], 52 | ['border', '1px solid blue'], 53 | ['padding', '5px'], 54 | ])); 55 | // For now we need to manually disconnect, eventually we might clean up the 56 | // instances automatically and auto reconnect when a method is called if the 57 | // end point has been cleaned up. 58 | self.services.disconnect(dom); 59 | })(); 60 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 |

16 | 72 | -------------------------------------------------------------------------------- /ES-support-main.md: -------------------------------------------------------------------------------- 1 | # Syntactic sugar for accessing DOM apis 2 | We could replace the need to register main thread APIs by allowing developers to pass a reference to the current context when they construct a tasklet: 3 | 4 | ```javascript 5 | // main.html 6 | import tasklet.js; // defines TextAdder 7 | import dom.js; // defines appendText(text) and appendBox(text, style) 8 | 9 | const textAdder = new textAdder(document); 10 | textAdder.doSomething(); 11 | ``` 12 | 13 | ```javascript 14 | // tasklet.js 15 | remote class TextAdder { 16 | constructor(context) { 17 | this.dom = context; 18 | } 19 | 20 | async doSomething() { 21 | await this.dom.appendText('Hello'); 22 | await this.dom.appendBox(' World!'); 23 | } 24 | } 25 | ``` 26 | 27 | Allowing this sort of context passing also makes it easier to define web components that split work between main and background: 28 | 29 | ```html 30 | 31 | 35 | 36 | 37 | 80 | ``` 81 | -------------------------------------------------------------------------------- /worker-service-api.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | const kSystemServiceId = 0; 4 | const kDispatch = 0; 5 | const kResolve = 1; 6 | const kReject = 2; 7 | const kWorkerApiScript = 'worker-service-api.js'; 8 | 9 | class ServiceContext { 10 | constructor(remote) { 11 | if (!remote || !remote.postMessage || !remote.addEventListener) 12 | throw new Error('Invalid remote endpoint.'); 13 | this.resolvers_ = []; 14 | this.services_ = new Map(); 15 | this.instances_ = [{ 16 | instance: this, 17 | serviceData: { 18 | name: 'System', 19 | type: null, 20 | methods: { 21 | connect: this.connectService_, 22 | disconnect: this.disconnectService_, 23 | importScripts: this.importScripts_, 24 | }, 25 | }, 26 | }]; 27 | this.remote_ = remote; 28 | remote.addEventListener('message', this.handleMessageEvent_.bind(this)); 29 | } 30 | 31 | async connect(name, args) { 32 | let [instanceId, methodNames] = await this.invoke_(kSystemServiceId, 33 | 'connect', [name, args || []]); 34 | let methods = new Map(methodNames.map((value) => [value, null])); 35 | return new Proxy({instanceId_: instanceId}, { 36 | get: (target, property) => { 37 | let handler = methods.get(property); 38 | if (handler) 39 | return handler; 40 | if (handler === null) { 41 | handler = (...args) => this.invoke_(instanceId, property, args); 42 | methods.set(property, handler); 43 | return handler; 44 | } 45 | }, 46 | }); 47 | } 48 | 49 | disconnect(proxy) { 50 | let descriptor = Object.getOwnPropertyDescriptor(proxy, 'instanceId_'); 51 | if (!descriptor) 52 | throw new Error('Invalid instance.'); 53 | return this.invoke_(kSystemServiceId, 'disconnect', [descriptor.value]); 54 | } 55 | 56 | importScripts(...scripts) { 57 | return this.invoke_(kSystemServiceId, 'importScripts', [scripts]); 58 | } 59 | 60 | register(name, type) { 61 | if (this.services_.has(name)) 62 | throw new Error(`Service '${name}': already registered.`); 63 | let exposed = type.exposed; 64 | if (!exposed) 65 | throw new Error(`Service '${name}': No methods exposed.`); 66 | if (!Array.isArray(exposed)) 67 | throw new Error(`Service '${name}': exposed property must be an array.`); 68 | if (!exposed.length) 69 | throw new Error(`Service '${name}': No methods exposed.`); 70 | let methods = {}; 71 | for (let methodName of exposed) { 72 | let method = type.prototype[methodName]; 73 | if (!(method instanceof Function)) { 74 | throw new Error(`Service '${name}': Exposed method '${methodName}' is` + 75 | 'not a function.'); 76 | } 77 | methods[methodName] = method; 78 | } 79 | this.services_.set(name, { 80 | name: name, 81 | type: type, 82 | methods: methods, 83 | }); 84 | } 85 | 86 | connectService_(name, args) { 87 | let serviceData = this.services_.get(name); 88 | if (!serviceData) 89 | throw new Error(`No service with name '${name}'.`); 90 | let instance = new serviceData.type(...args); 91 | this.instances_.push({ 92 | instance: instance, 93 | serviceData: serviceData, 94 | }); 95 | return [this.instances_.length - 1, Object.keys(serviceData.methods)]; 96 | } 97 | 98 | disconnectService_(instanceId) { 99 | // instanceId zero is the connection service, you can't disconnect from it. 100 | if (!instanceId) 101 | throw new Error(`Invalid instanceId ${instanceId}`); 102 | delete this.instances_[instanceId]; 103 | } 104 | 105 | importScripts_(scripts) { 106 | self.importScripts(...scripts); 107 | } 108 | 109 | handleMessageEvent_(event) { 110 | let data = event.data; 111 | if (!data || !Array.isArray(data) || data.length != 5) { 112 | console.error(event); 113 | throw new Error('Invalid message data.'); 114 | } 115 | let type = Number(data[0]); 116 | let resolverId = Number(data[1]); 117 | let instanceId = Number(data[2]); 118 | let methodName = String(data[3]); 119 | let args = data[4]; 120 | switch (type) { 121 | case kDispatch: 122 | this.dispatch_(resolverId, instanceId, methodName, args); 123 | break; 124 | case kResolve: 125 | this.resolve_(resolverId, args); 126 | break; 127 | case kReject: 128 | this.reject_(resolverId, args); 129 | break; 130 | } 131 | } 132 | 133 | invoke_(instanceId, methodName, args) { 134 | return new Promise((resolve, reject) => { 135 | this.resolvers_.push({ 136 | resolve: resolve, 137 | reject: reject, 138 | }); 139 | this.remote_.postMessage([kDispatch, this.resolvers_.length - 1, 140 | instanceId, methodName, args]); 141 | }); 142 | } 143 | 144 | resolve_(resolverId, value) { 145 | let resolver = this.resolvers_[resolverId]; 146 | if (!resolver) 147 | throw new Error(`Resolve: Bad resolverId '${resolverId}'.`); 148 | delete this.resolvers_[resolverId]; 149 | resolver.resolve(value); 150 | } 151 | 152 | reject_(resolverId, errorMessage) { 153 | let resolver = this.resolvers_[resolverId]; 154 | if (!resolver) 155 | throw new Error(`Reject: Bad resolverId '${resolverId}'.`); 156 | delete this.resolvers_[resolverId]; 157 | resolver.reject(new Error(errorMessage)); 158 | } 159 | 160 | async dispatch_(resolverId, instanceId, methodName, args) { 161 | let entry = this.instances_[instanceId]; 162 | try { 163 | if (!entry) 164 | throw new Error(`Invalid instance ${instanceId}`); 165 | let method = entry.serviceData.methods[methodName]; 166 | if (!method) { 167 | throw new Error( 168 | `Service '${entry.serviceData.name}': Invalid method name ` + 169 | `'${methodName}'.`); 170 | } 171 | let result = await method.apply(entry.instance, args); 172 | this.remote_.postMessage([kResolve, resolverId, instanceId, null, result]); 173 | } catch (e) { 174 | console.error(e); 175 | this.remote_.postMessage([kReject, resolverId, instanceId, null, 176 | e.message]); 177 | } 178 | } 179 | } 180 | 181 | class Worklet extends ServiceContext { 182 | constructor() { 183 | super(new Worker(kWorkerApiScript)); 184 | } 185 | 186 | terminate() { 187 | this.remote_.terminate(); 188 | } 189 | } 190 | 191 | // Exposed API. 192 | if (typeof window == 'object') 193 | window.Worklet = Worklet; 194 | else if (typeof self == 'object') 195 | self.services = new ServiceContext(self); 196 | 197 | })(); 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Problem 2 | Most modern development platforms favor a multi-threaded approach by default. Typically, the split for work is: 3 | 4 | - __Main thread__: UI manipulation, event/input routing 5 | - __Background thread__: All other work 6 | 7 | iOS and Android native platforms, for example, restrict (by default) the usage of any APIs not critical to UI manipulation on the main thread. 8 | 9 | The web has support for this model via `WebWorkers`, though the `postMessage()` interface is clunky and difficult to use. As a result, worker adoption has been minimal at best and the default model remains to put all work on the main thread. In order to encourage developers to move work off the main thread, we need a more ergonomic solution. 10 | 11 | In anticipation of increased usage of threads, we will also need a solution that scales well with regards to resource usage. Workers require ~5MB per thread. This proposal suggests using worklets as the fundamental execution context to better support a world where multiple parties are using threading heavily. 12 | 13 | # Tasklet API 14 | __Note__: APIs described below are just strawman proposals. We think they're pretty cool but there's always room for improvement. 15 | 16 | Today, many uses of `WebWorker`s follow a structure similar to: 17 | 18 | ```javascript 19 | const worker = new Worker('worker.js'); 20 | worker.postMessage({'cmd':'fetch', 'url':'example.com'}); 21 | ``` 22 | 23 | A switch statement in the worker then routes messages to the correct API. The tasklet API exposes this behavior natively, by allowing a class within one context to expose methods to other contexts: 24 | 25 | ```javascript 26 | // tasklet.js 27 | class Speaker { 28 | // We have to list what methods are exposed. 29 | static get exposed() { return ['concat']; } 30 | 31 | concat(message) { 32 | return `${message} world!`; 33 | } 34 | } 35 | services.register('speak', Speaker); 36 | ``` 37 | 38 | From the main thread, we can access these exposed methods directly, awaiting them as we would await a normal promise: 39 | 40 | ```javascript 41 | // main.html 42 | const tasklet = new Tasklet('tasklet.js'); 43 | const speaker = await tasklet.connect('speak'); 44 | 45 | speaker.concat('Hello'); 46 | ``` 47 | 48 | This makes it much simpler to dispatch computationally expensive work to a background context. 49 | 50 | # Main thread interaction 51 | In addition to dispatching occasional background work, we want sites to architect their entire app in a threaded way. While this is possible with `postMessage` (or the tasklet API), the difficulty in communicating progress and dispatching UI work to the main thread will likely discourage developers from building complex background logic. 52 | 53 | A straightforward use case for back and forth communication is progress UI. Imagine a site like Facebook loading its news feed in a background context. Ideally, as posts come in, the site would incrementally update the UI, rather than waiting on all of the background work to finish. 54 | 55 | This area is a bit more experimental, but there are a few ideas to make this easier: 56 | 57 | ## Event based communication 58 | If we allowed classes to extend `EventTarget`, tasklets could communicate with the main thread via messages: 59 | 60 | ```javascript 61 | // tasklet.js 62 | class NewsFeedTasklet extends EventTarget { 63 | static get exposed() { return []; } 64 | 65 | loadPosts(numPosts) { 66 | for(let i = 0; i < numPosts; i++) { 67 | fetch(`posts/today/${i}`) 68 | .then(res => res.json()) 69 | .then(data => dispatchEvent('new-post', data)); 70 | } 71 | } 72 | } 73 | services.register('news-feed', NewsFeedTasklet); 74 | ``` 75 | 76 | ```html 77 | 78 |
79 |
80 | 100 | ``` 101 | 102 | ## Exposing main thread methods to tasklets 103 | Another way to solve this problem is with the tasklet API in reverse: the main thread can expose methods to tasklet contexts: 104 | 105 | ```javascript 106 | // main.html 107 | const tasklet = new Tasklet('tasklet.js'); 108 | 109 | tasklet.register('dom', class { 110 | // We have to list what methods are exposed. 111 | static get exposed() { return ['appendText', 'appendBox']; } 112 | 113 | appendText(text) { 114 | let div = document.createElement('div'); 115 | document.body.appendChild(div).textContent = text; 116 | } 117 | 118 | appendBox(text, style) { 119 | let div = document.createElement('div'); 120 | if (style) { 121 | for (let [property, value] of style) 122 | div.style[property] = value; 123 | } 124 | document.body.appendChild(div).textContent = text; 125 | } 126 | }); 127 | ``` 128 | 129 | Those methods are then available within the tasklet's context without needing to break the control flow: 130 | 131 | ```javascript 132 | // tasklet.js 133 | const dom = await services.connect('dom'); 134 | 135 | // Kick off some main thread work to update the UI 136 | let text = "hello"; 137 | dom.appendText(text); 138 | 139 | // And then continue with our work here 140 | let box = " world!" 141 | dom.appendBox(box); 142 | ``` 143 | 144 | # ES language support 145 | While using this API provides numerous improvements, there are still some rough edges. There is a fair amount of boilerplate to register functions. A site's logic is split between multiple files for main and background work. There's no simple way to just run a function in a background context. 146 | 147 | A `remote` keyword could be used to signify work that should be placed in a tasklet: 148 | 149 | ## Remote classes 150 | A remote class would essentially replace all of the boilerplate mentioned above: 151 | 152 | ```javascript 153 | // tasklet.js 154 | remote class Speaker { 155 | concat(message) { 156 | return `${message} world!`; 157 | } 158 | } 159 | ``` 160 | 161 | ```javascript 162 | // main.html 163 | import tasklet.js; 164 | 165 | const speaker = new Speaker(); 166 | speaker.concat('Hello'); 167 | ``` 168 | 169 | ## Remote blocks 170 | Alternatively, remote could be used to delineate blocks that should run in a tasklet: 171 | 172 | ```javascript 173 | // tasklet.js 174 | class Speaker { 175 | @expose concat(message) { 176 | return `${message} world!`; 177 | } 178 | } 179 | ``` 180 | 181 | ```javascript 182 | // main.html 183 | const speaker = await remote { import 'tasklet.js'; } 184 | speaker.concat('Hello'); 185 | 186 | const expensive = await remote { 187 | function doExpensiveBackgroundThing() { 188 | // wow so expensive. 189 | }; 190 | } 191 | expensive.doExpensiveBackgroundThing(); 192 | ``` 193 | 194 | `remote` could also be used on individual functions: 195 | 196 | ```javascript 197 | remote function concat(text) { 198 | console.log(`${text} world!`); 199 | } 200 | 201 | await concat('Hello'); 202 | } 203 | ``` 204 | 205 | For crazier (and less realistic) ideas around language support for exposing main thread APIs, see [ES support for exposing main thread functions](ES-support-main.md). 206 | 207 | # Implications of worklets 208 | As more sites/content use tasklets, we will need some way to mitigate the performance overhead that traditionally comes with multiple threads (~5MB per). Worklets are a natural fit for this problem, as their contexts can be moved between threads as resource constraints demand. Similarly, the contexts from different tasklets could share a thread (even the main thread) in cases where device memory is critically low. This would allow the user agent to transparently optimize the experience for device memory. 209 | --------------------------------------------------------------------------------