Skip to content

Commit 74dbb42

Browse files
authored
feat(terser): Update WorkerPool to reuse Workers (#1409)
* Update WorkerPool to reuse Workers * test number of workers used * Address feedback * Fix ESLint warnings * Use regular `for` loop * Address feedback
1 parent e75744b commit 74dbb42

File tree

6 files changed

+134
-76
lines changed

6 files changed

+134
-76
lines changed

packages/terser/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const taskInfo = Symbol('taskInfo');
2+
export const freeWorker = Symbol('freeWorker');

packages/terser/src/module.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,23 @@ import { WorkerPool } from './worker-pool';
99
export default function terser(input: Options = {}) {
1010
const { maxWorkers, ...options } = input;
1111

12-
const workerPool = new WorkerPool({
13-
filePath: fileURLToPath(import.meta.url),
14-
maxWorkers
15-
});
12+
let workerPool: WorkerPool | null | undefined;
13+
let numOfChunks = 0;
14+
let numOfWorkersUsed = 0;
1615

1716
return {
1817
name: 'terser',
1918

2019
async renderChunk(code: string, chunk: RenderedChunk, outputOptions: NormalizedOutputOptions) {
20+
if (!workerPool) {
21+
workerPool = new WorkerPool({
22+
filePath: fileURLToPath(import.meta.url),
23+
maxWorkers
24+
});
25+
}
26+
27+
numOfChunks += 1;
28+
2129
const defaultOptions: Options = {
2230
sourceMap: outputOptions.sourcemap === true || typeof outputOptions.sourcemap === 'string'
2331
};
@@ -80,7 +88,18 @@ export default function terser(input: Options = {}) {
8088
return result;
8189
} catch (e) {
8290
return Promise.reject(e);
91+
} finally {
92+
numOfChunks -= 1;
93+
if (numOfChunks === 0) {
94+
numOfWorkersUsed = workerPool.numWorkers;
95+
workerPool.close();
96+
workerPool = null;
97+
}
8398
}
99+
},
100+
101+
get numOfWorkersUsed() {
102+
return numOfWorkersUsed;
84103
}
85104
};
86105
}

packages/terser/src/type.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import type { AsyncResource } from 'async_hooks';
2+
import type { Worker } from 'worker_threads';
3+
14
import type { MinifyOptions } from 'terser';
25

6+
import type { taskInfo } from './constants';
7+
38
export interface Options extends MinifyOptions {
49
nameCache?: Record<string, any>;
510
maxWorkers?: number;
@@ -12,6 +17,12 @@ export interface WorkerContext {
1217

1318
export type WorkerCallback = (err: Error | null, output?: WorkerOutput) => void;
1419

20+
interface WorkerPoolTaskInfo extends AsyncResource {
21+
done(err: Error | null, result: any): void;
22+
}
23+
24+
export type WorkerWithTaskInfo = Worker & { [taskInfo]?: WorkerPoolTaskInfo | null };
25+
1526
export interface WorkerContextSerialized {
1627
code: string;
1728
options: string;

packages/terser/src/worker-pool.ts

Lines changed: 64 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
1+
import { AsyncResource } from 'async_hooks';
12
import { Worker } from 'worker_threads';
23
import { cpus } from 'os';
34
import { EventEmitter } from 'events';
45

56
import serializeJavascript from 'serialize-javascript';
67

8+
import { freeWorker, taskInfo } from './constants';
9+
710
import type {
811
WorkerCallback,
912
WorkerContext,
1013
WorkerOutput,
1114
WorkerPoolOptions,
12-
WorkerPoolTask
15+
WorkerPoolTask,
16+
WorkerWithTaskInfo
1317
} from './type';
1418

15-
const symbol = Symbol.for('FreeWoker');
19+
class WorkerPoolTaskInfo extends AsyncResource {
20+
constructor(private callback: WorkerCallback) {
21+
super('WorkerPoolTaskInfo');
22+
}
23+
24+
done(err: Error | null, result: any) {
25+
this.runInAsyncScope(this.callback, null, err, result);
26+
this.emitDestroy();
27+
}
28+
}
1629

1730
export class WorkerPool extends EventEmitter {
1831
protected maxInstances: number;
@@ -21,37 +34,30 @@ export class WorkerPool extends EventEmitter {
2134

2235
protected tasks: WorkerPoolTask[] = [];
2336

24-
protected workers = 0;
37+
protected workers: WorkerWithTaskInfo[] = [];
38+
protected freeWorkers: WorkerWithTaskInfo[] = [];
2539

2640
constructor(options: WorkerPoolOptions) {
2741
super();
2842

2943
this.maxInstances = options.maxWorkers || cpus().length;
3044
this.filePath = options.filePath;
3145

32-
this.on(symbol, () => {
46+
this.on(freeWorker, () => {
3347
if (this.tasks.length > 0) {
34-
this.run();
48+
const { context, cb } = this.tasks.shift()!;
49+
this.runTask(context, cb);
3550
}
3651
});
3752
}
3853

39-
add(context: WorkerContext, cb: WorkerCallback) {
40-
this.tasks.push({
41-
context,
42-
cb
43-
});
44-
45-
if (this.workers >= this.maxInstances) {
46-
return;
47-
}
48-
49-
this.run();
54+
get numWorkers(): number {
55+
return this.workers.length;
5056
}
5157

52-
async addAsync(context: WorkerContext): Promise<WorkerOutput> {
58+
addAsync(context: WorkerContext): Promise<WorkerOutput> {
5359
return new Promise((resolve, reject) => {
54-
this.add(context, (err, output) => {
60+
this.runTask(context, (err, output) => {
5561
if (err) {
5662
reject(err);
5763
return;
@@ -67,51 +73,54 @@ export class WorkerPool extends EventEmitter {
6773
});
6874
}
6975

70-
private run() {
71-
if (this.tasks.length === 0) {
72-
return;
73-
}
74-
75-
const task = this.tasks.shift();
76-
77-
if (typeof task === 'undefined') {
78-
return;
76+
close() {
77+
for (let i = 0; i < this.workers.length; i++) {
78+
const worker = this.workers[i];
79+
worker.terminate();
7980
}
81+
}
8082

81-
this.workers += 1;
82-
83-
let called = false;
84-
const callCallback = (err: Error | null, output?: WorkerOutput) => {
85-
if (called) {
86-
return;
87-
}
88-
called = true;
89-
90-
this.workers -= 1;
91-
92-
task.cb(err, output);
93-
this.emit(symbol);
94-
};
95-
96-
const worker = new Worker(this.filePath, {
97-
workerData: {
98-
code: task.context.code,
99-
options: serializeJavascript(task.context.options)
100-
}
101-
});
83+
private addNewWorker() {
84+
const worker: WorkerWithTaskInfo = new Worker(this.filePath);
10285

103-
worker.on('message', (data) => {
104-
callCallback(null, data);
86+
worker.on('message', (result) => {
87+
worker[taskInfo]?.done(null, result);
88+
worker[taskInfo] = null;
89+
this.freeWorkers.push(worker);
90+
this.emit(freeWorker);
10591
});
10692

10793
worker.on('error', (err) => {
108-
callCallback(err);
94+
if (worker[taskInfo]) {
95+
worker[taskInfo].done(err, null);
96+
} else {
97+
this.emit('error', err);
98+
}
99+
this.workers.splice(this.workers.indexOf(worker), 1);
100+
this.addNewWorker();
109101
});
110102

111-
worker.on('exit', (code) => {
112-
if (code !== 0) {
113-
callCallback(new Error(`Minify worker stopped with exit code ${code}`));
103+
this.workers.push(worker);
104+
this.freeWorkers.push(worker);
105+
this.emit(freeWorker);
106+
}
107+
108+
private runTask(context: WorkerContext, cb: WorkerCallback) {
109+
if (this.freeWorkers.length === 0) {
110+
this.tasks.push({ context, cb });
111+
if (this.numWorkers < this.maxInstances) {
112+
this.addNewWorker();
114113
}
115-
});
114+
return;
115+
}
116+
117+
const worker = this.freeWorkers.pop();
118+
if (worker) {
119+
worker[taskInfo] = new WorkerPoolTaskInfo(cb);
120+
worker.postMessage({
121+
code: context.code,
122+
options: serializeJavascript(context.options)
123+
});
124+
}
116125
}
117126
}

packages/terser/src/worker.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import process from 'process';
2-
import { isMainThread, parentPort, workerData } from 'worker_threads';
1+
import { isMainThread, parentPort } from 'worker_threads';
32

43
import { hasOwnProperty, isObject } from 'smob';
54

@@ -22,21 +21,25 @@ function isWorkerContextSerialized(input: unknown): input is WorkerContextSerial
2221
);
2322
}
2423

25-
export async function runWorker() {
26-
if (isMainThread || !parentPort || !isWorkerContextSerialized(workerData)) {
24+
export function runWorker() {
25+
if (isMainThread || !parentPort) {
2726
return;
2827
}
2928

30-
try {
31-
// eslint-disable-next-line no-eval
32-
const eval2 = eval;
29+
// eslint-disable-next-line no-eval
30+
const eval2 = eval;
3331

34-
const options = eval2(`(${workerData.options})`);
32+
parentPort.on('message', async (data: WorkerContextSerialized) => {
33+
if (!isWorkerContextSerialized(data)) {
34+
return;
35+
}
36+
37+
const options = eval2(`(${data.options})`);
3538

36-
const result = await minify(workerData.code, options);
39+
const result = await minify(data.code, options);
3740

3841
const output: WorkerOutput = {
39-
code: result.code || workerData.code,
42+
code: result.code || data.code,
4043
nameCache: options.nameCache
4144
};
4245

@@ -48,8 +51,6 @@ export async function runWorker() {
4851
output.sourceMap = result.map;
4952
}
5053

51-
parentPort.postMessage(output);
52-
} catch (e) {
53-
process.exit(1);
54-
}
54+
parentPort?.postMessage(output);
55+
});
5556
}

packages/terser/test/test.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ test.serial('minify via terser options', async (t) => {
4646
});
4747

4848
test.serial('minify multiple outputs', async (t) => {
49+
let plugin;
50+
4951
const bundle = await rollup({
5052
input: 'test/fixtures/unminified.js',
51-
plugins: [terser()]
53+
plugins: [(plugin = terser({ maxWorkers: 2 }))]
5254
});
5355

5456
const [bundle1, bundle2] = await Promise.all([
@@ -60,6 +62,20 @@ test.serial('minify multiple outputs', async (t) => {
6062

6163
t.is(output1.code, '"use strict";window.a=5,window.a<3&&console.log(4);\n');
6264
t.is(output2.code, 'window.a=5,window.a<3&&console.log(4);\n');
65+
t.is(plugin.numOfWorkersUsed, 2, 'used 2 workers');
66+
});
67+
68+
test.serial('minify multiple outputs with only 1 worker', async (t) => {
69+
let plugin;
70+
71+
const bundle = await rollup({
72+
input: 'test/fixtures/unminified.js',
73+
plugins: [(plugin = terser({ maxWorkers: 1 }))]
74+
});
75+
76+
await Promise.all([bundle.generate({ format: 'cjs' }), bundle.generate({ format: 'es' })]);
77+
78+
t.is(plugin.numOfWorkersUsed, 1, 'used 1 worker');
6379
});
6480

6581
test.serial('minify esm module', async (t) => {
@@ -122,7 +138,7 @@ test.serial('throw error on terser fail', async (t) => {
122138
await bundle.generate({ format: 'esm' });
123139
t.falsy(true);
124140
} catch (error) {
125-
t.is(error.toString(), 'Error: Minify worker stopped with exit code 1');
141+
t.is(error.toString(), 'SyntaxError: Name expected');
126142
}
127143
});
128144

@@ -142,7 +158,7 @@ test.serial('throw error on terser fail with multiple outputs', async (t) => {
142158
await Promise.all([bundle.generate({ format: 'cjs' }), bundle.generate({ format: 'esm' })]);
143159
t.falsy(true);
144160
} catch (error) {
145-
t.is(error.toString(), 'Error: Minify worker stopped with exit code 1');
161+
t.is(error.toString(), 'SyntaxError: Name expected');
146162
}
147163
});
148164

0 commit comments

Comments
 (0)