Android 3.0 이상 플랫폼 버전은 다중 프로세서 아키텍처를 지원하는 데 최적화되어 있습니다. 이 문서에서는 C, C++ 및 자바 프로그래밍 언어(이후로는 간단히 '자바'라고 함)로 대칭형 다중 프로세서 시스템에 사용되는 멀티스레드 코드를 작성하는 경우 발생할 수 있는 문제를 살펴봅니다. 이 문서는 Android 앱 개발자를 위한 기본 지침서로, 이 주제를 자세하게 설명하지는 않습니다.
소개
SMP는 'Symmetric Multi-Processor'(대칭형 다중 프로세서)의 약어로, 두 개 이상의 동일한 CPU 코어가 기본 메모리에 대한 액세스를 공유하는 디자인을 나타냅니다. 몇 년 전까지만 해도 Android 기기는 모두 UP(Uni-Processor, 단일 프로세서)였습니다.
대부분의 Android 기기에는 언제나 여러 개의 CPU가 있었는데 이전에는 이러한 CPU 중 하나만 애플리케이션 실행에 사용되었고 나머지는 다양한 기기 하드웨어(예: 라디오)를 관리했습니다. CPU의 아키텍처가 서로 다를 수 있으며 CPU에서 실행되는 프로그램은 기본 메모리를 사용하여 서로 통신할 수 없었습니다.
현재 판매되는 Android 기기는 대부분 SMP 디자인을 기반으로 빌드되므로 소프트웨어 개발자의 업무가 좀 더 복잡해지고 있습니다. 멀티스레드 프로그램에서 발생한 경합 상태는 단일 프로세서에서 눈에 띄는 문제를 발생시키지 않을 수 있지만, 두 개 이상의 스레드가 서로 다른 코어에서 동시에 실행되는 경우 주기적으로 실패할 수 있습니다. 그뿐만 아니라 코드가 서로 다른 프로세서 아키텍처에서 실행되는 경우 또는 동일한 아키텍처의 서로 다른 구현에서 실행되는 경우에도 다소 쉽게 코드가 실패할 수 있습니다. x86에서 철저히 테스트된 코드가 ARM에서는 심각하게 손상될 수도 있고, 최신 컴파일러로 다시 컴파일하면 코드 실행이 실패할 수도 있습니다.
이 문서의 나머지 부분에서는 이러한 결과가 발생하는 이유를 설명하고 코드가 제대로 동작하도록 하기 위해 취해야 할 조치를 알려줍니다.
메모리 일관성 모델: SMP가 약간씩 서로 다른 이유
이 개요에서는 복잡한 주제를 빠르고 효율적인 방식으로 살펴봅니다. 불완전한 부분이 있을 수 있지만 오해의 소지가 있거나 잘못된 부분은 없습니다. 다음 섹션에서 확인할 수 있듯이 여기서 세부정보는 중요하지 않은 경우가 많습니다.
이 주제를 더 자세히 알아보려면 문서 끝부분에 있는 추가 자료를 참조하세요.
메모리 일관성 모델(주로 '메모리 모델'로 지칭)은 프로그래밍 언어 또는 하드웨어 아키텍처가 메모리 액세스를 어떻게 보장하는지를 표현합니다. 예를 들어, 주소 A에 값을 쓰고 나서 주소 B에 값을 쓰는 경우 메모리 일관성 모델에서는 모든 CPU 코어가 쓰인 순서대로 이러한 쓰기를 보도록 보장한다는 의미입니다.
대부분의 프로그래머에게 익숙한 이 모델이 순차적 일관성이며, 다음과 같이 설명할 수 있습니다(Adve 및 Gharachorloo).
- 모든 메모리 연산은 한 번에 하나씩 실행되는 것으로 나타납니다.
- 단일 스레드의 모든 연산은 프로세서 프로그램에 설명된 순서에 따라 실행되는 것으로 나타납니다.
임시로, 일반적인 아주 간단한 컴파일러 또는 인터프리터가 있다고 가정해보겠습니다. 이러한 컴파일러 또는 인터프리터는 액세스마다 명령 하나씩 상응하는 순서에 따라 정확하게 소스 코드의 할당을 로드 및 저장 명령으로 변환합니다. 또한, 설명을 간단히 하기 위해 각 스레드는 자체 프로세서에서 실행된다고 가정하겠습니다.
코드의 일부분을 보고 메모리에서 읽기 및 쓰기를 실행하는 것을 알면 순차적으로 일관된 CPU 아키텍처에서는 코드가 예상되는 순서에 따라 이러한 읽기 및 쓰기를 실행할 것을 알 수 있습니다. 실제로 CPU는 명령 순서를 변경하고 읽기 및 쓰기를 지연시킬 수 있으나, 기기에서 실행 중인 코드가 CPU에서 직접적인 방식으로 명령을 실행하는 것 이외의 다른 작업을 하도록 지시할 수는 없습니다. (메모리 매핑 기기 드라이버 I/O는 무시합니다.)
이러한 사항을 설명하는 데는 일반적으로 리트머스 테스트라고 하는 작은 코드 스니펫을 고려해보면 도움이 됩니다.
다음은 두 개의 스레드에서 코드가 실행되는 간단한 예입니다.
스레드 1 | 스레드 2 |
---|---|
A = 3 |
reg0 = B |
이 리트머스 예 및 이후의 모든 리트머스 예에서 메모리 위치는 대문자(A, B, C)로 나타내며 CPU 레지스터는 'reg'로 시작합니다. 모든 메모리는 초기에 0입니다. 명령은 위에서부터 아래로 실행됩니다. 여기서 스레드 1은 값 3을 위치 A에 저장한 후 값 5를 위치 B에 저장합니다. 스레드 2는 위치 B의 값을 reg0에 로드한 후 위치 A의 값을 reg1에 로드합니다. (하나의 순서로 쓰기가 진행되고 다른 순서로 읽기가 진행됩니다.)
스레드 1과 스레드 2는 서로 다른 CPU 코어에서 실행되는 것으로 가정합니다. 멀티스레드 코드에 관해 생각할 때는 항상 이렇게 가정해야 합니다.
순차적 일관성에서는 두 스레드 모두 실행이 완료되면 레지스터가 다음 중 한 가지 상태가 되도록 보장합니다.
레지스터 | 상태 |
---|---|
reg0=5, reg1=3 | 가능(스레드 1 먼저 실행) |
reg0=0, reg1=0 | 가능(스레드 2 먼저 실행) |
reg0=0, reg1=3 | 가능(동시 실행) |
reg0=5, reg1=0 | 불가능 |
A에 대한 저장 명령 앞에 B=5가 표시되도록 하려면 읽기 또는 쓰기가 비순차적으로 이루어져야 합니다. 순차적으로 일관된 머신에서는 이러한 상황이 발생할 수 없습니다.
x86 및 ARM을 비롯한 단일 프로세서는 보통 순차적으로 일관됩니다. 스레드 간에 OS 커널이 전환되는 것처럼 인터리브 방식으로 스레드가 실행되는 것으로 나타납니다. x86 및 ARM을 비롯한 SMP 시스템은 대부분 순차적으로 일관되지 않습니다. 예를 들어, 하드웨어는 메모리로 가는 도중 저장 명령이 버퍼링되어 메모리에 즉시 도달하지 않으므로 다른 코어에 표시되는 것이 일반적입니다.
세부정보는 상당히 다릅니다. 예를 들어, x86은 순차적으로 일관되지 않지만, 여전히 reg0 = 5 및 reg1 = 0이 불가능한 상태로 유지되도록 보장합니다. 저장 명령은 버퍼링되지만 순서는 유지됩니다. 한편, ARM에서는 다릅니다. 버퍼링된 저장 명령의 순서가 유지되지 않으며 저장 명령이 다른 모든 코어에 동시에 도달하지 못할 수 있습니다. 어셈블리 프로그래머에게는 이러한 차이가 중요합니다. 하지만 아래에서 볼 수 있는 것처럼 C, C++ 또는 자바 프로그래머는 이러한 아키텍처 차이를 숨기는 방식으로 프로그래밍할 수 있으며 그렇게 해야 합니다.
지금까지는 비현실적으로 하드웨어만 명령 순서를 변경한다고 가정했습니다. 실제로는 컴파일러도 성능 향상을 위해 명령 순서를 변경합니다. 이 예에서는 스레드 2의 일부 이후 코드에 reg1 값이 있어야 하며 그 뒤에 reg0 값이 있어야 하므로 reg1을 먼저 로드하도록 컴파일러가 결정할 수 있습니다. 또는 일부 이전 코드에서 이미 A를 로드했을 수 있으므로 컴파일러가 A를 다시 로드하는 대신 이미 로드된 값을 재사용하도록 결정할 수 있습니다. 어떤 경우이든 reg0 및 reg1의 로드 순서가 변경될 수 있습니다.
하드웨어나 컴파일러에서는 다양한 메모리 위치에 액세스하는 순서를 변경할 수 있습니다. 단일 스레드의 실행에 영향을 주지 않으며 성능을 크게 향상할 수 있기 때문입니다. 뒤에 나오는 것처럼 약간만 주의를 기울이면 멀티스레드 프로그램의 결과에 영향을 주지 않도록 할 수도 있습니다.
컴파일러는 메모리 액세스 순서를 변경할 수도 있으므로 이러한 문제는 사실 SMP에 새로운 것이 아닙니다. 단일 프로세서에서도 이 예의 컴파일러는 reg0 및 reg1의 로드 순서를 변경할 수 있으며 순서가 변경된 명령 사이에 스레드 1을 예약할 수 있습니다. 하지만 컴파일러가 순서를 변경하지 않으면 이러한 문제가 관찰되지 않습니다. 대부분의 ARM SMP에서는 컴파일러가 순서를 변경하지 않아도 성공적으로 많은 실행을 한 후 순서 변경이 나타날 수 있습니다. 어셈블리 언어로 프로그래밍하지 않는 한 일반적으로 SMP에 계속 문제가 발생하기 쉽습니다.
데이터 경합 방지 프로그래밍
다행히도 일반적으로 이러한 세부정보를 고려하지 않아도 되는 쉬운 방법이 있습니다. 몇 가지 간단한 규칙을 준수하면 일반적으로 '순차적 일관성' 부분을 제외한 앞에 나온 모든 섹션을 고려하지 않아도 괜찮습니다. 실수로 해당 규칙을 위반하면 다른 복잡한 문제가 나타날 수 있습니다.
최신 프로그래밍 언어에서는 '데이터 경합 방지'로 알려진 프로그래밍 스타일을 장려합니다. '데이터 경합'을 발생시키지 않기로 하고 컴파일러에 달리 명령하는 몇 가지 구성을 방지하면 컴파일러와 하드웨어는 순차적으로 일관된 결과를 제공합니다. 이것이 메모리 액세스 순서를 변경하지 않는다는 의미는 아닙니다. 규칙을 준수하면 메모리 액세스 순서가 변경되는 것을 알 수 없다는 뜻입니다. 소시지 공장을 방문하지 않는 한 소시지가 입맛을 살리는 맛있는 음식이라고 말하는 것과 아주 유사합니다. 데이터 경합은 메모리 순서 변경의 좋지 않은 진실을 보여주는 것입니다.
'데이터 경합'이란 무엇인가요?
두 개 이상의 스레드가 동일한 보통 데이터에 동시에 액세스하고 이러한 스레드 중 하나 이상에서 데이터를 수정하면 데이터 경합이 발생합니다. '보통 데이터'란, 스레드 통신을 위한 동기화 객체가 아닌 데이터를 의미합니다. 뮤텍스, 조건 변수, 자바 휘발성 객체 또는 C++ 원자적 객체는 보통 데이터가 아니므로 액세스에서 경합이 허용됩니다. 사실상 이러한 객체는 다른 객체의 데이터 경합을 방지하는 데 사용됩니다.
두 스레드가 동일한 메모리 위치에 동시에 액세스하는지를 결정하기 위해 위의 메모리 순서 변경에 관한 내용을 무시하고 순차적 일관성을 가정할 수 있습니다. 다음 프로그램에서 A
및 B
가 처음에는 거짓인 보통 부울 변수인 경우에는 데이터 경합이 없습니다.
스레드 1 | 스레드 2 |
---|---|
if (A) B = true |
if (B) A = true |
연산 순서가 변경되지 않았으므로 두 조건 모두 거짓으로 평가되며 두 변수 모두 업데이트되지 않습니다. 따라서 데이터 경합이 나타날 수 없습니다. 스레드 1에서 A
로부터 로드 및 B
에 저장, 이 순서가 변경되면 무슨 일이 발생할지 고려할 필요가 없습니다. 컴파일러는 'B = true; if (!A) B = false
'로 다시 써서 스레드 1의 순서를 변경할 수 없습니다. 이는 대낮에 도시 한가운데서 소시지를 만드는 것과 마찬가지입니다.
데이터 경합은 정수 및 참조 또는 포인터와 같은 기본 내장 유형에 관해 공식적으로 정의됩니다. 한 스레드에서 int
에 할당하면서 동시에 다른 스레드에서 이 항목을 읽는 것은 명확하게 데이터 경합입니다. 하지만 C++ 표준 라이브러리 및 자바 컬렉션 라이브러리는 둘 다 라이브러리 수준의 데이터 경합에 관해 추론할 수 있도록 작성됩니다. 동일한 컨테이너에 동시 액세스가 발생하지 않으며 액세스 중 하나 이상에서 컨테이너를 업데이트하지 않으면 이러한 라이브러리에서 데이터 경합을 발생시키지 않기로 합니다. 한 스레드에서 set<T>
를 업데이트하면서 동시에 다른 스레드에서 이 항목을 읽으면 라이브러리가 데이터 경합을 발생시킬 수 있으므로 비공식적으로 '라이브러리 수준 데이터 경합'으로 간주할 수 있습니다.
반대로 한 스레드에서 하나의 set<T>
를 업데이트하면서 동시에 다른 스레드에서 다른 항목을 읽으면 데이터 경합이 발생하지 않습니다. 라이브러리가 이런 경우 (하위 수준) 데이터 경합을 발생시키지 않기로 했기 때문입니다.
일반적으로 데이터 구조의 서로 다른 필드에 동시 액세스하는 것은 데이터 경합을 발생시킬 수 없습니다. 하지만 이 규칙에는 한 가지 중요한 예외가 있습니다. 바로, C 또는 C++의 연속된 비트 필드 시퀀스는 단일 '메모리 위치'로 처리된다는 것입니다. 이러한 시퀀스에서 비트 필드에 액세스하면 데이터 경합이 있을지 결정하기 위해 모든 비트 필드에 액세스하는 것으로 처리됩니다. 일반 하드웨어가 인접한 비트를 읽고 다시 작성하지 않으면 개별 비트를 업데이트할 수 없는 점이 반영된 것입니다. 자바 프로그래머에게는 유사한 문제가 발생하지 않습니다.
데이터 경합 방지
최신 프로그래밍 언어에서는 데이터 경합을 방지하기 위해 여러 동기화 메커니즘을 제공합니다. 가장 기본적인 도구는 다음과 같습니다.
- 잠금 또는 뮤텍스
- 뮤텍스(C++11
std::mutex
또는pthread_mutex_t
), 또는 자바의synchronized
블록을 사용하여 코드의 특정 섹션이 동일한 데이터에 액세스하는 코드의 다른 섹션과 동시에 실행되지 않도록 할 수 있습니다. 이와 같은 기능 및 다른 유사한 기능을 일반적으로 '잠금'이라고 합니다. 공유 데이터 구조에 액세스하기 전에 일관되게 특정 잠금을 획득한 후 나중에 잠금을 해제하면 데이터 구조에 액세스할 때 데이터 경합이 방지됩니다. 또한, 업데이트 및 액세스가 원자적 방식이 되도록 보장합니다. 즉, 데이터 구조에 대한 다른 어떤 업데이트도 중간에 실행될 수 없습니다. 데이터 경합을 방지하는 데 사용되는 가장 일반적인 도구라고 할 수 있습니다. 자바synchronized
블록, C++lock_guard
또는unique_lock
을 사용하면 예외가 발생할 경우 적절하게 잠금이 해제되도록 합니다. - 휘발성/원자적 변수
- 자바는 데이터 경합을 발생시키지 않고 동시 액세스를 지원하는
volatile
필드를 제공합니다. 2011년 이후로, C 및 C++에서는 의미 체계가 유사한atomic
변수 및 필드를 지원합니다. 이러한 변수는 일반적으로 잠금보다 사용하기가 더 어렵습니다. 단일 변수에 대한 개별 액세스가 원자적 방식이 되도록 보장할 뿐이기 때문입니다. (C++에서는 일반적으로 증분과 같은 간단한 읽기-수정-쓰기 연산으로 확장됩니다. 자바에서는 이러한 연산을 위한 특수 메서드 호출이 필요합니다.) 잠금과 달리volatile
또는atomic
변수는 다른 스레드가 더 긴 코드 시퀀스를 방해하지 못하도록 하는 데 직접 사용할 수 없습니다.
volatile
은 C++ 및 자바에서 의미가 서로 매우 다르다는 점에 유의해야 합니다. C++에서는 volatile
이 atomic
객체 부족의 해결 방법으로 이전 코드에서 사용되는 경우도 있지만 데이터 경합을 방지하지는 않습니다. 이 방법은 더 이상 사용하지 않는 것이 좋습니다. C++에서는 여러 스레드가 동시에 액세스할 수 있는 변수로 atomic<T>
을 사용하세요. C++ volatile
은 기기 레지스터 등을 의미합니다.
C/C++ atomic
변수 또는 자바 volatile
변수는 다른 변수의 데이터 경합을 방지하는 데 사용할 수 있습니다. flag
가 atomic<bool>
또는 atomic_bool
(C/C++)이나 volatile boolean
(자바) 유형으로 선언되고 처음에 거짓인 경우 다음 스니펫에서는 데이터 경합이 방지됩니다.
스레드 1 | 스레드 2 |
---|---|
A = ...
|
while (!flag) {}
|
스레드 2는 flag
가 설정되기를 기다리므로 스레드 2의 A
에 대한 액세스는 스레드 1의 A
에 대한 할당 이후에 비동시적으로 발생해야 합니다. 따라서 A
에 대한 데이터 경합은 없습니다. 휘발성/원자적 액세스는 '보통 메모리 액세스'가 아니므로 flag
에 대한 경합은 데이터 경합으로 간주하지 않습니다.
구현에서 앞에 나온 리트머스 테스트 같은 코드가 예상대로 동작하도록 메모리 순서 변경을 방지하거나 숨겨야 합니다. 일반적으로 이렇게 하는 데는 보통 액세스보다 휘발성/원자적 메모리 액세스가 훨씬 더 비용이 많이 듭니다.
앞의 예에서 데이터 경합이 방지되기는 하지만, 자바에서 Object.wait()
과 잠금을 함께 사용하거나 C/C++에서 조건 변수를 사용하면 일반적으로 루프에서 대기하면서 배터리 전원을 소모하지 않는 더 나은 솔루션을 제공합니다.
메모리 순서 변경이 표시되는 경우
데이터 경합 방지 프로그래밍을 사용하면 일반적으로 메모리 액세스 순서 변경 문제를 명시적으로 처리할 필요가 없습니다. 하지만 순서 변경이 표시되는 몇 가지 경우가 있습니다.- 의도치 않은 데이터 경합으로 인한 버그가 프로그램에 있는 경우 컴파일러 및 하드웨어 변환이 표시될 수 있으며 예기치 않은 프로그램 동작이 발생할 수 있습니다. 예를 들어 앞의 예에서
flag
를 휘발성으로 선언하지 않으면 스레드 2에 초기화되지 않은A
가 표시될 수 있습니다. 또는 컴파일러에서 스레드 2의 루프 중 플래그를 변경할 수 없다고 결정하여 다음과 같이 프로그램을 변환할 수 있습니다.스레드 1 스레드 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflag
가 참인데도 루프가 무한히 계속되는 것을 볼 수도 있습니다. - C++에서는 경합이 없는 경우에도 순차적 일관성을 명시적으로 완화하는 기능을 제공합니다. 원자적 연산에서는 명시적
memory_order_
... 인수를 사용할 수 있습니다. 마찬가지로,java.util.concurrent.atomic
패키지에서는 보다 제한된 유사한 기능 세트, 특히lazySet()
를 제공합니다. 자바 프로그래머가 의도적으로 데이터 경합을 사용하여 유사한 효과를 얻는 경우도 있습니다. 이러한 기능은 모두 프로그래밍 복잡성을 크게 높임으로써 성능을 향상시킵니다. 이 내용에 관해서는 아래에서 간략하게 설명합니다. - 일부 C 및 C++ 코드는 이전 스타일로 작성되기 때문에 일부분만 현재 언어 표준과 일관됩니다. 현재 언어 표준에서는
atomic
변수 대신volatile
변수를 사용하며, 일명 펜스 또는 배리어 삽입을 통한 명시적 메모리 순서 지정이 허용되지 않습니다. 액세스 순서 변경에 대한 명확한 추론과 하드웨어 메모리 모델에 관한 이해가 필요하기 때문입니다. Linux 커널에서는 계속 이러한 줄에 코딩 스타일이 사용됩니다. 새 Android 애플리케이션에서는 사용되지 않아야 하며, 또한 여기서는 이 내용에 관해 더 이상 설명하지 않습니다.
연습
메모리 일관성 문제를 디버깅하는 것은 매우 어려울 수 있습니다. 누락된 잠금, atomic
또는 volatile
선언으로 인해 일부 코드가 오래된 데이터를 읽게 되는 경우 디버거를 통한 메모리 덤프 검사로 이유를 파악하지 못할 수 있습니다. 디버거 쿼리를 실행할 수 있게 되면 CPU 코어 모두 전체 액세스 세트를 관측할 수 있고 메모리 콘텐츠와 CPU 레지스터가 '불가능' 상태로 표시될 수 있습니다.
C에서 실행하지 말아야 할 조치
여기에서는 잘못된 코드의 예와 이를 수정하는 간단한 방법을 함께 제공합니다. 시작하기 전에 기본 언어 기능 사용에 관해 먼저 살펴보아야 합니다.
C/C++ 및 '휘발성'
C 및 C++ volatile
선언은 매우 특별한 목적의 도구입니다.
이 선언은 컴파일러가 휘발성 액세스 순서를 변경하거나 삭제하지 않도록 합니다. 이렇게 하면 두 개 이상의 위치에 매핑되거나 setjmp
와 연결된 메모리인 하드웨어 기기 레지스터에 코드가 액세스하는 데 도움이 될 수 있습니다. 하지만 C 및 C++ volatile
은 자바 volatile
과는 달리 스레드 통신용으로 디자인되지 않았습니다.
C 및 C++에서 volatile
데이터에 대한 액세스는 비휘발성 데이터에 대한 액세스를 통해 순서가 변경될 수 있으며 원자성이 보장되지 않습니다. 따라서, 단일 프로세서에서도 이식 가능한 코드의 스레드 간 데이터를 공유하는 데는 volatile
을 사용할 수 없습니다. C volatile
은 일반적으로 하드웨어의 액세스 순서 변경을 방지하지 않으므로 그 자체로는 멀티스레드 SMP 환경에서 훨씬 덜 유용합니다. 이러한 이유로 C11 및 C++11에서는 atomic
객체를 지원합니다. 이러한 객체를 대신 사용해야 합니다.
여러 이전 C 및 C++ 코드는 여전히 스레드 통신을 위해 volatile
을 악용합니다. 명시적 펜스와 함께 사용하거나 메모리 순서 지정이 중요하지 않을 때 사용하면 대개 머신 레지스터에 적합한 데이터에 관해 제대로 작동합니다. 하지만 이후 컴파일러에서 제대로 작동한다고 보장하는 것은 아닙니다.
예
대부분의 경우 원자적 연산이 아닌 잠금(예: pthread_mutex_t
또는 C++11 std::mutex
)을 사용하는 것이 더 낫지만 원자적 연산이 실제 상황에서 어떻게 사용되는지 설명하기 위해 원자적 연산을 사용하겠습니다.
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
여기에서는 구조를 할당하고, 필드를 초기화하고, 마지막으로 전역 변수에 저장하여 '게시'합니다. 이때는 다른 모든 스레드에서 이 구조를 볼 수 있으나 완전히 초기화되어 있으므로 괜찮습니다. 맞죠?
문제는 필드가 초기화되기 전에 gGlobalThing
으로의 저장이 관찰될 수 있다는 점입니다. 이것은 일반적으로 컴파일러 또는 프로세서가 gGlobalThing
및 thing->x
로의 저장 순서를 변경했기 때문입니다. thing->x
에서 읽기를 실행하는 또 다른 스레드는 5, 0 또는 초기화되지 않은 데이터까지 볼 수 있습니다.
여기서 문제의 핵심은 gGlobalThing
에 관한 데이터 경합입니다.
스레드 1이 initGlobalThing()
을 호출할 때 스레드 2가 useGlobalThing()
을 호출한다면 쓰기가 실행되는 동안 gGlobalThing
을 읽을 수 있습니다.
이 문제는 gGlobalThing
을 원자적으로 선언하여 해결할 수 있습니다. C++11에서는 다음과 같이 선언합니다.
atomic<MyThing*> gGlobalThing(NULL);
이렇게 하면 쓰기가 적합한 순서로 다른 스레드에 표시됩니다. 다른 경우에는 허용되지만 실제 Android 하드웨어에서 발생할 가능성은 낮은 다른 실패 모드가 방지되도록 보장하기도 합니다. 예를 들어, 일부만 쓰인 gGlobalThing
포인터는 볼 수 없도록 합니다.
자바에서 실행하지 말아야 할 조치
일부 관련이 있는 자바 언어 기능에 관해 설명하지 않았으므로 먼저 이러한 기능에 관해 간단히 살펴보겠습니다.
자바는 기술적으로 코드에서 데이터 경합이 방지되도록 할 필요가 없습니다. 그리고 데이터 경합이 있을 때 제대로 작동하는, 신중하게 작성된 작은 규모의 자바 코드가 있습니다. 하지만 이러한 코드를 작성하는 것은 매우 어려우므로 아래에서 간략하게만 살펴보겠습니다. 더 어려운 점은 이러한 코드의 의미를 명시한 전문가가 더 이상 이 사양이 맞다고 생각하지 않는 것입니다. (데이터 경합 방지 코드의 사양으로는 문제가 없습니다.)
여기에서는 자바가 C 및 C++와 동일하게 보장을 제공하는 데이터 경합 방지 모델을 따릅니다. 또한 언어는 순차적 일관성을 명시적으로 완화하는 프리미티브, 특히 java.util.concurrent.atomic
의 lazySet()
및 weakCompareAndSet()
호출을 제공합니다.
C 및 C++에서와 마찬가지로 여기서는 이러한 프리미티브를 무시합니다.
자바의 'synchronized' 및 'volatile' 키워드
'synchronized' 키워드는 자바 언어의 내장 잠금 메커니즘을 제공합니다. 모든 객체에는 상호 배타적인 액세스를 제공하는 데 사용할 수 있는 연결된 '모니터'가 있습니다. 동일한 객체의 두 스레드가 '동기화'하려는 경우 두 스레드 중 하나는 다른 스레드가 완료될 때까지 대기합니다.
위에 언급된 것처럼, 자바의 volatile T
는 C++11의 atomic<T>
와 유사합니다. volatile
필드에 동시 액세스하는 것은 허용되지만 데이터 경합은 발생하지 않습니다.
lazySet()
등과 데이터 경합을 무시하므로, 자바 VM 작업에서 결과가 계속 순차적으로 일관되게 나타나도록 해야 합니다.
특히, 스레드 1이 volatile
필드에 쓰기를 하고 순차적으로 스레드 2가 동일한 필드에서 읽기 및 새로 작성된 값 보기를 하는 경우 스레드 2는 이전에 스레드 1이 작성한 모든 쓰기를 볼 수 있도록 보장됩니다. 메모리 효과 측면에서 볼 때 휘발성 메모리에 쓰는 것은 모니터 해제와 유사하며 휘발성 메모리에서 읽는 것은 모니터 획득과 같습니다.
C++ atomic
과 비교하여 주목할 만한 차이점이 한 가지 있습니다. 자바에서 volatile int x;
를 쓰면 x++
는 x = x + 1
과 같습니다. 원자적 로드를 실행하고 결과를 증분한 후 원자적 저장을 실행합니다. C++와 달리, 전체적으로 증분은 원자적으로 이루어지지 않습니다.
대신 원자적 증분 연산은 java.util.concurrent.atomic
에서 제공됩니다.
예
다음은 단조 카운터의 잘못된 구현에 대한 간단한 예입니다(자바 이론 및 연습: 휘발성 관리).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
get()
및 incr()
은 여러 스레드에서 호출되며 get()
이 호출될 때 모든 스레드에서 현재 개수를 보게 하려는 것으로 가정하겠습니다. 이 예에서 가장 눈에 띄는 문제는 mValue++
가 사실상 다음 세 가지 연산이라는 점입니다.
reg = mValue
reg = reg + 1
mValue = reg
incr()
에서 두 스레드가 동시에 실행되면 업데이트 중 하나가 손실될 수 있습니다. 원자적 방식으로 증분하려면 incr()
을 'synchronized'로 선언해야 합니다.
그래도 SMP에서는 아직 손상된 코드입니다. get()
이 incr()
과 동시에 mValue
에 액세스할 수 있으므로 데이터 경합이 여전히 존재합니다. 자바 규칙에 따라 get()
호출은 다른 코드와 관련하여 순서 변경된 것으로 나타날 수 있습니다. 예를 들어, 두 개의 카운터를 연속적으로 읽으면 하드웨어나 컴파일러에 의해 순서 변경된 get()
호출 때문에 결과가 일관되지 않은 것처럼 나타날 수 있습니다. get()
이 synchronized가 되도록 선언하면 문제를 해결할 수 있습니다. 이렇게 변경하면 올바른 코드가 됩니다.
유감스럽게도 잠금 경합이 발생할 가능성이 있습니다. 잠금 경합이 발생하면 성능이 저하됩니다. get()
을 synchronized로 선언하는 대신 mValue
를 'volatile'로 선언할 수 있습니다. (incr()
은 계속 synchronize
를 사용해야 합니다. 그러지 않으면 mValue++
가 단일 원자적 연산이 되지 않습니다.)
이렇게 하면 데이터 경합도 모두 방지되므로 순차적 일관성이 유지됩니다.
incr()
에서는 모니터 진입/종료 오버헤드와 휘발성 저장 관련 오버헤드가 둘 다 발생하여 속도가 어느 정도 느려지지만, get()
은 빨라지므로 읽기 횟수가 쓰기 횟수를 크게 능가하는 경우 경합 없이 성능이 높아집니다. (또한, synchronized 블록을 완전히 삭제하는 방법은 AtomicInteger
를 참조하세요.)
다음은 앞에 나온 C 예와 유사한 형식의 또 다른 예입니다.
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
이 예에는 C 코드와 동일한 문제가 있습니다. 즉, sGoodies
에 데이터 경합이 있습니다. 따라서 goods
의 필드 초기화 전에 sGoodies = goods
할당을 관찰할 수 있습니다. volatile
키워드를 사용하여 sGoodies
를 선언하면 순차적 일관성이 복원되고 연산이 예상대로 진행됩니다.
sGoodies
참조 자체만 휘발성입니다. 참조 내에 있는 필드에 대한 액세스는 휘발성이 아닙니다. sGoodies
가 volatile
이고 메모리 순서가 올바르게 유지되면 필드에 동시에 액세스하는 것이 불가능합니다. z =
sGoodies.x
명령문은 MyClass.sGoodies
의 휘발성 로드를 실행한 후 sGoodies.x
의 비휘발성 로드를 실행합니다. 로컬 참조 MyGoodies localGoods = sGoodies
를 작성하면 후속 z =
localGoods.x
가 휘발성 로드를 실행하지 않습니다.
자바 프로그래밍에서 더 일반적으로 사용되는 관용구로, 악명 높은 '이중 검사된 잠금'이 있습니다.
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
MyClass
인스턴스와 연결된 Helper
객체의 단일 인스턴스가 필요하다고 해보겠습니다. 이 인스턴스는 한 번만 만들어야 하므로 전용 getHelper()
함수를 통해 생성하고 반환합니다. 두 스레드가 인스턴스를 만드는 경합을 방지하려면 객체 생성을 동기화해야 합니다. 하지만 모든 호출에서 'synchronized' 블록에 대한 오버헤드 비용을 지불하지는 않을 것이므로 helper
가 현재 null인 경우에만 이러한 작업을 실행합니다.
이 예에서는 helper
필드에 데이터 경합이 있습니다. 다른 스레드에 helper == null
로 동시 설정될 수 있습니다.
이 코드가 어떻게 실패하는지 확인해보려면 다음과 같이 동일한 코드를 C와 유사한 언어로 컴파일한 것처럼 약간만 고쳐보세요(여기서는 Helper’s
생성자 활동을 나타내는 2~3개의 정수 필드를 추가함).
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
하드웨어 또는 컴파일러가 helper
로의 저장과 x
/y
필드로의 저장 순서를 변경하지 않도록 하는 조치가 없습니다. 또 다른 스레드는 null이 아닌 helper
를 찾을 수 있으나 필드가 아직 설정되지 않아 사용할 준비가 되어 있지 않습니다.
더 자세한 내용 및 더 많은 실패 모드를 보려면 부록의 '‘이중 검사된 잠금이 손상됨’ 선언' 링크 또는 조쉬 블록의 Effective Java, 2nd Edition(이펙티브 자바, 2판)에 있는 Item 71, 'Use lazy initialization judiciously'(항목 71, '적절한 초기화 지연 사용')를 참조하세요.
문제를 해결하는 두 가지 방법은 다음과 같습니다.
- 간단한 작업을 실행하고 외부 검사를 삭제하세요. 이렇게 하면 동기화된 블록 외부에서
helper
값을 검사하지 않습니다. helper
를 휘발성으로 선언하세요. 이러한 작은 변화로 자바 1.5 이상에서 예 J-3의 코드가 제대로 작동합니다. (이러한 결과를 실제로 확인하려면 시간이 약간 걸릴 수 있습니다.)
다음은 volatile
동작의 또 다른 예입니다.
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
useValues()
를 보면, 스레드 2가 아직 vol1
의 업데이트를 관찰하지 못한 경우 data1
또는 data2
가 설정되었는지 알 수 없습니다. vol1
의 업데이트를 확인하면 데이터 경합을 일으키지 않고도 data1
에 안전하게 액세스하여 올바르게 읽을 수 있다는 것을 알게 됩니다. 하지만 data2
에 관해서는 어떤 가정도 할 수 없습니다. 이 변수의 저장은 휘발성 저장 이후에 실행되었기 때문입니다.
volatile
은 서로 경합하는 다른 메모리 액세스의 순서 변경을 방지하는 데 사용할 수 없습니다. 머신 메모리 펜스 명령의 생성이 보장되지 않습니다. 다른 스레드가 특정 조건을 충족한 경우에만 코드를 실행하여 데이터 경합을 방지하는 데 사용할 수 있습니다.
시작 방법
C/C++에서는 std::mutex
와 같은 C++11 동기화 클래스를 선호합니다. 그렇지 않은 경우에는 이에 대응하는 pthread
연산을 사용하세요.
이러한 연산에는 적절한 메모리 펜스가 포함되어 있어 모든 Android 플랫폼 버전에서 정확하고(별도로 지정하지 않는 한 순차적으로 일관됨) 효율적인 동작을 지원합니다. 펜스는 적절하게 사용해야 합니다. 예를 들어 조건 변수 대기는 신호 없이 허위로 반환될 수 있으므로 루프에 표시되어야 합니다.
구현 중인 데이터 구조가 카운터처럼 매우 단순한 것이 아니라면 원자적 함수를 직접 사용하지 않는 것이 가장 좋습니다. pthread 뮤텍스를 잠금 및 잠금 해제하려면 각각 하나의 원자적 연산이 필요하며 경합이 없는 경우 단일 캐시 부적중보다도 적은 리소스를 사용하는 경우가 많으므로 뮤텍스 호출을 원자적 연산으로 바꿔도 리소스가 크게 절약되지 않습니다. 중요한 데이터 구조의 잠금 방지 디자인에서는 데이터 구조의 상위 수준 연산이 원자적으로(명시적으로 원자적인 부분만이 아니라 전체적으로) 나타나도록 훨씬 더 주의를 기울여야 합니다.
원자적 연산을 사용하는 경우 memory_order
... 또는 lazySet()
를 사용하여 순서 지정을 완화하면 성능상 이점을 얻을 수 있으나 지금까지 살펴본 것보다 더 깊이 있는 이해가 필요합니다.
이러한 요소를 사용하는 기존 코드의 많은 부분에서 이후에 버그가 발견되었습니다. 가능한 한 이러한 버그를 방지하세요.
사용 사례가 다음 섹션의 사례 중 하나와 정확히 일치하지 않는 경우 사용자가 전문가이거나 전문가의 컨설팅을 받아야 합니다.
C/C++의 스레드 통신에서는 volatile
을 사용하지 마세요.
자바에서는 java.util.concurrent
패키지의 적절한 유틸리티 클래스를 사용하면 동시성 문제가 가장 잘 해결됩니다. 코드는 SMP에서 잘 작성되고 테스트됩니다.
객체를 불변으로 하는 것이 가장 안전할 수 있습니다. 자바 String 및 Integer 같은 클래스의 객체는 객체가 만들어지면 변경될 수 없는 데이터를 보유하여 객체에 대한 데이터 경합 가능성을 방지합니다. 서적 Effective Java, 2nd Ed.(이펙티브 자바, 2판)의 'Item 15: Minimize Mutability'(항목 15: 변경 가능성 최소화)에 구체적인 안내가 있습니다. 특히, 자바 필드 'final' 선언의 중요성(Bloch)에 유의하세요.
객체가 불변인 경우에도 어떤 종류의 동기화 없이 다른 스레드와 통신하는 것은 데이터 경합입니다. 자바에서 허용되는 경우도 있으나(아래 참조) 많은 주의가 필요하며 손상되기 쉬운 코드가 될 수 있습니다. 성능이 매우 중요한 경우가 아니면 volatile
선언을 추가하세요. C++에서 데이터 경합과 같이 적절한 동기화 없이 불변 객체의 포인터 또는 참조를 전달하는 것은 버그입니다.
예를 들어, 이런 경우 수신 스레드에서 저장 순서 변경으로 인해 초기화되지 않은 메서드 테이블 포인터를 볼 수 있으므로 간헐적 장애가 발생할 가능성이 높습니다.
기존 라이브러리 클래스도 불변 클래스도 적절하지 않은 경우 자바 synchronized
구문 또는 C++ lock_guard
/unique_lock
을 사용하여 둘 이상의 스레드가 액세스할 수 있는 필드에 대한 액세스를 보호해야 합니다. 뮤텍스가 상황에 적합하지 않으면 공유 필드 volatile
또는 atomic
을 선언해야 하지만 스레드 간 상호작용을 이해하도록 주의를 기울여야 합니다. 이러한 선언이 일반적인 동시 프로그래밍 오류가 발생하는 것을 예방하지는 않지만, 컴파일러 및 SMP 문제를 최적화하는 것과 관련된 설명하기 어려운 실패를 방지하는 데는 도움이 됩니다.
생성자에서 객체에 대한 참조를 '게시'하지 않아야 합니다. 즉, 참조를 생성자의 다른 스레드에서 사용할 수 있게 하지 마세요. C++에서 작업하거나 자바에서 '데이터 경합 없음' 권장사항을 준수하는 경우에는 별로 심각하지 않을 수 있습니다. 하지만 언제나 객체 참조를 게시하지 않는 것이 좋고 자바 보안 모델이 중요한 다른 컨텍스트에서 자바 코드가 실행되는 경우 심각하게 될 수 있습니다. 신뢰할 수 없는 코드가 이러한 '유출된' 객체 참조에 액세스하여 데이터 경합을 일으킬 수도 있습니다. 경고를 무시하도록 선택하고 다음 섹션의 기술 중 일부를 사용하는 경우에도 심각하게 될 수 있습니다. 자세한 내용은 (자바의 안전한 생성 기법)을 참조하세요.
약한 메모리 순서에 관한 세부정보
C++11 이상에서는 데이터 경합 방지 프로그램의 순차적 일관성 보장을 완화하기 위한 명시적 메커니즘을 제공합니다. 원자적 연산을 위한 명시적 memory_order_relaxed
, memory_order_acquire
(로드 전용) 및 memory_order_release
(저장 전용) 인수는 각각 기본값보다 현저히 약한 보장을 제공하며, 일반적으로 암시적 memory_order_seq_cst
를 제공합니다. memory_order_acq_rel
은 원자적 읽기-수정-쓰기 연산에 memory_order_acquire
및 memory_order_release
를 둘 다 보장합니다. memory_order_consume
은 아직 사용하기 충분할 만큼 지정되거나 구현되지 않았으므로 현재로서는 무시해야 합니다.
Java.util.concurrent.atomic
의 lazySet
메서드는 C++ memory_order_release
저장과 유사합니다. 자바의 보통 변수는 실제로는 더 약하지만 memory_order_relaxed
액세스의 대체로 사용되는 경우가 있습니다. C++와 달리, volatile
로 선언된 변수에 순서가 지정되지 않은 액세스를 하는 실제 메커니즘은 없습니다.
성능상의 이유로 사용해야 하는 경우가 아니면 일반적으로 이러한 메커니즘은 피해야 합니다. ARM과 같이 약하게 순서가 지정된 머신 아키텍처에서는 이러한 메커니즘을 사용하면 일반적으로 원자적 연산마다 대략 수십 개의 머신 주기를 저장합니다. x86에서는 성능상의 이점이 저장으로 제한되어 있어 눈에 띄지 않기가 쉽습니다. 어느 정도 직관적으로 생각하는 것과 달리, 메모리 시스템이 더 제한적인 요소가 될수록 코어 수가 증가하여 이점이 감소할 수 있습니다.
약하게 순서 지정된 원자적 연산의 전체 의미 체계는 복잡합니다. 일반적으로 언어 규칙에 대한 정확한 이해가 필요한데, 여기서는 관련 내용을 다루지 않습니다. 예:
- 컴파일러 또는 하드웨어는 잠금 획득 및 해제로 제한되는 주요 섹션 내로(외부가 아님)
memory_order_relaxed
액세스를 이동할 수 있습니다. 즉, 두memory_order_relaxed
저장이 주요 섹션에 의해 분리되어 있어도 비순차적으로 표시될 수 있습니다. - 보통 자바 변수는 공유 카운터로 악용되면 다른 단일 스레드에 의해서만 증분되는 경우에도 또 다른 스레드에 감소하는 것으로 표시될 수 있습니다. 하지만 C++ 원자적
memory_order_relaxed
의 경우에는 해당되지 않습니다.
이러한 사항에 유의하면서 여기에서는 약하게 순서 지정된 원자적 연산의 여러 사용 사례에 사용되는 것으로 간주되는 소수의 관용구를 제공합니다. 이러한 관용구는 대부분 C++에만 적용됩니다.
비경합 액세스
변수는 때때로 쓰기와 동시에 읽기가 실행되므로 변수가 원자적인 것은 매우 일반적입니다. 하지만 모든 액세스에 이러한 문제가 있는 것은 아닙니다.
예를 들어, 변수는 주요 섹션 외부에서 읽기가 실행되므로 원자적이어야 할 수도 있으나 업데이트는 모두 잠금으로 보호됩니다. 이 경우 동일한 잠금으로 보호되는 읽기는 동시 쓰기가 있을 수 없으므로 경합이 불가능합니다. 이런 경우에는 C++ 코드의 정확성을 변경하지 않고 비경합 액세스(여기서는 로드)에 memory_order_relaxed
로 주석을 달 수 있습니다.
잠금 구현은 다른 스레드의 액세스와 관련하여 필요한 메모리 순서 지정을 이미 적용하고 있으며 memory_order_relaxed
는 기본적으로 원자적 액세스에 추가 순서 지정 제약 조건을 적용할 필요가 없음을 명시합니다.
자바에서는 이와 유사한 경우가 없습니다.
정확성의 경우 결과가 신뢰되지 않음
힌트를 생성하기 위해서만 경합 로드를 사용하는 경우 일반적으로 로드에 메모리 순서 지정을 적용하지 않아도 됩니다. 값을 신뢰할 수 없으면 결과를 신뢰하여 다른 변수의 값을 추론할 수도 없습니다. 따라서 메모리 순서 지정이 보장되지 않고 로드에 memory_order_relaxed
인수가 제공되어도 괜찮습니다.
이에 대한 일반적인 예는 C++ compare_exchange
를 사용하여 원자적으로 x
를 f(x)
로 대체하는 것입니다.
f(x)
를 계산할 x
의 초기 로드는 신뢰할 필요가 없습니다. 계산이 틀리면 compare_exchange
가 실패하고 다시 시도합니다.
x
의 초기 로드에서 memory_order_relaxed
인수를 사용해도 괜찮습니다. 실제 compare_exchange
의 메모리 순서 지정만 중요합니다.
원자적으로 수정되었으나 읽지 않은 데이터
데이터가 여러 스레드에서 병렬로 수정되지만 병렬 계산이 완료될 때까지 검토되지 않는 경우가 있습니다. 이에 대한 좋은 예로 병렬로 여러 스레드에서 원자적으로 증분되는(예: C++의 fetch_add()
또는 C의 atomic_fetch_add_explicit()
사용) 카운터가 있으나 이러한 호출의 결과는 항상 무시됩니다. 결과 값은 모든 업데이트가 완료된 후 마지막에만 읽게 됩니다.
이 경우 데이터에 액세스하는 순서가 변경되었는지 알 수 있는 방법이 없으므로 C++ 코드에서 memory_order_relaxed
인수를 사용할 수 있습니다.
이에 대한 일반적인 예로, 간단한 이벤트 카운터가 있습니다. 이 사례는 매우 일반적이므로 이 사례에 대한 다음 몇 가지 사항을 관찰해보는 것이 좋습니다.
-
memory_order_relaxed
를 사용하면 성능이 향상되지만 가장 중요한 성능 문제가 해결되지 않을 수 있습니다. 모든 업데이트는 카운터가 포함된 캐시 라인에 독점적 액세스가 필요합니다. 그러면 새 스레드가 카운터에 액세스할 때마다 캐시 부적중이 발생합니다. 업데이트가 스레드 간에 자주 번갈아 발생하는 경우, 예를 들어 스레드 로컬 카운터를 사용하고 끝에 합계를 구할 때마다 공유 카운터를 업데이트하지 않는 것이 속도가 훨씬 빠릅니다. - 이 기법은 이전 섹션과 결합할 수 있습니다. 업데이트되는 동안 대략적인 값과 신뢰할 수 없는 값을 동시에 읽을 수 있으며 모든 연산에서는
memory_order_relaxed
를 사용합니다. 하지만 결과 값을 완전히 신뢰할 수 없는 값으로 취급하는 것이 중요합니다. 개수가 한 번 증가한 것으로 나타난다고 해서 다른 스레드가 증분이 실행된 지점에 도달한 것으로 신뢰될 수 있음을 의미하는 것은 아닙니다. 대신 증분은 이전 코드를 사용하여 순서를 변경할 수 있습니다. (앞서 언급한 것과 비슷한 사례의 경우 C++에서는 이러한 카운터의 두 번째 로드가 동일한 스레드의 이전 로드보다 작은 값을 반환하지 않도록 보장합니다. 물론 카운터가 오버플로되지 않은 경우입니다.) - 증분 전체를 원자적으로 지정하지는 않으면서 개별 원자적(또는 비원자적) 읽기 및 쓰기를 실행하여 대략적인 카운터 값을 계산하려는 코드를 찾는 것은 일반적인 일입니다. 대개 성능 카운터 등에 '충분히 근접'했다고 주장합니다. 일반적으로는 그렇지 않습니다. 업데이트가 (아마도 현재 고려하고 있는 사례와 같이) 충분히 자주 일어나는 경우 일반적으로 대부분의 개수가 손실됩니다. 쿼드 코어 기기에서는 일반적으로 개수의 절반 이상이 손실될 수 있습니다. (간단한 연습: 카운터가 백만 번 업데이트되지만 최종 카운터 값은 1인, 두 개의 스레드가 사용되는 시나리오를 만들어보세요.)
단순 플래그 통신
memory_order_release
저장(또는 읽기-수정-쓰기 연산)은 순차적인 방식으로 memory_order_acquire
로드(또는 읽기-수정-쓰기 연산)가 기록된 값을 읽으면 memory_order_release
저장 앞에 나온 저장(보통 또는 원자적)도 모두 관찰할 수 있도록 합니다. 이와 반대로, memory_order_release
앞에 나온 로드는 memory_order_acquire
로드 뒤에 나오는 저장을 관찰하지 못합니다.
이렇게 하면 memory_order_relaxed
와 달리, 해당 원자적 연산을 사용하여 한 스레드의 진행 상태에 관해 다른 스레드와 통신할 수 있습니다.
예를 들어, C++로 된 위의 이중 검사된 잠금 예를 다음과 같이 다시 쓸 수 있습니다.
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
로드 획득 및 저장 해제를 통해 null이 아닌 helper
를 확인하는 경우 해당 필드가 올바르게 초기화된 것도 확인할 수 있습니다.
비경합 로드가 memory_order_relaxed
를 사용할 수 있는 이전 관찰 기능도 통합되었습니다.
자바 프로그래머는 helper
를 java.util.concurrent.atomic.AtomicReference<Helper>
로 나타내고 lazySet()
를 저장 해제로 사용할 수 있습니다. 로드 연산에서는 계속 일반 get()
호출을 사용합니다.
두 경우 모두 성능 조정이 초기화 경로에 집중되어 있으므로 성능이 중요할 가능성은 적습니다. 더 읽기 쉬운 버전은 다음과 같습니다.
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
이 버전은 동일한 빠른 경로를 제공하지만 성능이 중요하지 않은 느린 경로에서는 순차적으로 일관된 기본 연산을 기반으로 합니다.
여기서도, helper.load(memory_order_acquire)
는 현재 Android 지원 아키텍처에서 helper
에 대한 일반(순차적으로 일관된) 참조와 동일한 코드를 생성할 가능성이 높습니다. 실제로 여기에서 가장 유용한 최적화는 myHelper
를 활용하여 두 번째 로드를 제거하는 것일 수 있습니다. 향후 컴파일러에서는 이 작업을 자동으로 실행할 수 있습니다.
획득/해제 순서 지정은 저장이 눈에 띄게 지연되는 것을 방지하지 않으며 저장이 일관된 순서로 다른 스레드에 표시되도록 하지도 않습니다. 결과적으로 데커의 상호 배제 알고리즘을 통해 전형적으로 보여주는 까다롭지만, 상당히 일반적인 코딩 패턴을 지원하지 않습니다. 모든 스레드는 먼저 원하는 작업을 나타내는 플래그를 설정합니다. 그런 다음, 다른 스레드에서 작업을 실행하려고 하지 않음을 스레드 t에서 알게 되면 간섭이 없을 것을 알고 안전하게 작업을 진행할 수 있습니다. t의 플래그가 계속 설정되어 있으므로 다른 어떤 스레드도 작업을 진행할 수 없습니다. 획득/해제 순서 지정을 사용해 플래그에 액세스하는 경우는 실패합니다. 한 스레드의 플래그가 작업을 잘못 진행한 후 늦게 다른 스레드에 표시되는 것을 방지하지 않기 때문입니다. 기본 memory_order_seq_cst
에서는 방지합니다.
불변 필드
처음 사용할 때 객체 필드가 초기화된 다음 변경되지 않은 경우 약하게 순서 지정된 액세스를 사용하여 객체 필드를 초기화하고 순차적으로 읽을 수 있습니다. C++에서는 atomic
으로 선언되고 memory_order_relaxed
를 사용하여 액세스되거나 자바에서는 volatile
없이 선언되고 특별한 조치 없이 액세스될 수 있습니다. 이렇게 하려면 다음 사항이 모두 포함되어야 합니다.
- 필드 자체의 값을 통해 필드가 이미 초기화되었는지 여부를 알 수 있어야 합니다. 필드에 액세스하려면 빠른 경로 테스트 및 반환 값이 필드를 한 번만 읽어야 합니다. 자바에서는 후자가 필수적입니다. 필드가 초기화될 때 테스트한다고 해도 두 번째 로드는 이전의 초기화되지 않은 값을 읽을 수 있습니다. C++에서 '한 번 읽기' 규칙은 단지 좋은 습관일 뿐입니다.
- 초기화 및 후속 로드는 모두 부분적 업데이트가 표시되지 않아야 하므로 원자적이어야 합니다. 자바의 경우 필드는
long
또는double
이 아니어야 합니다. C++의 경우 원자적 할당이 필요합니다.atomic
생성은 원자적이지 않으므로 원자적 할당을 해당 위치에 생성해도 작동하지 않습니다. - 여러 스레드가 초기화되지 않은 값을 동시에 읽을 수 있기 때문에 반복되는 초기화는 안전해야 합니다. C++에서는 일반적으로 모든 원자적 유형에 적용되는 '쉽게 복사 가능' 요구사항을 따릅니다. 중첩된 소유 포인터가 있는 유형은 복사 생성자에서 할당 해제가 필요하며 쉽게 복사 가능하지 않습니다. 자바의 경우 특정 참조 유형을 사용할 수 있습니다.
- 자바 참조는 final 필드만 포함하는 불변 유형으로 제한됩니다. 불변 유형의 생성자는 객체에 대한 참조를 게시하지 않아야 합니다. 이 경우 자바 final 필드 규칙은 리더가 참조를 확인하면 초기화된 final 필드도 확인하도록 합니다. C++에는 이러한 규칙과 유사한 규칙이 없으므로 소유 객체에 대한 포인터도 이러한 이유로('쉽게 복사 가능' 요구사항 또한 위반됨) 허용되지 않습니다.
맺음말
이 문서에서는 주제를 피상적으로만 다룬 것은 아니지만 별로 깊이 있게 살펴보지도 못했습니다. 이 주제는 매우 광범위하며 깊이 있게 다뤄져야 합니다. 더 탐구해 볼 몇 가지 주제 영역은 다음과 같습니다.
- 실제 자바 및 C++ 메모리 모델은 두 작업이 특정 순서로 발생하는 것이 보장되는 경우를 지정하는 happens-before(선행) 관계로 표현됩니다. 데이터 경합을 정의할 때 약식으로 두 개의 메모리 액세스가 '동시'에 발생하는 것에 관해 이야기한 바가 있습니다.
공식적으로는 어떤 액세스도 다른 한 액세스보다 먼저 발생하지 않는 것으로 정의됩니다.
자바 또는 C++ 메모리 모델에서 happens-before(선행) 및 synchronizes-with(동기화)의 실제 정의에 관해 알아보는 것이 도움이 됩니다.
일반적으로 '동시'의 개념은 직관적으로 충분히 이해되는 것이지만 C++에서 약하게 순서 지정된 원자적 연산을 사용하려고 생각하고 있는 경우에는 특히, 이러한 실제 정의가 도움이 됩니다. (현재 자바 사양에서는
lazySet()
만 약식으로 정의합니다.) - 코드를 순서 변경할 때 컴파일러에서 실행할 수 있는 조치와 실행할 수 없는 조치를 알아봅니다. (JSR-133 사양에는 예기치 않은 결과를 초래하는 합법적 변환에 관한 몇 가지 좋은 예가 있습니다.)
- Java 및 C++에서 불변 클래스를 작성하는 방법을 알아봅니다. (단지 '생성 후 변경하지 않는 것'이 아닌 그 이상의 의미가 있습니다.)
- Effective Java, 2nd Edition(이펙티브 자바, 2판)의 동시성 섹션에 있는 권장사항을 내재화합니다. (예를 들어 synchronized 블록 내에 있는 동안 재정의하려는 메서드는 호출하지 않아야 합니다.)
java.util.concurrent
및java.util.concurrent.atomic
API를 꼼꼼히 살펴보고 사용할 수 있는 기능을 알아봅니다. net.jcip.annotations의@ThreadSafe
및@GuardedBy
같은 동시성 주석을 사용하는 것을 고려해보세요.
부록의 추가 자료 섹션에는 이러한 주제를 잘 설명하는 문서 및 웹사이트의 링크가 있습니다.
부록
동기화 저장 구현
(대부분의 프로그래머가 이러한 구현을 스스로 실행하지는 않겠지만 이러한 구현에 관해 논의하는 것은 프로그래머에게 도움이 됩니다.)
int
와 같은 소규모 내장 유형 및 Android에서 지원하는 하드웨어의 경우 보통 로드 및 저장 명령은 동일한 위치를 로드하는 다른 프로세서에 저장 전체가 표시되도록 하거나 전혀 표시되지 않도록 합니다. 이로써 '원자성'에 관한 기본 개념이 자연스럽게 습득될 수 있습니다.
앞에서 살펴본 것처럼, 이것만으로는 충분하지 않습니다. 순차적 일관성을 보장하기 위해 연산 순서가 변경되는 것을 방지해야 하며 메모리 연산이 일관된 순서로 다른 프로세스에 표시되도록 해야 합니다. Android 지원 하드웨어의 경우 연산 순서가 변경되는 것을 방지하기 위한 조치를 적절하게 선택하면 자동으로 메모리 연산이 일관된 순서로 다른 프로세스에 표시되므로 이에 관한 내용은 대부분 여기에서 다루지 않습니다.
메모리 연산 순서를 유지하는 방법은 컴파일러에 의한 순서 변경과 하드웨어에 의한 순서 변경을 모두 방지하는 것입니다. 여기서는 하드웨어의 순서 변경을 방지하는 데 초점을 맞춥니다.
ARMv7, x86 및 MIPS에서는 '펜스' 명령을 사용하여 메모리 순서를 지정합니다. 이 명령은 펜스 뒤에 있는 명령이 펜스 앞에 있는 명령보다 먼저 표시되는 것을 방지합니다. (일반적으로 '배리어' 명령이라고도 하지만, 이 명령보다 훨씬 더 많은 작업을 실행하는 pthread_barrier
스타일 배리어와 혼동될 위험이 있습니다.) 펜스 명령의 정확한 의미는 상당히 복잡한 주제로, 여러 다양한 종류의 펜스에서 제공하는 보장이 상호작용하는 방식과 이러한 보장이 일반적으로 하드웨어가 제공하는 다른 순서 지정 보장과 결합하는 방법이 다뤄져야 합니다. 본 문서는 간략한 개요이므로 이러한 세부정보에 관해서는 논의하지 않습니다.
가장 기본적인 종류의 순서 지정 보장은 C++ memory_order_acquire
및 memory_order_release
원자적 연산에서 제공하는 보장입니다. 저장 해제 앞에 오는 메모리 연산은 로드 획득 뒤에 표시되어야 합니다. ARMv7에서는 다음과 같이 순서가 적용됩니다.
- 적절한 펜스 명령이 저장 명령 앞에 옵니다. 이렇게 하면 모든 이전 메모리 액세스가 저장 명령으로 순서 변경되는 것을 방지합니다. (불필요하게 이후 저장 명령으로 순서가 변경되는 것도 방지합니다.)
- 로드 명령 뒤에 적절한 펜스 명령이 오도록 하여 후속 액세스에서 로드 순서가 변경되는 것을 방지합니다. (그리고 다시 한번 최소 이전의 로드를 사용하여 불필요한 순서 지정을 하도록 합니다.)
C++ 획득/해제 순서 지정에서는 이러한 보장으로 충분합니다.
자바 volatile
또는 C++의 순차적으로 일관된 atomic
에서는 이러한 보장이 필요하나 이것만으로는 충분하지 않습니다.
필요한 다른 조치를 알아보려면 앞에 잠깐 언급되었던 데커의 알고리즘 프래그먼트를 고려해보세요.
flag1
및 flag2
는 C++ atomic
또는 자바 volatile
변수이며, 둘 다 처음에는 거짓입니다.
스레드 1 | 스레드 2 |
---|---|
flag1 = true |
flag2 = true |
순차적 일관성은 flag
n 할당 중 하나가 먼저 실행되어야 하며 다른 스레드의 테스트에서 확인되어야 하는 것을 의미합니다. 따라서, 이러한 스레드가 'critical-stuff'를 동시에 실행하는 것은 볼 수 없습니다.
하지만 획득-해제 순서 지정에 필요한 펜스는 각 스레드의 시작 및 끝부분에만 펜스를 추가하므로, 여기서는 도움이 되지 않습니다. volatile
/atomic
저장 뒤에 volatile
/atomic
로드가 오는 경우 둘의 순서가 변경되지 않도록 추가로 확인해야 합니다.
순차적으로 일관된 저장의 앞뿐만 아니라 뒤에도 펜스를 추가하는 조치를 일반적으로 적용합니다.
(이 펜스는 일반적으로 모든 이후 메모리 액세스에 관해 모든 이전 메모리 액세스 순서를 지정하므로 반드시 필요합니다.)
대신, 추가 펜스를 순차적으로 일관된 로드와 연결할 수 있습니다. 저장 횟수가 적으므로 설명한 규칙이 더 일반적이고 Android에서도 사용됩니다.
이전 섹션에서 살펴본 것처럼, 두 연산 사이에 저장/로드 배리어를 삽입해야 합니다. 휘발성 액세스를 위해 VM에서 실행되는 코드는 다음과 유사합니다.
휘발성 로드 | 휘발성 저장 |
---|---|
reg = A |
fence for "release" (2) |
실제 머신 아키텍처에서는 일반적으로 여러 유형의 펜스를 제공하여 다양한 유형의 액세스 순서를 지정하며 비용도 다양할 수 있습니다. 이러한 펜스 중에서 선택하는 일은 세심히 이루어져야 하며 저장이 다른 코어에 일관된 순서로 표시되어야 하는지 그리고 여러 펜스 조합에 따라 적용되는 메모리 순서 지정이 올바르게 구성되도록 해야 하는지에 영향을 받습니다. 자세한 내용은 University of Cambridge 페이지의 실제 프로세서에 대한 원자적 연산 매핑 모음을 참조하세요.
x86과 같은 일부 아키텍처에서는 하드웨어가 항상 암시적으로 충분한 순서 지정을 시행하므로 '획득' 및 '해제' 배리어가 필요하지 않습니다. 따라서, x86에서는 최종 펜스(3)만 실제로 생성됩니다. 마찬가지로, x86에서는 원자적 읽기-수정-쓰기 연산에 강력한 펜스가 암시적으로 포함됩니다. 따라서, 펜스가 필요하지 않습니다. ARMv7에서는 위에 설명한 펜스가 모두 필요합니다.
ARMv8에서는 자바 휘발성 변수나 C++의 순차적으로 일관된 로드 및 저장의 요구사항을 직접 적용하는 LDAR 및 STLR 명령을 제공합니다. 위에 언급한 불필요한 순서 변경 제약 조건이 이러한 명령을 통해 방지됩니다. ARM의 64비트 Android 코드에서는 이러한 명령을 사용합니다. 여기서는 실제 요구사항을 해결하는 데 더 필요한 ARMv7 펜스 배치에 중점을 두었습니다.
추가 자료
다양하고 자세한 정보를 제공하는 웹 페이지와 문서를 소개합니다. 일반적으로 더 유용한 자료가 목록의 앞에 나옵니다.
- 공유 메모리 일관성 모델: 가이드
- 어드비와 개라코홀루가 1995년에 집필한 것으로 메모리 일관성 모델에 관해 더 깊이 알아보는 데 유용합니다.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - 메모리 배리어
- 문제를 요약한 작은 도움말입니다.
https://en.wikipedia.org/wiki/Memory_barrier - 스레드 기본 사항
- 한스 보임의 C++ 및 자바 멀티스레드 프로그래밍 개요입니다. 데이터 경합 및 기본 동기화 방법을 설명합니다.
http://www.hboehm.info/c++mm/threadsintro.html - 실제 자바 동시 실행
- 2006년에 발행된 이 책에서는 광범위한 주제를 매우 자세하게 다루고 있습니다. 자바에서 멀티스레드 코드를 작성하는 개발자에게 적극적으로 권장됩니다.
http://www.javaconcurrencyinpractice.com - JSR-133(자바 메모리 모델) FAQ
- 자바 메모리 모델에 관한 일반적인 개요로 동기화, 휘발성 변수 및 final 필드 생성에 관한 설명이 있습니다.
(특별히 다른 언어를 다루는 경우 오래된 정보가 있음)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - 자바 메모리 모델에서 프로그램 변환의 타당성
- 자바 메모리 모델에 남아 있는 문제에 관한 기술적 설명입니다. 이러한 문제는 데이터 경합 방지 프로그램에는 적용되지 않습니다.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - java.util.concurrent 패키지 개요
-
java.util.concurrent
패키지의 문서입니다. 다양한 클래스를 통해 적용되는 보장에 관해 설명한 '메모리 일관성 속성' 섹션이 페이지 아래쪽에 있습니다.java.util.concurrent
패키지 요약 - 자바 이론 및 실제: 자바의 안전한 생성 기술
- 이 문서에서는 객체 생성 중에 이스케이프 처리되는 참조의 위험을 자세히 살펴보고 스레드로부터 안전한 생성자 가이드라인을 제공합니다.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - 자바 이론 및 실제: 휘발성 관리
- 자바의 volatile 필드로 실행할 수 있는 작업과 실행할 수 없는 작업을 설명하는 유용한 도움말입니다.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - '이중 검사된 잠금이 손상됨' 선언
volatile
또는atomic
이 사용되지 않는 이중 검사된 잠금이 손상되는 여러 방식에 관해 빌 퓨가 자세히 설명합니다. C/C ++ 및 자바를 포함합니다.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html- [ARM] 배리어 리트머스 테스트 및 설명서
- ARM 코드의 짧은 스니펫을 사용하여 ARM SMP 문제를 설명합니다. 본 페이지에 나온 예가 너무 구체적이지 않거나 DMB 명령에 대한 공식적인 설명을 읽어보려면 여기 를 클릭하십시오. 실행 코드의 메모리 배리어에 사용되는 명령도 설명하고 있습니다(즉석에서 코드를 생성하는 경우 유용할 수 있음). 배리어 리트머스 테스트 및 설명서는 ARMv8보다 먼저 존재했습니다. ARMv8도 추가 메모리 순서 지정 명령을 지원하며 더 강력한 메모리 모델로 이동했습니다. 자세한 내용은 'ARM® 아키텍처 참조 매뉴얼 ARMv8, ARMv8-A 아키텍처 프로필'을 참조하세요.
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Linux 커널 메모리 배리어
- Linux 커널 메모리 배리어에 관한 문서입니다. 유용한 예와 ASCII 아트가 포함되어 있습니다.
http://www.kernel.org/doc/Documentation/memory-barriers.txt - ISO/IEC JTC1 SC22 WG21(C++ 표준) 14882(C++ 프로그래밍 언어), 1.10항 및 29절('원자적 연산 라이브러리')
- C++ 원자적 연산 기능의 표준 초안입니다. 이 버전은 C ++11에서 이 영역의 사소한 변경사항을 포함하는 C++14 표준에 가깝습니다.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(소개: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14(C 표준) 9899(C 프로그래밍 언어) 7.16장('원자적 연산 <stdatomic.h>')
- ISO/IEC 9899-201x C 원자적 연산 기능의 표준 초안입니다.
자세한 내용은 추후 결함 보고서도 확인하세요.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - 프로세서에 대한 C/C++11 매핑(University of Cambridge)
- 야로슬라프 쉐브치크 및 피터 시웰이 작성한 것으로, 여러 C++ 원자적 연산을 다양한 공통 프로세서 명령 집합으로 변환한 모음입니다.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - 데커의 알고리즘
- '동시 프로그래밍의 상호 배제 문제에 관한 최초의 올바른 솔루션'입니다. 위키 백과 문서에는 전체 알고리즘이 있으며 최신 최적화 컴파일러 및 SMP 하드웨어와 호환되도록 업데이트하는 방법에 관한 설명이 포함되어 있습니다.
https://en.wikipedia.org/wiki/Dekker's_algorithm - ARM 및 Alpha 비교와 주소 종속 항목에 관한 의견
- 카탈린 마리나스가 작성한 ARM 커널 메일링 리스트의 이메일입니다. 주소 및 제어 종속 항목에 관한 요약을 포함합니다.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - 모든 프로그래머가 메모리에 관해 알아야 할 사항
- 율리히 드레퍼가 작성한 것으로 다양한 메모리 유형(특히, CPU 캐시)에 관한 매우 길고 자세한 도움말입니다.
http://www.akkadia.org/drepper/cpumemory.pdf - ARM의 약하게 일관된 메모리 모델에 관한 추론
- ARM, Ltd.의 총과 이시티아크가 작성한 자료입니다. ARM SMP 메모리 모델에 관해 엄격하면서도 접근하기 쉬운 방식으로 설명하고 있습니다. 본 문서에 사용된 '관측 가능성'의 정의는 이 자료에서 가져온 것입니다. 다시 말해, ARMv8보다 이전 버전에 관한 설명입니다.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - 컴파일러 작성자용 JSR-133 설명서
- JSR-133(자바 메모리 모델) 문서의 자매 편으로 더그 레아가 작성한 설명서입니다. 많은 컴파일러 작성자가 사용했었고 여전히 광범위하게 인용되고 있으며 유용한 정보를 제공하는 자바 메모리 모델에 관한 초기 구현 가이드라인이 포함되어 있습니다.
안타깝게도 여기에서 논의된 4가지 펜스는 Android 지원 아키텍처만큼 유용하지 않으며, 현재는 위의 C++11 매핑이 더 유용하고 정확한 지침이 되고 있습니다. 이는 자바에서도 마찬가지입니다.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO: x86 다중 프로세서에 대한 엄격하고 사용 가능한 프로그래머 모델
- x86 메모리 모델에 관한 정확한 설명을 제공합니다. ARM 메모리 모델에 관한 정확한 설명은 훨씬 더 복잡합니다.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf