JavaScript로 메모리에 직접 접근할 수 있을까? 메모리에 직접 접근한다는 말의 의미를 다음과 같이 정의한다면, 가능하다.
원하는 크기의 메모리 블록을 할당할 수 있다.
그리고 해당 메모리 블록을 원하는 방식으로 잘라 데이터를 읽거나 쓸 수 있다.
메모리 할당
할당된 메모리를 표현하는 JavaScript 객체는 ArrayBuffer다. 다음 코드로 원하는 크기의 메모리를 할당할 수 있다.
const buffer = new ArrayBuffer(1024) // 1024 바이트의 메모리를 할당
메모리에 데이터 읽기 쓰기
할당된 메모리는 View를 통해서 조작한다. View는 ArrayBuffer가 할당한 메모리 블록을 들여다보는 창이다. View 기능을 위해 TypedArray 객체가 사용된다. JavaScript 랭귀지가 지원하는 TypedArray에는 Int8Array, Uint8Array, Int16Array, Uint16Array 등이 있다. TypedArray는 단지 ArrayBuffer를 들여다보는 창일뿐이다. ArrayBuffer를 기반으로 한 TypedArray 객체를 여러 개 생성해도 메모리 중복이 발생하지 않는다 (낭비되지 않는다). 여러 개의 TypedArray 객체가 동일한 메모리 블록을 들여다볼 뿐이다.
const buffer = new ArrayBuffer(1024) // 1024 바이트 메모리 할당
const byteArray = new Uint8Array(buffer) // buffer를 바이트 배열로 다루는 view 생성
byteArray[0] = 1 // 1 바이트 메모리 쓰기
const data = byteArray[0] // 1 바이트 메모리 읽기
ArrayBuffer와 TypedArray 사이의 변환
ArrayBuffer와 TypedArray 사이의 변환은 숨 쉬는 것만큼 쉽다.
const buffer = new ArrayBuffer(1024) // 1024 바이트 메모리 할당
const byteArray = new Uint8Array(buffer) // ArrayBuffer로부터 TypedArray 생성
const buffer2 = byteArray.buffer // TypedArray로부터 ArrayBuffer 획득
console.log(buffer === buffer2) // true
// TypedArray로부터 획득한 버퍼가 원본 버퍼와 같은 것인지 확인
그래서 crypto 객체 같은 데이터 처리 모듈은 처리할 데이터를 인자로 받을 때, ArrayBuffer와 TypedArray를 구분하지 않고 모두 허용한다.
const buffer = new ArrayBuffer(1024) // 1024 바이트 메모리 할당
const byteArray = new Uint8Array(buffer) // ArrayBuffer로부터 TypedArray 생성
// (생략) 메모리 블록에 데이터 채우기
// (생략) 암호화에 필요한 iv, key 확보
const encryptedResult = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key,
buffer
) // OK
const encryptedResult2 = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key,
byteArray
) // OK
요약
메모리에 직접 접근하는 작업을 위한 전통적인 랭귀지는 C/C++였다. 이제 JavaScript로도 메모리에 직접 접근하는 작업이 가능하다. 랭귀지 사이의 우월을 논하는 것이 점점 의미 없어지고 있다. 어쩌면 이는 JavaScript의 성능과 활용도가 크게 향상됐음을 의미한다. 수많은 기계에 이미 탑재되어 있는, JavaScript를 지원하는 웹브라우저야말로 웹3 세상을 여는 강력한 플랫폼이 될 것이다.
지난 글에서, 저는 SharedArrayBuffer 를 사용할 때 레이스 컨디션이 발생할 수 있다고 이야기했습니다. 레이스 컨디션 때문에 SharedArrayBuffer 는 다루기 어렵습니다. 그래서 우리는 어플리케이션 개발자가 SharedArrayBuffer 를 직접 사용하리라고 생각하지 않습니다.
하지만 다른 랭귀지를 사용해서 멀티스레드 프로그래밍을 해 본 경험이 있는 라이브러리 개발자라면 새로 만들어진 이 저수준(low-level) API를 이용해서 고수준(higher-level) 도구를 만들 수 있을 것입니다. 그러면 어플리케이션 개발자들은 SharedArrayBuffer 나 Atomics 를 직접 건들지 않고도 이렇게 만들어진 도구를 이용할 수 있을 것입니다.
당신은 대부분의 경우 SharedArrayBuffer 와 Atomics 를 직접 사용하지 않을 것입니다. 그래도, SharedArrayBuffer 와 Atomics 의 동작 방식을 이해하는 것은 여전히 재미 있는 일입니다. 그래서 이번 글을 통해, 발생 가능한 레이스 컨디션의 종류와 Atomics 을 이용해서 이를 해결하는 방법에 대해 설명하려고 합니다.
우선, 레이스 컨디션이 뭐죠?
레이스 컨디션 (Race conditions): 전에 한번 했던 얘기
레이스 컨디션은 당신이 2개의 스레드 사이에서 변수를 공유할 때 발생합니다. 여기 한 스레드가 파일을 읽고 다른 스레드는 그 파일이 존재하는지 체크하는 경우가 있다고 가정해봅시다. 두 스레드는 통신을 위해 fileExists 변수를 공유합니다.
우선, fileExists 변수의 초기값을 false 로 설정하기로 합시다.
Thread 2 의 코드가 먼저 실행되는 한, 그 파일은 읽혀질 것입니다.
하지만 Thread 1 의 코드가 먼저 실행되면, log 구문이 사용자에게 에러 문구를 출력해서 파일이 존재하지 않는다고 알릴 것입니다.
하지만 이것은 큰 문제가 아닙니다. 파일이 존재하지 않는 것은 아니니까 어떻게든 해결할 수 있습니다. 정말 문제가 되는 것은 레이스 컨디션입니다.
방금 예를 든 종류의 레이스 컨디션은 싱글스레드 코드를 작성하는 JavaScript 개발자들도 겪는 문제입니다. 이런 종류의 레이스 컨디션은 멀티스레드 프로그래밍이 아닐 때도 발생합니다.
하지만, 싱글 스레드 코드에서는 발생하지 않는 종류의 레이스 컨디션이 있습니다. 그런 종류의 레이스 컨디션은 스레드를 여러 개 사용할 때, 그리고 그 스레드들이 메모리를 공유할 때 발생합니다.
다른 종류의 레이스 컨디션과 Atomics 를 이용한 문제 해결
이제 멀티스레드 코드에서 발생할 수 있는 몇 가지 레이스 컨디션들을 살펴 봅시다. 그리고 Atomics 를 이용해서 레이스 컨디션 문제를 해결하는 방법을 알아 봅시다. 이 글은 레이스 컨디션의 모든 것을 설명하지 않습니다. 다만 Atomics API 를 제공하는 이유를 설명하는 것이 목적입니다.
시작하기 전에, 한번 더 이야기 하고 싶습니다. Atomics 를 직접 사용하지 마세요. 멀티스레드 코드를 작성하는 것은 익히 알려진 바대로 힘든 일입니다. 대신, 믿을 수 있는 라이브러리들을 이용하세요. 그러면 멀티스레드 상황에서 메모리를 공유할 때 발생하는 문제에 적절히 대응할 수 있습니다.
그러면 시작합니다.
단일 연산에서의 레이스 컨디션
당신이 2개의 스레드를 가지고 어떤 변수를 증가시킨다고 가정해봅시다. 당신은 아마도 어떤 스레드가 먼저 실행되든 상관 없이 결과가 동일할 거라고 생각할 것입니다.
하지만, 소스 코드에서 변수를 증가시키는 연산이 단일 연산처럼 보이는 이 경우도, 컴파일된 코드를 살펴보면 단일 연산이 아닌 것을 알게 될 것입니다.
CPU 레벨에서 보면, 어떤 값을 증가시키기 위해 3개의 명령을 실행해야 합니다. 왜냐하면 컴퓨터가 롱텀 메모리(long-term memory)와 숏텀 메모리(short-term memory)를 사용하기 때문입니다. (이에 대해서는 다른 글에서 자세히 설명하겠습니다).
롱텀 메모리는 모든 스레드들이 공유합니다. 하지만 숏텀 메모리 (즉, 레지스터)는 스레드들이 공유하지 않습니다.
각 스레드는 롱텀 메모리로에서 값을 읽어와 숏텀 메모리에 저장합니다. 그런 다음, 각 스레드는 숏텀 메모리에 있는 값에 대해 연산을 수행합니다. 그리고 나서 각 스레드는 숏텀 메모리의 결과 값을 롱텀 메모리에 옮겨 저장합니다.
만약 Thread 1 의 모든 연산이 완료되고, 그 다음 Thread 2 의 모든 연산이 실행된다면, 우리는 원하는 결과를 얻게 될 수 것입니다.
하지만 각 스레드의 연산들이 섞여서 진행되면, Thread 2 가 롱텀 메모리에서 연산이 마무리되지 않아 올바르지 못한 값을 레지스터로 가져오게 됩니다. 이는 Thread 2 가 Thread 1 의 연산 결과를 반영하지 못하게 됨을 의미합니다. 그 결과, Thread 2 가 Thread 1 의 롱텀 메모리 저장 값을 덮어쓰게 됩니다.
Atomics 연산이 하는 일들 중 하나가 이처럼 사람들은 단일 연산이라고 생각하지만 컴퓨터에서는 여러 개의 명령들로 수행되는 연산을 컴퓨터도 단일 연산으로 취급하게 만드는 것입니다.
이것이 Atomics 연산입니다. Atomics 연산은 여러 개의 명령들을 하나의 작업으로 정의합니다. 각 명령들 각각은 잠시 멈춰질 수도 있고 다시 시작될 수도 있지만, 모든 명령들이 한 덩어리로 실행됩니다. 그래서 모든 명령들을 하나의 연산으로 취급할 수 있습니다. 마치 원자(Atom)처럼 더이상 나눠지지 않습니다.
Atomics 연산을 이용하면, 변수 값을 증가시키는 코드가 조금 달라집니다.
이제 우리가 Atomics.add 코드를 사용했기 때문에, 변수 값을 증가시키는데 사용된 여러 단계의 연산들이 스레드 사이에서 섞이지 않게 됩니다. 대신, 어느 한 스레드가 Atomics 연산을 수행하는 동안에는 다른 스레드가 관련 연산을 할 수 없게 금지됩니다. 한 스레드가 Atomics 연산을 종료해야 다른 스레드가 자신의 Atomics 연산을 시작할 수 있습니다.
이 목록이 무척 제한적이라고 느껴지시나요? 이 목록에는 심지어 나눗셈과 곱셈 연산도 포함되어 있지 않습니다. 하지만 라이브러리 개발자라면, 그런 종류의 유사-Atomics 연산들을 만들 수 있을 것입니다.
유사-Atomics 연산들을 만들려면, Atomics.compareExchange 메소드를 이용해야 합니다. 이 메소드를 이용하면, 당신은 SharedArrayBuffer 에서 값을 가져와서, 가져온 값에 대해 어떤 연산을 수행하고, 다른 스레드가 SharedArrayBuffer 의 값을 변경하지 않은 경우에만 결과 값을 SharedArrayBuffer 에 저장합니다. 만약 다른 스레드가 값을 변경시켰다면, 변경된 값을 가져와서 다시 연산을 시도해야 합니다.
여러 개의 연산에 걸친 레이스 컨디션
이런 Atomics 연산은 “단일 연산”에서 레이스 컨디션을 회피하는데 도움을 줍니다. 하지만 종종 당신은 어떤 객체에 속한 여러 개의 변수값들을 (여러 개의 연산을 사용해서) 바꾸길 원합니다. 그리고 다른 연산이 해당 객체를 동시에 수정하지 않도록 보장 받기를 원합니다. 기본적으로, 이것은 객체를 수정하는 모든 구간에서 객체를 잠그고 그래서 다른 스레드가 접근하지 못하도록 막아야 한다는 것을 의미합니다.
Atomics 객체는 이런 상황을 직접 다룰 수 있는 어떤 도구도 제공하지 않습니다. 하지만 Atomics 객체는 라이브러리 제작자들이 이런 상황에 대처할 때 이용할 수 있는 도구들을 제공합니다. 라이브러리 제작자들은 이를 이용해서 Lock 객체를 만들 것입니다.
만약 어떤 코드가 Lock 객체로 잠겨진 데이터에 접근하고자 한다면, 해당 코드는 해당 데이터에 대한 Lock 객체의 소유권을 획득해야 합니다. 그러면 해당 코드는 Lock 객체를 이용해서 다른 스레드들이 접근하지 못하도록 막을 수 있습니다. 해당 코드는 Lock 객체를 획들했을 때만 해당 데이터에 접근하거나 해당 데이터를 수정할 수 있습니다.
이 경우, Thread 2 는 데이터에 접근하기 위해 Lock 객체를 획득하고 locked 의 값을 true 로 설정합니다. 이는 Thread 2 가 Lock 객체를 해지하기 전에는 Thread 1 이 데이터에 접근할 수 없음을 의미합니다.
만약 Thread 1 이 해당 데이터에 접근해야 한다면, Thread 1 은 Lock 객체를 획득하려고 시도할 것입니다. 하지만 Lock 객체가 이미 사용 중이기 때문에, Thread 1 은 Lock 객체 획득에 실패합니다. 그러면 Thread 1 은 Lock 객체가 사용 가능해질 때까지 기다려야 합니다 (즉 block 됩니다).
Thread 2 가 일을 끝내면, Thread 2 는 unlock 함수를 호출할 것입니다. 그럼 lock 함수는 기다리고 있는 스레드를 깨워서 데이터에 접근 가능함을 알릴 것입니다.
깨어난 스레드는 Lock 객체를 획득해서, 데이터를 쓰는 동안 다른 스레드가 접근하지 못하게 막을 것입니다.
Lock 라이브러리는 Atomics 객체에 존재하는 여러 메소드들을 이용할 것입니다. 그 중 중요한 것만 고르면 다음과 같습니다.
Atomics 로 해결해야 하는 3번째 동기화 문제가 있습니다. 이 문제는 놀라울 것입니다.
당신은 아마도 이 문제를 인식하지 못했을 것입니다. 하지만 아주 많은 경우 당신이 작성한 코드는 당신이 기대하는 순서대로 실행되지 않습니다. 컴파일러와 CPU 모두 실행 속도를 높이기 위해 명령어의 호출 순서를 재배치합니다.
예를 들어, 어떤 값들을 더해서 총합을 계산하는 코드를 가정해봅시다. 우리는 계산이 끝나면 플랙 값을 바꿔서 계산이 종료됐음을 표시하려고 합니다.
이를 컴파일하자면, 먼저 각 변수 값을 저장하기 위해 어떤 레지스터를 이용할 지 결정해야 합니다. 그래야 우리는 소스 코드를 기계어 명령어로 바꿀 수 있습니다.
여기까지는 모든 것이 예측 대로 입니다.
컴퓨터가 칩 레벨에서 어떻게 동작하는지 이해하지 못했을 때 (그리고 CPU 가 명령어를 실행시킬 때 어떤 방식으로 파이프라인을 사용하는지 이해하지 못했을 때) 어렵게 느껴지는 것은, 우리 코드의 2번째 라인이 실행 전에 잠시 기다려야 한다는 사실입니다.
대부분의 컴퓨터들은 명령어 처리 과정을 여러 단계로 잘게 나눕니다. 이렇게 하면 모든 시간에 걸쳐 CPU 의 모든 구성요소가 쉬지 않고 일하게 만들 수 있습니다. 그래서 CPU 활용률을 극대화시킬 수 있습니다.
여기 명령어를 잘게 나누는 방식의 한 예가 있습니다
메모리로부터 명령어 가져오기
명령어가 지시하는 의미를 파악하고 (즉 명령어를 디코딩하고), 레지스터로에서 값을 가져오기
명령어 실행하기
결과 값을 레지스터에 기록하기
그래서 이것이 한 명령어가 파이프라인을 거쳐 실행되는 방식입니다. 이상적으로, 우리는 2번째 명령어가 즉시 실행되기를 원합니다. 2번째 단계 실행을 위해, 다음번 명령어를 가져와야 합니다.
문제는 명령어 #1 과 명령어 #2 사이에 의존 관계가 존재한다는 점입니다.
우리는 명령어 #1 이 레지스터에 존재하는 subTotal 값의 수정을 완료할 때까지 CPU 를 잠시 멈춰야 할 것입니다. 하지만 이렇게 하면 처리 속도가 느려질 것입니다.
좀 더 효율적으로 처리하기 위해, 많은 컴파일러들과 CPU 들은 명령어의 처리 순서를 재배치합니다. 많은 컴파일러들과 CPU 들은 subTotal 또는 total 을 사용하지 않는 다른 명령어들을 찾습니다. 그래서 찾은 명령어들을 앞의 2개 라인 사이로 옮깁니다.
이렇게 하면 파이프를 따라 움직이는 명령어의 흐름이 안정적이 됩니다.
3번째 라인이 1번째 라인이나 2번째 라인의 어떤 값에도 의존하지 않기 때문에 컴파일러 또는 CPU 는 명령어를 이렇게 재배치하는 것이 안전하다고 판단합니다. 싱글 스레드 상황에서 코드를 실행시킨다면, 다른 어떤 코드도 전체 함수가 완료되기 전에는 중간 단계의 이 값들을 보지 못할 것입니다.
하지만 다른 프로세서에서 동시에 실행되는 다른 스레드가 있는 상황에서는 이야기가 달라집니다. 다른 스레드는 함수가 종료될 때까지 기다릴 필요가 없기 때문에 이런 중간 단계의 변화된 값을 보게 됩니다. 변화된 값이 메모리에 기록되자마자 바뀐 값을 보게 될 것입니다. 그래서 해당 스레드는 total 값이 계산되기도 전에 isDone 값이 설정됐다고 인식하게 됩니다.
만약 당신이 isDone 변수를 total 값이 계산됐어 다른 스레드가 사용해도 됨을 알리는 플랙으로 사용했다면, 이런 종류의 명령어 재배치는 레이스 컨디션을 일으킬 것입니다.
Atomics 는 이런 버그를 해결하려고 시도합니다. 당신이 Atomics 를 이용해서 어떤 값을 기록하려고 하는 것은, 당신 코드의 2개 부분에 담장을 두르는 것과 같습니다.
Atomics 오퍼레이션은 명령어 재배치를 허용하지 않습니다. 그래서 다른 명령어가 그 주변에 끼어들지 않습니다. 특히, 순서를 지키도록 강제할 때 다음과 같은 함수를 사용합니다.
소스 코드에서 Atomics.store 구문 이전 위치에서 실행되는 모든 변수값 변경 작업은 Atomics.store 구문이 어떤 값을 메모리에 기록하기 전에 실행되는 것을 보장 받습니다. non-Atomics 명령들의 실행 순서가 재배치되더라도 결코 소스 코드 상에서 아래에 있는 Atomics.store 구문 이후로는 재배치되지 않을 것입니다.
그리고 함수에서 Atomics.load 구문 이후 읽어오는 모든 변수값은 Atomics.load 구문이 값을 읽은 이후 실행되는 것을 보장 받습니다. 다시 한번, non-Atomics 명령들이 재배치되더라도 결코 소스 코드 상에서 위에 있는 Atomics.load 구문 이전으로는 재배치되지 않을 것입니다.
Note: 여기서 예시한 while 루프는 스핀락(spinlock)이라고 불리며 매우 비효율적입니다. 만약 스핀락이 메인 스래드에 위치한다면, 당신의 어플리케이션을 비정상 종료시킬지도 모릅니다. 실제 코드에서는 사용하지 않기를 바랍니다.
다시 한번 언급하지만, 이 메소드들은 어플리케이션에서 직접 호출할 메소드들이 아닙니다. 대신, 라이브러리들이 이 메소드들을 이용해서 Lock 객체를 구현할 것입니다.
결론
메모리를 공유하는 멀티스레드 프로그램을 만드는 것은 힘듭니다. 거기에는 고려해야 할 매우 다양한 종류의 레이스 컨디션들이 존재합니다.
그렇기 때문에 당신은 어플리케이션을 만들 때 SharedArrayBuffer 와 Atomics 를 직접 사용하면 안됩니다. 대신, 멀티스레드 경험이 많고 메모리 모델에 대해 오래 연구한 개발자가 만든 검증된 라이브러리를 이용하는 것이 좋습니다.
SharedArrayBuffer 와 Atomics 는 아직 발표된지 얼마 안됩니다. 그래서 아직 검증된 라이브러리들이 나오지 않았습니다. 그래도 이 새로운 API 들이 그런 라이브러리를 만드는 토대가 될 것입니다.
왜냐하면 ArrayBuffers 를 이용하면 JavaScript 를 사용하는 경우에도 데이터를 수동으로 관리할 수 있는 여지가 생기기 때문입니다. JavaScript 랭귀지가 메모리 자동 관리 랭귀지이지만 말입니다.
어떨 때 메모리를 수동으로 관리하고 싶어질까요?
지난 글에서 이야기한 것처럼, 메모리 자동 관리 방식에는 장단점이 존재합니다. 메모리 자동 관리 방식이 개발자가 쓰기에는 편리하지만, 실행 성능 측면에서는 약간의 오버헤드를 감수해야 합니다. 어떤 상황에서는 이런 오버헤드가 문제 될 수 있습니다.
예를 들어, 우리가 JS 에서 변수를 만들 때, JS 엔진은 변수의 타입과 변수 값의 표현하는 방식을 추측해야 합니다. 이것이 단지 추측이기 때문에, JS 엔진은 보통 변수가 정말로 필요로 하는 것보다 많은 공간을 확보합니다. 변수에 따라, 필요한 공간보다 2~8배 많은 메모리 슬롯(memory slot)을 확보합니다. 상당히 많은 공간이 낭비됩니다.
그리고 JS 객체를 만들고 사용하는 어떤 종류의 패턴은 가비지 컬렉터를 힘들게 만듭니다. 만약 우리가 메모리 수동 관리 방식을 사용한다면, 우리의 유즈케이스(use case)에 꼭 맞는 메모리 할당 정책과 해지 정책을 선택할 수 있습니다.
대부분의 경우는, 이런 일로 고민할 필요가 없습니다. 대부분의 유즈케이스는 메모리 수동 관리 방식을 선택할만큼 실행 성능에 민감하지 않습니다. 일반적인 유즈케이스에서는 메모리 수동 관리 방식의 실행 성능이 오히려 늦을 수도 있습니다.
하지만 우리가 작성한 코드에서 성능 최고치를 뽑아내기 위해 깊숙한 레벨(low-level)에서 일해야 하는 경우, ArrayBuffers 와 SharedArrayBuffers 가 도움 될 것입니다.
그래서 ArrayBuffer 는 어떻게 동작하나요?
기본적으로 ArrayBuffer 는 다른 JavaScript 배열과 비슷하게 동작합니다. 다만, ArrayBuffer 를 이용할 때, 우리는 객체나 문자열 같은 JavaScript 가 제공하는 타입(type)들을 ArrayBuffer 에 저장할 수 없습니다. ArrayBuffer 에 저장할 수 있는 것은 오로지 (숫자로 표현할 수 있는) 바이트열(bytes) 뿐입니다.
여기서 분명히 해둘 것은 우리가 바이트 값을 ArrayBuffer 에 직접 추가하지 않는다는 것입니다. ArrayBuffer 스스로는 바이트열의 크기가 얼마나 큰지, 또 바이트열로부터 어떤 타입의 숫자를 변환해야 하는지 알지 못합니다.
ArrayBuffer 자체는 단지 0 과 1 이 한 줄로 나열된 덩어리일 뿐입니다. ArrayBuffer 는 배열 첫째 요소와 둘째 요소 사이의 구분이 어디에 위치하는지도 모릅니다.
문맥에 맞는 정보를 제공하려면, 그러니까 이 덩어리를 적절한 규격의 박스로 나누려면, ArrayBuffer 를 view 라고 불리는 것으로 감싸야 합니다. 우리는 view 로 표현된 데이터를 typed array (형식화 배열)에 추가할 수 있습니다. JavaScript 는 view 를 다룰 수 있는 다양한 typed array (형식화 배열)를 제공합니다.
예를 들어, 우리는 Int8 typed array (Int8 형식화 배열)를 이용해서 데이터 덩어리를 8-bit 단위의 바이트 값들로 나눌 수 있습니다.
또는 unsigned Int16 배열을 이용해서 데이터 덩어리를 16-bit 단위의 바이트 값들로 나눌 수 있습니다. 그래서 나뉘어진 값들을 unsigned integer 값들로 다룰 수 있습니다.
심지어 우리는 한개의 버퍼에 여러개의 view 를 적용하는 것도 할 수 있습니다. 적용하는 view 가 달라지면 동일한 연산의 수행 결과도 달라집니다.
예를 들어, 우리가 Int8 view 를 통해 ArrayBuffer 에서 0 번째 요소와 1 번째 요소를 가져오는 경우, 그 값은 Uint16 view 를 통해 가져오는 값과 다를 것입니다. ArrayBuffer 가 완전히 동일한 bit 값들을 갖고 있음에도 불구하고 말이죠.
이런 방식으로, ArrayBuffer 는 기본적으로 메모리 자체인 것처럼 동작합니다. ArrayBuffer 는 C 같은 랭귀지를 사용할 때처럼 메모리를 직접 다루는 방식과 비슷한 효과를 만들어 냅니다.
아마 당신은 프로그래머에게 메모리를 직접 다루는 수단을 주지 않고 이런 추상적인 계층을 만든 이유가 궁금할 것입니다. 메모리에 대한 직접적인 접근을 허용하면 보안 측면에서 헛점이 노출되기 쉽습니다. 다음에 다른 글을 통해 이 주제에 대해 설명하겠습니다.
SharedArrayBuffer 는 또 뭔가요?
SharedArrayBuffer 를 설명하기 전에, JavaScript 를 이용한 병렬 처리 코드에 대해 조금 설명해야 할 것 같습니다.
우리는 프로그램을 좀더 빠르게 만들기 위해, 또는 프로그램이 사용자 이벤트에 좀더 빠르게 반응하도록 만들기 위해 병렬 처리 코드를 사용합니다. 이를 위해, 우리는 작업을 분할합니다.
통상적인 어플리케이션에서는 작업을 한 사람(즉, 메인 스레드)이 처리합니다. 제가 예전에 이에 대해 설명한 적이 있는데, 그때 저는 메인 스레드를 풀-스택 개발자에 비유했었습니다. 메인 스레드가 JavaScript 실행, DOM 처리, 레이아웃 처리 등 모든 일을 담당합니다.
메인 스레드의 작업을 보조하는 것이라면 무엇이더라도 작업 효율을 개선합니다. 이런 상황에서는, ArrayBuffer 가 메인 쓰레드의 작업을 보조할 수 있습니다.
하지만 언젠가 메인 쓰레드의 작업을 보조하는 것으로 충분하지 않은 때가 옵니다. 무엇인가를 결단해야 하는 순간… 그러니까 작업을 분리해야 하는 순간이 옵니다.
대부분의 프로그래밍 랭귀지들의 경우, 스레드(thread)라는 것을 이용해서 작업을 분할합니다. 이것은 기본적으로 여러 명이 한 프로젝트를 함께 수행하는 것과 비슷합니다. 만약 다른 작업들과 특별히 연관 없는 어떤 작업이 존재한다면, 해당 작업을 별도의 스레드로 처리할 수 있습니다. 그러면, 2개의 쓰레드가 분리된 작업을 동시에 처리합니다.
JavaScript 에서는 이런 일은 web worker 를 이용해서 구현합니다. web worker 는 다른 랭귀지의 스레드와 조금 다릅니다. 기본적으로 web worker 는 메모리를 공유하지 않습니다.
이는 만약 우리가 다른 스레드와 어떤 데이터를 공유하고 싶다면, 그 데이터를 복사해서 전달해야만 한다는 뜻입니다. 이 작업은 postMessage 함수에 의해 처리됩니다.
postMessage 에 어떤 객체를 전달하면, postMessage 함수는 그것을 직렬화해서(serialize) 다른 web worker 에 전달합니다. 그러면 데이터를 전달 받은 web worker 가 데이터를 풀어서(deserialize) 메모리에 복사합니다.
이건 매우 느린 작업입니다.
ArrayBuffer 같은 메모리의 경우, 메모리 전달하기(transferring memory)라는 것이 가능합니다. 메모리 전달하기란 메모리의 특정 블록 소유권을 다른 web worker 로 이전하는 것입니다.
그러면 원래 해당 메모리 블록을 소유하고 있던 web worker 는 더이상 그 블록에 접근할 수 없게 됩니다.
어떤 유스케이스에서는 이 방식이 적절합니다. 하지만 고성능 병렬 처리 코드가 필요한 많은 경우, 우리가 정말 원하는 것은 공유 메모리 (shared memory)입니다.
SharedArrayBuffer 가 바로 이 기능을 제공합니다.
SharedArrayBuffer 를 쓰면 2개의 web work (즉 2개의 스레드) 모두가 메모리의 같은 영역을 읽고 쓸 수 있습니다.
이는 더이상 postMessage 를 쓸 때 감수해야 했던 통신 오버헤드와 시간지연을 겪지 않아도 된다는 뜻입니다. 2개의 web worker 모두가 데이터에 즉시 접근할 수 있습니다.
이렇게 2개의 스레드가 동시에 즉각적으로 접근할 수 있기 때문에 어떤 위험한 상황을 감수해야 합니다. 즉 레이스 컨디션(race condition)이 발생할 수 있습니다.
Safari (Safari 10.1) 는 이미 SharedArrayBuffer 를 지원합니다. Firefox 와 Chrome 은 7월/8월 버전에서 SharedArrayBuffer 를 지원하기 시작할 것입니다. Edge 는 가을에 있을 윈도우즈 업데이트를 통해 SharedArrayBuffer 를 지원할 것입니다.
ArrayBuffer 와 SharedArrayBuffer 가 모든 주요 브라우저들에서 지원되더라도, 어플리케이션 개발자가 ArrayBuffer 와 SharedArrayBuffer 를 직접 이용하지는 않을 것입니다. 사실, 우리는 그러지 않는 것을 추천합니다. 당신은 가능한 가장 높은 수준의 추상화 도구를 선택하는 것이 좋습니다.
우리는 JavaScript 라이브러리 개발자들이 당신을 위해 SharedArrayBuffer 를 쉽고 안전하게 사용할 수 있는 라이브러리를 개발하리라고 기대합니다.
덧붙여, 일단 SharedArrayBuffer 가 플랫폼에 장착되면, WebAssembly 가 이를 이용해서 스레드를 지원할 수 있게 됩니다. 그게 현실이 되면, 우리는 컨커런시(concurrency)를 추상적인 레벨에서 쉽게 사용할 수 있습니다. 마치 Rust 같은 랭귀지처럼요. Rust 랭귀지는 두려움 없이 컨커런시를 쓰는 것을 목표로 하는 랭귀지 입니다.
다음 글에서, 우리는 도구들 (Atomics)을 살펴볼 것입니다. 이 도구들은 라이브러리 개발자들이 레이스 컨디션을 회피하는 용도로 사용하는 것입니다.
한국 Mozilla Hacks 기술 블로그에서 번역 활동을 했다. 원체 좋은 글이 많이 올라오는 곳이라서 번역하면서 많이 배웠다. 내가 한 일이라곤 잘 갖춰진 커뮤니티 인프라 위에 숟가락 하나 얹은 것뿐이었다. 그런데 오랜만에 생각나는 글이 있어 살펴보니, 한국 Mozilla Hacks의 현재 상태가 별로 안 좋다. 블로그 기사에서 이미지가 누락되어 안 보인다. 숟가락 하나만 들고 있는 나로서는 할 수 있는 일이 없다. 어쩔 수 없이 Hacks의 글을 편집해서 여기에 복사본을 만든다. 모질라 커뮤니티가 건강하게 오래 가길 빈다.
ArrayBuffer 와 SharedArrayBuffer 가 JavaScript 에 추가된 이유를 이해하려면, 우리는 메모리 관리에 대해 조금 알아야할 필요가 있습니다.
머신에 탑재된 메모리를 상자 더미로 생각해 봅시다. 나는 이것이 사무실에 있는 사서함이나, 유치원에서 있는 사물함과 비슷하다고 생각합니다.
만약 원생들에게 어떤 선물을 남겨주고 싶다면, 우리는 선물을 사물함 상자에 넣어 두면 됩니다.
각 상자들 옆면에는 숫자가 붙어 있는데, 이것을 메모리 주소라고 부릅니다. 그것이 바로 선물을 어디다 남겨야 하는지 식별하는 방식입니다.
이 상자들 각각은 모두 같은 크기이며 일정량의 정보를 보관할 수 있습니다. 상자의 크기는 머신의 종류에 따라 결정되는데, 워드 사이즈(word size)라고 부릅니다. 워드 사이즈는 통상 32-bit 또는 64-bit 입니다. 하지만 그림으로 쉽게 표현하기 위해 저는 워드 사이즈를 8-bit라고 가정하겠습니다.
알파벳 H 를 숫자로 표현할 방법이 필요합니다. 그러기 위해 만든 것이 인코딩입니다. UTF-8 같은 것 말이죠. 이제 알파벳 H 를 숫자로 인코딩해줄 도구가 필요합니다. 예를 들어 인코더-링(encoder ring) 같은 것 말입니다. 이제 우리는 알파벳 H 를 저장할 수 있습니다.
저장했던 정보를 다시 가져오고 싶을 때에는, 정보를 변환해주는 디코더를 사용해서 상자에서 꺼낸 정보를 다시 알파벳 H 로 변환합니다.
메모리 자동 관리
JavaScript 로 일할 때 우리는 메모리에 대한 생각을 할 필요가 없습니다. 메모리 문제는 전적으로 JavaScript 가 책임집니다. 이는 우리가 메모리에 직접 접근할 수 없다는 뜻입니다.
대신, JS 엔진이 중재자 역할을 합니다. JS 엔진이 우리 대신 메모리를 관리합니다.
어떤 JS 코드(가령 React 같은 코드)가 변수를 만드는 상황을 생각해봅시다.
그러면 JS 엔진은 그 변수에 저장할 값(value)을 인코더에 넣고 돌려서 해당 값(value)에 대한 이진수 표현을 얻어냅니다.
그런 다음 JS 엔진은 비어 있는 메모리 공간을 찾아서 이진수로 표현된 값(value)을 거기에 넣습니다. 이것이 메모리 할당이라고 불리는 과정입니다.
이제부터 JS 엔진은 이 변수가 프로그램에 의해 사용되고 있는지 계속 확인합니다. 만약 더이상 변수가 참조되지 않으면 변수가 차지하고 있던 메모리 공간이 반환됩니다. 그러면 JS 엔진이 반환된 메모리 공간을 다시 사용할 수 있게 됩니다.
이렇게 (문자열, 객체, 기타 모든 값들을 저장하는) 변수를 감시하다가, 더이상 변수가 사용되지 않을 때 청소하는 작업을 가비지 컬렉션(garbage collection)이라고 합니다.
JavaScript 처럼 개발자가 메모리 관리를 직접 하지 않는 랭귀지를 메모리-매니지드 랭귀지(memory-managed language)라고 부릅니다.
개발자에게는 메모리 자동 관리 방식이 편합니다. 하지만 (가비지 컬렉션으로 인한) 성능 저하를 감수해야 합니다. 그리고 보통 예상치 못한 시점에 성능 저하가 발생합니다.
메모리 수동 관리
메모리를 직접 관리하는 랭귀지는 다릅니다. 예를 들어, React 코드가 C 랭귀지로 작성됐다면 어떤 방식으로 메모리를 다루는지 알아봅시다 (WebAssembly 덕분에 실제로 정말 있을 수 있는 일입니다).
C 랭귀지에는 JavaScript 가 메모리 관리를 위해 제공하는 추상화 계층이 없습니다. 우리는 메모리에서 직접 값을 읽어 올 수도 있고, 직접 값을 저장할 수도 있습니다.
C 또는 다른 랭귀지를 WebAssembly 로 컴파일할 때 우리가 사용하는 도구가 WebAssembly 결과물에 헬퍼 코드(helper code)를 추가합니다. 예를 들어, 바이트(byte) 값을 인코딩하고 디코딩하는 코드 말입니다. 이런 코드를 런타임 환경(runtime environment)이라고 부릅니다. 런타임 환경을 통해 JS 엔진이 JS 코드를 처리할 때 제공하는 편의 기능들의 일부를 제공받을 수 있습니다.
하지만 런타임은 가비지 컬렉션 기능은 제공하지 않습니다.
이것이 모든 것을 프로그래머 혼자 처리해야 한다는 뜻은 아닙니다. 메모리 수동 관리 방식을 따르는 랭귀지도 런타임으로부터 약간의 도움을 제공 받습니다. 예를 들면, C 랭귀지에서, 런타임 환경은 소위 프리-리스트(free list)라고 불리는 메모리 주소 구조체를 제공합니다.
우리는 malloc (memory allocate 의 단축 표현) 함수를 사용해서 런타임에게 데이터를 저장할 메모리 주소를 달라고 요청합니다. 그러면 이 함수는 프리-리스트에서 적당한 주소를 가져다 전해줍니다. 우리가 데이터 처리를 끝내고 나면, 우리는 free 함수를 호출해서 해당 메모리를 반환합니다. 그러면 그 주소는 다시 프리-리스트에 추가됩니다.
우리는 이 함수들의 호출 시점을 결정해야 합니다. 즉, 메모리를 스스로 관리해야 합니다. 그래서 이런 처리 방식을 메모리 수동 관리 방식이라고 부릅니다.
개발자로서 언제 어떤 메모리를 반환해야 하는지 매번 결정하는 것이 번거로울 수 있습니다. 적절하지 않은 시점에 메모리를 반환하면, 버그가 발생하거나 심각한 보안문제(security hole)가 발생할 수 있습니다. 만약 개발자가 사용한 메모리를 전혀 반환하지 않으면 시스템의 메모리가 모두 소진될 것입니다.
그래서 많은 수의 최신 랭귀지들은 메모리 자동 관리 방식을 사용합니다. 인간의 실수를 예방하기 위해서 입니다. 그러기 위해 실행 성능을 대가로 지불합니다. 다음 글에서 좀 더 자세히 설명하겠습니다.