트랜잭션 메모리

트랜잭션 메모리 (Transactional memory, TM TS)

⚠️ 이 페이지의 기능은 **트랜잭션 메모리 기술 사양(Transactional Memory Technical Specification, ISO/IEC TS 19841:2015, TM TS)**에 속하는 실험적 기능이에요. 표준 C++에 포함된 기능이 아니라 별도의 기술 사양이므로, 프로덕션 코드에 적용하기 전에 지원 여부를 반드시 확인하세요.

**트랜잭션 메모리(transactional memory)**는 문장들의 그룹을 트랜잭션으로 묶는 동시성 동기화 메커니즘이에요. 트랜잭션으로 묶인 그룹은 다음 성질을 가져요.

  • 원자적(atomic) — 모든 문장이 일어나거나, 아무것도 일어나지 않거나.
  • 격리적(isolated) — 트랜잭션 안의 문장은 다른 트랜잭션이 반쯤 쓴 쓰기를 관찰하지 못해요, 설령 병렬로 실행되더라도.

일반적인 구현은 지원되고 사용 가능한 한도(예를 들어 changeset이 포화될 때까지)에서 하드웨어 트랜잭션 메모리를 쓰고, 소프트웨어 트랜잭션 메모리로 폴백해요. 소프트웨어 구현은 보통 낙관적 동시성(optimistic concurrency)으로 구현돼요. 한 트랜잭션이 다른 트랜잭션이 사용한 변수 중 일부를 갱신했다면, 그것은 조용히 재시도돼요. 그래서 재시도 가능한 트랜잭션("atomic 블록")은 트랜잭션 안전(transaction-safe) 함수만 호출할 수 있어요.

트랜잭션 안에서 변수에 접근하고, 트랜잭션 밖에서 다른 외부 동기화 없이 같은 변수에 접근하는 것은 **데이터 경쟁(data race)**이에요.

기능 검사가 지원된다면, 여기 설명된 기능들은 값이 201505 이상인 매크로 상수 __cpp_transactional_memory로 표시돼요.

출처: cppreference

본문

동기화 블록 (Synchronized blocks)

synchronized compound-statement

복합문을 전역 잠금 아래에서 실행하는 것처럼 수행해요. 즉 프로그램의 모든 최상위 동기화 블록이 하나의 전체 순서(single total order)로 실행돼요. 각 동기화 블록의 끝은 그 순서에서 다음 동기화 블록의 시작과 동기화돼요(synchronizes-with). 다른 동기화 블록 안에 중첩된 동기화 블록은 특별한 의미가 없어요.

동기화 블록은 (아래의 atomic 블록과 달리) 트랜잭션이 아니므로 트랜잭션 안전하지 않은 함수를 호출할 수 있어요.

#include <iostream>
#include <thread>
#include <vector>

int f()
{
    static int i = 0;
    synchronized { // 동기화 블록 시작
        std::cout << i << " -> ";
        ++i;       // f() 호출마다 i의 유일한 값을 얻음
        std::cout << i << '\n';
        return i;  // 동기화 블록 끝
    }
}

int main()
{
    std::vector<std::thread> v(10);
    for (auto& t : v)
        t = std::thread([] { for (int n = 0; n < 10; ++n) f(); });
    for (auto& t : v)
        t.join();
}

출력:

0 -> 1
1 -> 2
2 -> 3
...
99 -> 100

어떤 방식으로든 동기화 블록을 빠져나오면(끝에 도달, goto, break, continue, return 실행, 또는 예외 던지기), 그 블록을 빠져나가고, 빠져나간 블록이 바깥쪽 블록이었다면 단일 전체 순서에서 다음 블록과 동기화돼요. std::longjmp로 동기화 블록을 빠져나가면 동작이 정의되지 않아요.

goto나 switch로 동기화 블록에 들어가는 것은 허용되지 않아요.

동기화 블록이 전역 잠금 아래에서 실행되는 것처럼 동작하지만, 구현들은 각 블록의 코드를 검사해 트랜잭션 안전 코드에는 낙관적 동시성(가능하면 하드웨어 트랜잭션 메모리가 뒷받침)을, 트랜잭션 안전하지 않은 코드에는 최소 잠금을 사용할 것으로 기대돼요. 동기화 블록이 인라인되지 않은 함수를 호출하면, 그 함수가 transaction_safe로 선언되어 있거나(아래 참조) [[optimize_for_synchronized]] 속성(아래 참조)을 쓰지 않는 한, 컴파일러는 추측 실행을 포기하고 호출 전체에 잠금을 걸어야 할 수 있어요.

원자 블록 (Atomic blocks)

atomic_noexcept compound-statement
atomic_cancel compound-statement
atomic_commit compound-statement
  1. 예외가 던져지면 std::abort가 호출돼요.
  2. 예외가 던져지면, 트랜잭션 취소에 쓰이는 예외(아래 참조) 중 하나가 아니라면 std::abort가 호출돼요. 그런 예외라면 트랜잭션이 취소돼요. 즉 원자 블록의 연산들의 부수 효과가 수정한 프로그램의 모든 메모리 위치의 값이 원자 블록의 시작이 실행된 시점의 값으로 복원되고, 예외는 평소처럼 스택 되감기를 계속해요.
  3. 예외가 던져지면 트랜잭션은 정상적으로 커밋돼요.

atomic_cancel 블록에서 트랜잭션 취소에 쓰이는 예외는 std::bad_alloc, std::bad_array_new_length, std::bad_cast, std::bad_typeid, std::bad_exception, std::exception과 그로부터 파생된 모든 표준 라이브러리 예외, 그리고 특별한 예외 타입 std::tx_exception<T>예요.

원자 블록의 복합문은 트랜잭션 안전하지 않은 표현식·문장을 실행하거나 함수를 호출할 수 없어요(이것은 컴파일 타임 오류예요).

// f() 호출마다 i의 유일한 값을 가져옴, 병렬로 실행돼도
int f()
{
    static int i = 0;
    atomic_noexcept { // 트랜잭션 시작
//  printf("before %d\n", i); // error: 트랜잭션 안전하지 않은 함수는 호출 불가
        ++i;
        return i; // 트랜잭션 커밋
    }
}

예외가 아닌 어떤 방식으로든(끝에 도달, goto, break, continue, return) 원자 블록을 빠져나오면 트랜잭션이 커밋돼요. std::longjmp로 원자 블록을 빠져나가면 동작이 정의되지 않아요.

트랜잭션 안전 함수 (Transaction-safe functions)

함수는 그 선언에 transaction_safe 키워드를 써서 명시적으로 트랜잭션 안전임을 선언할 수 있어요.

람다 선언에서는 캡처 목록 바로 뒤나 (쓰였다면) mutable 키워드 바로 뒤에 나타나요.

extern volatile int * p = 0;
struct S
{
    virtual ~S();
};
int f() transaction_safe
{
    int x = 0;  // ok: volatile이 아님
    p = &x;     // ok: 포인터는 volatile이 아님
    int i = *p; // error: volatile glvalue를 통한 읽기
    S s;        // error: 안전하지 않은 소멸자 호출
}
int f(int x) { // 암시적으로 트랜잭션 안전
    if (x <= 0)
        return 0;
    return x + f(x - 1);
}

트랜잭션 안전하지 않은 함수가 트랜잭션 안전 함수에 대한 참조나 포인터를 통해 호출되면 동작이 정의되지 않아요.

트랜잭션 안전 함수에 대한 포인터와 트랜잭션 안전 멤버 함수에 대한 포인터는 각각 함수에 대한 포인터와 멤버 함수에 대한 포인터로 암시적으로 변환될 수 있어요. 결과 포인터가 원래 것과 같다고 비교될지는 불특정이에요.

트랜잭션 안전 가상 함수 (Transaction-safe virtual functions)

transaction_safe_dynamic 함수의 최종 오버라이더(final overrider)가 transaction_safe로 선언되지 않았다면, 원자 블록에서 그것을 호출하는 것은 정의되지 않은 동작이에요.

표준 라이브러리 (Standard library)

트랜잭션 메모리 기술 사양은 새 예외 템플릿 std::tx_exception을 도입하는 것 외에도 표준 라이브러리에 다음 변경을 가해요.

다음 함수들을 명시적으로 transaction_safe로 만든다.

  • std::forward, std::move, std::move_if_noexcept, std::align, std::abort
  • 전역 기본 operator new, 전역 기본 operator delete
  • 호출된 생성자가 트랜잭션 안전이면 std::allocator::construct, 호출된 소멸자가 트랜잭션 안전이면 std::allocator::destroy
  • std::get_temporary_buffer, std::return_temporary_buffer, std::addressof
  • std::pointer_traits::pointer_to
  • 트랜잭션 취소를 지원하는 모든 예외 타입의 각 비가상 멤버 함수(위의 atomic_cancel 참조)

다음 함수들을 명시적으로 transaction_safe_dynamic으로 만든다.

  • 트랜잭션 취소를 지원하는 모든 예외 타입의 각 가상 멤버 함수(위의 atomic_cancel 참조)

할당자 X에서 트랜잭션 안전인 모든 연산이 X::rebind<>::other에서도 트랜잭션 안전일 것을 요구한다.

속성 (Attributes)

[[optimize_for_synchronized]] 속성은 함수 선언의 선언자에 적용될 수 있고, 함수의 첫 선언에 나타나야 해요.

한 번역 단위에서 함수가 [[optimize_for_synchronized]]로 선언되고, 다른 번역 단위에서 같은 함수가 그 속성 없이 선언되면, 프로그램은 ill-formed이고 진단은 요구되지 않아요.

이 속성은 함수 정의가 동기화 문장에서의 호출에 최적화되어야 함을 나타내요. 특히 대부분의 호출에서는 트랜잭션 안전하지만 모든 호출에서 그렇지는 않은 함수(예를 들어 리해시가 필요할 수 있는 해시 테이블 삽입, 새 블록을 요청해야 할 수 있는 할당자, 드물게 로깅할 수 있는 단순 함수)에 대한 호출을 하는 동기화 블록의 직렬화를 피하게 해줘요.

std::atomic<bool> rehash{false};

// 유지보수 스레드가 이 루프를 실행
void maintenance_thread(void*)
{
    while (!shutdown)
    {
        synchronized
        {
            if (rehash)
            {
                hash.rehash();
                rehash = false;
            }
        }
    }
}

// 작업자 스레드들이 매초 수십만 번 이 함수를 호출.
// 다른 번역 단위의 동기화 블록에서 insert_key()를 호출하면
// 그 블록들이 직렬화되는데, insert_key()가
// [[optimize_for_synchronized]]로 표시되어 있지 않으면 그렇다
[[optimize_for_synchronized]] void insert_key(char* key, char* value)
{
    bool concern = hash.insert(key, value);
    if (concern)
        rehash = true;
}

속성이 없는 GCC 어셈블리: 함수 전체가 직렬화됨

insert_key(char*, char*):
	subq	$8, %rsp
	movq	%rsi, %rdx
	movq	%rdi, %rsi
	movl	$hash, %edi
	call	Hash::insert(char*, char*)
	testb	%al, %al
	je	.L20
	movb	$1, rehash(%rip)
	mfence
.L20:
	addq	$8, %rsp
	ret

속성이 있는 GCC 어셈블리:

transaction clone for insert_key(char*, char*):
	subq	$8, %rsp
	movq	%rsi, %rdx
	movq	%rdi, %rsi
	movl	$hash, %edi
	call	transaction clone for Hash::insert(char*, char*)
	testb	%al, %al
	je	.L27
	xorl	%edi, %edi
	call	_ITM_changeTransactionMode # Note: 이것이 직렬화 지점
	movb	$1, rehash(%rip)
	mfence
.L27:
	addq	$8, %rsp
	ret

속성이 있으면 Hash::insert를 트랜잭션 클론(transaction clone)으로 호출하다가, 실제로 리해시가 필요한 경우에만(rehash가 참일 때) _ITM_changeTransactionMode로 직렬화 지점에 들어가는 걸 볼 수 있어요. 반면 속성이 없으면 호출 전체가 직렬화돼요.

참고 (Notes)

이 절은 아직 채워지지 않았어요(Wyatt 논문/발표에서의 경험 메모).

키워드 (Keywords)

atomic_cancel, atomic_commit, atomic_noexcept, synchronized, transaction_safe, transaction_safe_dynamic

컴파일러 지원 (Compiler support)

이 기술 사양은 GCC 6.1부터 지원돼요(활성화에는 -fgnu-tm 필요). 이 사양의 더 오래된 변형은 GCC 4.7에서 지원됐어요.

더 알아보기

  • 스레드(Thread, C++11): 동시성 프로그래밍의 기본 단위.
  • 원자 연산(Atomic operations, C++11): std::atomic과 메모리 모델.
  • 동기화(Synchronization): 뮤텍스 등 동시성 동기화 기본 요소.