19. Atomic access and memory consistency

C에서 데이터에 대한 동시적 접근은 여러 종류가 있다.

  • 일반적인 오래된 C 코드는 순차적으로 작동하는 것 같아 보이지만 그렇지 않다. 상태 변화에 대한 가시성은 실행 사이의 굉장히 특별한 부분인 분절 지점들에 한해서만 보장되는데, 이는 데이터의 직접적인 의존성과 함수 호출의 완성을 위함이다. 최근의 플랫폼들은 제공된 여유분들을 더욱 더 잘 이용함으로써 순서가 매겨지지 않은 연산들을 복수의 실행 파이프라인 내에서 뒤섞거나 병렬로 실행한다.
  • 롱 점프와 신호 핸들러는 순차적으로 실행되지만, 저장으로 인한 효과는 손실될 수 있다.
  • 원자적 객체에 대한 접근은 상태 변화에 대한 가시성을 일관적으로 어디에서나 보장한다.
  • 스레드들은 동시에 병렬로 실행됨으로써, 공유된 데이터에 대한 접근에 규칙을 두지 않는 경우 데이터의 일관성을 위협한다. 이들은 원자적 객체에 대한 접근 이외에도, thrd_join이나 mtx_lock 같은 함수들에 대한 호출을 통해 동기화될 수 있다.

그러나 프로그램은 메모리에 대한 접근만 하는 것이 아니다. 프로그램 실행의 추상 상태는 다음으로 이루어진다:

  • 실행 지점 (스레드 당 하나)
  • 중간 값 (계산된 표현식이나 오브젝트들)
  • 저장된 값
  • 은닉 상태

이러한 상태들에 대한 변화는 다음과 같이 표현된다.

  • 점프 : 실행 지점을 변화시킨다. (숏 점프, 롱 점프, 함수 호출)
  • 값 계산 : 중간 값을 변화시킨다.
  • 부가 효과 : 값을 저장하거나 입출력을 수행한다.

또는 은닉 상태들에 대한 변화, 즉 mtx_t의 락 상태, once_flag의 초기화 상태, atomic_flag에 대한 세팅 상태 등에 영향을 미칠 수 있다. 이러한 추상 상태에 대한 변화를 효과라고 지칭하자.

Takeaway 3.19.0.1. 모든 평가는 효과를 동반한다.

이는 모든 평가는 그 다음 평가가 해당 평가 뒤에 실행되어야 함을 강제하기 때문이다. 심지어 (void) 0; 같은 표현식도 다음 실행 지점의 중간 값을 버림으로써 추상 상태를 변화시킨다. 복잡한 코드의 경우에는, 특정 실행 지점에서의 실제 추상 상태를 논하는 것조차 쉽지 않다. 일반적으로는 관측 가능하지도 않고, 추상 상태가 잘 정의되지도 않는다. 여러 개의 컴퓨터 코드를 멀티스레드에서 실행하는 환경이라면 그들간의 참조 시간도 정의할 수 없다. 그래서 C에서는 서로 다른 스레드간에 잘 계량되는 시각의 개념조차 가정하지 않는다. 서로 다른 스레드들이 있을 때, 이 스레드들 각각에서의 시간은 상대적이고, 이들간의 동기화는 한 스레드에서 다른 스레드로 보낸 신호가 도착할 때에만 이루어진다. 이러한 신호의 발신 또한 시간을 소비하므로, 신호가 도착했을 때에는 신호를 발신한 스레드는 이미 실행 상태가 더 전진되어 있다. 그러므로, 두 스레드가 서로간을 파악하는 내용은 언제나 일부분일 뿐이다.

19.1. The “happened before” relation.

프로그램의 실행에 대해 논하려면 모든 스레드의 부분적 실행 상태와 그 정보를 한데 모아 전체 정보를 알 수 있어야 한다. 즉, 두 평가 E와 F 사이에 ‘이전에 실행된‘ 관계를 파악하는 것이 중요하다. 이는 F → E로 표기한다.

Takeaway 3.19.1.1. F의 순서가 E 이전으로 정해졌다면, F → E이다.

즉, C에서는 분절 지점 이전의 코드에서 발생한 평가는 분절 지점 이후 코드의 실행이 그 평가 내용을 기반으로 함을 보장한다. 스레드간 이벤트 발생 순서는 동기화에 의해 정해진다. 그 형태에는 두 가지가 있다. 원자적 객체에 대한 연산에 의한 동기화와, 특정한 C 라이브러리 함수의 호출에 의한 동기화. 원자적 객체는 두 스레드를 동기화시키기 위해 쓰일 수 있다. 하나의 스레드가 값을 쓰고 다른 스레드가 그 값을 읽을 때. 이러한 원자적 객체에 대한 연산은 지역적으로 일관적임이 보장된다.

Takeaway 3.19.1.2. 원자적 오브젝트에 대한 수정 연산의 집합은 그 오브젝트를 다루는 모든 스레드들에 대해서 ‘이전에 실행된’ 관계와 일관성 있는 순서대로 실행된다.

이를 해당 오브젝트에 대한 수정 순서라 한다.

원자적 오브젝트를 통한 두 스레드의 동기화는 둘 중 하나가 해당 오브젝트를 수정할 때 이루어진다. 이러한 이벤트들은 상호 배타성이 보장되지 않지만 원자적 오브젝트를 사용하는 경우에는 두 스레드 간 하나가 먼저 쓰기를 성공하는 것이 보장된다. 원자적 오브젝트에 대한 연산을 C 라이브러리에서는 해제 (쓰는 스레드 쪽에서), 획득 (읽는 스레드 쪽에서), 획득-해제 (읽기-쓰기 스레드에 대해) 특성을 가진다고 한다. 원자적 오브젝트를 수정하는 모든 연산은 해제 특성을 가져야 한다. 원자적 오브젝트를 읽는 모든 연산은 획득 특성을 가져야 한다.

Takeaway 3.19.1.3. 어떤 스레드 A가 쓴 값을 다른 스레드 B가 읽을 경우, A의 쓰기 연산의 해제는 B의 읽기 연산의 획득과 동기화된다.

획득-해제 특성의 존재 이유는 해당하는 연산들의 효과에 대한 가시성을 보장하기 위함이다. 즉, 오브젝트에 대한 어떠한 평가 연산을 그 오브젝트의 상태를 쓰는 적절한 읽기 연산이나 함수 호출로 대체할 수 있음을 보장하기 위함이다.

Takeaway 3.19.1.4. 연산 F가 연산 E와 동기화된다면, 오브젝트 X에 대해 F 이전에 발생한 모든 효과는 E 이후에 발생한 모든 평가 G에 대해 관측 가능해야 한다.

읽기와 쓰기를 함께 진행할 수 있는 원자적 연산이 존재한다.

  • _Atomic 오브젝트에 대한 atomic_exchange와 atomic_compare_exchange_weak 호출
  • _Atomic 산술 오브젝트에 대한 증감 연산
  • atomic_flag에 대한 atomic_flag_test_and_set 호출

이러한 연산은 한 스레드의 읽기 부분과 동기화하고 다른 스레드들의 쓰기 부분과 동기화한다. 이런 읽기-수정-쓰기 연산들은 전부 획득과 해제 특성을 갖고 있다.

Takeaway 3.19.1.5. 한 평가가 다른 평가 이전에 수행되었음을 판단하기 위해서는 그 평가들을 연결하는 동기화 목록이 존재해야만 한다.

동기화랑 ‘이전에 발생한’ 관계는 다르다. 스레드의 시작과 끝이라는 두 예외 경우를 제외하면, 동기화는 원자적 오브젝트나 뮤텍스에 대한 데이터 의존성을 통해서 판단된다.

Takeaway 3.19.1.6. 평가 F가 E에 선행하면, F 이전에 발생함이 알려진 모든 효과들은 E 이전에 발생함이 보장된다.

19.2. C library calls that provide synchronization.

동기화 특성을 가진 C 라이브러리 함수들은 짝맞춰 등장한다.

  • thrd_create에서의 해제, f(x)에서의 획득.
  • 스레드 id에 대한 thrd_exit에서의 해제, 스레드 id에 대한 tss_t 소멸자 시작 시점에서의 획득.
  • 스레드 id에 대한 tss_t 소멸자 끝 시점에서의 해제, thrd_join(id) 또는 atexit/at_quick_exit 에서의 획득.
  • call_once의 첫 번째 호출에서의 해제, 이후의 모든 call_once의 호출에서의 획득.
  • 뮤텍스 해제에서의 해제, 뮤텍스 획득에서의 획득.

처음 3개의 항목들에 대해서는 어떤 이벤트가 어떤 이벤트와 동기화되는지 명확히 판단할 수 있다. 스레드를 떼어내고 thrd_join을 사용하지 않는다면 동기화는 스레드의 끝과 atexit/at_quick_exit 핸들러의 시작 사이에서만 이루어진다.

Takeaway 3.19.2.1. 동일한 뮤텍스에 의해 보호되는 임계 영역은 순차적으로 발생한다.

Takeaway 3.19.2.2. 한 뮤텍스에 의해 보호되는 임계 영역에서는, 해당 뮤텍스에 의해 보호된 이전 임계 영역에서의 모든 효과들이 관측 가능하다.

조건 변수에 대한 대기 함수는 획득-해제 특성과는 정반대이다.

Takeaway 3.19.2.3. cnd_wait과 cnd_timedwait는 뮤텍스에 대한 해제-획득 특성을 갖는다.

즉, 호출 스레드를 대기시키기 전에 해제 연산을 하고, 반환할 때 획득 연산을 한다. 또 유념해야 할 점은 동기화는 뮤텍스를 통해 이루어지지, 조건 변수를 통해 이루어지지 않는다는 것이다.

Takeaway 3.19.2.4. cnd_signal과 cnd_broadcast에 대한 호출의 동기화는 뮤텍스를 통해 이루어진다.

즉, 조건 변수에 대한 신호를 발생시키는 스레드와 해당 신호를 대기하는 스레드는 반드시 동기화된다는 보장이 없다. 동기화를 시키기 위해서는 cnd_signal이나 cnd_broadcast에 대한 호출이 같은 뮤텍스에 의해 보호 되는 임계 영역에서 이루어져야 한다. 또한, 오브젝트에 대한 비-원자적 수정은 해당 연산이 뮤텍스에 의해 보호되지 않는다면 해당 수정으로 인해 일어난 신호에 의해 깨워지는 스레드에서 관측조차 불가능해질 수 있다. 이런 경우들을 방지하기 위한 간단한 규칙은 다음과 같다.

Takeaway 3.19.2.5. cnd_signal과 cnd_broadcast에 대한 호출은 대기자 관점에서 같은 뮤텍스에 의해 보호되는 임계 영역 내에서만 이루어져야 한다.

cnd_signal 호출이 뮤텍스에 의해 보호되지 않는 것이 가능한 경우는 다른 분기에 의한 모든 수정이 원자적일 때만 가능하다. 이런 식으로 조건 변수를 사용하려면 극히 주의해야 한다.

19.3. Sequential consistency.

원자적 객체에 대한 데이터 일관성은 ‘이전에 발생한’ 관계들로부터 보장된다. 이는 획득-해제 일관성이라 불린다. 하지만 원자적 객체에 대한 접근은 다른 일관성 모형을 통해서도 특정되어질 수 있다. 모든 원자적 오브젝트는 같은 오브젝트에 작용하는 모든 연산의 ‘이전에 발생한’ 관계와 들어맞는 수정 순서를 갖는다. 순서적 일관성은 이것보다 강한 조건이다. 다른 프로세서에서 다른 원자적 오브젝트에 연산을 작용하더라도, 플랫폼은 모든 스레드가 단일한 선형 순서에 의해 동작함을 보장해야 한다.

Takeaway 3.19.3.1. 순서적 일관성에 의해 동작하는 모든 원자적 연산은 그 연산이 어떤 원자적 오브젝트에 작용했는지에 상관없이 단일한 전역적인 순서를 따른다.

즉 프로그램 실행을 병렬화시키고 싶다면 순서적 일관성은 좋은 선택이 아닐 것이다.

C 표준에서는 원자적 타입에 대해 다음의 함수 인터페이스들을 제공한다. 이들은 이름대로 동작하며 동기화를 수행한다.

void atomic_store (A volatile * obj , C des );
C atomic_load (A volatile * obj );
C atomic_exchange (A volatile * obj , C des );
bool atomic_compare_exchange_strong (A volatile * obj , C *expe , C des );
bool atomic_compare_exchange_weak (A volatile * obj , C *expe , C des );
C atomic_fetch_add (A volatile * obj , M operand );
C atomic_fetch_sub (A volatile * obj , M operand );
C atomic_fetch_and (A volatile * obj , M operand );
C atomic_fetch_or (A volatile * obj , M operand );
C atomic_fetch_xor (A volatile * obj , M operand );
bool atomic_flag_test_and_set ( atomic_flag volatile * obj );
void atomic_flag_clear ( atomic_flag volatile * obj );

여기서 함수 리턴/인자 타입 C 는 적절한 자료형, A는 대응되는 원자적 자료형, M은 C 타입과 산술 연산을 할 수 있는 타입이다. 이 때 fetch + 연산자 함수들은 객체가 수정되기 전의 값을 리턴한다. 그러므로 atomic_fetch_add는 += 연산과 같지 않다. += 연산은 객체가 수정된 후의 값을 리턴하기 때문이다. 이 함수들은 모두 순서적 일관성을 제공한다.

Takeaway 3.19.3.2. 원자적 오브젝트에 대한 모든 연산이나 함수 인터페이스들은 따로 명시되지 않는 한 순서적 일관성을 제공한다.

atomic_fetch_add가 +=와 다른 또 하나의 이유는 volatile 한정자 때문이다.

원자적 객체에 적용되면서 동기화를 하지 않는 또 다른 함수 호출도 있다.

void atomic_init(A volatile* obj, C des);

이는 atomic_store나 대입과 효과가 같지만, 다른 스레드에 의한 동시성 호출에 대해 레이스 컨디션으로부터 보호하지 않는다. 값싼 대입으로 이해해도 좋다.

19.4. Other consistency models.

다른 일관성 모델도 대응하는 함수 인터페이스를 통해 명시적으로 요구될 수 있다. 획득-해제 일관성을 가진 ++ 연산은 다음과 동일하다.

_Atomic (unsigned) at = 67;
if (atomic_fetch_add_explicit(&at, 1, memory_order_acq_rel)) {
   // ...
}

Takeaway 3.19.4.1. 원자적 오브젝트에 대한 동기화된 함수 인터페이스는 _explicit을 붙여서 원하는 일관성 모델을 특정할 수 있다.

  • memory_order_seq_cst는 순서적 일관성을 요구한다. _explicit을 붙이지 않는다면 기본적으로 이 모델을 사용한다.
  • memory_order_acq_rel은 획득-해제 일관성을 요구한다. atomic_fetch_add나 atomic_compare_exchange_weak에 대한 읽기-수정-쓰기 연산에 쓸 수 있다.
  • memory_order_release는 해제 특성만 가진다. atomic_store나 atomic_flag_clear 등에 쓰인다.
  • memory_order_acquire는 획득 특성만 가진다. atomic_load 등에 쓰인다.
  • memory_order_consume은 획득 일관성보다 약한 데이터 의존성이다. 역시 atomic_load 등에 쓰인다.
  • memory_order_relaxed는 동기화 조건이 없는 연산으로, 연산이 쪼개지지 않음만 보장한다. 여러 스레드에서 쓰이는 카운터에 대해 오직 최종 값에만 관심이 있을 때 쓸 수 있을 것이다.

memory_order_seq_cst나 memory_order_relaxed는 모든 연산에 쓸 수 있지만, 다른 일관성 모델들은 조건이 있다. atomic_store나 atomic_flag_clear처럼 세팅만 하는 연산은 획득 특성을 가질 수 없다. atomic_load나, 실패 경우에 한해 atomic_compare_exchange_weak나 atomic_compare_exchange_strong처럼 로딩만 하는 연산은 해제나 소비 특성을 가질 수 없다. 즉, 뒤의 2개의 경우 성공한 경우와 실패한 경우로 나누어 메모리 일관성 모델을 모두 지정해줘야 한다.

bool atomic_compare_exchange_strong_explicit(A volatile* obj, C *expe, C des, memory_order success, memory_order failure);
bool atomic_compare_exchange_weak_explicit(A volatile* obj, C *expe, C des, memory_order success, memory_order failure);

이 때 success 일관성은 최소한 failure 일관성 이상이어야 한다.

동기화에 있어 획득과 해제 특성은 대칭적이지 않다. 데이터 쓰기는 한 스레드에서 하고 읽기는 여러 스레드에서 할 수 있기 때문이다. 새로운 데이터를 여러 프로세서나 코어들로 복사하는 것은 비싼 연산이기 때문에, 어떤 플랫폼들은 원자적 연산 이전에 발생한 관측가능한 효과들을 새로운 값을 읽는 스레드에서도 관측할 수 있게 해 주는 것을 금지하는 것을 허용하기도 한다. C의 소비 일관성은 이를 위해 만들어졌다. 이는 원자적 읽기 이전에 있었던 효과들이 읽는 스레드에 영향을 끼치지 않음이 보장될 때에만 사용해야 한다.

요점 정리

  • “이전에 발생한” 관계는 다른 스레드간 시간 순서를 정할 수 있는 유일한 방법이다. 이는 원자적 오브젝트를 통한 동기화나 극히 일부분의 C 라이브러리 함수를 통해서만 이루어진다.
  • 순서적 일관성은 원자적 오브젝트에 대한 기본 일관성 모델이지만, 다른 C 라이브러리 함수들에 대해서는 아니다. 이는 모든 대응하는 동기화에 단일한 전체 순서를 매길 수 있다는 조건이므로, 때에 따라 비효율적일 수 있다.
  • 획득-해제 일관성을 명시적으로 사용하는 것은 효율적인 코드를 쓸 수 있지만, 원자적 함수에 대해 걸맞는 일관성을 제공해야 하는 세심함이 필요하다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중