본문 바로가기

FrontEnd

멀티쓰레드 Javascript 2편 : Atomics 객체와 원자성, 직렬화

반응형

 

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

 

Javascript의 공유 메모리 : SharedArrayBuffer에 대해 알아보자.

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

itchallenger.tistory.com

데이터 조작을 위한 원자적 연산

원자성은 ACID(atomicity, consistency, isolation, durability)라는 약어의 첫 번째 단어로
데이터베이스와 관련하여 이전에 들어본 적이 있을 용어입니다.
기본적으로 연산이 원자적일 경우 한 연산이 여러 개의 작은 단계로 구성될 수 있지만
각 단계의 합인 전체 연산은 실패하거나 성공하는 두 가지 경우만 존재함을 의미합니다.
예를 들어, 데이터베이스로 전송된 단일 쿼리는 원자적이지만 세 개의 개별 쿼리는 원자적이지 않습니다.
 
만약 이 세 가지 쿼리가 데이터베이스 트랜잭션에 래핑되면 전체가 원자적이 됩니다.
즉, 세 개의 쿼리가 모두 성공적으로 실행되거나 아무 것도 성공적으로 실행되지 않습니다.
또한 동일한 상태를 조작하거나 서로에게 영향을 줄 수 있는 부작용이 있다고 가정하여 연산이 특정 순서로 실행되는 것도 중요합니다.
격리(isolation) 부분은 다른 연산이 중간에서 실행될 수 없음을 의미합니다.
예를 들어 일부 오레이션만 적용된 경우 읽을 수 없습니다.
 
아토믹한 연산은 특히 분산 컴퓨팅과 관련하여 컴퓨팅에서 매우 중요합니다.
많은 클라이언트 연결이 있을 수 있는 데이터베이스는 아토믹한 연산을 지원해야 합니다.
네트워크의 많은 노드가 통신하는 분산 시스템도 원자적 연산을 지원해야 합니다.
데이터 액세스가 여러 스레드에서 공유되는 단일 컴퓨터 내에서도
그 아이디어를 약간 외삽(Extrapolating)하면 원자성이 중요합니다.
 
JavaScript는 사용 가능한 몇 가지 정적 메서드가 있는 Atomics라는 전역 객체를 제공합니다.
친숙한 Math 전역 객체와 동일한 패턴을 따릅니다.
두 경우 모두 new 연산자를 사용하여 새 인스턴스를 만들 수 없으며 사용 가능한 메서드는 무상태이므로
전역 객체 자체에 영향을 미치지 않습니다.
대신 Atomics에 수정할 데이터에 대한 참조를 전달하여 사용됩니다.
(정적 메서드와 유사함)
 
해당 글에서는 상호 배제와 무관한 Atomics의 메서드들과 자료구조들에 대해 배워봅니다.

Atomics의 메서드들

Atomics.add()

old = Atomics.add(typedArray, index, value)​
 
이 메서드는 index에 위치한 typedArray의 기존 값에 제공된 값을 추가합니다.
리턴 값으로는 이전 값이 반환됩니다.
비원자적 버전은 다음과 같습니다.
const old = typedArray[index];
typedArray[index] = old + value;
return old;

Atomics.and()

old = Atomics.and(typedArray, index, value)
이 메소드는 index에 위치한 typedArray의 기존 값과 value에 비트 and(&) 연산을 수행합니다.
이전 값이 반환됩니다.
비원자적 버전은 다음과 같습니다.
const old = typedArray[index];
typedArray[index] = old & value;
return old;

Atomics.compareExchange()

old = Atomics.compareExchange(typedArray, index, oldExpectedValue, value)
이 메서드는 typedArray를 검사하여 oldExpectedValue 값이 index에 있는지 확인합니다.
그렇다면 값은 value 인자로 대체됩니다.
그렇지 않으면 아무 일도 일어나지 않습니다.
항상 이전 값아 반환되므로 oldExpectedValue === old인 경우 교환이 성공했는지 알 수 있습니다.
비원자적 버전은 다음과 같습니다.
const old = typedArray[index];
if (old === oldExpectedValue) {
  typedArray[index] = value;
}
return old;​

 

Atomics.exchange()

old = Atomics.exchange(typedArray, index, value)​

이 메서드는 index에 위치한 typedArray의 값을 value로 설정합니다.

이전 값이 반환됩니다.

비원자적 버전은 다음과 같습니다.

const old = typedArray[index];
typedArray[index] = value;
return old;

Atomics.isLockFree()

free = Atomics.isLockFree(size)
이 메서드는 size가 TypedArray 하위 클래스의 BYTES_PER_ELEMENT(일반적으로 1, 2, 4, 8)값 중 하나일 경우(TypedArray.BYTES_PER_ELEMENT)
true를 반환하고,그렇지 않은 경우 false를 반환합니다
리턴 값이 true인 경우 Atomics 메서드를 사용하면 현재 시스템의 하드웨어를 사용하여 빠르게 처리할 수 있으며,
false인 경우 수동 잠금 메커니즘을 이용할 필요가 있습니다.

Atomics.load()

value = Atomics.load(typedArray, index)

이 메서드는 index에 있는 typedArray의 값을 반환합니다.
비원자적 버전은 다음과 같습니다.

const old = typedArray[index];
return old;

Atomics.or()

old = Atomics.or(typedArray, index, value)

이 메소드는 index에 위치한 typedArray의 기존 값과 value를 이용해 비트 or(|) 연산을 수행합니다.
이전 값이 반환됩니다.
비원자적 버전은 다음과 같습니다.

const old = typedArray[index];
typedArray[index] = old | value;
return old;

Atomics.store()

value = Atomics.store(typedArray, index, value)

이 메서드는 제공된 값을 index에 위치한 typedArray에 저장합니다.
전달된 value가 반환됩니다.
비원자적 버전은 다음과 같습니다.

typedArray[index] = value;
return value;
 

Atomics.sub()

old = Atomics.sub(typedArray, index, value)

이 메서드는 index에 위치한 typedArray의 기존 값에서 제공된 값을 뺍니다.
이전 값이 반환됩니다.
비원자적 버전은 다음과 같습니다.

const old = typedArray[index];
typedArray[index] = old - value;
return old;

Atomics.xor()

old = Atomics.xor(typedArray, index, value)

이 메소드는 index에 위치한 typedArray의 기존 값과 value를 이용해 비트별 xor(^) 연산을 수행합니다.
이전 값이 반환됩니다.
비원자적 버전은 다음과 같습니다.

const old = typedArray[index];
typedArray[index] = old ^ value;
return old;

원자성이 중요한 이유

위에서 소개한 메서드는 각각 원자적으로 실행되도록 보장됩니다.
예를 들어 Atomics.compareExchange() 메서드는
oldExpectedValue와 새 값을 사용하여 oldExpectedValue와 새 값이 동일한 경우에만 기존 값을 바꿉니다.
이 작업은 JavaScript로 나타내기 위해 여러 개의 개별 명령문이 필요하지만
전체 작업이 항상 한번에 모두 실행된다는 것을 보장합니다.
 
이를 설명하기 위해 typedArray라는 이름의 Uint8Array가 있고 0번째 요소가 7로 설정되어 있다고 가정합니다.
그런 다음 여러 스레드가 동일한 typedArray에 액세스할 수 있고 각각 다음 코드 줄의 일부 변이(뮤테이션)을 실행한다고 가정합니다.
let old1 = Atomics.compareExchange(typedArray, 0, 7, 1); // Thread #1
let old2 = Atomics.compareExchange(typedArray, 0, 7, 2); // Thread #2
이 두 가지 메서드가 호출되는 순서 또는 호출 타이밍은 완전히 비결정적입니다.
사실, 그들은 동시에 호출될 수 있습니다.
그러나 Atomics 객체의 원자성 보장 덕택에,
스레드 중 정확히 하나가 초기 7 값을 반환하고 다른 스레드는 업데이트된 값 1 ​​또는 2를 반환하도록 보장됩니다.
이러한 작업이 작동하는 방식에 대한 타임라인은 아래 그림에서 볼 수 있으며,
CEX(oldExpectedValue, value)는 Atomics.compareExchange()의 줄임말입니다.
Atomics.compareExchange()의 원자 형식
반면에 typedArray[0]에 직접 읽고 쓰는 것과 같이 compareExchange()와 비원자적 등가물을 사용하는 경우
프로그램이 실수로 값을 손상시킬 가능성이 있습니다.
이 경우 두 스레드는 거의 동시에 기존 값을 읽은 다음 둘 다 원래 값이 있음을 확인한 다음 거의 동시에 씁니다.
다음은 원자성이 보장되지 않는 compareExchange() 코드의 주석 버전입니다.
const old = typedArray[0]; // GET()
if (old === oldExpectedValue) {
  typedArray[0] = value;   // SET(value)
}​
이 코드는 공유 데이터, 특히 데이터가 검색되는 라인(GET() 주석)과
나중에 데이터가 나중에 설정되는 라인(SET(value) 주석)과 여러 상호 작용을 수행합니다.
이 코드가 제대로 동작하려면 코드가 실행되는 동안 다른 스레드가 값을 읽거나 쓸 수 없도록 보장해야 합니다.
이렇게 하면 하나의 스레드만 공유 리소스에 독점적으로 액세스할 수 있으며 이를 임계 섹션(critical section)이라고 합니다.
Atomics.compareExchange()의 비원자적 형태
 

이 경우 두 스레드 모두 성공적으로 값을 설정했다고 생각하지만 원하는 결과는 두 번째 스레드에서만 영속됩니다.
이러한 종류의 버그를 경쟁 조건(race condition)이라고 하며 두 개 이상의 스레드가 어떤 작업을 수행하기 위해 서로 경쟁하고 있습니다.
이러한 버그의 가장 나쁜 점은 지속적으로 발생하지 않고 재현하기가 매우 어렵다는 것입니다.
프로덕션 서버와 같은 한 환경에서만 발생할 수 있으며
개발 노트북과 같은 다른 환경에서는 발생하지 않을 수 있습니다.
Atomics 개체의 원자 속성을 활용하려면, 배열 버퍼와 상호 작용할 시
즉, Atomics 호출과 직접적인 배열 버퍼 액세스를 혼합하여 사용할 시 주의할 필요가 있습니다.
 
응용 프로그램의 한 스레드가 compareExchange() 메서드를 사용하고
다른 스레드가 동일한 버퍼 위치를 직접 읽고 쓰는 경우
안전 메커니즘이 무력화되고 응용 프로그램이 비결정적 동작을 갖게 됩니다.
Atomics 호출을 사용할 때 상호 작용을 편리하게 하기 위해 암시적 잠금이 있습니다.
안타깝게도 공유 메모리로 수행해야 하는 모든 작업을 Atomics 메서드를 사용하여 표현할 수 있는 것은 아닙니다.
그런 일이 발생하면 더 많은 수동 잠금 메커니즘을 통해 한 쓰레드가 자유롭게 읽고 쓸 수 있는 동안,
다른 스레드가 그렇게 하는 것을 방지해야 합니다. 
(해당 방법은 다음 게시물에서 알아봅니다.)

주의 : 반환 값은 변환을 무시합니다.

Atomics 메서드에 관한 한 가지 주의 사항은 반환된 값이 특정 TypedArray가 통과할 변환을 반드시 인식하지 않는다는 것입니다.
대신 변환을 진행하기 전의 값을 고려합니다.
예를 들어, 지정된 View로 나타낼 수 있는 것보다 큰 값이 저장되는 다음 상황을 고려하십시오.
const buffer = new SharedArrayBuffer(1);
const view = new Uint8Array(buffer);
const ret = Atomics.store(view, 0, 999);
console.log(ret); // 999
console.log(view[0]); // 231
이 코드는 버퍼를 생성한 다음 해당 배열에 Uint8Array 뷰를 생성합니다.
그런 다음 Atomics.store()를 사용하여 View에 값 999를 저장합니다.
Atomics.store() 호출의 반환 값은 기본 버퍼에 실제로 저장된 값이 231(999는 지원되는 최대값 255보다 큼)인 경우에도
우리가 전달힌 값인 999입니다.
애플리케이션을 구축할 때 이 제한 사항을 염두에 두어야 합니다.
안전을 유지하려면 이 데이터 변환에 의존하지 않고 범위 내에 있는 값만 쓰도록 애플리케이션을 만들어야 합니다.

데이터 직렬화

버퍼를 사용하여 숫자가 아닌 데이터를 나타내는 항목을 저장해야 하는 경우가 있습니다.
이런 일이 발생하면 해당 데이터를 버퍼에 쓰기 전에 어떤 방식으로든 직렬화해야 하며 나중에 버퍼에서 읽을 때 역직렬화해야 합니다.
표현하려는 데이터 유형에 따라 직렬화하는 데 사용할 수 있는 다양한 도구가 있습니다.
일부 도구는 다양한 상황에서 잘 동작하지만
각 도구는 저장소 크기 및 직렬화 성능과 관련하여 서로 다른 장단점이 있습니다.

부울 값(Boolean)

부울 값은 데이터를 저장하는 데 단일 비트를 사용하고
비트는 바이트보다 작기 때문에 표현하기 쉽습니다.
Uint8Array와 같은 가장 작은 View 중 하나를 생성한 다음 바이트 길이가 1인 ArrayBuffer를 가리키게 한 후, 부울값을 설정합니다.
단일 바이트를 사용하여 이러한 부울을 최대 8개까지 저장할 수 있습니다.
실제로 많은 부울 값을 처리하는 경우 각 부울 인스턴스에 대한 추가 메타데이터 오버헤드가 있기 때문에
많은 수의 부울 값을 버퍼에 저장하여 JavaScript 엔진의 성능을 능가할 수 있습니다.
아래 그림은 바이트로 표시되는 부울 리스트를 보여줍니다.
 
바이트에 저장된 부울 값
이와 같이 개별 비트에 데이터를 저장할 때 최하위 비트(인덱스 0)부터 시작하는 것이 가장 좋습니다.
그 이유는 간단합니다.
저장해야 하는 부울의 수가 증가함에 따라 버퍼의 크기와 기존 비트 위치도 올바르게 유지되어야 합니다.
버퍼 자체는 동적으로 커질 수 없지만 응용 프로그램의 최신 릴리스는 큰 버퍼를 인스턴스화해야 할 수 있습니다.
const buffer = new ArrayBuffer(1);
const view = new Uint8Array(buffer);
function setBool(slot, value) {
  view[0] = (view[0] & ~(1 << slot)) | ((value|0) << slot);
}
function getBool(slot) {
  return !((view[0] & (1 << slot)) === 0);
}
이 코드는 1바이트 버퍼(바이너리로 0b00000000)를 생성한 다음 버퍼에 뷰를 생성합니다.
ArrayBuffer의 최하위 숫자 값을 true로 설정하려면 setBool(0, true) 호출을 사용합니다.
두 번째 최하위 숫자를 false로 설정하려면 setBool(1, false)를 호출합니다.
세 번째 최하위 숫자에 저장된 값을 검색하려면 getBool(2)을 호출합니다.
setBool() 함수는
  1. 부울 값을 가져와 정수로 변환합니다(value|0은 false를 0으로, true를 1로 변환).
  2. 그런 다음 저장할 슬롯에 따라 오른쪽에 0을 추가하여 "값을 왼쪽으로 이동"합니다(0b1<<0은 0b1로 유지, 0b1<<1은 0b10이 됨).
  3. 또한 숫자 1을 가져와 슬롯을 기준으로 이동한 다음(슬롯이 3이면 0b1000) 비트를 반전(~ 사용)하고 이 새 값으로 기존 값을 AND 처리(&)하여 새 값을 가져옵니다. 새 값은 (view[0] & ~(1 << 슬롯)).
  4. 마지막으로 수정된 이전 값과 이동된 새 값을 함께 OR(|)하여 view[0]에 할당합니다.
즉 기존 비트를 읽고 적절한 비트로 교체한 다음 비트를 다시 씁니다.
해당 위치를 0으로 만든 뒤 or 하기 위함
getBool() 함수는 숫자 1을 슬롯 값에 따라 이동한 다음 &를 사용하여 기존 값과 비교하는 방식으로 작동합니다.
만약 기존 값이 1이면 () 내부는 false가 되어 리턴 값은 true가 되며,
기존 값이 0이면 내부는 true가 되어 리턴 값은 false가 됩니다.
 
 
이 코드에는 몇 가지 단점이 있습니다.
예를 들어 단일 바이트보다 큰 버퍼로 작업하기 위한 것이 아니며,
7이 넘는 항목을 읽거나 쓸 때 정의되지 않은 동작이 발생합니다.
프로덕션을 위한 버전은 스토리지 크기를 고려해야 하며, 경계값 체크를 수행해야 합니다.

문자열;스트링(String)

문자열은 언뜻 보기에 인코딩하기 쉽지 않습니다.
문자열의 각 문자는 단일 바이트를 사용하여 나타낼 수 있고
문자열의 .length 속성은 저장할 버퍼의 크기를 선택하기에 충분하다고 가정하기 쉽습니다만,
복잡한 데이터를 처리할 때 곧 오류가 발생합니다.
 
이것이 간단한 문자열에서 작동하는 이유는 ASCII를 사용하여 표현된 데이터가 단일 바이트에 맞는 단일 문자를 허용하기 때문입니다.
실제로 C 프로그래밍 언어에서는 단일 바이트의 데이터를 나타내는 데이터 저장 유형을 char라고 합니다.
문자열을 사용하여 개별 문자를 인코딩하는 방법에는 여러 가지가 있습니다.
ASCII를 사용하면 전체 범위의 문자를 바이트로 표현할 수 있지만
다양한 문화, 언어 및 이모티콘이 있는 세계에서는 이러한 방식으로 이러한 모든 문자를 나타내는 것이 절대 불가능합니다.
대신 단일 문자를 나타내는 데 가변 바이트 수를 사용할 수 있는 인코딩 시스템을 사용합니다.
내부적으로 JavaScript 엔진은 상황에 따라 문자열을 표현하기 위해 다양한 인코딩 형식을 사용하며
이 복잡성은 애플리케이션에서 숨겨집니다.
가능한 내부 포맷 중 하나는
문자를 나타내는 데 2바이트 또는 4바이트를 사용하거나 특정 이모티콘을 나타내는 데 최대 14바이트를 사용하는 UTF-16입니다.
보다 보편적인 표준은 문자당 1~4바이트의 저장 공간을 사용하고 ASCII와 역호환되는 UTF-8입니다.

다음은 문자열이 .length 속성을 사용하여 반복되고 결과 값이 Uint8Array 인스턴스에 매핑될 때 발생하는 일의 예입니다.
// Warning: Antipattern!
function stringToArrayBuffer(str) {
  const buffer = new ArrayBuffer(str.length);
  const view = new Uint8Array(buffer);
  for (let i = 0; i < str.length; i++) {
    view[i] = str.charCodeAt(i);
  }
  return view;
}

stringToArrayBuffer('foo'); // Uint8Array(3) [ 102, 111, 111 ]
stringToArrayBuffer('€');   // Uint8Array(1) [ 172 ]​
이 경우 기본 문자열 foo를 저장하는 것은 문제가 없습니다.
그러나 실제로 값 8,364로 표현되는 € 문자는 Uint8Array에서 지원하는 최대 값 255보다 크므로 172로 잘렸습니다.
해당 숫자를 다시 문자로 변환하면 잘못된 값이 됩니다.
 
ArrayBuffer 인스턴스에 문자열을 직접 인코딩 및 디코딩하기 위해 최신 JavaScript에서 API를 사용할 수 있습니다.
이 API는 전역 TextEncoder 및 TextDecoder에서 제공됩니다.
둘 다 생성자이며 브라우저 및 Node.js를 포함한 최신 JavaScript 환경에서 전역적으로 사용할 수 있습니다.
이러한 API는 UTF-8 인코딩을 사용하여 인코딩 및 디코딩합니다.
 
다음은 이 API를 사용하여 문자열을 UTF-8 인코딩으로 안전하게 인코딩하는 방법의 예입니다.
const enc = new TextEncoder();
enc.encode('foo'); // Uint8Array(3) [ 102, 111, 111 ]
enc.encode('€');   // Uint8Array(3) [ 226, 130, 172 ]​
이러한 값을 디코딩하는 방법은 다음과 같습니다.
const ab = new ArrayBuffer(3);
const view = new Uint8Array(ab);
view[0] = 226; view[1] = 130; view[2] = 172;
const dec = new TextDecoder();
dec.decode(view); // '€'
dec.decode(ab);   // '€'​
 
TextDecoder#decode()는 Uint8Array 뷰 또는 기본 ArrayBuffer 인스턴스와 함께 사용할 수 있습니다.
이렇게 하면 먼저 View에서 래핑할 필요 없이 네트워크 호출에서 가져올 수 있는 데이터를 디코딩하는 것이 편리합니다.

객체(Objects)

객체가 이미 JSON을 사용하여 문자열로 표시될 수 있다는 점을 고려하면
두 스레드에서 사용하려는 객체를 JSON 문자열로 직렬화하고 다음을 사용하여 해당 문자열을 배열 버퍼에 쓰는 옵션이 있습니다.
이전 섹션에서 작업한 것과 동일한 TextEncoder API를 이용해서 말입니다.
const enc = new TextEncoder();
return enc.encode(JSON.stringify(obj));
JSON은 JavaScript 객체를 가져와 문자열 표현으로 변환합니다.
이런 일이 발생하면 출력 포맷에 많은 중복(redundancies)이 있습니다.
페이로드의 크기를 훨씬 더 줄이려면 MessagePack과 같은 포맷을 사용할 수 있습니다.
이 포맷은 이진 데이터를 사용하여 개체 메타데이터를 나타내어 직렬화된 개체의 크기를 훨씬 더 줄일 수 있습니다.
이로 인해 MessagePack과 같은 도구는 이메일과 같이 일반 텍스트가 적합한 상황에 반드시 적합하지는 않지만
바이너리 버퍼가 전달되는 상황에서는 그렇게 나쁘지 않을 수 있습니다.
msgpack5 npm 패키지는 이를 위한 브라우저 및 Node.js 호환 패키지입니다.
 
즉, 스레드 간 통신 시 성능 절충은
일반적으로 전송되는 페이로드의 크기 때문이 아니라 페이로드 직렬화 및 역직렬화 비용 때문일 가능성이 높습니다.
이러한 이유로 스레드 간에 더 간단한 데이터 표현을 전달하는 것이 일반적으로 더 좋습니다.
스레드 간에 개체를 전달하는 경우에도 .onmessage 및 .postMessage 메서드와 결합된  structured clone algorithm
객체를 직렬화하고 버퍼에 쓰는 것보다 빠르고 안전하다는 것을 알 수 있습니다.
 
객체를 직렬화 및 역직렬화하고 SharedArrayBuffer에 기록하는 애플리케이션을 개발하는 경우,
응용 프로그램의 일부 아키텍처를 재고해야 할 수 있습니다.
객체 타입을 저수준으로 직렬화하여 전달하는 방법을 찾는 것입니다.
 
다음번에는 js에서 상호 배제를 구현하는 메서드들에 대해 알아봅니다.
 

참고

Multithreaded JavaScript

 

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

 

 

 
반응형