16장 공유 메모리

2023.06.16

16.1 소개

  • 거의 10년 동안 브라우저의 자바스크립트가 단일 스레드가 아니였는데도(웹 워커 덕분에) 브라우저의 스레드 간에 메모리를 공유할 방법이 없었다.
  • 타입이 있는 배열이 자바스크립트 외부에서 정의된 다음 ES2015에 추가되었을 때, 많은 경우 데이터를 복사하지 않고 한 스레드에서 다른 스레드로 데이터를 전송할 수 있게 되었지만 보내는 스레드는 전송되는 데이터에 접근을 포기해야 했다
  • ES2017은 SharedArrayBuffer로 이를 변경했다.
  • SharedArrayBuffer를 사용하면 한 스레드에서 실행되는 자바스크립트 코드가 다른 스레드에서 실행되는 자바스크립트 코드와 메모리를 공유할 수 있다.

16.2 위험! 이곳에는 용이 살고 있다!

  • 대부분의 사용 사례에서 공유 메모리가 필요하지 않다. 부분적으로 스레드 간에 복사하지 않고 스레드 간에 전송할 수 있는 객체인 트랜스퍼러블(transferable) 덕분이다. ArrayBuffer를 포함하여 다양한 객체(브라우저의 일부 이미지 유형이나 캔버스 포함)를 전송할 수 있다.
const MB = 1024 * 1024;
let array = new Uint8Array(20 * MB);
// .. 20MB의 데이터를 채운다..
worker.postMessage({ array }, [array.buffer]);
;
  • 위의 예제에서
    • 첫 번째 인수는 보낼 데이터(배열)이다.
    • 두 번째 인수는 복사가 아닌 전송할 트랜스퍼러블의 배열이다.
  • 워커 스레드는 배열 객체의 복제본을 수신되고, 해당 데이터는 전송된다.
  • 보내는 스레드는 버퍼(배턴)를 워커 스레드로 전달하고 워커 스레드는 버퍼를 가져와 함께 실행한다. postMessage 호출 후 전송 스레드의 배열은 더 이상 버퍼에 접근할 수 없다.
  • 워커가 배열을 수신하면 작업 중인 다른 스레드에 대해 걱정할 필요 없이 데이터를 사용하고 작업할 수 있으며 적절한 경우 원래 스레드(또는 다른 스레드)로 다시 전송할 수 있다.
  • 공유 메모리를 사용하기 전에 전송이 충분한 지 자문해 볼 가치가 있다.

16.3 브라우저 지원

  • 2018년 1월에 브라우저는 CPU의 스펙터(Spectre)와 멜트다운(Meltdown) 취약점에 대응하여 공유 메모리를 비활성화했다. 크롬은 그해 7월 사이트 격리 기능이 활성화된 플랫폼에서 일부 사용 사례에 대한 지원을 다시 추가했지만 2019년 말에 브라우저에 표시되는 많은 사람들의 노력을 통해 보다 일반적인 접근 방식이 나타났다.
  • 2020년 초 이제 메모리를 다시 공유할 수 있지만 보안 컨텍스트간 에만 가능하다.
  • 두 개의 작업으로 메모리를 공유하면 된다.
    • 문서와 스크립트를 안전하게 또는 로컬로 제공한다(즉, htts localhost 또는 유사한 것)
    • 다음 두HTTP 헤더를 포함한다.
    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Embedder-Policy: require-corp
  • 컨텍스트는 창/랩/프레임 또는 워커(예: 웹/전용 워커 또는 서비스 워커)의 컨테이너에 대한 웹 사양 개념이다.
  • 창을 포함하는 컨테스트는 한 원정에서 다른 원점으로 이동할 때에 도 창에서 탐색이 수행될 때 사용된다. DOM과 자바스크립트 영역은 탐색 중에 삭제되고 새 영 역이 생성되지만 적어도 전통적으로 동일한 컨텍스트 내에 있다.
  • 브라우저는 관련 컨테스트를 컨텍스트 그룹으로 그룹화한다.
  • 예를 들어, 창이 iframe을 포함하는 경우 창과 iframe의 컨테스트는 전통적으로 동일한 컨텍스트 그룹에 있다.
  • 마찬가지로 창의 컨텍스트와 창이 열리는 팝업은 상호 작용할 수 있기 때문에 전통적으로 동일한 컨텍스트 그룹에 있다.
  • 컨텍스트가 다른 출처에서 온 경우 기본적으로 상호 작용이 제한되지만 여전히 일부 상호 작용이 가능하다.
  • 컨텍스트와 컨텍스트 그룹이 중요한 이유는 컨텍스트 그룹의 모든 컨텍스트는 동일한 운영 체제 프로세스 내에서 메모리를 사용하기 때문이다.

16.4 공유메모리 기초

16.4.1 임계 구역, 잠금, 조건 변수

16.4.2 공유 메모리 생성

  • 공유 메모리가 있는 배열을 만들려면 먼저 SharedArrayBuffer를 만든 다음 배열을 만들 때 사용한다.
  • 예를 들어, 5개의 엔트리가 있는 공유 Uint16Array를 생성하려면 다음과 같이 한다.
const sharedBuf = new SharedArrayBuffer(5 * Uint16Array.BYTES_PER_ELEMENT);
const sharedArray = new Uint16Array(sharedBuf);
  • 해당 배열을 브라우저의 웹 워커와 공유하려면 postMessage를 통해 메시지에 포함할 수 있다.
  • 예를 들어 worker가 웹 워커를 참조한다고 가정하면 다음과 같이 앞의 sharedArray를 공유할 수 있다.
worker.postMessage(sharedArray);
  • 워커는 message 이벤트에 대한 event 객체의 data 속성으로 공유 배열을 받는다.
  • 종종 배열을 보내는 것보다 메시지 내용에 대한 일종의 표시와 함께 객체를 보내어 배열을 해당 객체의 속성으로 만드는 것이 유용하다.
  • 아래는 속성 유형("init")과 sharedArray(공유 배열)를 가진 객체를 보낸다. 워커는 이벤트의 data 속성으로 해당 객체를 받는다.
worker.postMessage({ type: 'init', sharedArray });
  • 다른 환경에서 공유 메모리와 다중 스레드를 사용한 적이 있다면 다음과 같이 궁금해할 수 있다.
    • 업데이트가 메인 스레드에서 읽을 준비가 되었는지 어떻게 알 수 있을까?
    • 업데이트가 스레드별 캐시(자바스크립트 가상 머신 또는 스레드별 CPU 캐시)에 있으면 어떻게 될까?
  • 이 특정 예에 대한 대답은 postMessage가 동기화 엣지(synchronization edge. 동기화가 발생하는 경계)로 정의된다는 것이다.
  • 워커 스레드는 메인 스레드에 postMessage를 사용하여 작업을 완료했음을 알리고 메인 스레드는 해당 메시지를 수신할 때만 결과를 읽으려고 하기 때문에 읽기가 발생 하기 전에 쓰기가 완료되고 스레드별 캐시가 무효화되었다.
  • postMessage가 유일한 동기화 엣지는 아니다. Atomics.wait와 Atomics.notify도 있다.

16.5 객체가 아니라 메모리가 공유된다

  • 공유 메모리를 사용할 때 공유되는 것은 메모리뿐이다.
  • 래퍼 객체(SharedArrayBuffer나 이 를 사용하는 타입이 있는 모든 배열)는 공유되지 않는다.
  • 예를 들어, 메인 스레드에서 워커로 SharedArrayBuffer를 사용하여 Uint16Array를 전달한 이전 예에서 Uint16Array와 SharedArrayBuffer 객체는 공유되지 않았다.
  • 대신 새로운 Uint16Array와 SharedArrayBuffer 객체가 수신측에서 생성되어 송신 스레드의 SharedArrayBuffer의 기본 메모리 블록에 연결되었다.

16.6 레이스 컨디션, 비순차 저장, 신선하지 않은 값, 찢어짐 등

  • 한 가지 시나리오를 살펴보겠다. 다음과 같이 스토리지에 SharedArrayBuffer를 사용하는 Uint8Array가 있고 처음 몇 개의 엔트리를 특정 값으로 설정했다고 가정한다.
const sharedBuf = new SharedArrayBuffer(10);
const sharedArray = new Uint8Array(sharedBuf);
  • 설정한 후 다른 스레드와 공유했다. 이제 두 스레드가 모두 실행 중이며 이 예에서는 스레드에 동기화나 조정을 수행하지 않았다.
  • 특정 시점에 메인 스레드는 다음 두 엔트리(첫 번째 인덱스 0. 다음 인맥스 1)에 쓴다.
sharedArray[0] = 100;
sharedArray[1] = 200;
  • 동시에 워커 스레드는 반대 순서(첫 번째 인덱스 1. 인덱스 0)로 두 엔트리를 읽는다.
console.log(`1 is ${sharedArray[1]}`);
console.log(`0 is ${sharedArray[0]}`);

// 워커 스레드는 다음을 출력할 수 있다.
// 1 is 220
// 0 is 110

// 엄청 간단하다.
// 워커는 메인 스레드가 업데이트를 수행한 후 값을 읽고 워커는 업데이트된 값을 보았다.

// 다음과 같이 출력할 수도 있다.
// 1 is 200
// 0 is 100
// 아마도 워커는 주 스레드가 업데이트를 수행하기 직전에 값을 읽었을 것이다.
  • 메인 스레드가 업데이트를 수행한 후 작업자가 이를 읽었지만 여전히 이전 값을 볼 수도 있다.
  • 성능을 최대화하기 위해 운영 체제, CPU 및/또는 자바스크립트 엔진은 짧은 시간동안 각 스레드에 대한 작은 메모리 부분의 캐시된 복사본을 유지할 수 있다.
  • 더 까다로운 것은 아래와 같이 출력할 수도 있다는 것이다.
// 1 is 220
// 0 is 100
  • 메인 스레드는 새 값을 sharedArray[0]에 쓰기 전에 sharedArray[1]에 새 값을 쓰고, 워커는 sharedArray[0]을 읽기 전에 sharedArray[1]을 읽는다.
  • 대답은 최적화를 위해 자바스크립트 컴파일러 또는 CPU에서 읽기와 쓰기를 모두 재정렬할 수 있다는 것이다. 스레드 내에서 이러한 종류의 재정렬은 결코 명백하지 않지만 스레드 간에 메모리를 공유할 때 스레드 간에 적절하게 동기화하지 않으면 관찰이 가능해질 수 있다.
  • 코드가 실행되는 플랫폼의 아키텍처에 따라 전체 값이 부실하거나 기타 유사한 문제가 될 수 있 을 뿐만 아니라 읽기 작업이 진행 중인 쓰기의 일부만 읽을 수 있다.
  • 해결책은 동일한 공유 메모리에서 작동하는 스레드 간에 동기화 양식을 보장하는 것이다. 이전에 브라우저 환경에 특정한 하나의 양식인 postMessage를 보았다.
  • 자바스크립트 자체에 의해 정의된 이를 수행하는 방법도 있다. 바로 Atomics 객체이다.

16.7 Atomics 객체

  • 데이터 레이스(data race), 신선하지 않은 값 읽기(stale read), 비순차적 쓰기(out-of-order write), 찢어짐(tearing) 등을 처리하기 위해 자바스크립트는 Atomics 객체를 제공하는데, 이 객체는 일관되고 순차적이며 동기화된 방식으로 공유 메모리를 처리하기 위한 고수준과 저수준 도구를 모두 제공한다.
  • Atomics 객체에 의해 노출된 메서드는 읽기-수정-쓰기 작업이 중단되지 않도록 할 뿐만 아니라 작업에 순서를 부과한다.
  • Atomics 객체는 동기화 엣지를 보장하는 스레드 간의 신호를 제공한다. (브라우저가 postMessage를 통해 제공하는 것과 같다)

16.7.1 저수준 Atomics 객체 기능

16.7.2 Atomics 객체를 사용하여 스레드 일시 중단 및 재개하기

  • 대부분의 환경은 메인 스레드를 일시 중단하는 것을 허용하지 않지만 워커 스레드를 일시 중단하는 것은 허용한다.
  • 스레드를 일시 중단하려면 공유 Int32Array의 엔트리에서 Atomics.wait를 호출한다.
// result = Atomics.wait(theArray, index, expectedValue, timeout)

result = Atomics.wait(sharedArray, index, 42, 30000);
  • 위의 예제 코드에서 Atomics.wait은 sharedArray[index]에서 값을 읽고 값이 42와 같으면 스레드를 일시 중단한다.
  • 스레드는 무언가가 다시 시작되거나 시간 초과가 발생할 때까지 일시 중단된 상태 로 유지된다(예에서 시간 초과는 30000ms, 30초),
  • 제한 시간을 그대로 두면 기본값은 Number.Infinity다. 즉, 다시 시작될 때까지 영원히 기다린다.
  • Atomics.wait은 결과가 무엇인지 알려주는 문자열을 반환한다.
    • "ok": 스레드가 일시 중단되었다가 이후에 재개된 경우(시간 초과가 아님)
    • "timed-out": 스레드가 일시 중단되고 시간 초과에 도달하여 재개된 경우
    • "not-equal" : 배열의 값이 주어진 값과 같지 않기 때문에 스레드가 일시 중단되지 않은 경우
  • 배열의 해당 엔트리를 기다리는 스레드를 재개하려면 Atomics.notify를 호출한다.
// result = Atomics.notify(theArray, index, numberToResume);

result = Atomics.notify(sharedArray, index, 1);
  • 위의 예제 코드에서 전달한 숫자(1)는 재개할 대기 스레드 수이다. 이 예에서는 여러 스레드가 해당 엔트리를 기다리고 있는 경우에도 하나의 스레드만 재개하도록 요청하고 있다.
  • Atomics.notify는 실제로 재개된 스레드 수를 반환한다(대기가 없는 경우 0이다).
  • 일시 중단되는 동안 스레드는 작업 대기열에서 더 이상 작업을 처리하지 않는다.
  • 이는 자바스크립트의 "완료할 때까지 실행한다"는 원칙 때문이다. 스레드는 수행 중인 작업을 완료할 때까지 대기 열에서 다음 작업을 선택할 수 없으며 작업 중간에 일시 중단된 경우 수행할 수 없는데, 대부분의 환경에서 메인 스레드가 일시 중단될 수 없는 이유가 된다.

16.10 과거 습관을 새롭게

16.10.1 대규모 데이터 블록을 반복적으로 교환하는 대신 공유 블록 사용

  • 스레드 간에 큰 데이터 블록을 주고받는 대신 정말로 필요한 경우 적절한 동기화/조정으로 스레드 간에 데이터 블록을 공유하자.

16.10.2 워커 작업을 분할하는 대신 Atomics.wait 및 Atomics.notify 를 사용하여 이벤트 루프 지원(적절한 경우)

  • 워커의 작업을 완료할 수 있는 작업으로 인위적으로 분할하여 작업 대기열의 다음 메시지를 처리하는 대신Atomics.wait과 Atomics.notify를 통해 작업자를 일시 중단/재개하는 것을 고려하자.