JS Memory Model


JS Memory Model

하드웨어 메모리 모델

golang의 메인테이너인 rsc 의 글 번역입니다.

Intro

메모리 모델이란 메모리에 저장된 데이터에 대한 변경 사항의 가시성과 일관성을 유지하기 위한 메모리 일관성 모델을 말합니다.

프로그래밍 언어 메모리 모델은 병렬 프로그램이 스레드 간에 메모리를 공유하기 위해 어떤 동작에 의존할 수 있는지에 대한 질문에 답합니다. 예를들어 변수 x, done이 0으로 시작하는 다음의 프로그램을 생각해보겠습니다.

// 스레드 1             // 스레드 2
x = 1;                while(done == 0) { /* loop */ }
done = 1;             print(x);

이 프로그램은 메시지를 수신할 준비가 되었다는 신호로 done을 사용하여 스레드1에서 스레드2로 x의 메시지를 보내려고 시도합니다.

스레드1과 스레드2가 각각 dedicated processor(단일 논리 파티션에 할당된 전체 프로세서) 에서 실행되고 둘 다 완료될 때 까지 실행되는 경우를 상정하겠습니다. 이 경우 이 프로그램이 의도한 대로 완료되고 1을 인쇄하도록 보장됩니까? 프로그래밍 언어 메모리 모델은 이 질문에 답합니다.

각 프로그래밍 언어는 세부 사항은 다르지만 C, Cpp, Go, Java, Javascript, Rust, Switf를 포함한 모든 모던 멀티 스레드 언어에 대해 몇 가지 일반적인 답변이 있습니다.

  • x와 done이 일반 변수라면 스레드 2의 루프는 절대 멈추지 않을 수 있습니다. 일반적인 컴파일러 최적화는 변수를 처음 사용할 때 레지스터에 로드하고, 가능한 오랫동안 변수에 대한 이후 액세스를 위해 레지스터를 재사용합니다. 스레드1이 실행되기 전에 스레드2가 레지스터에 복사된다면, 스레드1이 나중에 done을 수정한다는 사실을 결코 알아차리지 못하고 전체 루프동안 해당 레지스터를 계속 사용할 수 있습니다.
  • 스레드 2의 루프가 done == 1 을 관찰한 후 중지되더라도 x가 0이라고 출력할 수 있습니다. 컴파일러는 종종 최적화 휴리스틱, 해시 테이블, 기타 중간 데이터 구조가 코드를 생성하는 동안 트래버스되는 방식에 따라 프로그램 읽기 쓰기 순서를 재지정합니다. 스레드 1에 대해 컴파일된 코드는 이전 done 대신 이후에 x에 쓰게 되거나 스레드 2에 대한 컴파일된 코드가 루프 전에 x를 읽게 될 수 있습니다.

모던 언어는 프로그램이 스레드를 동기화 할 수 있도록 원자 변수atomic variables 또는 원자 연산atomic operation이라는 특수 기능을 제공합니다. 이러한 접근 방식을 사용하는 언어에서 원자 변수를 만들거나 원자 연산을 사용하여 변수를 조작하면 프로그램이 완료되고 1을 출력하도록 보장됩니다. 원자 변수를 사용하면 많은 효과가 있습니다:

  • 스레드 1에 대한 컴파일된 코드는 완료에 대한 쓰기가 표시되기 전에 x에 대한 쓰기가 완료되고 다른 스레드에 표시되는지 확인해야 합니다.
  • 스레드 2에 대한 컴파일된 코드는 루프의 모든 반복에서 done을 (다시)읽어야 합니다.
  • 스레드 2에 대한 컴파일된 코드는 done에서 읽기가 완료된 후에 x에서 읽어야 합니다.
  • 컴파일된 코드는 이러한 문제를 다시 유발할 수 있는 하드웨어 최적화를 비활성화하는 데 필요한 모든 작업을 수행해야 합니다.

이 문서에서 atomic 은 synchronizing atomic 으로 읽어야 합니다.

아키텍처에 따라 instruction의 순서 변경이 허용되는 정도가 다르기 때문에 여러 프로세서에서 병렬로 실행되는 코드는 아키텍처에 따라 허용되는 결과가 달라질 수 있습니다. 가장 좋은 표준은 순차적 일관성으로, 모든 실행은 서로 다른 프로세서에서 실행된 프로그램이 단일 프로세서에서 일정한 순서로 인터리빙된 것처럼 동작해야 합니다. 이 모델은 개발자가 추론하기 훨씬 쉽지만, 약한 보장으로 인한 성능 향상으로 인해 현재 이 모델을 제공하는 주요 아키텍처는 없습니다.

서로 다른 메모리 모델을 비교하여 완전히 일반적인 진술을 하는 것은 어렵습니다. 대신 리트머스 테스트라고 하는 특정 테스트 사례에 집중하는 것이 도움이 될 수 있습니다. 두 메모리 모델이 주어진 리트머스 테스트에서 서로 다른 동작을 허용하는 경우, 이는 서로 다르다는 것을 증명하며 적어도 해당 테스트 케이스에 대해 한 모델이 다른 모델보다 약하거나 강한지 여부를 확인하는 데 도움이 됩니다. 예를 들어, 다음은 앞서 살펴본 프로그램의 리트머스 테스트 형태입니다:

Javascript Memory Model

싱글 스레드 언어로 악명 높은 JavaScript는 코드가 여러 프로세서에서 병렬로 실행할 때 발생하는 메모리 모델에 대해 걱정할 필요가 없다고 생각할 수 있습니다. 저는 틀림없이 그랬고, 틀렸습니다.

JS에는 다른 스레드에서 코드를 실행할 수 있는 웹 워커(Web Worker)가 있습니다. 원래 개념대로 워커는 명시적 메시지 복사를 통해서만 JS 메인 스레드와 통신했습니다. 쓰기 가능한 공유 메모리가 없기 때문에, 데이터 레이스(data races)와 같은 문제를 고려할 필요가 없었습니다. 그러나 메인 스레드와 워커가 쓰기 가능한 메모리 블록을 공유할 수 있는 SharedArrayBuffer 객체가 추가되었습니다. 왜 그랬을까요? 프로포절의 초안의 첫 번째 이유는 멀티 스레드 C++ 코드를 JS로 컴파일하기 위해서 입니다.

물론 쓰기 가능한 메모리를 공유하려면 동기화 및 메모리 모델을 위한 원자 조작atomic operation을 정의해야 합니다. JS는 세 가지 중요한 측면에서 C++과 다릅니다.

  1. 원자 조작을 순차적이고 일관된 원자(sequentially consistent atomics)로 제한합니다. 다른 원자 조작은 효율성은 떨어지지만 정확성의 손실없이 순차적이고 일관된 원자 조작으로 컴파일 할 수 있습니다. 한 종류만 있으면 시스템의 나머지 부분이 단순화됩니다.
  2. JS는 “DRF-SC(Data-Race-Free Sequential Consistency) or Catch Fire”를 채택하지 않았습니다. 대신 Java와 마찬가지로 racy 액세스로 인해 발생 가능한 결과를 신중하게 정의합니다. 그 이론적 해석은 특히 보안에서 Java와 거의 동일합니다. 임의의 값(any value)을 반환하는 racy read를 허용하면, 구현에서 관련없는 데이터가 허용(심지어 권장되기도 합니다.)되어 런타임에 개인 데이터가 유출로 이어질 수 있습니다.
  3. JS는 부분적으로 고성능 프로그램을 위한 의미론을 제공하기 때문에, 원자 조작과 비-원자 조작이 동일한 메모리 위치에서 사용될 때 발생하는 일과, 크기가 다른 액세스를 사용하여 동일한 메모리 위치에 액세스 할 때 어떤 일이 발생하는지 정의합니다.

racy 프로그램의 동작을 정확하게 정의하면 메모리 의미론이 느슨해지고 비공식 읽기 등을 허용하지 않는 방법 등 일반적인 복잡성으로 발생합니다. 다른 곳과 거의 동일한 이러한 문제 외에도 ES2017 정의에는 흥미로운 두 가지 버그가 있습니다. 새로운 ARMv8 원자 명령어의 의미 체계와의 불일치에서 발생했습니다. 이 예제는 Conrad Watt의 2020년 논문 “Repairing and Mechanising the JavaScript Relaxed Memory Model”에서 발췌했습니다.

이전 섹션에서 언급했듯이 ARMv8은 순차적으로 일관된 원자 로드, 저장을 위한 ldar(Load-Acquire), stlr(Store-Release)명령어를 추가했습니다. 이들은 데이터 경쟁이 있는 프로그램의 동작을 정의하지 않는 C++을 대상으로 했습니다. 당연히 레이시 프로그램에서 이러한 명령어의 동작은 ES2017 작성자의 기대와 일치하지 않았고, 특히 레이시 프로그램 동작에 대한 대한 ES2017 요구 사항을 충족하지 못했습니다.

// 리트머스 테스트: ES2017 ARMv8 에서 읽기 경쟁
// r1 = 0, r2 = 1을 볼 수 있을까요?

// 스레드 1           // 스레드 2
x = 1                 y = 1
r1 = y                x = 2 // (non-atomic)
                      r2 = x
// C++: yes (data race, can do anything at all).
// Java: the program cannot be written.
// ARMv8 using ldar/stlr: yes.
// ES2017: no! (contradicting ARMv8)

이 프로그램에서 모든 읽기와 쓰기는 x = 2를 제외하고 순차적으로 일관된 원자이며, 스레드 1은 원자 저장소를 사용하여 x = 1 을 쓰지만 스레드 2는 비원자 저장소를 사용하여 x = 2 쓰기를 합니다. C++에서 이것은 데이터 경쟁이므로 모든 내기를 걸 수 없습니다. 자바에서는 이 프로그램을 작성할 수 없습니다. x를 휘발성으로 선언하거나 작성하지 않아야 하며, 원자적으로만 액세스할 수 있는 경우도 있습니다. ES2017에서 메모리 모델은 r1 = 0, r2 = 1을 허용하지 않는 것으로 밝혀졌습니다. r1 = y가 0을 읽으면 스레드 2가 시작되기 전에 스레드 1이 완료되어야 하는데, 이 경우 비원자성 x = 2가 그 후에 발생하여 x = 1을 덮어쓰고 원자성 r2 = x가 2를 읽게 되는 것처럼 보일 수 있습니다. 이 설명은 합리적으로 보이지만 ARMv8 프로세서의 작동 방식은 아닙니다.

동등한 시퀀스의 ARMv8 인스트럭션(명령어)에서 x에 대한 비-원자 쓰기가 y에 대한 원자 쓰기 보다 먼저 재정렬될 수 있으므로 이 프로그램은 실제로 r1 = 0, r2 = 1을 생성합니다. 경쟁은 프로그램이 무엇이든 할 수 있다는 것을 의미하므로 C++에서는 문제가 되지 않지만, 경쟁 동작을 r1 = 0, r2 = 1이 포함되지 않는 결과 집합으로 제한하는 ES2017에서는 문제가 됩니다.

순차적으로 일관된 원자 연산을 구현하기 위해 ARMv8 인스트럭션을 사용하는 것이 ES2017의 명시적인 목표였기 때문에 Watt 등은 표준의 다음 개정판에 포함될 예정인 제안된 수정 사항이 이러한 결과를 허용할 허용할 정도로 경쟁 동작 제약을 약화시킬것이라고 보고했습니다. (당시 “다음 개정”이 ES2020을 의미했는지 아니면 ES2021을 의미했는지는 불분명합니다.)

// 리트머스 테스트: ES2017 데이터 경쟁 없는(DRF) 프로그램
// r1 = 1, r2 = 2 를 볼 수 있나요?

// 스레드 1           // 스레드 2
x = 1                 x = 2
                      r1 = x
                      if (r1 == 1) {
                          r2 = x // non-atomic
                      }
// 순차적으로 일관된 하드웨어: no.
// C++: I'm not enough of a C++ expert to say for sure.
// Java: the program cannot be written.
// ES2017: yes! (DRF-SC 위반).

이 프로그램에서 모든 읽기와 쓰기는 표시된 대로 r2 = x를 제외한 모든 읽기 및 쓰기가 순차적으로 일관된 원자 연산입니다. 이 프로그램은 데이터 경쟁이 없습니다. 데이터 경쟁에 관여해야 하는 비원자성 읽기는 r1 = 1일 때만 실행되며, 이는 스레드 1의 x = 1이 r1 = x보다 먼저 발생하고 따라서 r2 = x보다 먼저 발생한다는 것을 증명합니다. DRF-SC는 프로그램이 순차적으로 일관된 방식으로 실행되어야 한다는 의미이므로 r1 = 1, r2 = 2는 불가능하지만 ES2017 사양에서는 이를 허용합니다.

따라서 프로그램 동작에 대한 ES2017 사양은 너무 강하고(경쟁 프로그램에 대한 실제 ARMv8 동작을 허용하지 않음) 동시에 너무 약했습니다(경쟁이 없는 프로그램에 대해 순차적으로 일관되지 않은 동작을 허용함). 앞서 언급했듯이 이러한 실수는 수정되었습니다. 그럼에도 불구하고, 이는 DRF(data-race-free) 프로그램과 경쟁 프로그램의 의미를 정확히 지정하는 것이 얼마나 미묘할 수 있는지, 그리고 언어 메모리 모델을 기본 하드웨어 메모리 모델과 일치시키는 것이 얼마나 미묘할 수 있는지를 다시 한 번 상기시켜 줍니다.

적어도 현재로서는 자바스크립트가 순차적으로 일관된 원자 외에 다른 원자를 추가하지 않고 “DRF-SC or Catch Fire”에 저항했다는 것은 고무적인 일입니다. 그 결과 C/C++ 컴파일 대상으로 유효하지만 자바에 훨씬 더 가까운 메모리 모델이 탄생했습니다.

Reference