본문 바로가기

Backend/Node.js

SharedArrayBuffer & Atomics (SharedMemory)

ES8 에선 새로운 공유 메모리 모델에 대해 소개하고 있습니다. 먼저 그 배경을 짧게 얘기해볼까 합니다.

 

기본적으로 Node.JS 단일 프로세스 단일 스레드에서 실행되고 이벤트 루프 또한 그렇습니다.

이런 기본 컨셉때문에 Node.JS 개발자들은 다른 언어들이 겪는 동시성 문제에서 꽤나 자유로웠지만, 그로 인해 갖는 단점도 있습니다.

단일 프로세스이다보니 멀티 코어 환경에선 cpu 를 제대로 활용하지 못하기때문에, cluster 모듈을 사용하는 설계를 대부분의 경우엔 깔고 들어가야 합니다. 또한 단일 스레드 모델은 cpu 집약적인 연산에 약하고, 이런 작업의 처리가 오래 걸리면 전체 성능이 저하됩니다.

 

그러다보니 Node.JS 도 꽤 예전버전부터 worker_threads 라는 내장 모듈을 통해 멀티 쓰레드 프로그래밍을 지원했습니다.

ES8 의 Shared memory 는 이런 환경의 메인 쓰레드를 포함한 여러 쓰레드에서 동시에 같은 데이터에 접근하여 읽고 쓸 수 있는 모델입니다. 이 데이터는 아무 타입이나 가능한건 아니고, SharedArrayBuffer 객체만 가능합니다.

 

SharedArrayBuffer 객체가 갖는 차이점을 보기 위해 ArrayBuffer 클래스에 대해 먼저 알아보겠습니다.

 

- ArrayBuffer

 

ArrayBuffer 는 자바스크립트가 바이너리 데이터를 다루기 위해 제공하는 객체입니다.

관련한 ArrayBufferView, TypeArray 등에 대해선 다른 포스팅에서 좀 더 자세히 보도록 하고, 넘어갑니다.

 

// main.js
const { Worker } = require('worker_threads');

const sharedBufferArray = new ArrayBuffer(8);
const array = new Int32Array(sharedBufferArray);

const worker = new Worker('./worker.js');

worker.on('message', data => {
  console.info('data from worker', data);
  console.info('main', array);
});

worker.postMessage({ array });

// worker.js
const { parentPort } = require('worker_threads');

parentPort.on('message', data => {
  const { array } = data;
  console.info('data from main', array);
  array[0] = 2;
  array[1] = 4;

  console.info('worker', array);
  parentPort.postMessage('ok');
});


// node main.js
/*
  data from main Int32Array(2) [ 0, 0 ]
  data from worker ok
  main Int32Array(2) [ 0, 0 ]
  worker Int32Array(2) [ 2, 4 ]
*/

 

Node.JS 에서 제공하는 worker_threads 모듈을 사용한 worker 사용 예제입니다.

main.js 메인 스레드에서 worker 를 하나 생성하고 postMessage 를 이용해 32bit integer 를 2개 저장할 수 있는 ArrayBuffer 객체 데이터를 보냅니다. worker 에선 받은 데이터를 가공하고, ok message 를 보냅니다.

 

가공엔 사실 별 의미는 없습니다. 그냥 차이를 보이기 위한 코드일 뿐인데, 당연하게도 postMessage 에 의해 다른 쓰레드로 전달된 데이터는 복사된 데이터입니다. 정확히는 postMessage 는 데이터를 serialize 에서 객체를 worker 로 전달하고, worker 가 deserialize 해서 데이터를 메모리에 복사합니다.

그렇다보니 당연히 worker 에서 전달받은 데이터를 수정해도, 메인 쓰레드의 원본 데이터는 변경되지 않습니다.

 

-  SharedArrayBuffer

 

// main.js
const { Worker } = require('worker_threads');

const sharedBufferArray = new SharedArrayBuffer(8);
const array = new Int32Array(sharedBufferArray);

const worker = new Worker('./worker.js');

worker.on('message', data => {
  console.info('data from worker', data);
  console.info('main', array);
});

worker.postMessage({ array });

// worker.js
const { parentPort } = require('worker_threads');

parentPort.on('message', data => {
  const { array } = data;
  console.info('data from main', array);
  array[0] = 2;
  array[1] = 4;

  console.info('worker', array);
  parentPort.postMessage('ok');
});

// node main.js
/*
  data from main Int32Array(2) [ 0, 0 ]
  data from worker ok
  main Int32Array(2) [ 2, 4 ]
  worker Int32Array(2) [ 2, 4 ]
*/

 

위 예시 코드에서 ArrayBuffer -> SharedArrayBuffer 로 변경된 것 외에는 동일한 코드입니다.

똑같이 32bit integer를 2개 저장할 수 있는 객체를 SharedArrayBuffer 로 만들어서 worker 로 전달했고, worker 에선 동일하게 전달받은 데이터를 수정하고 있습니다. 이전과 다른것은, worker 의 수정이 메인 쓰레드의 원본 데이터도 변경시켰다는 점 입니다.

 

이것이 ES8 에서 얘기하는 새로운 공유 메모리 모델입니다.

SharedArrayBuffer 를 사용하면 서로 다른 쓰레드가 메모리의 같은 영역을 읽고 쓸 수 있습니다.

이미 아실듯하지만 데이터를 복사하는 과정에서 serialize, deserialize 는 굉장히 느리고 오버헤드가 큰 연산이지만, 이런 손해를 보지 않아도 되는 것입니다.

 

하지만 당연하게도.. 위에서도 한번 언급한 것 처럼 여러 쓰레드가 같은 메모리 영역에 접근한다는 것은 동시성 문제를 신경써야 한다는 얘기입니다. 대표적으로 race condition 이 발생할 수 있는데 이를 해결할 수 있는 Atomics 도 이번 ES8 에서 같이 소개하고 있습니다.

 

- Atomics

 

Atomics 객체는 atomic operation 을 지원하기 위한 정적 메서드를 제공하며, 위의 SharedArrayBuffer 와 같이 사용됩니다.

SharedArrayBuffer 를 사용해 여러 워커에서 동일한 객체에 값을 읽고 쓸 수 있게 되었지만 이런 작업을 동시에 하게 되면 문제가 생길 수 밖에 없습니다. 이러한 문제를 해결하기 위해서 Atomics 의 정적 메서드를 사용해 다른 워커의 작업이 끝나길 기다리거나 lock 을 걸게 됩니다.

 

Atomics 객체의 정적 메서드 예시를 몇가지 보도록 하겠습니다. (사용법은 Math 와 비슷합니다)

 

const sharedBufferArray = new SharedArrayBuffer(8);
const array = new Int32Array(sharedBufferArray);

Atomics.add(array, 0, 5);
console.info(Atomics.load(array, 0)); // 5

Atomics.and(array, 0, 3);
console.info(Atomics.load(array, 0)); // 1

Atomics.compareExchange(array, 0, 1, 4);
console.info(Atomics.load(array, 0)); // 4

Atomics.or(array, 0, 1);
console.info(Atomics.load(array, 0)); // 5

 

Atomics.add 는 해당 인덱스의 기존 값에 추가합니다. Atomics.load 는 해당 인덱스의 값을 반환하는 메서드이구요.

Atomics.and 는 비트 and 연산, Atomics.compareExchange 는 해당 인덱스의 값과 비교해 동일하면 변경, Atomics.or 는 bit or 연산 등을 수행하는 메서드입니다. 아래는 그 외의 메서드들입니다.

 

  • Atomics.exchange : 지정된 인덱스에 값을 저장 (이전 값을 반환)
  • Atomics.store : 지정된 인덱스에 값을 저장. 위 메서드와 다른 점은 저장한 값을 반환
  • Atomics.sub : 지정된 인덱스의 기존 값에서 뺌
  • Atomics.xor : 지정된 인덱스의 값에 비트 xor 연산
  • Atomics.wait : 지정된 인덱스에 값이 있고 대기중이거나 time-out 인지 확인하는 메서드. ok, not-equal, timed-out 를 반환하며 wait 가 허용되지 않을 경우 error 를 반환
  • Atomics.wake : 지정된 인덱스가 wait queue 에서 대기중인 agent 들을 깨우는 메서드이며 깨운 agent 의 수를 반환
  • Atomics.isLockFree : 지정된 사이즈의 배열에 대한 Atomic 연산이 하드웨어 Atomic 연산을 사용하여 구현되는 경우 true 를 반환. 전문가용이라고 하는데.. 설명만 봐도 그런 것 같네요.

 

이렇게 공유 메모리에 대해 race condition 을 해결할 수 있는 Atomics 객체의 정적 메서드에 대해 간단히 살펴봤지만,

이 메서드들을 직접 호출하는 것은 별로 권하지 않습니다. 대부분의 경우엔 검증된 라이브러리를 사용하는 것이 훨씬 좋은 선택입니다.

그리고 Intel cpu 에서 Shared Memory 에 대한 버그가 있어 아직 위의 기능들은 표준 기능이 아닌 것으로 알고 있습니다.

관련해 다른 소식이 들려오면, 그때 Javascript 에서 멀티 쓰레드 프로그래밍을 시도해봐야 할 것 같습니다.

'Backend > Node.js' 카테고리의 다른 글

Node.JS - Reactor 패턴  (0) 2022.07.09
Cluster Module in Node.JS  (0) 2022.04.09
package-lock.json 이란?  (0) 2022.01.22
axios configs 정리  (0) 2021.04.18
[Node.js] Date 라이브러리 퍼포먼스 비교  (1) 2021.03.06