본문 바로가기

FrontEnd

멀티쓰레드 Javascript 1편 : SharedArrayBuffer

반응형

javascript의 공유 메모리(shared memory)에 대해 알아보고, SharedArrayBuffer와 TypedArray에 대해 알아봅니다.

 

브라우저에는 세 종류의 멀티쓰레딩 방법이 있습니다.

  • web worker
  • shared werker
  • service worker

node.js에는 worker_thread 모듈이 존재합니다.

 

각자 용도가 있지만, 대부분 이벤트 루프 기반 메세지 패싱 시스템 API 기반으로 동작합니다.

이는 여러 스레드가 동시에 공유 메모리에 접근할 수 있는 병럴 처리 방식보다 효율이 떨어지게 됩니다.

 

자바스크립트는 멀티쓰레딩의 힘을 더욱 강력하게 해주는 두 가지 도구를 갖고 있습니다.

  • Atomics 객체
  • SharedArrayBuffer 클래스
이를 통해 메시지 전달에 의존하지 않고 두 스레드 간에 메모리를 공유할 수 있습니다.
 
물론 이 도구는 단일 쓰레드 기반 쉬운 동기화라는 js의 컨셉의 탈출구 이기에,
잘못하면 또 다른 종류의 버그를 도입할 수 있습니다.
하지만 잘 사용하면 하드웨어의 퍼포먼스를 더욱 강력하게 이용할 수 있습니다.

공유 메모리 사용 예제 : browser

공유 메모리는 약간의 조정이 필요하지만 node.js, 브라우저 환경 양쪽에서 유사하게 활용할 수 있습니다.

 

간한한 예제로 알아봅시다.

예제는 아래 github 레포지토리의 소스코드를 사용합니다.

https://github.com/MultithreadedJSBook/code-samples

 

GitHub - MultithreadedJSBook/code-samples: Code samples for the book Multithreaded JavaScript, O'Reilly, 2021

Code samples for the book Multithreaded JavaScript, O'Reilly, 2021 - GitHub - MultithreadedJSBook/code-samples: Code samples for the book Multithreaded JavaScript, O'Reilly, 2021

github.com

Example 4-1. ch4-web-workers/index.html

<html>
  <head>
    <title>Shared Memory Hello World</title>
    <script src="main.js"></script>
  </head>
</html>

Example 4-2. ch4-web-workers/main.js

if (!crossOriginIsolated) { // 1
  throw new Error('Cannot use SharedArrayBuffer');
}

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

const buffer = new SharedArrayBuffer(1024); // 2
const view = new Uint8Array(buffer); // 3

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo); // 4
}, 500);
  1. crossOriginIsolated가 true이면 SharedArrayBuffer를 사용할 수 있습니다.
  2. 1kb 버퍼를 초기화 합니다
  3. 버퍼에 대한 view가 생성됩니다.
  4. 버퍼에서 수정된 foo 속성의 값을 읽습니다. (뭔가 이상하다 생각할 수 있지만, 뒤에 설명합니다.)
해당 예제는 web worker를 사용하고 있으며,
최신 브라우저에서 사용할 수 있는 전역 변수인 crossOriginIsolated 값을 확인합니다.
  • 이 값은 현재 실행 중인 JavaScript 코드가 SharedArrayBuffer 인스턴스를 인스턴스화할 수 있는지 여부를 알려줍니다.
보안상의 이유로(Spectre CPU 공격과 관련하여)
SharedArrayBuffer 객체를 항상 인스턴스화할 수 있는 것은 아닙니다.
사실, 몇 년 전 브라우저는 이 기능을 완전히 비활성화했습니다.
 
이제 Chrome과 Firefox 모두 해당 객체의 인스턴스화를 지원하며,
html 문서가 서빙될 시, SharedArrayBuffer 인스턴스화를 허용하기 전에 추가 HTTP 헤더를 설정해야 합니다.
(Node.js에는 해당 제한이 없습니다.)
필수 헤더는 다음과 같습니다.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

SharedArrayBuffer 인스턴스를 사용할 경우, 이러한 헤더를 설정해야 한다는 것을 기억해야 합니다.

web worker가 인스턴스화된 후 SharedArrayBuffer의 인스턴스도 인스턴스화됩니다.

인수(이 경우 1,024)는 버퍼에 할당된 바이트 수입니다.

이 버퍼는 생성된 후에 크기가 줄어들거나 커질 수 없습니다.

(몇몇 환경은 가능 : https://github.com/tc39/proposal-resizablearraybuffer)

view에 대해서는 이후에 좀 더 설명합니다. 지금은 버퍼에 읽고 쓰는 수단이라 생각하시면 됩니다.
해당 view를 배열 인덱스 구문을 사용하여 읽을 수 있습니다.
view[0]에 대한 호출을 로깅하여 버퍼의 0번째 바이트를 검사할 수 있습니다.

버퍼 인스턴스는 worker.postMessage() 메서드를 사용하여 작업자로 전달됩니다.
이 경우 버퍼만 전달됩니다.
그러나 버퍼가 속성 중 하나인 더 복잡한 객체도 전달될 수 있습니다.
(직렬화가 가능할 경우)
 
 

Example 4-3. ch4-web-workers/worker.js

self.onmessage = ({data: buffer}) => {
  buffer.foo = 42; // 1
  const view = new Uint8Array(buffer);
  view[0] = 2; // 2
  console.log('updated in worker');
};
  1. 버퍼 객체의 속성을 작성합니다.
  2. 0번째 인덱스는 숫자 2로 설정됩니다.
이 파일은 main.js의 .postMessage() 메서드가 실행된 후 실행되는 onmessage 이벤트에 대한 핸들러를 등록합합니다.
일단 호출되면 버퍼 인수가 확보됩니다.
핸들러에서 발생하는 첫 번째 일은 .foo 속성이 SharedArrayBuffer 인스턴스에 연결된다는 것입니다.
다음으로 버퍼에 대해 다른 View가 생성됩니다.
그 후 View를 통해 버퍼가 업데이트됩니다.
 
이제 위 소스들을 실행하면 다음과 같이 로그가 출력됩니다.
now 0 main.js:10:9
updated in worker worker.js:5:11
later 2 main.js:15:11
prop undefined main.js:16:11

초기 postmessage 시점 외에는 스레드 간 메세지 전달은 발생하지 않습니다.

main은 버퍼를 메세지로 전달하고, worker는 버퍼에 값을 쓰며, main은 0.5초 후에 worker에 의해 쓰여진 값 2를 출력합니다.
보통 이러한 방식으로 멀티쓰레드 프로그램을 작성하지는 않으며, 매우 단순한 예제입니다.

 

버퍼 값이 인쇄된 후 .foo 값이 undefined 값으로 표시됩니다. 왜 그럴까요?
버퍼에 포함된 바이너리 데이터를 저장하는 메모리 위치에 대한 참조가 두 JavaScript 환경 간에 공유된 것은 사실이지만
실제 buffer 객체 자체는 공유되지 않기 때문입니다.
이는 객체 참조가 스레드 간에 공유될 수 없는 구조화된 복제 알고리즘의 제약 조건을 위반함을 나타냅니다.
(간단하게 buffer 객체가 json 직렬화 형태로 넘어가는건 아니라는 의미)

공유 메모리 사용 예제 : node.js

이 애플리케이션과 동등한 Node.js는 대부분 비슷합니다.
그러나 브라우저에서 제공하는 Worker 글로벌 객체는 사용할 수 없스며,
워커 스레드는 self.onmessage를 사용하지 않습니다.
또한 worker_threads 모듈의 import가 필요합니다.
Node.js는 브라우저가 아니므로 index.html 파일은 사용할 수 없습니다.
 
따라서 위와 유사한 예제를 위해선 두 개의 파일만 있으면 됩니다.
 
Example 4-4. ch4-web-workers/main-node.js
#!/usr/bin/env node

const { Worker } = require('worker_threads');
const worker = new Worker(__dirname + '/worker-node.js');

const buffer = new SharedArrayBuffer(1024);
const view = new Uint8Array(buffer);

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo);
  worker.unref();
}, 500);

 

 
  • Worker 전역 객체를 사용할 수 없기 때문에 worker_threads 모듈에서 Worker 속성을 가져와서 액세스합니다.
  • 작업자 스레드 객체를 인스턴스화할 때, 브라우저에서 허용하는 것보다 더 명확한 경로를 제공해야 합니다.
    • 브라우저는 worker.js만으로도 충분하지만 ./worker-node.js 경로가 필요합니다.
  • worker.unref() 호출이 추가되어 작업자가 프로세스를 계속 실행하지 못하게 합니다.

Example 4-5. ch4-web-workers/worker-node.js

const { parentPort } = require('worker_threads');

parentPort.on('message', (buffer) => {
  buffer.foo = 42;
  const view = new Uint8Array(buffer);
  view[0] = 2;
  console.log('updated in worker');
});
node.js worker는 self.onmessage 값을 사용할 수 없습니다.
대신 worker_threads 모듈의 parentPort 속성이 사용됩니다.
호출하는 JavaScript 환경의 포트에 대한 연결을 나타내는 데 사용됩니다.
 
.onmessage 핸들러는 parentPort 객체에 할당할 수 있으며 .on('message', cb) 메서드를 호출할 수 있습니다.
둘 다 사용하는 경우 사용된 순서대로 호출됩니다.
메시지 이벤트에 대한 콜백 함수는 전달되는 객체(이 경우 buffer)를 인수로 직접 받는 반면 onmessage 핸들러는 버퍼를 포함하는 .data 속성이 있는 MessageEvent 인스턴스를 제공합니다.
주로 사용하는 접근 방식은 개인 취향에 따라 다릅니다.
 
코드가 Node.js와 브라우저 사이에서 정확히 동일하다는 것 외에는
SharedArrayBuffer와 같은 동일한 적용 가능한 전역 변수가 여전히 사용 가능하며
이 예제를 위해 여전히 동일하게 작동합니다.
 
다음 명령을 사용하여 실행할 수 있습니다.
$ node main-node.js

이 명령의 출력은 브라우저에 표시되는 출력과 동일해야 합니다.
동일한 구조화된 복제 알고리즘을 사용하면 SharedArrayBuffer의 인스턴스를 전달할 수 있지만
객체 자체에 대한 직접 참조가 아닌 바이너리 버퍼 데이터만 전달할 수 있습니다.

SharedArrayBuffer and TypedArrays

전통적으로 JavaScript 언어는 이진 데이터와의 상호 작용을 실제로 지원하지 않았습니다.
문자열이 있었지만 실제로는 기본 데이터 저장 메커니즘을 추상화했습니다.
배열은 모든 입의 값을 포함할 수 있으며 이진 버퍼를 나타내는 데 적합하지 않습니다.
수년 동안, 특히 Node.js가 등장하고 웹 페이지 컨텍스트 외부에서 JavaScript를 실행하는 인기가 높아지기 전에는 충분히 좋은 수준이었습니다.
 
Node.js 런타임은 무엇보다도 파일 시스템에 대한 읽기 및 쓰기, 네트워크 간 데이터 스트리밍 등의 기능을 수행할 수 있습니다.
이러한 상호 작용은 ASCII 기반 텍스트 파일로 제한되지 않으며 파이프 바이너리 데이터도 포함될 수 있습니다.
사용할 수 있는 편리한 버퍼 데이터 구조가 없었기 때문에 사용자가 직접 만들곤 했습니다.
그렇기에 Node.js Buffer가 탄생했습니다.
JavaScript 언어 자체의 바운더리가 확장됨에 따라 브라우저 창 외부의 세계와 상호 작용할 수 있는 API 및 언어 기능도 확장되었습니다. 결국 ArrayBuffer 객체와 SharedArrayBuffer 객체가 생성되었고 이제 언어의 핵심 부분이 되었습니다.
오늘날 Node.js가 만들어졌다면 node.js 자체적으로 Buffer 구현을 만들지 않았을 가능성이 큽니다.
 
ArrayBuffer 및 SharedArrayBuffer의 인스턴스는 길이가 고정되어 있고 크기를 조정할 수 없는 이진 데이터의 버퍼를 나타냅니다.
(주 : 현재는 조정할 수 있는 경우도 있는듯 https://github.com/tc39/proposal-resizablearraybuffer)
 
이 둘은 매우 유사하지만 응용 프로그램이 스레드 간에 메모리를 공유할 수 있도록 하기 때문에 후자가 좀 더 중요합니다.
이진 데이터는 C와 같은 많은 기존 프로그래밍 언어엔 항상 존재하는 일급 개념이지만
JavaScript와 같은 고급 언어를 사용하는 개발자에게는 생소할 수 있습니다.
 
 
이진수는 2를 기반으로 하는 계산 시스템이며 저수준에서 1과 0으로 표시됩니다.
이러한 각 숫자를 비트라고 합니다.
인간이 주로 계산에 사용하는 시스템인 10진수는 10을 기반으로 하며 0에서 9까지의 숫자로 표시됩니다.
8비트의 조합을 바이트라고 하며 일반적으로 처리하기 더 쉽기 때문에 메모리에서 주소 지정 가능한 가장 작은 값인 경우가 많습니다.
이는 CPU(및 프로그래머)가 개별 비트 대신 바이트(8비트)를 기본 단위로 작업함을 의미합니다.
 
이러한 바이트는 종종 숫자 0-9와 문자 A-F를 사용하는 16 기반 계산 시스템인 두 개의 16진수 문자로 표시됩니다.
실제로 Node.js를 사용하여 ArrayBuffer의 인스턴스를 기록하면 결과 출력에 16진수를 사용하여 버퍼 값이 표시됩니다.

디스크나 컴퓨터의 메모리에 저장된 임의의 바이트 세트가 주어지면 데이터가 의미하는 바가 약간 모호합니다.
예를 들어, 16진수 값 0x54(JavaScript에서 0x 접두어는 값이 16진수임을 의미함)은 무엇을 의미할까요?
문자열의 일부라면 대문자 T를 의미할 수도 있습니다.
정수를 나타내면 십진수 84를 의미할 수도 있습니다.
JPEG 이미지에서 픽셀의 일부인 메모리 위치를 나타낼 수도 있습니다.
이 외에도 다른 많은 것들을 의미할 수 있습니다.
위와 같이 문맥은 매우 중요합니다.
동일한 숫자는 이진수로 표현하면 0b01010100처럼 보입니다(0b 접두사는 이진수를 나타냄).
 
이 모호성과 동시에 ArrayBuffer(및 SharedArrayBuffer)의 내용을 직접 수정할 수 없다는 점도 기억하세요.
대신 버퍼에 대한 "view"를 먼저 만들어야 합니다.
 
또한 버려진 메모리에 대한 액세스를 제공할 수 있는 다른 언어와 달리,
JavaScript의 ArrayBuffer가 인스턴스화되면 버퍼의 내용이 0으로 초기화됩니다.
 
이러한 버퍼 객체는 숫자 데이터만 저장한다는 점을 고려할 때 데이터 저장을 위한 매우 기본적인 도구입니다.
해당 버퍼를 기반으로 더 복잡한 시스템이 구축되는 경우가 많습니다.
 
 
ArrayBuffer와 SharedArrayBuffer는 모두 Object를 상속하며 관련 메서드와 함께 제공됩니다.
그 외에 두 가지 속성이 있습니다.
첫 번째는 버퍼의 바이트 길이를 나타내는 읽기 전용 값 .byteLength이고
두 번째는 제공된 범위에 따라 버퍼의 복사본을 반환하는 .slice(begin, end) 메서드입니다.
 
.slice()의 begin은 해당 인덱스를 포함하며, end 값은 제외됩니다.
두 번째 매개변수가 길이인 String#substr(begin, length)와 차이가 있습니다.
음수는 버퍼 끝의 값을 나타내며, begin, end는 각각 생략할 수 있습니다.
ArrayBuffer의 몇 가지 기본적인 상호 작용 방법입니다.
const ab = new ArrayBuffer(8);
const view = new Uint8Array(ab)
for (i = 0; i < 8; i++) view[i] = i;
console.log(view);
// Uint8Array(8) [
//   0, 1, 2, 3,
//   4, 5, 6, 7
// ]
ab.byteLength; // 8
ab.slice(); // 0, 1, 2, 3, 4, 5, 6, 7
ab.slice(4, 6); // 4, 5
ab.slice(-3, -2); // 5
다른 JavaScript 환경은 ArrayBuffer 인스턴스의 내용을 다르게 표시합니다.
Node.js는 데이터가 Uint8Array로 표시되는 것처럼 16진수 쌍 목록을 표시합니다.
Chrome v8은 여러 view로 확장 가능한 객체를 표시합니다.
Chrome 예시
 
그러나 Firefox는 데이터를 표시하지 않으며 먼저 view를 통괘해야 합니다.
 
이진 데이터의 의미는 그 자체로는 모호하기 때문에
view를 사용하여 기본 버퍼를 읽고 써야 합니다.
 
JavaScript에서 사용할 수 있는 view의 몇 종류가 있습니다.
각 view는 TypedArray라는 기본 클래스에서 확장됩니다.
이 클래스는 직접 인스턴스화할 수 없고 전역으로 사용할 수 없지만,
인스턴스화된 자식 클래스에서 .prototype 속성을 가져와서 액세스할 수 있습니다.
 

아래는 TypedArray에서 확장되는 View 클래스 목록입니다.

Class Bytes Minimum Value Maximum Value
Int8Array 1 –128 127
Uint8Array 1 0 255
Uint8ClampedArray 1 0 255
Int16Array 2 –32,768 32,767
Uint16Array 2 0 65,535
Int32Array 4 –2,147,483,648 2,147,483,647
Uint32Array 4 0 4294967295
Float32Array 4 1.4012984643e-45 3.4028235e38
Float64Array 8 5e–324 1.7976931348623157e308
BigInt64Array 8 –9,223,372,036,854,775,808 9,223,372,036,854,775,807
BigUint64Array 8 0 18,446,744,073,709,551,615
  • 클래스 열은 인스턴스화에 사용할 수 있는 클래스의 이름입니다.
    • 이러한 클래스는 전역 클래스이며 최신 JavaScript 엔진에서 액세스할 수 있습니다.
  • Bytes 열은 뷰의 각 개별 요소를 나타내는 데 사용되는 바이트 수입니다.
  • 최소값 및 최대값 열에는 버퍼의 요소를 나타내는 데 사용할 수 있는 유효한 숫자 범위가 표시됩니다.
이러한 View 중 하나를 만들 때 ArrayBuffer 인스턴스가 View의 생성자에 전달됩니다.
버퍼 바이트 길이는 전달되는 특정 View에서 사용되는 요소 바이트 길이의 배수여야 합니다.
예를 들어, 한 6바이트로 구성된 ArrayBuffer가 생성된 경우 이를 Int16Array(바이트 길이 2)로 전달할 수 있습니다.
이는 3개의 Int16 요소를 나타내기 때문입니다.
그러나 동일한 6바이트 버퍼는 Int32Array에 전달할 수 없습니다
이는 유효하지 않은 요소 1.5개를 나타내기 때문입니다.
 
이러한 클래스의 절반에 대한 U 접두사는 unsigned를 의미하며 이는 양수만 표시할 수 있음을 의미합니다.
U 접두사가 없는 클래스는 부호가 있으므로 최대값의 절반만 사용하는 대신 음수 및 양수를 나타낼 수 있습니다.
이는 부호 있는 숫자가 첫 번째 비트를 사용하여 숫자가 양수인지 음수인지를 전달하는 "부호"를 나타내기 때문입니다.

숫자 범위 제한은 숫자를 고유하게 식별하기 위해 단일 바이트에 저장할 수 있는 데이터 양에서 비롯됩니다.
10진수와 마찬가지로 숫자는 0부터 base(지수)까지 계산한 다음 왼쪽에 있는 숫자로 롤오버합니다.
따라서 Uint8 숫자 또는 "8비트로 표현되는 부호 없는 정수"의 경우 최대값(0b11111111)은 255입니다.
 
JavaScript에는 정수 데이터 유형이 없고 IEEE 754 부동 소수점 숫자를 구현한 Number 타입만 있습니다.
이는 Float64 데이터 타입과 동일합니다.
 
따라서 Float64Array에 값을 쓸 때 대부분 동일하게 남을 수 있습니다.
허용되는 최소값은 Number.MIN_VALUE와 동일하지만 최대값은 Number.MAX_VALUE입니다.
Float32Array에 값을 쓰면 최소값과 최대값 범위가 줄어들 뿐만 아니라 소수점 정밀도도 절삭됩니다.


이에 대한 예로 다음 코드를 생각해봅니다.
const buffer = new ArrayBuffer(16); // 16 bytes

const view64 = new Float64Array(buffer);
view64[0] = 1.1234567890123456789; // bytes 0 - 7
console.log(view64[0]); // 1.1234567890123457

const view32 = new Float32Array(buffer);
view32[2] = 1.1234567890123456789; // bytes 8 - 11
console.log(view32[2]); // 1.1234568357467651

이 경우 float64 숫자의 소수점 정밀도는 소수점 이하 15번째까지 정확하지만
float32 숫자의 정밀도는 소수점 이하 6번째까지만 정확합니다.

 
이 코드는 흥미로운 또 다른 사실을 보여줍니다.
이 버퍼 데이터를 다양하게 표현할 수 있다는 것입니다.
이 경우 단일 ArrayBuffer 인스턴스가 있지만 
두 개의 다른 TypedArray 인스턴스로 표현할 수 도 있습니다.
 
단일 ArrayBuffer 및 다중 TypeArray View
 
view64[1], view32[0] 또는 view32[1] 중 하나를 읽으면 어떤 결과가 반환될끼요?
반환된 값은 잘못된 방식으로 해석되며 무의미합니다.

 

지원되는 TypedArray의 범위를 벗어나는 숫자 값(nonfloats)이 기록되면
대상 데이터 타입에 맞추기 위해 일종의 변환 프로세스를 거쳐야 합니다.
  • 먼저 Math.trunc()에 전달된 것처럼 숫자를 정수로 변환해야 합니다.
  • 값이 허용 가능한 범위를 벗어나면 계수(%) 연산자를 사용하는 것처럼 순환되어 0에서 재설정됩니다.
다음은 Uint8Array(최대 요소 값이 255인 TypedArray)에서 발생하는 몇 가지 예입니다.
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view[0] = 255;    view[1] = 256;
view[2] = 257;    view[3] = -1;
view[4] = 1.1;    view[5] = 1.999;
view[6] = -1.1;   view[7] = -1.9;
console.log(view);

아래 출력은 다음과 같습니다.

{
    "0": 255,
    "1": 0,
    "2": 1,
    "3": 255,
    "4": 1,
    "5": 1,
    "6": 255,
    "7": 255
}


이 동작은 Uint8ClampedArray에서 약간 다릅니다.
(사용 사례에 따라 이 view를 사용하는 것이 더 합리적일 수 있습니다.)
  • 음수 값이 쓰여지면 0으로 변환됩니다.
  • 255보다 큰 값이 쓰여지면 255로 변환됩니다.
  • 정수가 아닌 값이 제공되면 대신 Math.round()에 전달됩니다.
마지막으로 BigInt64Array 및 BigUint64Array 항목에도 특별한 주의가 필요합니다.
Number 타입으로 동작하는 다른 TypedArray 뷰와 달리 이 두 변형은 BigInt 타입으로 작동합니다(1은 숫자이고 1n은 BigInt임).
64바이트로 표현할 수 있는 수치가 자바스크립트의 숫자로 표현할 수 있는 범위를 벗어나기 때문입니다.
따라서 이러한 뷰로 값을 설정하는 작업은 BigInt로 수행해야 하며 검색된 값도 BigInt 타입이 됩니다.

참고:

https://www.oreilly.com/library/view/multithreaded-javascript/9781098104429/

 

Multithreaded JavaScript

Traditionally, JavaScript has been a single-threaded language. Nearly all online forum posts, books, online documentation, and libraries refer to the language as single threaded. Thanks to recent advancements in the … - Selection from Multithreaded JavaS

www.oreilly.com






 

반응형