[Web] Web Worker
Web worker?
자바스크립트가 단일 쓰레드입니다. event callback & asynchronous 지원때문에 멀티 쓰레드도 가능하지 않나? 하고 생각할 수 있으나, 위 두가지가 가능한 이유도 브라우저의 서포트를 받기에 (event stack, timer etc..) 가능하지 실제 동작은 단일 쓰레드로 작동합니다.
그렇다면 자바스크립트는 이대로 평생 싱글 스레드의 한계를 안고 있어야하느냐? 그건 아닙니다.
비록 자바스크립트가 싱글 스레드여도 스크립트 실행 주체인 브라우저는 멀티 쓰레드이기에, 브라우저의 지원을 받아 멀티 쓰레딩이 가능합니다.
다만 완벽하다 말하긴 어려운게, 메인 쓰레드에서 하는 작업에 비해, 워커 쓰레드(백그라운드)에서 할 수 있는 작업들이 한정되어 있기 때문입니다. (ref: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers)
그리고 이를 가능하게 하는게 Worker Api 입니다.
Cons : Single Thread
단일 쓰레드여서 발생하는 문제는 굉장히 명확합니다. 당장 고비용 작업만 시행시켜도 그 문제점을 체감할 수 있습니다.
먼저 예시를 보겠습니다.
//brutal task..
let cnt = 0;
while(cnt <= 5000000000){
cnt += 1;
}
console.log('what a heavy task!!!');
현재 50억까지 세는 루프를 실행 시킨후, 동적으로 DOM 요소를 생성시켰습니다.
위 gif에서 새로고침 후 한 5초 정도 지연 후 렌더링 되는게 보이시나요?
단일 쓰레드여서 말 그대로 한번에 한가지 태스크만 가능합니다.
실제 웹사이트에선 굉장히 많은 유저 인터렉션 + 비동기 처리 etc.. 등이 태스크 큐에 쌓일 텐데, 고비용 작업들이 비슷한 시점에 발생하는 경우, 위와 같은 사례는 충분히 일어날봄직 합니다.
How to use?
1. Worker 객체를 생성합니다. 첫번째 인자는, 해당 worker가 실행할 task로, 실행 파일의 주소를 넘겨주면됩니다.
2. 쓰레드간 통신 로직을 만듭니다.
2-1. 메인 쓰레드에서 워커 쓰레드에서 응답이 올 시, 시행할 로직을 onmessage 프로퍼티에 할당합니다.
2-2. 메인 쓰레드에서 워커 쓰레드로 메세지를 보냅니다. (postMessage)
2-3. 워커 쓰레드에서도 메인쓰레드와 같이 응답과 요청 로직을 구현합니다.
//main.js
const task = './worker-task.js';
const myworker = new Worker(task);
myworker.onmessage = (msg) => {
console.log(`Message from Worker : ${msg}`);
}
;
//or
myworker.addEventListener('message', callback..);
myworker.postMessage('hello from mainThread');
//worker-task.js
self.addEventListener('message' , (msg) => {
console.log(`Worker Received Message: ${msg.data}`);
self.postMessage('hello from worker')
});
다만, file:// 프로토콜로 시행 시, 크롬 같은 경우, 보안 요소때문에, 아마 실행이 안될겁니다.
두가지 해결법이 있는데,
1. 크롬을 다음과 같은 방식으로 시행합니다 .chrome.exe --allow-file-access-from-files
2. 웹 서버를 통해 시행합니다.
만약, 웹 서버를 통해 시행할 경우, 높은 확률로 번들링 모듈(webpack , vite , snowpack etc..)를 사용할듯한데,
번들링 된 파일의 경우, 기존과 다른 파일이름으로 번들링 될 것입니다.
그런 경우, 다음과 같은 방법을 통해 해결합시다.
const worker = new Worker(new URL('./my-worker.js' , import.meta.url))
Validation
worker의 컨셉에 대해 알아보니, 고비용 태스크가 있을 시, 워커를 많이 생성하면 할 수록 더 빨리 태스크를 끝낼 수 있을것 같습니다.
위를 확인해보기 위해 performance api를 이용해, single thread vs worker을 한번 측정해보도록 하겠습니다.
테스트, 크기가 5000 * 5000 인 배열의 모든 원소의 합을 구하는 것 입니다.
아래는 단일 쓰레드와 worker쓰레드의 성능 측정을 위한 프로파일러 코드입니다
//profiler.js
function singleThreadTest(name, test, num) {
const times = [];
const startTestTime = performance.now();
for (let i = 0; i < num; i++) {
const startTime = performance.now();
test();
const totalTime = performance.now() - startTime;
times.push(totalTime);
}
const totalTestTime = performance.now() - startTestTime;
console.log('-----------------------------');
console.log('!Single Threaded Test!');
console.log('-----------------------------');
for (let i = 0; i < num; i++) {
console.log(name + '[' + i + ']' + times[i] + 'ms');
}
console.log('Total: ' + totalTestTime + 'ms');
}
function workerThreadTest(name, entry, work, num) {
const workers = [...Array(num)].map((_) => new Worker(entry, { type: 'module' }));
let totalTime = null;
let done = 0;
const times = [];
const startTime = performance.now();
for (let i = 0; i < workers.length; ++i) {
workers[i].onmessage = (msg) => {
done += 1;
times.push(performance.now() - startTime);
if (done == work.length) {
totalTime = performance.now() - startTime;
console.log('-----------------------------');
console.log('Multi Threaded Test');
console.log(name + `thread count : ${num} Test`);
console.log('-----------------------------');
console.log('Total: ' + totalTime + 'ms');
}
};
}
for (let i = 0; i < work.length; i++) {
workers[i % workers.length].postMessage(work[i]);
}
}
이제 worker 태스크를 정의해 주고, 실행 시켜보도록 하겠습니다.
//worker-tast.js
function sigma(vals) {
let sum = 0;
console.log('worker task starting...');
for (let i = 0; i < vals.length; i++) {
sum += vals[i] * vals[i];
}
return sum;
}
self.onmessage = (msg) => {
const startTime = performance.now();
const sums = sigma(msg.data);
console.log('workerTask finishes by : ', performance.now() - startTime);
self.postMessage(sums);
};
실행!
function createTonsOfNums(nums) {
const arr = new Float32Array(nums);
for (let i = 0; i < nums; i++) {
arr[i] = Math.random();
}
return arr;
}
function sigma(vals) {
let sum = 0;
for (let i = 0; i < vals.length; i++) {
sum += vals[i];
}
return sum;
}
const nums = createTonsOfNums(5000 * 5000);
singleThreadTest('single thread result : ', () => sigma(nums), 8);
// 25000000 짜리 작업 * 8
workerThreadTest(
'Multi thread result : ',
new URL('./worker-sum.js', import.meta.url),
[nums, nums, nums, nums, nums, nums, nums, nums],
2
);
//두개의 worker thread로 25000000 * 8 짜리 작업 처리
하..하핫.. 예상과는 다르게 오히려 4배에 근사하게 더 느립니다!
이론상으론 더 빨라야하는데 말이죠..
chrome performance툴을 이용해 프로파일링을 해보겠습니다.
worker thread의 프로파일 결과인데, 혹시 전체 task 대비 sigma 함수의 수행비율이 보이시나요?
평균적으로 각 태스크가 280ms, sigma가 75ms이니, 메세지 받고 보내면서 75%의 시간을 손해보고 있습니다.
그렇다면 이부분을 어떻게 최적화 시킬 수 있을까요?
MDN문서에서 worker에 대해 발췌하자면,
1. Data is sent between workers and the main thread via a system of messages — both sides send their messages using the postMessage() method, and respond to messages via the onmessage event handler (the message is contained within the message event's data property). The data is copied rather than shared.
2. Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application.
즉, msg인자로 보낸 데이터가 공유되지 않고 복사되며, worker thread는 global execution context가 아닌, 별도의 execution context에서 시행되는점입니다. (말만 들어도, 단순 포인터로 참조하는게 아니라 복사한다니.. 무시무시합니다)
그리고 web worker는 postMessage()시, structured clone algorithm을 사용하여 데이터를 복사합니다. (https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
현재 테스트 케이스는, 단순 참조 토탈 25만 짜리 배열객체 8개를 clone해서 보내니 일단 이부분에서 굉장한 손실이 일어납니다.
해당 알고리즘에 대해 벤치마크 테스트한 결과가 있어 공유해봅니다.
결국 객체를 넘기면 필연적으로 손실을 감수해야겠군요...
다행히 모든 객체가 그렇지는 않습니다!!
postMessage 메소드가 객체를 복사했던 이유는 worker thread가 다른 execution context에서 시행되기 때문이었습니다.
MDN문서에 따르면, postMessage의 두번째 인자로 transferable Object 배열을 넘길 수 있다고 합니다
postMessage(message, transfer?)
Transferable objects are objects that own resources that can be transferred from one context to another, ensuring that the resources are only available in one context at a time. Following a transfer, the original object is no longer usable; it no longer points to the transferred resource, and any attempt to read or write the object will throw an exception.
즉, 아까 말한것 처럼 해당 객체의 포인터 값을 다른 execution context로 인가할 수 있습니다!
참고로 transferable object의 종류는 다음과 같습니다.
- ArrayBuffer
- MessagePort
- ReadableStream
- WritableStream
- TransformStream
- AudioData
- ImageBitmap
- VideoFrame
- OffscreenCanvas
- RTCDataChannel
일반적으로 객체를 넘긴다면 ArrayBuffer를 사용해 넘기면 최적화가 가능할것 같습니다.
//worker-task.js
...
const sums = sigma(msg.data);
const dataPack = new Float32Array(new ArrayBuffer(sums.length * 4));
dataPack.set(sums);
self.postMessage(dataPack.buffer, [dataPack.buffer]);
//main.js
...
function createTonsOfNums(nums) {
const buffer = new ArrayBuffer(nums * 4);
const arr = new Float32Array(buffer);
const payload = new Array(nums);
for (let i = 0; i < nums; i++) {
payload[i] = Math.random();
}
arr.set(payload);
return arr;
}
...
여담으로 sharedArrayBuffer은 쉐어중인 execution context들이 접속 가능하다던데 추후에 한번 공부해 보고 써보도록 하겠습니다.