├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test ├── test.js └── workers ├── exitcode.js ├── hang.js ├── normal.js └── throw.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | test 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Thomas Watson Steen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # worker-threads-pool 2 | 3 | Easily manage a pool of [Node.js Worker 4 | Threads](https://nodejs.org/api/worker_threads.html). 5 | 6 | [![npm](https://img.shields.io/npm/v/worker-threads-pool.svg)](https://www.npmjs.com/package/worker-threads-pool) 7 | [![Build status](https://travis-ci.org/watson/worker-threads-pool.svg?branch=master)](https://travis-ci.org/watson/worker-threads-pool) 8 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 9 | 10 | ## Installation 11 | 12 | ``` 13 | npm install worker-threads-pool --save 14 | ``` 15 | 16 | ## Prerequisites 17 | 18 | Worker Threads in Node.js are still an experimental feature and is only 19 | supported in Node.js v10.5.0 and above. To use Worker Threads, you need 20 | to run `node` with the `--experimental-worker` flag: 21 | 22 | ``` 23 | node --experimental-worker app.js 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```js 29 | const Pool = require('worker-threads-pool') 30 | 31 | const pool = new Pool({max: 5}) 32 | 33 | for (let i = 0; i < 100; i++) { 34 | pool.acquire('/my/worker.js', function (err, worker) { 35 | if (err) throw err 36 | console.log(`started worker ${i} (pool size: ${pool.size})`) 37 | worker.on('exit', function () { 38 | console.log(`worker ${i} exited (pool size: ${pool.size})`) 39 | }) 40 | }) 41 | } 42 | ``` 43 | 44 | ## API 45 | 46 | ### `pool = new Pool([options])` 47 | 48 | `options` is an optional object/dictionary with the any of the following properties: 49 | 50 | - `max` - Maximum number of workers allowed in the pool. Other workers 51 | will be queued and started once there's room in the pool (default: 52 | `1`) 53 | - `maxWaiting` - Maximum number of workers waiting to be started when 54 | the pool is full. The callback to `pool.acquire` will be called with 55 | an error in case this limit is reached 56 | 57 | ### `pool.size` 58 | 59 | Number of active workers in the pool. 60 | 61 | ### `pool.acquire(filename[, options], callback)` 62 | 63 | The `filename` and `options` arguments are passed directly to [`new 64 | Worker(filename, 65 | options)`](https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options). 66 | 67 | The `callback` argument will be called with the an optional error object 68 | and the worker once it's created. 69 | 70 | ### `pool.destroy([callback])` 71 | 72 | Calls 73 | [`worker.terminate()`](https://nodejs.org/api/worker_threads.html#worker_threads_worker_terminate_callback) 74 | on all workers in the pool. 75 | 76 | Will call the optional `callback` once all workers have terminated. 77 | 78 | ## License 79 | 80 | [MIT](https://github.com/watson/worker-threads-pool/blob/master/LICENSE) 81 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {Worker} = require('worker_threads') 4 | const {AsyncResource} = require('async_hooks') 5 | const afterAll = require('after-all') 6 | 7 | const noop = function () {} 8 | 9 | module.exports = class Pool { 10 | constructor (opts) { 11 | opts = opts || {} 12 | this._workers = new Set() 13 | this._queue = [] 14 | this._max = opts.max || 1 15 | this._maxWaiting = opts.maxWaiting || Infinity 16 | } 17 | 18 | get size () { 19 | return this._workers.size 20 | } 21 | 22 | acquire (filename, opts, cb) { 23 | if (typeof opts === 'function') return this.acquire(filename, undefined, opts) 24 | if (this._workers.size === this._max) { 25 | if (this._queue.length === this._maxWaiting) { 26 | process.nextTick(cb.bind(null, new Error('Pool queue is full'))) 27 | return 28 | } 29 | this._queue.push(new QueuedWorkerThread(this, filename, opts, cb)) 30 | return 31 | } 32 | 33 | const self = this 34 | 35 | const worker = new Worker(filename, opts) 36 | worker.once('error', done) 37 | worker.once('exit', done) 38 | 39 | this._workers.add(worker) 40 | 41 | process.nextTick(cb.bind(null, null, worker)) 42 | 43 | function done () { 44 | self._workers.delete(worker) 45 | worker.removeListener('error', done) 46 | worker.removeListener('exit', done) 47 | const resource = self._queue.shift() 48 | if (resource) resource.addToPool() 49 | } 50 | } 51 | 52 | destroy (cb = noop) { 53 | const next = afterAll(cb) 54 | for (let worker of this._workers) { 55 | worker.terminate(next()) 56 | } 57 | } 58 | } 59 | 60 | class QueuedWorkerThread extends AsyncResource { 61 | constructor (pool, filename, opts, cb) { 62 | super('worker-threads-pool:enqueue') 63 | this.pool = pool 64 | this.filename = filename 65 | this.opts = opts 66 | this.cb = cb 67 | } 68 | 69 | addToPool () { 70 | this.pool.acquire(this.filename, this.opts, (err, worker) => { 71 | this.runInAsyncScope(this.cb, null, err, worker) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker-threads-pool", 3 | "version": "2.0.0", 4 | "description": "Easily manage a pool of Node.js Worker Threads", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "after-all": "^2.0.2" 11 | }, 12 | "devDependencies": { 13 | "standard": "^11.0.1", 14 | "tape": "^4.9.1" 15 | }, 16 | "scripts": { 17 | "test": "standard && node --experimental-worker test/test.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/watson/worker-threads-pool.git" 22 | }, 23 | "engines": { 24 | "node": ">=10.5.0" 25 | }, 26 | "keywords": [ 27 | "pool", 28 | "worker", 29 | "workers", 30 | "thread", 31 | "threads", 32 | "worker_threads", 33 | "parallel" 34 | ], 35 | "author": "Thomas Watson (https://twitter.com/wa7son)", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/watson/worker-threads-pool/issues" 39 | }, 40 | "homepage": "https://github.com/watson/worker-threads-pool#readme", 41 | "coordinates": [ 42 | 55.777628, 43 | 12.590195 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const asyncHooks = require('async_hooks') 5 | const test = require('tape') 6 | const Pool = require('../') 7 | 8 | const HANG = path.resolve(path.join('test', 'workers', 'hang.js')) 9 | const NORMAL = path.resolve(path.join('test', 'workers', 'normal.js')) 10 | const EXITCODE = path.resolve(path.join('test', 'workers', 'exitcode.js')) 11 | const THROW = path.resolve(path.join('test', 'workers', 'throw.js')) 12 | 13 | test('pool.size', function (t) { 14 | let count = 0 15 | const pool = new Pool({max: 10}) 16 | const opts = {workerData: 1000} // hang for 1000ms 17 | 18 | t.equal(pool.size, 0, 'should be 0 before any call to acquire') 19 | count++ 20 | pool.acquire(HANG, opts, function (err, worker) { 21 | t.error(err) 22 | t.equal(pool.size, 1, 'should be 1 when 1st worker have been created') 23 | worker.on('exit', onExit) 24 | count++ 25 | pool.acquire(HANG, opts, function (err, worker) { 26 | t.error(err) 27 | t.equal(pool.size, 2, 'should be 2 when 2nd worker have been created') 28 | worker.on('exit', onExit) 29 | count++ 30 | pool.acquire(HANG, opts, function (err, worker) { 31 | t.error(err) 32 | t.equal(pool.size, 3, 'should be 3 when 3rd worker have been created') 33 | worker.on('exit', onExit) 34 | }) 35 | t.equal(pool.size, 3, 'should be 3 after 3rd call to acquire') 36 | }) 37 | t.equal(pool.size, 2, 'should be 2 after 2nd call to acquire') 38 | }) 39 | t.equal(pool.size, 1, 'should be 1 after 1st call to acquire') 40 | 41 | function onExit () { 42 | t.equal(pool.size, --count, 'should count down on exit') 43 | if (count === 0) t.end() 44 | } 45 | }) 46 | 47 | test('pool max size - serial', function (t) { 48 | let count = 0 49 | let exits = 0 50 | const pool = new Pool({max: 2}) 51 | const opts = {workerData: 1000} // hang for 1000ms 52 | 53 | t.equal(pool.size, 0, 'should be 0 before any call to acquire') 54 | count++ 55 | pool.acquire(HANG, opts, function (err, worker) { 56 | t.error(err) 57 | t.equal(pool.size, 1, 'should be 1 when 1st worker have been created') 58 | worker.on('exit', onExit) 59 | count++ 60 | pool.acquire(HANG, opts, function (err, worker) { 61 | t.error(err) 62 | t.equal(exits, 0, 'should have experienced 0 exits') 63 | t.equal(pool.size, 2, 'should be 2 when 2nd worker have been created') 64 | worker.on('exit', onExit) 65 | count++ 66 | pool.acquire(HANG, opts, function (err, worker) { 67 | t.error(err) 68 | t.equal(exits, 1, 'should have experienced 1 exit') 69 | t.equal(pool.size, 2, 'should be 2 when 3rd worker have been created') 70 | worker.on('exit', onExit) 71 | }) 72 | t.equal(pool.size, 2, 'should be 2 after 3rd call to acquire') 73 | }) 74 | t.equal(pool.size, 2, 'should be 2 after 2nd call to acquire') 75 | }) 76 | t.equal(pool.size, 1, 'should be 1 after 1st call to acquire') 77 | 78 | function onExit () { 79 | exits++ 80 | t.equal(pool.size, --count, 'should count down on exit') 81 | if (count === 0) t.end() 82 | } 83 | }) 84 | 85 | test('pool max size - parallel', function (t) { 86 | let count = 0 87 | let exits = 0 88 | const pool = new Pool({max: 2}) 89 | const opts = {workerData: 1000} // hang for 1000ms 90 | 91 | t.equal(pool.size, 0, 'should be 0 before any call to acquire') 92 | count++ 93 | pool.acquire(HANG, opts, function (err, worker) { 94 | t.error(err) 95 | t.equal(exits, 0, 'should have experienced 0 exits') 96 | worker.on('exit', onExit) 97 | }) 98 | 99 | t.equal(pool.size, 1, 'should be 1 before 2nd call to acquire') 100 | count++ 101 | pool.acquire(HANG, opts, function (err, worker) { 102 | t.error(err) 103 | t.equal(exits, 0, 'should have experienced 0 exits') 104 | worker.on('exit', onExit) 105 | }) 106 | 107 | t.equal(pool.size, 2, 'should be 2 before 3rd call to acquire') 108 | count++ 109 | pool.acquire(HANG, opts, function (err, worker) { 110 | t.error(err) 111 | t.equal(exits, 1, 'should have experienced 1 exit') 112 | t.equal(pool.size, 2, 'should be 2 when last worker have been created') 113 | worker.on('exit', onExit) 114 | }) 115 | 116 | t.equal(pool.size, 2, 'should be 2 after 3rd call to acquire') 117 | 118 | function onExit () { 119 | exits++ 120 | t.equal(pool.size, --count, 'should count down on exit') 121 | if (count === 0) t.end() 122 | } 123 | }) 124 | 125 | test('pool max size - default', function (t) { 126 | let exists = 2 127 | const pool = new Pool() 128 | const opts = {workerData: 1000} // hang for 1000ms 129 | 130 | pool.acquire(HANG, opts, function (err, worker) { 131 | t.error(err) 132 | worker.on('exit', onExit) 133 | }) 134 | pool.acquire(HANG, opts, function (err, worker) { 135 | t.error(err) 136 | worker.on('exit', onExit) 137 | }) 138 | t.equal(pool.size, 1, 'should be 1 after 2nd call to acquire') 139 | 140 | function onExit () { 141 | if (--exists === 0) t.end() 142 | } 143 | }) 144 | 145 | test('pool max queue size', function (t) { 146 | t.plan(9) 147 | let exists = 3 148 | const pool = new Pool({maxWaiting: 2}) 149 | const opts = {workerData: 1000} // hang for 1000ms 150 | 151 | pool.acquire(HANG, opts, function (err, worker) { 152 | t.error(err) 153 | t.ok(worker) 154 | worker.on('exit', onExit) 155 | }) 156 | pool.acquire(HANG, opts, function (err, worker) { 157 | t.error(err) 158 | t.ok(worker) 159 | worker.on('exit', onExit) 160 | }) 161 | pool.acquire(HANG, opts, function (err, worker) { 162 | t.error(err) 163 | t.ok(worker) 164 | worker.on('exit', onExit) 165 | }) 166 | pool.acquire(HANG, opts, function (err, worker) { 167 | t.equal(err.message, 'Pool queue is full') 168 | t.notOk(worker) 169 | }) 170 | t.equal(pool.size, 1) 171 | 172 | function onExit () { 173 | if (--exists === 0) t.end() 174 | } 175 | }) 176 | 177 | test('normal', function (t) { 178 | t.plan(3) 179 | const pool = new Pool() 180 | const opts = {workerData: 'hello from main'} 181 | pool.acquire(NORMAL, opts, function (err, worker) { 182 | t.error(err) 183 | worker.on('message', function (msg) { 184 | t.equal(msg, 'hello from worker') 185 | }) 186 | worker.on('error', function (err) { 187 | t.error(err) 188 | }) 189 | worker.on('exit', function (code) { 190 | t.equal(code, 0) 191 | t.end() 192 | }) 193 | }) 194 | }) 195 | 196 | test('exit code', function (t) { 197 | t.plan(2) 198 | const pool = new Pool() 199 | pool.acquire(EXITCODE, function (err, worker) { 200 | t.error(err) 201 | worker.on('message', function (msg) { 202 | t.fail('should not send message') 203 | }) 204 | worker.on('error', function (err) { 205 | t.error(err) 206 | }) 207 | worker.on('exit', function (code) { 208 | t.equal(code, 42) 209 | t.end() 210 | }) 211 | }) 212 | }) 213 | 214 | test('throw', function (t) { 215 | t.plan(3) 216 | const pool = new Pool() 217 | pool.acquire(THROW, function (err, worker) { 218 | t.error(err) 219 | worker.on('message', function (msg) { 220 | t.fail('should not send message') 221 | }) 222 | worker.on('error', function (err) { 223 | t.equal(err.message, 'boom!') 224 | }) 225 | worker.on('exit', function (code) { 226 | t.equal(code, 1) 227 | t.end() 228 | }) 229 | }) 230 | }) 231 | 232 | test('pool.destroy()', function (t) { 233 | t.plan(5) 234 | const pool = new Pool({max: 10}) 235 | const opts = {workerData: 1e6} // hang for a loooong time 236 | pool.acquire(HANG, opts, function (err, worker) { 237 | t.error(err) 238 | worker.on('exit', function (code) { 239 | t.equal(code, 1) 240 | }) 241 | worker.on('online', function () { 242 | pool.acquire(HANG, opts, function (err, worker) { 243 | t.error(err) 244 | worker.on('exit', function (code) { 245 | t.equal(code, 1) 246 | }) 247 | worker.on('online', function () { 248 | pool.destroy() 249 | setTimeout(function () { 250 | t.equal(pool.size, 0) 251 | t.end() 252 | }, 1000) 253 | }) 254 | }) 255 | }) 256 | }) 257 | }) 258 | 259 | test('pool.destroy(callback)', function (t) { 260 | t.plan(4) 261 | const pool = new Pool({max: 10}) 262 | const opts = {workerData: 1000} // hang for 1000ms 263 | pool.acquire(HANG, opts, function (err, worker) { 264 | t.error(err) 265 | worker.on('exit', function (code) { 266 | t.equal(code, 1) 267 | }) 268 | worker.on('online', function () { 269 | pool.acquire(HANG, opts, function (err, worker) { 270 | t.error(err) 271 | worker.on('exit', function (code) { 272 | t.equal(code, 1) 273 | }) 274 | worker.on('online', function () { 275 | pool.destroy(function () { 276 | t.end() 277 | }) 278 | }) 279 | }) 280 | }) 281 | }) 282 | }) 283 | 284 | test('async_hooks', function (t) { 285 | t.plan(4) 286 | 287 | class Context extends Map { 288 | get current () { 289 | const asyncId = asyncHooks.executionAsyncId() 290 | return this.has(asyncId) ? this.get(asyncId) : null 291 | } 292 | set current (val) { 293 | const asyncId = asyncHooks.executionAsyncId() 294 | this.set(asyncId, val) 295 | } 296 | } 297 | const context = new Context() 298 | 299 | const hook = asyncHooks.createHook({ 300 | init (asyncId, type, triggerAsyncId, resource) { 301 | context.set(asyncId, context.current) 302 | }, 303 | destroy (asyncId) { 304 | context.delete(asyncId) 305 | } 306 | }) 307 | hook.enable() 308 | 309 | let workers = 2 310 | const pool = new Pool() // max 1 worker at a time 311 | const opts = {workerData: 1000} // hang for 1000ms 312 | 313 | context.current = 1 314 | pool.acquire(HANG, opts, function (err, worker) { 315 | t.error(err) 316 | t.equal(context.current, 1) 317 | worker.on('exit', onExit) 318 | }) 319 | context.current = 2 320 | pool.acquire(HANG, opts, function (err, worker) { 321 | t.error(err) 322 | t.equal(context.current, 2) 323 | worker.on('exit', onExit) 324 | }) 325 | 326 | function onExit () { 327 | if (--workers === 0) t.end() 328 | } 329 | }) 330 | -------------------------------------------------------------------------------- /test/workers/exitcode.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.exit(42) 4 | -------------------------------------------------------------------------------- /test/workers/hang.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {workerData} = require('worker_threads') 4 | 5 | const delay = Number.parseInt(workerData) 6 | 7 | setTimeout(function () {}, delay) 8 | -------------------------------------------------------------------------------- /test/workers/normal.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const {parentPort, workerData} = require('worker_threads') 5 | 6 | assert.equal(workerData, 'hello from main') 7 | parentPort.postMessage('hello from worker') 8 | -------------------------------------------------------------------------------- /test/workers/throw.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | throw new Error('boom!') 4 | --------------------------------------------------------------------------------