Javascript를 다중 스레드 처럼 사용하기 - 웹 워커(Web Worker), 공유메모리(SharedArrayBuffer), Atomics 객체 정리

해당 내용은 만화로 소개하는 ArrayBuffer 와 SharedArrayBufferT.J 크라우더의 웹 개발자를 위한 자바스크립트의 모든 것이라는 책을 읽고 작성한 글입니다.
 

웹 개발자를 위한 자바스크립트의 모든 것 : 네이버 도서

네이버 도서 상세정보를 제공합니다.

search.shopping.naver.com


여는글

여러분들은 자바스크립트가 싱글 스레드로 동작하는것을 알고 있으신가요?아마 어느정도 자바스크립트의 이해도가 있는 프론트엔드 개발자라면 자바스크립트는 싱글 스레드 언어란 것을 알고 있을겁니다.하지만, 이 말이 맞는 말이기도 하고, 틀린 말이기도 합니다.왜냐하면 웹 워커로 메인스레드와 워커 스레드를 만들 어 사용할 수 있기 때문이죠.

 


웹 워커(Web Worker)

웹 워커란 웹 브라우저(혹은 노드)에서 워커 쓰레드를 활용할 수 있는 웹 API입니다. 따라서 단일 스레드 기반의 자바스크립트도 이 웹 워커를 사용하면 충분히 멀티스레드가 가능합니다. 다만, 이 웹 워커는 복잡한 연산에는 사용할 수 있지만, Window나 Document같은 Dom 객체에 접근할 수 없어서 UI접근이 불가능합니다. 즉 자바스크립트의 단일 스레드는 UI스레드라고 보면 되고, UI스레드가 처리하기 어려운 무거운 연산(AI, games, image encoding, etc)은 웹 워커를 이용해 CPU가 처리한다고 보면 됩니다.


웹 워커를 활용한 멀티스레드의 문제 그리고 SharedArrayBuffer

스레드를 다뤄봤던 사람들이라면 잘 아시겠지만, 스레드는 각각이 독립적인 단위로 움직입니다. 그러면서 자연적으로 발생하는 문제는 자원 공유가 어렵다는 것 입니다. 따라서 멀티스레드를 지원하는 언어들 중, 대부분이 자원을 공유할 수 있는 방법을 제공했습니다. 하지만, 자바스크립트는 브라우저의 스레드 간 메모리를 공유할 방법을 제공하지 않았습니다(물론 반쪽자리 공유방법인 ArrayBuffer는 존재했습니다…) 하지만 ES2017에서 포함된 SharedArrayBuffer가 이것을 가능하게 했습니다. 어지럽습니다... ArrayBuffer는 뭐고 SharedArrayBuffer는 뭘까요?


ArrayBuffer가 뭔가요?

일단 먼저 저희는 ArrayBuffer에 대해서 알고 가야 합니다. ArrayBuffer란 오로지 숫자로만 표현할 수 있는 배열입니다. 실직적인 동작은 Javascript의 배열과 비슷하게 동작합니다. 다만, 이 ArrayBuffer란 녀석은 문자열이나 객체를 받을 수 없고 숫자로 된 바이트열을 값으로 받을 수 있습니다.예를 한번 보도록 하겠습니다.

// 4바이트로 버퍼를 만든 상태, 1byte는 8bit이기 때문에 계산하면 4바이트는 32비트가 된다.
const buffer = new ArrayBuffer(4);

// 32비트의 버퍼를 1바이트당 8비트로 할당을 해주면 총 배열 길이는 4가 되고 
// 각 배열 공간마다 8비트의 공간이 할당된다.
const view = new Int8Array(buffer);
 
console.log(view);
 
view[0] = 256;
view[1] = 257;
view[2] = 258;
view[3] = 259;
 
console.log(view);

확실히 뭔가 많이 복잡합니다. 여기서 집중해서 볼 부분은 ArrayBuffer로 객체를 생성하는 부분입니다. 이 ArrayBuffer는 생성 될 때 구간을 나누지 않고 전달받은 바이트 열을 그대로 크기만 가지고 버퍼를 만듭니다. 아래 그림처럼 말이죠

ArrayBuffer에 담긴 정보를 직접 수정하는건 불가능합니다. 즉, 이 ArrayBuffer는 단순한 이진 데이터 배열에 불과하단 것이죠. 그렇다면 이 이진데이터 덩어리들을 어떻게 사용해야 할까요? 그건 바로 view 라고 불리는 것으로 감싸야 합니다. view 로 표현된 데이터를 typed array (형식화 배열)에 추가할 수 있습니다. JavaScript 는 view 를 다룰 수 있는 다양한 typed array (형식화 배열)을 제공합니다. 예를 들어, 우리는 Int8 typed array (Int8 형식화 배열)를 이용해서 데이터 덩어리를 8-bit 단위의 바이트 값들로 나눌 수 있습니다. 아래 그림처럼 말입니다.

이런 방식으로, ArrayBuffer 는 기본적으로 메모리 자체인 것처럼 동작합니다. ArrayBuffer 는 C 같은 랭귀지를 사용할 때처럼 메모리를 직접 다루는 방식과 비슷한 효과를 만들어 냅니다. 그래서 이걸 어디다가 사용하냐구요? 아래에서 더 설명하도록 하겠습니다.


SharedArrayBuffer가 뭔가요?

SharedArrayBuffer란 이름에서 알 수 있다시피,  ArrayBuffer를 공유하는 객체입니다. 잘 이해가 안가실겁니다. 이 말의 이해를 위해서 Javascript의 병렬 처리 로직을 조금 설명하고 들어가겠습니다. 개발자들은 기본적으로 유저에게 빠른 결과값을 제공하기 위해 별렬 처리 로직을 이용해 코드를 작성해 왔습니다. Javascript에서도 웹 워커를 이용하여 메인 스레드(UI 스레드)와 보조 스레드(연산 스레드)가 역할을 분리하여 작업을 할 수 있도록 처리되어져 왔고 사용자는 보다 빠른 결과값을 제공 받을 수 있었습니다. 하지만, 웹 워커는 단점이 존재하는데 워커들 즉 스레드 들끼리 메모리를 공유하지 않습니다.

이러한 단점은 꽤 크게 다가오는데 만약 우리가 다른 스레드와 어떤 데이터를 공유하고 싶다면, 그 데이터를 복사해서 전달해야만 한다는 뜻입니다. 이 작업은 postMessage 함수에 의해 처리됩니다. postMessage 에 어떤 객체를 전달하면, postMessage 함수는 그것을 직렬화해서(serialize) 다른 web worker 에 전달합니다. 그러면 데이터를 전달 받은 web worker 가 데이터를 풀어서(deserialize) 메모리에 복사합니다. 하지만 이러한 방법은 매우 느린 작업입니다.

ArrayBuffer 같은 메모리의 경우, 메모리 전달하기(transferring memory)라는 것이 가능합니다. 메모리 전달하기란 메모리의 특정 블록 소유권을 다른 web worker 로 이전하는 것입니다. 그러면 원래 해당 메모리 블록을 소유하고 있던 web worker 는 더이상 그 블록에 접근할 수 없게 됩니다. 사실 이러한 방식으로도 충분히 웹 워커 끼리 통신을 할 수 있고 고성능 병렬 처리 코드가 필요한 많지 않은 경우에는 이 방식이 적절하기도 합니다. 하지만 , 우리가 정말 원하는 것은 공유 메모리 (shared memory)입니다. SharedArrayBuffer 가 바로 이 기능을 제공합니다.

SharedArrayBuffer 를 쓰면 2개의 web work (즉 2개의 스레드) 모두가 메모리의 같은 영역을 읽고 쓸 수 있습니다. 이는 더이상 postMessage 를 쓸 때 감수해야 했던 통신 오버헤드와 시간지연을 겪지 않아도 된다는 뜻입니다. 2개의 web worker 모두가 데이터에 즉시 접근할 수 있습니다. 그러면 코드로 한번 확인해 보도록 하겠습니다. 먼저 ArrayBuffer에 코드를 한번 확인해 보도록 하겠습니다.

 

test.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
        <title>Basic SharedArrayBuffer Example</title>
    </head>
    <body>
        <script src="test-main.js"></script>
    </body>
</html>

test-main.js

// ArrayBuffer 메모리
const TYPE = "ArrayBuffer : ";
const buff = new ArrayBuffer(5 * Uint16Array.BYTES_PER_ELEMENT);
const view = new Uint16Array(buff);
 
// SharedArrayBuffer 메모리
// const TYPE = "SharedArrayBuffer : ";
// const buff = new SharedArrayBuffer(5 * Uint16Array.BYTES_PER_ELEMENT);
// const view = new Uint16Array(buff);
 
const worker = new Worker("./test-worker.js");
let counter = 0;
console.log("initial: " + formatArray(view));
worker.addEventListener("message", (e) => {
    if (e.data && e.data.type === "ping") {
        console.log(TYPE + "recieve Ping Type => " + formatArray(view));
        if (++counter < 10) {
            worker.postMessage({ type: "pong" });
        } else {
            console.log("done");
        }
    }
});
worker.postMessage({ type: "init", memoryType: TYPE, memory: view });
 
function formatArray(array) {
    return Array.from(array, (b) =>
        b.toString(16).toUpperCase().padStart(4, "0")
    ).join(" ");
}

test-worker.js

let TYPE;
let shared;
let index;
const updateAndPing = () => {
    ++shared[index];
    index = (index + 1) % shared.length;
    console.log(TYPE + "recieve Pong Type => " + formatArray(shared));
    this.postMessage({ type: "ping" });
};
this.addEventListener("message", (e) => {
    console.log("===============구분자================");
    if (e.data) {
        switch (e.data.type) {
            case "init":
                shared = e.data.memory;
                TYPE = e.data.memoryType;
                index = 0;
                updateAndPing();
                break;
            case "pong":
                updateAndPing();
                break;
        }
    }
});
 
function formatArray(array) {
    return Array.from(array, (b) =>
        b.toString(16).toUpperCase().padStart(4, "0")
    ).join(" ");
}

이 코드는 ArrayBuffer를 사용해서 메모리를 전달하는 방식으로 동작하는 방식입니다. 코드에서 보면 한번 postMessage로 ArrayBuffer메모리를 전달 한 후 워커에서 메모리를 +1씩 증가합니다. 그럼 코드를 실행하면 아래와 같은 결과가 나옵니다.

 

그럼 이렇게 워커에서는 계속해서 메모리를 증가시키는데 메인에서는 증가되지 않는 값을 가지고 있게되어집니다. main과 worker가 동시에 메모리를 업데이트 하려면 postMessage를 통해서 지속적으로 메모리를 전달해야할 겁니다. 이 방식은 SharedArrayBuffer에 비해 코스트가 많이 들지만 메모리를 공유함에 따라 발생하는 사이드 이펙트를 줄일 수 있습니다. 이제 ArrayBuffer대신 SharedArrayBuffer를 사용해서 메모리를 공유해 보겠습니다. `test-main.js`에서 2번째줄 ~ 4번째줄까지 주석 처리 후 7번째 줄 ~ 9번째 줄까지 주석을 해제해 줍니다. 그런 다음 코드를 실행하면 아래와 같은 결과가 나옵니다.

메모리의 값을 업데이트 하는 결과는 똑같지만, worker와 main이 정상적으로 결과값을 공유받는것을 알 수 있습니다. 이렇게 2개의 스레드가 메모리를 공유함에 따라 코스트가 ArrayBuffer에 비해 상대적으로 적게 들게됩니다. 다만, 2개 이상의 스레드가 동시에 즉각적으로 접근할 수 있기 때문에 그에 따라서 어떤 위험한 상황을 감수해야 합니다. 즉 레이스 컨디션(race condition)이 발생할 수 있습니다.


스레드를 사용하면 겪게되는 문제! 레이스 컨디션

일단, 레이스 컨디션이 뭔지 부터 알아봅시다. 레이스 컨디션 이란 두 개 이상의 스레드가 공유 메모리의 특정 자원을 병행적으로(concurrently) 읽거나 쓸 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 달라지는 상황을 말합니다. 에를 들어볼까요? 아래 그림을 보면 총 2개의 스레드가 열심히 일을 하고 있습니다. 중간에서 파일이 존재하는 지 체크하는 플래그를 공유 메모리로 가지고 있습니다.

코드는 두번째 스레드가 파일을 읽을 수 있도록 처리해 준 후 첫번째 스래드가 파일을 읽는 코드입니다. fileExists 공유 메모리의 초깃값은 false입니다.

개발자가 작성한 로직대로 잘 움직인다면 첫번째 스레드는 무리없이 원하는 결과인 파일을 로드할 수 있을겁니다.

하지만 모종의 이유로 첫번째 스레드가 먼저 읽혀지게된다면 첫번째 스레드는 파일을 읽지 못하고 특정 콘솔만 읽고 종료되어질 것입니다. 예시는 그렇게 심각한 문제가 아니지만, 이러한 상황이 엄청 중대한 로직에서 발생했을 때 후 폭풍은 상상도 하기 어려울 것 입니다. 사실 이러한 종류의 레이스 컨디션은 javascript로 서비스를 개발하다보면, 심심치 않게 볼 수 있습니다. 싱글스레드인데도 말이죠... 하지만, 싱글 스레드 코드에서는 발생하지 않는 종류의 레이스 컨디션이 있습니다. 이러한 레이스 컨디션을 막기위해서 Atomics라는 객체가 나오게 되었습니다.


Atomics 객체

Atomics 객체는 일관되고 순차적인 방식으로 공유 메모리를 관리 및 처리하기 위한 도구를 제공하는 객체입니다. 일반적으로 Atomics 객체는 생성자 함수가 아니라 정적메소드 이기 때문에 new 연산자를 사용하여 호출하거나 함수로 직접 호출할 수 없습니다. 또한, 레이스 컨디션이나 Stale하지 않은 값을 읽거나 비순차적 쓰기, 티어링같은 경우를 해결하기 위해 쓰이는 객체입니다.  단 SharedArrayBuffer 대상의 원자만 컨트롤할 수 있고 ArrayBuffer는 컨트롤 할 수 없습니다. 그럼 이게 어떻게 동작하는지 알아보도록 하겠습니다.

위 그림을 보면총 2개의 스레드가 존재합니다. 이 두개의 스레드는 한개의 공유메모리 블락을 공유하고 있습니다.

여기서 두번재 스레드가 공유메모리의 특정 부분이 필요합니다. 그러면 두번째 스레드는 필요한 메모리에 Lock 객체로 메모리를 잠그고, 메모리의 소유권을 흭득합니다.그러면 두번째 스레드가 소유권을 가진 메모리는 다른 스레드들이 접근하지 못하도록 막을 수 있습니다. 해당 코드는 Lock 객체를 획들했을 때만 해당 데이터에 접근하거나 해당 데이터를 수정할 수 있습니다.

만약 두번째 쓰레드가 메모리를 사용하고 있을 때 첫번째 스레드가 두번째 스레드가 사용중인 메모리가 필요 할 때 첫번째 스레드는 메모리의 Lock이 걸려있는지 확인하고 Lock이 걸려 있으면 Lock이 풀릴때 까지 기다립니다.

그리고 두번째 스레드가 일을 끝내면, 두번째 스레드는 unlock 함수를 호출할 것입니다. 그럼 lock 함수는 기다리고 있는 첫번째 스레드를 깨워서 데이터에 접근 가능함을 알릴 것입니다.깨어난 스레드는 Lock 객체를 획득해서, 데이터를 쓰는 동안 다른 스레드가 접근하지 못하게 막습니다.

이러면 레이스 컨디션 혹은 티어링 등의 문제가 해결되어 질 수 있습니다.말로 들으면 잘 이해가 안갈 수 있기 때문에 코드를 보며 이해해 보도록 하겠습니다.

 

index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>SharedArrayBuffer demo</title>
    </head>
    <body>
        <script src="main.js"></script>
    </body>
</html>

main.js

var arr = new SharedArrayBuffer(256);
new Int16Array(arr)[0] = 0;
var workers = [];
for (let i = 0; i < 1000; i++) workers.push(new Worker("worker.js"));
workers.forEach((w) => w.postMessage(new Int16Array(arr)));

worker.js

onmessage = function (e) {
    /* 레이스 컨디션 발생 => 마지막 라인의 콘솔로그가 1000이 아님 */
    e.data[0]++;
 
    /* 레이스 컨디션 방지 => 마지막 라인의 콘솔로그가 1000 임 */
    // Atomics.add(e.data, 0, 1);
 
    console.log(e.data[0]);
};

 

여기서 중요하게 볼 부분은 worker.js입니다. Atomic을 사용하면 공유 메모리 사용의 결과값이 보장됩니다. 하지만 Atomics객체를 사용하지 않으면 결과값을 보장하기 어렵습니다. 이렇게, Atomics가 특정 공유 메모리를 접근할 때 그 메모리는 lock을 걸게 되고 해당 메모리의 소유권은 Atomics를 사용한 워커 스레드가 됩니다.

 

사실 이 예제 코드 아주 단순하기에 Atomics의 개념을 자세히 이해할 수 있는 코드가 아닙니다. Atomics의 개념을 이해하기 위해서는 아래 링크를 참고하는것을 추천 드립니다. (TIP : Atomics.wait, Atomics.notify등 다양한 함수를 이용되어지기에 링크를 먼저 보시는것을 추천 드립니다.)

 

 

GitHub - mozilla-spidermonkey/js-lock-and-condition: Simple Lock and Condition Variable Library for JavaScript with SharedArrayB

Simple Lock and Condition Variable Library for JavaScript with SharedArrayBuffer and Atomics - GitHub - mozilla-spidermonkey/js-lock-and-condition: Simple Lock and Condition Variable Library for Ja...

github.com


 🐲 이곳에는 용이 살고 있다!(Here be dragons)

Here be dragons라는 말을 아시나요? 관용구로 미 개척지 혹은 용이 사는 위험한 곳이란 뜻입니다. 위 내용들을 설명한 책과 참고문서에서 하나같이 말하는 내용은 이 공유메모리가 과연 필요한지를 생각해 보라고 적혀 있습니다. 그럼 이제 한번 생각해봅시다. 우리는 과연 이러한 공유 메모리가 필요할까요? 아마 대부분의 서비스 개발자는 이러한 기능이 필요 없을 확률이 높을겁니다. 우리가 만약 웹 워커, 공유 메모리, 동시성 기능을 사용했을 때 발생하는 복잡 미묘한 버그로 인해 발생하는 코스트는 어마무시합니다. 따라서, 진짜 필요한 경우를 제외하고는 잘 나와있는 라이브러리를 사용하되 공유메모리를 건드려 서비스를 망가트리는 일은 최대한 자제하는게 좋습니다.

 

참고 문서

  1. 웹 개발자를 위한 자바스크립트의 모든 것 - T.J 크라우더
  2. 만화로 소개하는 ArrayBuffer 와 SharedArrayBuffer
  3. JavaScript의 ArrayBuffer
  4. MDN ArrayBuffer

이 글을 공유하기

댓글