본문 바로가기

FrontEnd

멀티쓰레드 Javascript 3편 : 상호 배제를 위한 조정(coordination)

반응형

2023.01.23 - [프론트엔드 아키텍처] - 멀티쓰레드 Javascript 1편 : SharedArrayBuffer

 

멀티쓰레드 Javascript 1편 : SharedArrayBuffer

javascript의 공유 메모리(shared memory)에 대해 알아보고, SharedArrayBuffer와 TypedArray에 대해 알아봅니다. 브라우저에는 세 종류의 멀티쓰레딩 방법이 있습니다. web worker shared werker service worker node.js에는

itchallenger.tistory.com

2023.01.24 - [프론트엔드 아키텍처] - 멀티쓰레드 Javascript 2편 : Atomics 객체와 원자성, 직렬화

 

Javascript의 공유 메모리 : Atomics와 원자성, 직렬화에 대해 알아보자

이전글 : 2023.01.23 - [프론트엔드 아키텍처] - Javascript의 공유 메모리 : SharedArrayBuffer에 대해 알아보자. Javascript의 공유 메모리 : SharedArrayBuffer에 대해 알아보자. javascript의 공유 메모리(shared memory)에

itchallenger.tistory.com

SharedArrayBuffer 개체를 사용하여 별도의 스레드에서 공유 데이터 컬렉션을 직접 읽고 쓸 수 있습니다,

하지만 잘못하면 한 스레드가 다른 스레드가 작성한 데이터를 망가뜨릴 수 있습니다

Atomics 객체 덕택에 데이터가 손상되는 것을 방지하는 방식으로 해당 데이터로 매우 기본적인 작업을 수행할 수 있습니다.

 

Atomics에서 제공하는 기본 연산들은 편리하지만 데이터와 더 복잡한 상호 작용을 수행해야 하는 경우가 많습니다.
예를 들어,데이터를 직렬화하면(예: 1킬로바이트 문자열) 해당 데이터를 SharedArrayBuffer 인스턴스에 기록해야 하며
기존 Atomics 메서드 중 어느 것도 한번에 1kb를 설정하는 연산을 허용하지 않습니다.
 
기본 Atomics 메서드가 충분하지 않은 상황에서
스레드 간에 공유 데이터에 대한 읽기 및 쓰기를 조정하는 방법을 배워봅니다.

조정을 위한 Atomic 메서드

이 메서드들은 "데이터 조작을 위한 원자적 메서드"들과 약간 다릅니다.
다른 메서드는 모든 종류의 TypedArray와 함께 작동하며 SharedArrayBuffer 및 ArrayBuffer 인스턴스 모두에서 동작합니다.
그러나 여기에 나열된 메서드는 Int32Array 및 BigInt64Array 인스턴스에서만 작동하며
SharedArrayBuffer 인스턴스와 함께 사용할 때만 의미가 있습니다.
잘못된 유형의 TypedArray와 함께 이러한 메서드를 사용하려고 하면 다음 오류 중 하나가 발생합니다.
# Firefox v88
Uncaught TypeError: invalid array type for the operation

# Chrome v90 / Node.js v16
Uncaught TypeError: [object Int8Array] is not an int32 or BigInt64 typed array.
이러한 방법은 fast userspace mutex의 줄임말인 futex라고 하는 Linux 커널에서 사용할 수 있는 기능을 모델로 합니다.
mutex 자체는 상호 배제(mutual exclusion)의 줄임말로,
단일 실행 스레드가 특정 데이터 조각에 독점적으로 액세스하는 경우입니다.
뮤텍스는 lock이라고도 하며, 한 스레드가 데이터에 대한 액세스를 잠그고 작업을 수행한 다음
액세스 잠금을 해제하여 다른 스레드가 데이터에 접근할 수 있도록 합니다.
 
futex는 두 가지 기본 연산을 기반으로 합니다. 하나는 "대기(wait)"이고 다른 하나는 "깨우기(wake)"입니다.

Atomics.wait()

status = Atomics.wait(typedArray, index, value, timeout = Infinity)
이 메서드는 먼저 typedArray를 검사하여 index의 값이 value와 같은지 확인합니다.
그렇지 않은 경우 함수는 not-equal을 반환합니다.
값이 같으면 최대 타임아웃 밀리초 동안 스레드를 고정합니다.
그 시간 동안 아무 일도 일어나지 않으면 함수는 timed-out 값을 반환합니다.
반면에 다른 스레드가 해당 기간 내에 동일한 인덱스에 대해 Atomics.notify()를 호출하면 함수는 ok 값을 반환합니다.
 
not-equal
제공된 value가 버퍼에 있는 값과 같지 않습니다.
timed-out
다른 스레드가 할당된 제한 시간 내에 Atomics.notify()를 호출하지 않았습니다.
ok
다른 스레드가 적시에 Atomics.notify()를 호출했습니다.
전체 스레드를 잠그는 것은 극단적으로 보일 수 있습니다.
전체 JavaScript 스레드를 잠그는 또 다른 예는 브라우저의 alert() 함수입니다.
해당 함수가 호출되면 브라우저는 대화 상자를 표시하고 대화 상자가 닫힐 때까지 아무것도 실행할 수 없습니다.
이벤트 루프를 사용하는 백그라운드 작업도 마찬가지입니다.
Atomics.wait() 메서드는 마찬가지로 스레드를 고정합니다.
 
웹 작업자 외부에서 JavaScript를 실행할 때 사용할 수 있는 디폴트 스레드인 "메인" 스레드는
적어도 브라우저에서는 이 메서드를 호출할 수 없습니다.
그 이유는 메인 스레드를 잠그는 것은 API 작성자가 허용하고 싶지 않을 정도로 열악한 사용자 경험이기 때문입니다.
브라우저의 기본 스레드에서 이 메서드(Atomics.wait())를 호출하려고 하면 다음 오류 중 하나가 발생합니다.
# Firefox
Uncaught TypeError: waiting is not allowed on this thread

# Chrome v90
Uncaught TypeError: Atomics.wait cannot be called in this context​
반면에 Node.js는 메인 스레드에서 Atomics.wait()를 호출할 수 있습니다.
Node.js에는 UI가 없기 때문에 이것이 반드시 나쁜 것은 아닙니다.
실제로 fs.readFileSync() 호출이 허용되는 스크립트를 작성할 때 유용할 수 있습니다.
 모바일 또는 데스크톱 개발자와 함께 회사에서 일한 적이 있는 JavaScript 개발자라면
"메인 스레드에서 작업 오프로드" 또는 "메인 스레드 잠금"에 대한 이야기를 들었을 것입니다.
전통적으로 네이티브 앱 개발자의 몫이었던 이러한 우려는 언어가 발전함에 따라 JavaScript 엔지니어가 점점 더 좋아하게 될 것입니다.
브라우저와 관련하여 이 문제는 종종 스크롤 버벅거림(scroll jank)이라고 합니다.
스크롤하는 동안 CPU가 너무 바빠서 UI를 그릴 수 없는 경우입니다.

Atomics.notify()

awaken = Atomics.notify(typedArray, index, count = Infinity)
 
Atomics.notify()메서드는 동일한 typedArray 및 동일한 인덱스에서 Atomics.wait()를 호출한 다른 스레드를 깨우려고 시도합니다.
Atomics.notify()는 원래 Linux futex와 같은 Atomics.wake()였지만,
"wake"와 "wait" 메서드와 혼동을 방지하기 위해 이름이 변경되었습니다.
 
현재 중지되어 있는 다른 쓰레드가 깨어납니다.
여러 스레드가 동시에 중지될 수 있으며
각 스레드는 알림을 대기합니다.
그런 다음 카운트 값은 깨어날 수 있는 객체 수를 결정합니다.
카운트 값은 기본적으로 Infinity로 설정되어 있으며 이는 모든 스레드가 깨어날 것임을 의미합니다.
그러나 4개의 스레드가 대기 중이고 값을 3으로 설정하면 그 중 하나를 제외한 모든 스레드가 깨어납니다.
이러한 깨우는 스레드의 순서를 조사하는 방법은 뒤에 논의합니다.
 
리턴 값은 메서드가 완료되면 깨어난 스레드 수입니다.
공유되지 않은 ArrayBuffer 인스턴스를 가리키는 TypedArray 인스턴스를 전달하는 경우 항상 0을 반환합니다.
당시 수신 대기 중인 스레드가 없는 경우도 0을 반환합니다.
이 메서드는 스레드를 차단하지 않기 때문입니다.
항상 JavaScript 메인 스레드에서 호출할 수 있습니다.

Atomics.waitAsync()

promise = Atomics.waitAsync(typedArray, index, value, timeout = Infinity)
 
 
이것은 기본적으로 Atomics.wait()의 promise 버전이며 Atomics 계열에 가장 최근에 추가된 것입니다.
이 글을 쓰는 시점에서 Node.js v16 및 ​​Chrome v87에서 사용할 수 있지만
Firefox 또는 Safari에서는 아직 사용할 수 없습니다.
이 메서드는 대기 작업의 상태를 리졸브하는 Promise을 반환하는 Atomics.wait()의 프로미스 버전이며
즉, 성능이 떨어지는 비차단 버전입니다.
성능 손실로 인해(프로미스 리졸브는 스레드를 일시 중지하고 문자열을 반환하는 것보다 더 많은 오버헤드가 발생함)
CPU 사용량이 많은 알고리즘에 반드시 유용하지는 않습니다.
 
대신 락 변경이 postMessage()를 통해 메시지 전달 작업을 수행하는 것보다
다른 스레드에 신호를 보내는 것이 더 편리한 상황에서는 유용할 수 있습니다.
이 메서드는 스레드를 차단하지 않기 때문에 애플리케이션의 메인 스레드에서 사용할 수 있습니다.
 
해당 메소드를 사용하면 좋은 예시는
멀티 쓰레드를 이용하여 컴파일한 코드가(ex C to 웹어셈블리) 여러 스레드에서 사용되길 원하는 경우입니다.

타이밍과 비결정론적(Timing and Nondeterminism)

애플리케이션이 정확하려면 일반적으로 결정론적으로 작동해야 합니다.
Atomics.notify() 함수는 깨울 스레드 수를 포함하는 인수 count를 허용합니다.
문제는 어떤 스레드가 어떤 순서로 깨어나느냐는 것인지 입니다..

비결정론적 예제

스레드는 FIFO(선입선출) 순서로 깨어납니다.
즉, Atomics.wait()를 호출한 첫 번째 스레드가 가장 먼저 깨어나고 두 번째로 호출한 스레드가 두 번째로 깨어납니다.
그러나 이를 측정하는 것은 어려울 수 있습니다.
다른 작업자에서 인쇄된 로그 메시지가 실행된 실제 순서대로 터미널에 표시된다는 보장이 없기 때문입니다.
이상적으로는 스레드가 깨어난 순서에 관계없이 계속해서 제대로 작동하는 방식으로 응용 프로그램을 빌드해야 합니다.
 
이를 직접 테스트하기 위해 새 애플리케이션을 만들 수 있습니다.
먼저 ch5-notify-order/라는 새 디렉토리를 만듭니다.
그리고 기본 index.html 파일을 만듭니다.
(전체 예제 소스 : https://github.com/MultithreadedJSBook/code-samples)
 

Example 5-1. ch5-notify-order/index.html

<html>
  <head>
    <title>Shared Memory for Coordination</title>
    <script src="main.js"></script>
  </head>
</html>

Example 5-2. ch5-notify-order/main.js

if (!crossOriginIsolated) throw new Error('Cannot use SharedArrayBuffer');

const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);

for (let i = 0; i < 4; i++) { // 1
  const worker = new Worker('worker.js');
  worker.postMessage({buffer, name: i});
}

setTimeout(() => {
  Atomics.notify(view, 0, 3); // 2
}, 500); // 3

 

  1. 4명의 전용 작업자 스레드가 인스턴스화됩니다.
  2. 공유 버퍼의 인덱스 0에 락 걸린 쓰레드를 깨웁니다.
  3. 알림은 0.5초 후에 전송됩니다.
이 파일은 먼저 필요한 Int32Array view를 지원할 수 있는 가장 작은 버퍼인 4바이트 버퍼를 만듭니다.
다음으로 for 루프를 사용하여 4개의 서로 다른 전용 작업자를 만듭니다.
각 워커에 대해 적절한 postMessage() 호출을 즉시 호출하여 버퍼와 스레드 식별자를 모두 전달합니다.
그 결과 우리가 관심을 갖는 5개의 서로 다른 스레드가 생성됩니다.
즉, 우리가 0, 1, 2, 3이라는 별명을 붙인 스레드와 메인 스레드입니다.
 
JavaScript는 이러한 스레드를 생성하고, 리소스를 조립하고 메모리를 할당하는 작업을 수행하며,
그 외에도 백그라운드에서 우리를 위해 많은 마법을 수행합니다.
이러한 작업을 수행하는 데 걸리는 시간은 비결정적입니다.
예를 들어 준비 작업을 완료하는 데 항상 100ms가 걸린다는 것을 알 수 없습니다.
실제로 이 숫자는 코어 수 및 코드가 실행될 때 시스템이 얼마나 바쁜지에 따라 시스템 간에 크게 변경됩니다.
다행스럽게도 postMessage() 호출은 기본적으로 큐에서 대기 중입니다.
JavaScript 엔진은 작업자의 onmessage 함수가 준비되면 postMessage 함수를 호출합니다.
 
그 후 메인 스레드는 작업을 마치고 setTimeout을 사용하여 0.5초(500ms)를 기다린 다음 마지막으로 Atomics.notify()를 호출합니다.
 
setTimeout 값이 너무 낮으면(예: 10ms) 어떻게 될까요?
또는 setTimeout 외부의 동일한 스택에서 호출된 경우에도 마찬가지일까요?
이 경우 스레드는 아직 초기화되지 않았기에
작업자는 Atomics.wait()를 호출할 시간이 없었을 것이며
호출은 즉시 0으로 반환됩니다.
 
timeout 값이 너무 높으면 어떻게 될까요?
애플리케이션이 너무 느려지거나 Atomics.wait()에서 사용하는 timeout 값이 초과될 수 있습니다.
 
만약 한 노트북에서 준비 임계값이 일반적으로 약 120ms라고 가정해봅시다.
이 시점에서 스레드 중 일부는 준비되고 일부는 준비되지 않습니다.
약 100ms에서는 일반적으로 모든 스레드가 준비되지 않고
180ms에서는 일반적으로 모든 스레드가 준비된다 해 봅시다.
 
그러나 "일반적으로"라는 말은 좋지 않은 단어입니다.
스레드가 준비되기까지의 정확한 시간을 알기는 어렵습니다.
종종 이는 응용 프로그램의 수명 주기 동안 나타나는 문제가 아니라 응용 프로그램을 처음 시작할 때 발생하는 문제입니다.
 

Example 5-3. ch5-notify-order/worker.js

self.onmessage = ({data: {buffer, name}}) => {
  const view = new Int32Array(buffer);
  console.log(`Worker ${name} started`);
  const result = Atomics.wait(view, 0, 0, 1000); // 1
  console.log(`Worker ${name} awoken with ${result}`);
};
 
1. 초기 값을 0으로 가정하고 최대 1초 동안 버퍼의 0번째 항목을 기다립니다.
 
작업자는 공유 버퍼와 작업자 스레드의 이름을 수락하고 값을 저장하여 스레드가 초기화되었다는 메시지를 출력합니다.
그런 다음 버퍼의 0번째 인덱스를 사용하여 Atomics.wait()를 호출합니다.
버퍼에 초기 값 0이 있다고 가정합니다(값을 수정하지 않았기 때문에 0입니다).
메서드 호출은 또한 1초(1,000ms)의 시간 초과 값을 사용합니다.
마지막으로 메서드 호출이 완료되면 값이 터미널에 인쇄됩니다.

이러한 파일 생성이 완료되면 터미널로 전환하고 다른 웹 서버를 실행하여 콘텐츠를 봅니다.
다음 명령을 실행하여 그렇게 할 수 있습니다.
 
해당 프로그램 실행 결과 로그 출력은 다음과 같습니다.
Worker 1 started worker.js:4:11
Worker 0 started worker.js:4:11
Worker 3 started worker.js:4:11
Worker 2 started worker.js:4:11
Worker 0 awoken with ok worker.js:7:11
Worker 3 awoken with ok worker.js:7:11
Worker 1 awoken with ok worker.js:7:11
Worker 2 awoken with timed-out worker.js:7:11
매 실행마다 다른 출력을 얻을 가능성이 큽니다.
즉, 페이지를 다시 새로 고침하면 다른 출력이 표시될 수 있습니다.
또는 여러 실행에서 일관된 출력을 얻을 수도 있습니다.
그러나 이상적으로는 항상 "started" 메시지와 함께 인쇄되는
최종 작업자 이름이 "timed-out" 메시지와 함께 실패한 작업자여야 합니다.
(이상적으로는 스레드가 깨어난 순서에 관계없이 계속해서 제대로 작동하는 방식으로 응용 프로그램을 빌드해야 합니다.)
 
로그 출력 또한 약간 혼란스러울 수 있습니다. FIFO 순서가 아니기 때문입니다.
그 이유는 순서가 스레드가 생성된 순서(0, 1, 2, 3)에 의존하지 않기 때문입니다.
스레드가 Atomics.wait() 호출을 실행한 순서는 1, 0, 3, 2 이며,
"깨어난" 메시지의 순서는 0, 3, 1, 2 입니다.
이는 서로 다른 스레드가 메시지를 거의 동시에 인쇄하는 JavaScript 엔진의 경쟁 조건 때문일 수 있습니다.
 
console.log 함수는 화면에 메세지를 직접 출력하지 않습니다.
그런 일이 발생하면 메시지가 서로 덮어쓰게 되어 시각적으로 픽셀이 찢어질 수 있습니다.
대신 엔진은 인쇄할 메시지를 대기열에 넣으며,
브라우저 내부에 있지만 개발자에게는 숨겨져 있는 다른 메커니즘이 메시지가 대기열에서 가져와 화면에 인쇄되는 순서를 결정합니다.
이러한 이유로 두 메시지 집합의 순서가 반드시 상관관계가 있는 것은 아닙니다.
순서를 진정으로 알 수 있는 유일한 출력은, 시간이 초과된 메시지가 시작된 최종 스레드에서 발생한다는 것입니다.
실제로 이 경우 "timed-out" 메시지는 항상 마지막으로 시작된 작업자에서 온 것입니다.

스레드 준비 감지

스레드가 초기 설정을 완료하여 작업을 시작할 준비가 된 시점을 응용 프로그램이 어떻게 결정적으로 알 수 있을까요?

간단한 방법은 작업자 스레드 내에서 postMessage()를 호출하여
onmessage() 핸들러 내부에서 어느 시점에 부모 스레드로 다시 메세지를 전달하는 방법입니다.
해당 방법은 onmessage() 핸들러가 호출될 때,
작업자 스레드가 초기 설정을 완료한 후 JavaScript 코드를 실행하기 때문에 잘 먹힙니다.
 
이전 파일의 index.html을 유지한 후 다음 파일들을 추가합니다.
 
 
Example 5-4. ch5-notify-when-ready/main.js
if (!crossOriginIsolated) throw new Error('Cannot use SharedArrayBuffer');

const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);
const now = Date.now();
let count = 4;

for (let i = 0; i < 4; i++) { // 1
  const worker = new Worker('worker.js');
  worker.postMessage({buffer, name: i}); // 2
  worker.onmessage = () => {
    console.log(`Ready; id=${i}, count=${--count}, time=${Date.now() - now}ms`);
    if (count === 0) { // 3
      Atomics.notify(view, 0);
    }
  };
}​
  1. 4개의 워커를 초기화 합니다.
  2. 워커에 즉시 메세지를 보냅니다.
  3. 4명의 작업자 모두의 0번째 항목에 알립니다.
스크립트가 수정되어 Atomics.notify()는 네 명의 작업자가 각각 메인 스레드에 메시지를 게시한 후에 호출됩니다.
즉, 네 번째이자 마지막 작업자가 메시지를 post하면 호출됩니다.
 
이를 통해 애플리케이션은 준비되는 즉시 메시지를 게시할 수 있으므로 최상의 경우 수백 밀리초를 절약하고
최악의 경우(예: 매우 느린 단일 코어 컴퓨터에서 코드를 실행할 때) 오류를 방지할 수 있습니다.
 
Atomics.notify() 호출도 3개가 아닌 모든 스레드를 깨우도록 업데이트되었으며
제한 시간이 기본값 Infinity로 다시 설정되었습니다.
모든 스레드가 정시에 메시지를 수신한다는 것을 보여주기 위해서 입니다.

Example 5-5. ch5-notify-when-ready/worker.js

self.onmessage = ({data: {buffer, name}}) => {
  postMessage('ready'); // 1
  const view = new Int32Array(buffer);
  console.log(`Worker ${name} started`);
  const result = Atomics.wait(view, 0, 0); // 2
  console.log(`Worker ${name} awoken with ${result}`);
};

 

  1. 메시지를 상위 스레드에 다시 포스트하여 준비 상태를 알립니다.
  2. 0번째 항목에 대한 알림을 기다립니다.
이번에는 onmessage 핸들러가 즉시 postMessage()를 호출하여 메시지를 다시 부모에게 보냅니다.
그런 다음 잠시 후 wait 호출이 발생합니다.
기술적으로, Atomics.wait() 호출이 수행되기 전에 상위 스레드가 메시지를 수신하면 응용 프로그램이 중단될 수 있습니다.
(주 : wait 이전에 notify가 호출되면...)
하지만 이 코드는 메시지 전달이 동기식 JavaScript 함수 내에서 코드 줄을 반복하는 것보다 훨씬 느리다는 사실에 의존하고 있습니다.

명심해야 할 한 가지는 Atomics.wait()를 호출하면 스레드가 일시 중지된다는 것입니다.
이것은 postMessage()를 이후 호출될 수 없음을 의미합니다.

이 코드를 실행하면 새 로그는 스레드 이름, 카운트다운(항상 3, 2, 1, 0 순서),
마지막으로 스크립트 시작 이후 스레드가 준비되는 데 걸린 시간을 출력합니다.

Firefox v88 Chrome v90
T1, 86ms T0, 21ms
T0, 99ms T1, 24ms
T2, 101ms T2, 26ms
T3, 108ms T3, 29ms
16코어 노트북을 사용하는 경우 Firefox는 작업자 스레드를 초기화하는 데 Chrome보다 약 4배 더 오래 걸리는 것 같습니다.
또한 Firefox는 Chrome보다 더 많은 임의의 스레드 순서를 제공합니다.
페이지가 새로고침될 때마다 Firefox의 스레드 순서는 변경되지만 Chrome의 순서는 그렇지 않습니다.
이는 Chrome에서 사용하는 V8 엔진이
Firefox에서 사용하는 SpiderMonkey 엔진보다
새로운 JavaScript 환경을 시작하거나 브라우저 API를 인스턴스화하는 데 더 최적화되어 있음을 시사합니다.

.
여러 브라우저에서 이 코드를 테스트하여 얻은 결과를 비교해야 합니다. (멀티 브라우저 테스팅)
명심해야 할 또 다른 사항은 스레드를 초기화하는 데 걸리는 속도는 컴퓨터에서 사용 가능한 코어 수에 따라 달라질 수 있다는 것입니다.
이 프로그램을 좀 더 재미있게 하려면 count 변수와 for 루프에 할당된 4 값을 더 높은 숫자로 변경한 다음 코드를 실행하고 어떤 일이 발생하는지 확인하세요.
값을 128로 늘리면 두 브라우저에서 스레드를 초기화하는 데 걸리는 시간이 크게 증가합니다.
이는 또한 Chrome의 일관적인 스레드 준비 순서를 깨뜨립니다.
일반적으로 너무 많은 스레드를 사용하면 성능이 저하됩니다.

예제 : 콘웨이의 인생 게임(Conway's Game of Life)

Atomics.wait()Atomics.notify()를 살펴보았으므로 이제 구체적인 예를 살펴보겠습니다.
병렬 프로그래밍에 적합한 잘 정립된 개념인 콘웨이의 인생 게임을 다루어 봅니다.
"게임"은 실제로 인구 증가와 쇠퇴의 시뮬레이션입니다.
이 시뮬레이션이 존재하는 "세계"는 살아 있거나 죽은 두 가지 상태 중 하나에 있는 셀 그리드입니다.
시뮬레이션은 반복적으로 동작하며 각 반복에서 각 셀에 대해 다음 알고리즘이 수행됩니다.
 
  1. 세포가 살아있는 경우:
    1. 2~3개의 이웃이 살아 있으면 셀은 살아 있습니다.
    2. 살아있는 이웃이 0개 또는 1개 있으면 셀은 죽습니다(인구 부족을 죽음의 원인으로 시뮬레이션 합니다.).
    3. 4개 이상의 이웃이 살아 있으면 셀은 죽습니다(죽음의 원인으로 인구 과잉을 시뮬레이션 합니다).
  2. 세포가 죽은 경우:
    1. 정확히 3개의 이웃이 살아 있으면 셀이 살아납니다(이것은 번식을 시뮬레이션 합니다).
    2. 그 외에는 셀은 죽은 상태입니다.

이웃 셀의 판단 조건은 대각선을 포함하여 현재 셀에서 최대 1단위 떨어져 있는 모든 셀을 의미하며
상태는 현재 반복 이전의 상태를 의미합니다.
이러한 규칙을 다음과 같이 단순화할 수 있습니다.

  1. 정확히 3개의 이웃이 살아 있다면 새로운 셀 상태는 살아 있는 것입니다(시작 방법에 관계없이).
  2. 셀이 살아 있고 정확히 2개의 이웃이 살아 있으면 셀은 계속 살아 있습니다.
  3. 다른 모든 경우에는 새 셀 상태는 죽은 것입니다.

구현을 위해 다음과 같은 가정을 합니다.

  • 그리드는 정사각형입니다. 걱정할 차원이 하나 줄어들도록 약간 단순화한 것입니다.
  • 그리드의 모서리는 연결되어 있습니다.
    • 가장자리에 있을 때 경계 외부의 이웃 셀을 평가해야 할 때 다른 쪽 끝에 있는 셀을 보게 된다는 것을 의미합니다.
Game of Life 세계의 상태를 표시할 수 있는 편리한 캔버스 요소를 제공하는 웹 브라우저용 코드를 작성합니다.
즉, 일종의 이미지 렌더링이 있는 다른 환경에 예제를 적용하는 것은 비교적 간단합니다.
Node.js에서는 ANSI 이스케이프 코드를 사용하여 터미널에 쓸 수도 있습니다.

싱글 스레드 인생 게임

우선 인생 게임 세계를 배열로 유지하고 각 반복을 처리하는 Grid 클래스를 구축합니다.
프론트엔드에 영향을 미치지 않는 방식으로 클래스를 만들어,
다중 스레드 예제를 변경하지 않고도 사용할 수 있도록 만들 것입니다.
Game of Life를 제대로 시뮬레이션하려면 셀 그리드를 나타내는 다차원 배열이 필요합니다.
배열의 배열을 사용할 수 있지만 작업을 더 쉽게 하기 위해 1차원 배열(실제로는 Uint8Array)에 저장한 다음
셀[크기 * x + y]로 좌표를 나타냅니다.
해당 배열은 현재 상태용, 이전 상태용 두 개가 필요합니다.

ch5-game-of-life/gol.js (part 1) 

class Grid {
  constructor(size, buffer, paint = () => {}) {
    const sizeSquared = size * size;
    this.buffer = buffer;
    this.size = size;
    this.cells = new Uint8Array(this.buffer, 0, sizeSquared);
    this.nextCells = new Uint8Array(this.buffer, sizeSquared, sizeSquared);
    this.paint = paint;
  }​
 
생성자와 함께 Grid 클래스를 구현 시작합니다.
정사각형의 너비인 size, ArrayBuffer인 buffer, 나중에 사용할 페인트 함수를 받습니다.
그런 다음 셀과 nextCell을 버퍼에 나란히 저장된 Uint8Array의 인스턴스로 설정합니다.
(주 : 두 셀은 버퍼 내에 공간적으로 연결되어 저장됨을 의미)
다음으로 나중에 반복을 수행할 때 필요한 셀 검색 메서드를 추가할 수 있습니다.

ch5-game-of-life/gol.js (part 2) 

  getCell(x, y) {
    const size = this.size;
    const sizeM1 = size - 1;
    x = x < 0 ? sizeM1 : x > sizeM1 ? 0 : x;
    y = y < 0 ? sizeM1 : y > sizeM1 ? 0 : y;
    return this.cells[size * x + y];
  }
주어진 좌표 집합으로 셀을 검색하려면 인덱스를 정규화해야 합니다.
그리드가 감싸고 있다는 것을 기억하십시오.
여기에서 수행한 정규화는 범위 위 또는 아래에 한 단위 있는 경우
대신 범위의 다른 쪽 끝에 있는 셀을 검색하도록 합니다.
 
이제 모든 반복에서 실행되는 실제 알고리즘을 추가합니다.

ch5-game-of-life/gol.js (part 3) 

  static NEIGHBORS = [ // 1
    [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]
  ];

  iterate(minX, minY, maxX, maxY) { // 2
    const size = this.size;

    for (let x = minX; x < maxX; x++) {
      for (let y = minY; y < maxY; y++) {
        const cell = this.cells[size * x + y];
        let alive = 0;
        for (const [i, j] of Grid.NEIGHBORS) {
          alive += this.getCell(x + i, y + j);
        }
        const newCell = alive === 3 || (cell && alive === 2) ? 1 : 0;
        this.nextCells[size * x + y] = newCell;
        this.paint(newCell, x, y);
      }
    }

    const cells = this.nextCells;
    this.nextCells = this.cells;
    this.cells = cells;
  }
}
  1. 이웃 좌표 집합은 알고리즘에서 8개 방향에서 이웃 셀을 보는 데 사용됩니다.
    • 이 배열은 모든 셀에 사용해기 위해 미리 선언해 둡니다
  2.  iterate() 메서드는 최소 X 및 Y 값(포함) 및 최대 X 및 Y 값(제외)의 형태로 작동할 범위를 사용합니다.
    • 단일 스레드 예제의 경우 항상 (0, 0, 크기, 크기)이지만 여기에 범위를 지정하면 다중 스레드 구현으로 이동할 때 이러한 X와 Y를 사용하여 분할하기가 더 쉬워집니다.
    • 이 X 및 Y 경계를 사용하여 전체 그리드를 작업할 각 스레드의 섹션으로 나눕니다.
우리는 그리드의 모든 셀을 반복하고 각 셀에 대해 살아있는 이웃 수를 얻습니다.
우리는 살아있는 세포를 나타내기 위해 숫자 1을 사용하고 죽은 세포를 나타내기 위해 0을 사용하므로 모두 더하여 인접한 살아있는 세포의 수를 셀 수 있습니다.
일단 그것이 있으면 단순화된 Game of Life 알고리즘을 적용할 수 있습니다.
새로운 셀 상태를 nextCells 배열에 저장한 다음 시각화를 위해 페인트 콜백에 새로운 셀 상태와 좌표를 제공합니다. 그
런 다음 후속 반복에서 사용할 셀과 nextCells 배열을 교환합니다.
이렇게 하면 각 반복 내에서 cell은 항상 이전 반복의 결과를 나타내고 newCells는 항상 현재 반복의 결과를 나타냅니다.

이 시점까지의 모든 코드는 다중 스레드 구현과 공유됩니다. 
Grid 클래스가 완료되면 이제 Grid 인스턴스를 생성 및 초기화하고 이를 UI에 연결할 수 있습니다.

ch5-game-of-life/gol.js (part 4) 

const BLACK = 0xFF000000; // 1
const WHITE = 0xFFFFFFFF;
const SIZE = 1000;

const iterationCounter = document.getElementById('iteration'); // 2
const gridCanvas = document.getElementById('gridcanvas');
gridCanvas.height = SIZE;
gridCanvas.width = SIZE;
const ctx = gridCanvas.getContext('2d');
const data = ctx.createImageData(SIZE, SIZE); // 3
const buf = new Uint32Array(data.data.buffer);

function paint(cell, x, y) { // 4
  buf[SIZE * x + y] = cell ? BLACK : WHITE;
}

const grid = new Grid(SIZE, new ArrayBuffer(2 * SIZE * SIZE), paint); // 5
for (let x = 0; x < SIZE; x++) { // 6
  for (let y = 0; y < SIZE; y++) {
    const cell = Math.random() < 0.5 ? 0 : 1;
    grid.cells[SIZE * x + y] = cell;
    paint(cell, x, y);
  }
}

ctx.putImageData(data, 0, 0); // 7

 

  1. 화면에 그릴 흑백 픽셀에 대한 상수를 할당하고 사용 할 그리드의 크기(너비)를 설정합니다.
  2. HTML의 반복 카운터와 캔버스 요소를 가져옵니다. 캔버스 너비와 높이를 SIZE로 설정하고 작업할 2D 컨텍스트를 가져옵니다.
  3. ImageData 인스턴스를 사용하여 Uint32Array를 통해 캔버스의 픽셀을 직접 수정합니다.
  4. 이 paint() 함수는 그리드 초기화와 각 반복에서 ImageData 인스턴스를 지원하는 버퍼를 수정하는 데 사용됩니다. 세포가 살아 있으면 검게 칠합니다. 그렇지 않으면 흰색으로 칠해집니다.
  5. 이제 그리드 인스턴스를 생성하고 size 인수, 셀과 nextCells를 둘 다 담을 수 있을 만큼 큰 ArrayBuffer 인수, 그리고 paint() 함수를 전달합니다.
  6. 그리드를 초기화하기 위해 모든 셀을 반복하며 각 셀에 임의의 죽은 또는 살아있는 상태를 할당합니다.
    • 동시에 결과를 paint() 함수에 전달하여 이미지가 업데이트되도록 합니다.
  7. ImageData가 수정될 때마다 캔버스에 다시 추가해야 합니다.
    • 위에서 초기화가 완료되었으므로 여기서 수행합니다.

ch5-game-of-life/gol.js (part 5) 

let iteration = 0;
function iterate(...args) {
  grid.iterate(...args);
  ctx.putImageData(data, 0, 0);
  iterationCounter.innerHTML = ++iteration;
  window.requestAnimationFrame(() => iterate(...args));
}

iterate(0, 0, SIZE, SIZE);

초기화가 끝났으니, 각 반복에 대해 셀이 어떻게 변경되는지 업데이트 하는 함수를 작성합니다.

해당 함수 내부 로직은 다음과 같습니다.

각 반복에 대해 셀을 적절하게 수정하는 grid.iterate() 메서드를 호출합니다.
각 셀에 대해 paint() 함수를 호출하므로 이미지 데이터가 이미 설정되어 있으므로
putImageData()를 사용하여 캔버스 컨텍스트에 추가하기만 하면 됩니다.
그런 다음 페이지의 반복 카운터를 업데이트하고 requestAnimationFrame() 콜백에서 다음 iteratnion이 발생하도록 예약합니다.

 

이제 캔버스를 렌더링하기 위한 HTML이 필요합니다.

다행히도 이것은 매우 짧습니다.

ch5-game-of-life/gol.html

<h3>Iteration: <span id="iteration">0</span></h3>
<canvas id="gridcanvas"></canvas>
<script src="gol.js"></script>
이제 최대한 빨리 반복되는 Conway의 Game of Life를 표시하는 1,000 x 1,000 이미지가 표시되어야 합니다.
(주 : 16.6ms 주기 반복)
컴퓨터에 따라 선명하고 매끄럽지 않고 약간 지연될 수 있습니다.
이러한 모든 셀을 반복하고 계산을 수행하려면 많은 컴퓨팅 성능이 필요합니다.
작업 속도를 약간 높이기 위해 웹 작업자 스레드를 사용하여 컴퓨터에서 더 많은 CPU 코어를 활용해 보겠습니다.
290회 반복 후 Conway의 Game of Life

멀티쓰레드 인생게임

단일 스레드 버전의 경우 많은 코드를 재사용할 수 있습니다.
특히 HTML은 변경되지 않으며 Grid 클래스도 변경되지 않습니다.
이미지 데이터를 조정하고 수정하기 위해 일부 작업자 스레드와 추가 스레드를 설정합니다.
기본 브라우저 스레드에서 Atomics.wait()를 사용할 수 없기 때문에 추가 스레드가 필요합니다.
단일 스레드 예제에서 사용되는 일반 ArrayBuffer 대신 SharedArrayBuffer를 사용할 것입니다.
스레드를 조정하려면 조정을 위해 8바이트, 특히 각 방향에서 동기화하려면 4바이트가 필요합니다.
Atomics.wait()에는 최소한 Int32Array가 필요하기 때문입니다.
우리의 조정 스레드도 이미지 데이터를 생성할 것이므로 이를 보관할 충분한 공유 메모리도 필요합니다.
조정 스레드와 유사한 예시 : HTTP 서버
 
높이, 너비가 SIZE인 그리드의 경우
이는 아래와 같이 메모리가 배치된 SharedArrayBuffer를 의미합니다.
Purpose # of Bytes
Cells (or next cells) SIZE * SIZE
Cells (or next cells) SIZE * SIZE
Image data 4 * SIZE * SIZE
Worker thread wait 4
Coordination thread wait 4

이전 예제의 .html 및 .js 파일을 각각 thread-gol.html 및 thread-gol.js라는 새 파일에 복사합니다.
이 새로운 JavaScript 파일을 참조하도록 thread-gol.html을 편집합니다.

ch5-game-of-life/thread-gol.js (part 1)

const BLACK = 0xFF000000;
const WHITE = 0xFFFFFFFF;
const SIZE = 1000;
const THREADS = 5; // must be a divisor of SIZE

const imageOffset = 2 * SIZE * SIZE
const syncOffset = imageOffset + 4 * SIZE * SIZE;

const isMainThread = !!self.window;
BLACK, WHITE 및 SIZE 상수는 단일 스레드 예제와 동일한 목적을 갖습니다.
 
THREADS 상수를 SIZE의 제수인 임의의 숫자로 설정합니다.
이 상수는 인생계임 계산을 수행하기 위해 생성할 작업자 스레드의 수를 나타냅니다.
 
그리드를 각 스레드에서 처리할 수 있는 청크로 나눌 것입니다.
 
THREADS가 SIZE를 나누는 한 THREADS 및 SIZE 변수를 자유롭게 가지고 놀 수 있습니다.
 
이미지 데이터와 동기화 바이트가 저장되는 위치에 대한 오프셋이 필요합니다.
 
마지막으로 동일한 파일을 사용하여 메인 스레드와 작업자 스레드에서 실행할 것이므로
메인 스레드에 있는지 여부를 알 수 있는 방법이 필요합니다.
다음으로 메인 스레드에 대한 코드 작성을 시작합니다.

ch5-game-of-life/thread-gol.js (part 2)

if (isMainThread) {
  const gridCanvas = document.getElementById('gridcanvas');
  gridCanvas.height = SIZE;
  gridCanvas.width = SIZE;
  const ctx = gridCanvas.getContext('2d');
  const iterationCounter = document.getElementById('iteration');

  const sharedMemory = new SharedArrayBuffer( // 1
    syncOffset + // data + imageData
    THREADS * 4 // synchronization
  );
  const imageData = new ImageData(SIZE, SIZE);
  const cells = new Uint8Array(sharedMemory, 0, imageOffset);
  const sharedImageBuf = new Uint32Array(sharedMemory, imageOffset);
  const sharedImageBuf8 =
    new Uint8ClampedArray(sharedMemory, imageOffset, 4 * SIZE * SIZE);

  for (let x = 0; x < SIZE; x++) {
    for (let y = 0; y < SIZE; y++) {
      // 50% chance of cell being alive
      const cell = Math.random() < 0.5 ? 0 : 1;
      cells[SIZE * x + y] = cell;
      sharedImageBuf[SIZE * x + y] = cell ? BLACK : WHITE;
    }
  }

  imageData.data.set(sharedImageBuf8);
  ctx.putImageData(imageData, 0, 0);
  1. SharedArrayBuffer는 syncOffset보다 16바이트 뒤에서 종료됩니다. 4개의 스레드 각각에 대한 동기화에 4바이트가 필요하기 때문입니다.
첫 번째 부분은 단일 스레드 예제와 거의 동일합니다.
우리는 단지 DOM 요소의 그리드 크기를 설정하는 것입니다.
다음으로, 우리는 sharedMemory라고 부르는 SharedArrayBuffer를 설정하고
셀에 대한 뷰를 배치하고(곧 값을 할당할 것임) 이미지 데이터를 가져옵니다.
ImageData 인스턴스에 대한 수정 및 할당을 위해
각각 이미지 데이터에 대해 Uint32Array 및 Uint8ClampedArray를 모두 사용합니다.
그런 다음 그리드를 무작위로 초기화하고
동시에 그에 따라 이미지 데이터를 수정하고 해당 이미지 데이터를 캔버스 컨텍스트에 채웁니다.
이렇게 하면 그리드의 초기 상태가 설정됩니다.
 
이제 작업자 스레드 생성을 시작할 수 있습니다. 

ch5-game-of-life/thread-gol.js (part 3)

  const chunkSize = SIZE / THREADS;
  for (let i = 0; i < THREADS; i++) {
    const worker = new Worker('thread-gol.js', { name: `gol-worker-${i}` });
    worker.postMessage({
      range: [0, chunkSize * i, SIZE, chunkSize * (i + 1)],
      sharedMemory,
      i
    });
  }

  const coordWorker = new Worker('thread-gol.js', { name: 'gol-coordination' });
  coordWorker.postMessage({ coord: true, sharedMemory });

  let iteration = 0;
  coordWorker.addEventListener('message', () => {
    imageData.data.set(sharedImageBuf8);
    ctx.putImageData(imageData, 0, 0);
    iterationCounter.innerHTML = ++iteration;
    window.requestAnimationFrame(() => coordWorker.postMessage({}));
  });
 
루프에서 일부 작업자 스레드를 설정합니다.
각각에 대해 디버깅 목적을 위해 고유한 이름을 지정하고
동작 대상 그리드 범위(range:예: minX, minY, maxX 및 maxY 경계)와 sharedMemory를 알려주는 메세지를 전송합니다.
 

그런 다음 조정 작업자를 추가하고다음 메시지를 통해 조정 작업자임을 알리며  sharedMemory를 전달합니다.

메인 브라우저 스레드는 이 조정 작업자와만 대화합니다.

이 조정 작업자는 SharedMemory에서 이미지 데이터를 가져오고, 적절한 UI를 업데이트하고,

애니메이션 프레임을 요청한 후에만 메시지를 수신할 때마다 메시지를 게시하여 반복하도록 설정합니다.

 
나머지 코드는 다른 스레드에서 실행됩니다.

ch5-game-of-life/thread-gol.js (part 4)

} else {
  let sharedMemory;
  let sync;
  let sharedImageBuf;
  let cells;
  let nextCells;

  self.addEventListener('message', initListener);

  function initListener(msg) {
    const opts = msg.data;
    sharedMemory = opts.sharedMemory;
    sync = new Int32Array(sharedMemory, syncOffset);
    self.removeEventListener('message', initListener);
    if (opts.coord) {
      self.addEventListener('message', runCoord);
      cells = new Uint8Array(sharedMemory);
      nextCells = new Uint8Array(sharedMemory, SIZE * SIZE);
      sharedImageBuf = new Uint32Array(sharedMemory, imageOffset);
      runCoord();
    } else {
      runWorker(opts);
    }
  }
 
우리는 이제 isMainThread 조건의 반대편에 있으므로
작업자 스레드 또는 조정 스레드에 있음을 알 수 있습니다.
여기에서 몇 가지 변수를 선언한 다음 메시지 이벤트에 초기 리스너를 추가합니다.
 
이것이 조정 스레드인지 작업자 스레드인지에 관계없이 채워진 sharedMemory 및 동기화 변수가 필요하므로 리스너에 할당합니다.
그런 다음 더 이상 필요하지 않으므로 initListener를 제거합니다.
작업자 스레드는 전달되는 메시지에 전혀 의존하지 않으며
조정 스레드는 잠시 후 보게 될 다른 리스너를 갖게 됩니다.
 
조정 스레드를 초기화한 경우 새 메시지 리스너를 추가합니다
바로 나중에 정의할 runCoord 함수 입니다.
 
그런 다음 작업자 스레드의 Grid 인스턴스에서 진행되는 작업과 별도로
조정 스레드를 추적해야 하므로 셀 및 nextCells에 대한 참조를 얻을 수 있습니다.
 
조정 스레드에서 이미지를 생성하므로 이미지도 필요합니다.
그 다음 runCoord의 첫 번째 반복을 실행합니다.
작업자 스레드를 초기화한 경우 계속해서 옵션(작동할 범위 포함)을 runWorker()에 전달합니다.

계속해서 runWorker()를 정의해 보겠습니다.

ch5-game-of-life/thread-gol.js (part 5)

  function runWorker({ range, i }) {
    const grid = new Grid(SIZE, sharedMemory);
    while (true) {
      Atomics.wait(sync, i, 0);
      grid.iterate(...range);
      Atomics.store(sync, i, 0);
      Atomics.notify(sync, i);
    }
  }
 
작업자 스레드는 Grid 클래스의 인스턴스가 필요한 유일한 스레드이므로
먼저 인스턴스화하여 sharedMemory를 백업 버퍼로 전달합니다.
이는 단일 스레드 예제에서와 같이 sharedMemory의 첫 번째 부분이 셀과 nextCells가 될 것이라고 결정했기 때문에 동작합니다.
 

그런 다음 무한 루프를 시작합니다. 루프는 다음 작업을 수행합니다.

  1. 동기화 배열의 i번째 요소에서 Atomics.wait()를 수행합니다.
    • 조정 스레드에서 적절한 Atomics.notify()를 수행하여 계속 진행할 수 있습니다.
    • 다른 스레드가 준비되어 데이터가 기본 브라우저 스레드로 이동하기 전에 데이터 변경과 셀 및 nextCells에 대한 참조 교환을 시작할 수 있기 때문에 여기에서 조정 스레드를 기다리고 있습니다.
  2. Grid 인스턴스에서 반복을 수행합니다. 조정 스레드가 전달한 범위(range)에서만 동작하고 있음을 기억합니다.
    • 이전 코드를 자세히 보면 y 기준으로 쪼개서 작업하고 있습니다.
  3. Grid 반복이 완료되면 이 작업을 완료했음을 메인 스레드에 알립니다.
    • 조정 스레드에서 Atomics.store()를 사용하여 동기화 배열의 i번째 요소를 1로 설정한 다음 Atomics.notify()를 통해 대기 중인 스레드를 깨워 수행합니다.
    • 우리는 0 상태에서 벗어나는 전환을 우리가 어떤 작업을 해야 한다는 표시로 사용하고,
    • 0 상태로 다시 전환하여 작업을 완료했음을 알립니다.
우리는 Atomics.wait()를 사용하여 작업자 스레드가 데이터를 수정하는 동안 조정 스레드 실행을 중지한 다음
조정 스레드가 작업을 수행하는 동안 Atomics.wait()를 사용하여 작업자 스레드를 중지합니다.
양쪽에서 우리는 Atomics.notify()를 사용하여 다른 스레드를 깨우고 즉시 대기 상태로 돌아가
다른 스레드가 다시 알릴 때까지 기다립니다.
우리는 원자적 작업을 사용하여 데이터를 수정하고 수정 시 제어하기 때문에
모든 데이터 액세스가 순차적으로 일관성이 있음을 알고 있습니다.
 
스레드 간 인터리빙 프로그램 흐름에서는
항상 조정 스레드에서 작업자 스레드로 실행을 앞뒤로 뒤집기 때문에 교착 상태가 발생할 수 없습니다.
작업자 스레드는 서로 동일한 메모리 부분에서 실행되지 않으므로
작업자 스레드만의 관점에서는 해당 개념에 대해 걱정할 필요가 없습니다.
 
작업자 스레드는 무한대로 실행할 수 있습니다.
무한 루프는 Atomics.wait()가 반환되는 경우에만 진행되며,
다른 스레드가 동일한 배열 요소에 대해 Atomics.notify()를 호출해야 하기 때문에 걱정할 필요가 없습니다.

이제 초기화 메시지 이후 메인 브라우저 스레드의 메시지를 통해 트리거되는 runCoord() 함수로 코드를 마무리하겠습니다.

ch5-game-of-life/thread-gol.js (part 6)

  function runCoord() {
    for (let i = 0; i < THREADS; i++) {
      Atomics.store(sync, i, 1);
      Atomics.notify(sync, i);
    }
    for (let i = 0; i < THREADS; i++) {
      Atomics.wait(sync, i, 1);
    }
    const oldCells = cells;
    cells = nextCells;
    nextCells = oldCells;
    for (let x = 0; x < SIZE; x++) {
      for (let y = 0; y < SIZE; y++) {
        sharedImageBuf[SIZE * x + y] = cells[SIZE * x + y] ? BLACK : WHITE;
      }
    }
    self.postMessage({});
  }
}
 
가장 먼저 발생하는 일은
조정 스레드가 각 작업자 스레드에 대한 동기화 배열의 i번째 요소를 통해 작업자 스레드를 알리고 반복을 수행하도록 깨우는 것입니다.
완료되면 작업자 스레드가 동기화 배열의 동일한 요소를 통해 notify 할 것이므로 실행을 기다립니다.
Atomics.wait()에 대한 각 호출이 스레드 실행을 차단한다는 사실이
바로 메인 브라우저 스레드에서 모든 작업을 수행하는 것이 아니기에 이 조정 스레드가 필요한 이유입니다.
(주 : 워커 스레드의 작업이 끝나면 1이 아니라 0이 되어 wait이 풀림)
 
다음으로 셀과 nextCells 참조를 교환합니다.
작업자는 이미 iterate() 메서드 내에서 스스로 이 작업을 수행했으므로 여기에서도 해당 작업을 수행해야 합니다.
그런 다음 모든 셀을 반복하고 해당 값을 이미지 데이터의 픽셀로 변환할 준비를 합니다.
마지막으로 메인 브라우저 스레드에 다시 메시지를 post하여 데이터가 UI에 표시될 준비가 되었음을 나타냅니다.
조정 스레드는 다음에 메시지를 수신할 때까지 할 일이 없으며
이때 runCoord가 다시 실행됩니다.
 
이제 모든 작업이 끝났습니다!
셀 상태를 계산하고 픽셀을 플로팅하는 작업을 별도의 스레드로 옮겼기 때문에
이제 메인 스레드는 자유롭게 애니메이션을 더 매끄럽게 움직일 수 있고
작업을 수행하기 위해 더 많은 CPU 코어를 병렬로 사용하기 때문에 반복이 더 빠르게 발생합니다.
 
위 코드의 핵심은 Atomics.notify()를 사용하여 다른 스레드가 Atomics.wait()로 자신을 일시 중지한 후 계속할 수 있음을 알림으로써
조정을 위해 스레드 간에 메시지를 전달하는 오버헤드의 대부분을 피하고 있다는 것입니다.

지금까지 소스코드를 쪼개어 보아 이해가 더 어려울 수 있습니다.

깃헙의 전체 소스코드와 설명을 대조해가며 읽으면 더 쉽게 이해하실 수 있습니다.

(핵심은 조정 스레드를 이용한 낙관적 락인 것 같습니다.)


Atomics와 이벤트

JavaScript의 코어에는 언어가 새로운 콜 스택을 생성하고 이벤트를 처리할 수 있도록 하는 이벤트 루프가 있습니다.
우리 JavaScript 엔지니어는 항상 그것에 의존해 왔습니다.
브라우저에서 실행되는 JavaScript(DOM에서 클릭 이벤트를 수신 대기하는 jQuery
또는 서버에서 실행되는 JavaScript(수신되는 TCP 연결을 기다리는 Fastify 서버가 있을 수 있음))
모두 말입니다.
 
새로운 친구를 만나보세요: Atomics.wait() 및 공유 메모리 입니다.
이제 이 패턴을 사용하면 애플리케이션이 JavaScript 실행을 중지할 수 있으므로 이벤트 루프 전체가 멈출 수 있습니다.
이 때문에 애플리케이션에 멀티스레딩을 사용하기 위한 호출을 시작한 뒤 문제 없이 작동하기를 기대할 수 없습니다.
대신 응용 프로그램이 제대로 동작하도록 하려면 특정 제한 사항을 따라야 합니다.
 
브라우저와 관련하여 이러한 제한 중 하나가 암시됩니다.
  • 응용 프로그램의 메인 스레드는 Atomics.wait()를 호출하면 안 됩니다.
  • 간단한 Node.js 스크립트의 메인 스레드에서 Atomics.wait() 호출을 수행할 수 있지만 더 큰 애플리케이션에서는 그렇게 하지 않는 것이 좋습니다.

예를 들어 기본 Node.js 스레드가 들어오는 HTTP 요청을 처리하거나 운영 체제 신호를 수신하는 핸들러가 있는 경우

Atomics.waiy() 작업이 시작될 때 이벤트 루프가 중지되면 어떻게 될까요?

ch5-node-block/main.js

#!/usr/bin/env node

const http = require('http');

const view = new Int32Array(new SharedArrayBuffer(4));
setInterval(() => Atomics.wait(view, 0, 0, 1900), 2000); // 1

const server = http.createServer((req, res) => {
  res.end('Hello World');
});

server.listen(1337, (err, addr) => {
  if (err) throw err;
  console.log('http://localhost:1337/');
});
  1. 2초마다 앱이 1.9초 동안 일시 중지됩니다.
$ node main.js

노드 프로세스가 실행되면 터미널에서 다음 명령을 여러 번 실행하고 각 호출 사이에 임의의 시간을 기다립니다.

$ time curl http://localhost:1337
 
이 애플리케이션이 하는 일은 먼저 HTTP 서버를 생성하고 요청을 수신하는 것입니다.
그런 다음 2초마다 Atomics.wait()를 호출합니다.
긴 일시 중지의 효과를 과장하기 위해 응용 프로그램이 1.9초 동안 멈추는 방식으로 구성됩니다.
실행 중인 curl 명령에는 다음 명령을 실행하는 데 걸리는 시간을 표시하는 time 명령이 접두어로 붙습니다.
그러면 출력이 0초에서 1.9초 사이에서 무작위로 달라지며,
이는 웹 요청이 일시 중지되는 엄청난 시간입니다.
제한 시간 값을 점점 0에 가깝게 줄이더라도 수신되는 모든 요청에 ​​전체적으로 영향을 미치는 미세 끊김 현상이 계속 발생합니다.
 
웹 브라우저가 기본 스레드에서 Atomics.wait() 호출을 허용했다면
오늘 방문하는 웹사이트에서 분명히 미세한 끊김 현상(micro stutters)이 발생했을 것입니다.

또 다른 질문은 여전히 ​​남아 있습니다.
각 스레드가 자체 이벤트 루프를 가지고 있다는 점을 고려할 때,
응용 프로그램이 생성하는 각 추가 스레드에 어떤 종류의 제한이 적용되어야 할까요?
 

생성된 각 스레드의 주요 목적이 무엇인지 미리 지정하는 것이 좋습니다.
각 스레드는 Atomics 호출을 많이 사용하는 CPU 사용량이 많은 스레드이거나
Atomics 호출을 최소화하는 이벤트 사용량이 많은 스레드가 됩니다.

 
이러한 접근 방식을 사용하면 복잡한 계산을 지속적으로 수행하고,
결과를 공유 배열 버퍼에 쓰는 진정한 의미의 작업자 스레드를 가질 수 있습니다.
또한 메시지 전달을 통해 주로 통신하고 이벤트 루프 기반 작업을 수행하는 메인 스레드가 있을 것입니다.
그런 다음 Atomics.wait()를 호출하는 간단한 중간 스레드를 갖는 것이 타당할 수 있습니다.
다른 스레드가 작업을 완료할 때까지 기다린 다음 postMessage()를 호출하여
결과 데이터를 주 스레드로 다시 보내 결과를 처리할 수 있습니다.
이 섹션의 개념을 요약하면 다음과 같습니다.
  • 메인 스레드에서 Atomics.wait()를 사용하지 마세요
  • CPU를 많이 사용하는 스레드를 지정하고 Atomics 호출을 많이 사용하고 이벤트가 발생하는 스레드를 지정합니다.
  • 간단한 "bridge" 스레드를 사용하여 적절한 경우 메시지를 대기하고 게시하는 것을 고려하십시오.

다음은 응용 프로그램을 디자인할 때 따를 수 있는 매우 높은 수준의 지침입니다.
그러나 때로는 좀 더 구체적인 패턴이 요점을 파악하는 데 도움이 됩니다.
다음번에는 좀 더 구체적인 패턴들을 다루어 봅니다.

반응형