개막 공연

개막 공연

학습 연습 문제

소개

원자적 연산

명령어 하나가 마치 더 나눌 수 없는 하나의 동작처럼 보이지만, 실제로는 전혀 그렇지 않은 경우가 많아요. 메모리에 있는 값에 5를 더하는 경우를 살펴봐요:

add qword [rel counter], 5

그 속을 들여다보면, 프로세서는 메모리에 직접 값을 더하는 방법을 가지고 있지 않아요. 그래서 이 명령어 하나를 마이크로 연산이라고 부르는 세 단계로 나눠요:

  1. 읽기: 메모리에서 현재 값을 읽어요;
  2. 수정: 레지스터에서 이 값에 5를 더해요;
  3. 쓰기: 결과를 다시 써요.
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

이것은 **읽기-수정-쓰기(RMW)**라고 부르는 흔한 패턴이에요.

읽기(또는 로드)와 쓰기(또는 스토어)는 서로 별개의 사건이라, 그 사이에는 시간적인 틈이 생겨요. 보통은 이 틈을 눈치채지 못해요. 코어 하나에서는 각 명령어가 다음 명령어로 넘어가기 전에 그 효과를 완전히 내는 것이 보장되니까요. 그래서 그 틈은 보이지 않고, add는 하나의 단위처럼 동작해요.

그런데 요즘 CPU는 코어가 하나뿐인 경우가 드물고, 애플리케이션은 여러 코어에서 동시에 실행되는 일이 많아요. 코어들 사이에는 순서를 정해 주는 장치가 없어요. 여러 코어가 정말 같은 순간에 실행되면, 다른 코어가 이 코어의 로드 이후, 스토어 이전이라는 틈 안에서 counter를 읽거나 쓸 수 있어요. 두 스레드가 각각 같은 예전 값을 로드하고, 각각 5를 더한 다음, 각각 결과를 스토어해요. 덧셈은 두 번 일어났지만, 값은 5만큼만 움직였어요. 업데이트 하나가 조용히 사라진 거예요.

이것이 바로 데이터 레이스이고, 멀티스레드 코드에서 흔히 생기는 문제예요.

모든 값이 이렇게 위험에 노출되는 것은 아니에요. 각 스레드는 자기만의 레지스터와 스택을 가지고 있어요. 그래서 레지스터에 담긴 값이나 스레드의 스택에 있는 지역 변수는 그 스레드만의 것이고, 데이터 레이스가 생길 수 없어요. 위의 counter처럼 스레드들이 공유하는 메모리만 보호하면 돼요.

x86-64는 명령어를 더 나눌 수 없게 만들어서 이 문제를 해결하는 명령어 집합을 제공해요. 그러면 그 명령어를 처리하는 코어뿐만 아니라 다른 모든 코어에게도 하나의 단위처럼 동작해요. 다른 코어가 쪼갤 수 없이 하나로 붙어 있는 동작을 원자적이라고 해요.

Note

여러 코어에서 실행되는 애플리케이션을 멀티스레드라고 부르는 경우가 많아요. 하지만 스레드는 코어와 같은 것이 아니에요. 두 스레드가 같은 코어에서 서로 번갈아 가며 동시에 실행될 수도 있고, 서로 다른 코어에서 병렬로 실행될 수도 있어요.

운영체제는 어느 두 명령어 사이에서든 스레드를 전환할 수 있어요. 그래서 읽기-수정-쓰기가 여러 명령어로 나뉘어 있다면, 번갈아 실행되는 스레드끼리도 이미 레이스가 생길 수 있어요. 그런데 하나의 명령어 안에 있는 틈은 진짜 병렬 코드에서만 드러나요. 운영체제는 명령어와 명령어 사이에서만 스레드를 전환하고, 명령어 안에서는 절대 전환하지 않아요. 그래서 코어 하나에서는 명령어 하나가 원래부터 안전해요.

아래 명령어들이 제공하는 것은 여러 코어에 걸친 진짜 원자성이에요.

원자적 교환

xchg 명령어는 두 피연산자를 맞바꿔요. 목적지 피연산자는 소스 피연산자에 있던 이전 값이 되고, 소스 피연산자는 목적지 피연산자에 있던 이전 값이 돼요. 개념적으로는 두 개의 mov 명령어가 동시에 일어나는 것으로 생각할 수 있어요.

늘 그렇듯이, 레지스터 피연산자 두 개와 함께 쓸 수도 있고, 메모리 피연산자 하나와 레지스터 피연산자 하나와 함께 쓸 수도 있어요:

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

메모리 피연산자와 함께 쓰면, xchg는 항상 원자적이에요.

Caution

xchg는 피연산자 중 하나가 메모리 위치일 때 자동으로 원자적이에요. 이는 그런 상황에서 이 연산이 훨씬 느리다는 뜻이기도 해요.

원자성이 필요 없다면, 대신 여분의 레지스터를 거쳐 평범한 mov 명령어로 값을 맞바꿔요.

lock 접두사

x86-64에서 명령어를 원자적으로 만드는 가장 흔한 방법은 lock 접두사를 붙이는 거예요. 이 접두사는 읽기, 수정, 쓰기를 하나의 더 나눌 수 없는 단계로 합쳐요. 즉, 그 과정 전체에 걸쳐 코어가 메모리를 독점하기 때문에, 그 사이에 다른 코어가 그 위치를 읽거나 쓸 수 없어요.

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

lock은 목적지가 메모리일 때만, 그리고 그 메모리를 읽기-수정-쓰기하는 명령어에만 동작해요:

  1. add, sub, inc, dec, neg 같은 산술 연산;
  2. and, or, xor, not 같은 비트 연산;
  3. bts, btr, btc 같은 비트 조작 명령어;
  4. 아래에서 설명할 xadd, cmpxchg 같은 몇 가지 전용 명령어.
Caution

어떤 위치를 독점하고 다른 모든 코어를 배제하는 데는 비용이 들어요. lock 접두사가 붙은 연산은 접두사 없는 형태보다 눈에 띄게 느리고, 여러 코어가 같은 위치를 두고 경쟁하면 더 느려져요.

이 접두사는 둘 이상의 스레드가 변경할 것으로 예상되는 메모리에만 써야 해요. 메모리가 공유되지 않거나 항상 읽기만 한다면 쓰지 않는 게 좋아요.

교환 후 더하기

그냥 lock add는 메모리를 갱신하지만 예전 값은 버려요. 예전 값이 바로 필요한 경우가 많아요. 예를 들어 각 스레드에 서로 다른 티켓 번호를 나눠 줄 때요.

xadd 명령어(x는 교환을 뜻해요)는 더하는 동시에 이전 값을 반환해요. 합은 목적지에 쓰고, 원래 목적지에 있던 값은 소스 레지스터에 남겨 둬요.

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

lock 접두사와 함께 쓰면 이것은 원자적인 페치 앤드 애드예요. 여러 스레드가 같은 카운터에 대해 실행하면, 각 호출이 서로 다른 예전 값을 반환해요.

lock add와 마찬가지로, 카운터는 정확히 호출 횟수만큼 증가한 값에서 끝나요. 그런데 lock add와 달리, 중간 값들도 모두 반환돼요. 호출한 쪽마다 하나씩요.

비교 후 교환

xadd는 더하고 xchg는 덮어쓰지만, 둘 다 새 값을 현재 값에 의존하게 만들거나, 그 사이에 아무것도 바뀌지 않았을 때만 반영하게 할 수는 없어요. 그런 조건부 갱신을 제공하는 것이 바로 cmpxchg, 즉 비교 후 교환이고, 이 원시 연산들 가운데 가장 범용적이에요.

cmpxchg dest, src는 rax를 암묵적인 누산기로 사용해서 dest와 비교해요:

  • dest == rax라면, dest = src이고 ZF = 1이에요.
  • dest != rax라면, rax = dest이고 ZF = 0이에요.

dest는 앞서 rax에 로드해 둔 기대값과 같을 때만 갱신된다는 점에 주목해요. 이 동등성 덕분에 dest가 아직 새 값을 계산할 때의 그 값 그대로임이 보장돼요. 그래서 오래된 읽기에 기반한 갱신은 절대 반영되지 않아요. 그래서 cmpxchg는 **비교 후 교환(CAS)**이라고도 부르는 원자적 갱신의 구성 요소가 돼요:

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

이 재시도 루프가 락 없는 갱신의 핵심이에요. 읽기와 비교 후 교환 사이의 틈이 바로 다른 스레드가 끼어들 수 있는 순간인데, cmpxchg는 오래된 읽기로 계산한 값을 저장하지 않음으로써 그걸 잡아내요.

메모리 순서

지금까지 다룬 모든 연산은 하나의 위치만 건드렸어요. 스레드들이 둘 이상의 위치를 통해 협력하면 새로운 질문이 생겨요. 한 스레드의 쓰기가 다른 스레드에 어떤 순서로 보이게 되는 걸까요. 이 질문에 답하는 규칙이 바로 프로세서의 메모리 순서예요.

분기 없는 코드 개념에서 현대적인 코어는 명령어를 한 번에 하나씩 느릿느릿 처리하지 않는다는 이야기를 했어요. 코어는 여러 명령어를 동시에 처리 중인 상태로 두고, 가능한 곳에서는 앞질러 나아가요. 그래서 어떤 쓰기가 프로그램이 암시하는 시점보다 늦게 다른 코어에 보일 수 있고, 그 뒤의 명령어는 이미 앞서 나가 있을 수 있어요.

x86-64는 일반적인 로드와 스토어 사이에 강한 메모리 순서를 유지해요. 그래서 각 코어에서:

  1. 로드가 뒤의 로드보다 뒤로 재배치되지 않아요;
  2. 스토어가 뒤의 스토어보다 뒤로 재배치되지 않아요;
  3. 로드가 뒤의 스토어보다 뒤로 재배치되지 않아요.

가능한 재배치는 하나뿐인데, 다른 주소를 나중에 로드한 뒤에 스토어가 완료된 것처럼 보이는 경우예요.

lock 접두사가 붙은 명령어나 메모리 피연산자를 쓰는 xchg는 완전한 배리어예요. 어느 방향으로도 그 배리어를 넘어 이동하는 것처럼 보이는 것은 없어요. 그래서 대부분의 상황에서 완전한 순서를 보장하기에 이들로 충분해요.

스핀 대기와 pause

플래그를 설정하면서 그 이전 상태도 함께 반환하는 명령어를 테스트 앤드 세트라고 해요. 이런 명령어는 스핀락의 기반으로 쓰일 수 있어요. 스핀락은 어떤 코어가 코드의 일부를 독점적으로 사용하도록 보장해요.

이진 플래그와 xchg 명령어를 사용한 전체 알고리즘은 이래요:

  1. 플래그는 0으로 시작해요.
  2. 잠금을 얻기 위해, 코어는 플래그의 값을 1과 맞바꿔요.
  3. 반환된 값이 1이면, 다른 코어가 잠금을 쥐고 있다는 뜻이에요. 그러면 현재 코어는 기다렸다가 다시 잠금을 얻으려고 시도해요.
  4. 반환된 값이 0이면, 잠금이 비어 있었다는 뜻이에요. 이제 xchg가 플래그를 1로 설정했고, 다른 코어들은 이 코어가 잠금을 풀 때까지 기다려요.
  5. 현재 코어가 작업을 마치면, 플래그를 0으로 갱신해서 잠금을 풀어요.
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

대기 루프에 있는 pause 명령어는 코드가 계산하는 결과를 바꾸지 않아요. 이것은 프로세서에게 지금이 스핀 대기라는 힌트를 줘요. 그러면 CPU는 기다리는 스레드의 전력 사용을 줄이고, 같은 코어를 공유하는 형제 스레드에게 자리를 양보할 수 있어요. pause 없는 스핀 루프도 여전히 올바르지만, 낭비일 뿐이에요.

스레드가 작업을 마치면, mov로 평범하게 0을 스토어해서 잠금을 풀 수 있어요. mov 이상의 것은 필요 없어요. x86-64에서는 로드와 스토어가 뒤의 스토어보다 뒤로 재배치되지 않기 때문이에요. 앞선 접근들을 앞지르지 않는 스토어는 릴리스 순서를 가진다고 해요. 그리고 x86에서는 모든 평범한 스토어가 이 순서를 지녀요.

Note

메모리 위치를 원자적으로 검사하고 설정하는 명령어라면 무엇이든 스핀락에 쓸 수 있어요. 예를 들어 xchg 대신 lock bts를 써서, 특정 비트가 이미 설정되어 있는지 확인하면서 그 비트를 설정할 수 있어요.

bts가 바꾸는 CF 같은 플래그는 레지스터인 rflags의 일부라는 점을 기억해요. 따라서 이 플래그들은 각 스레드에만 속해요.

지침

Grand Marquee의 개막 밤이에요. 직원 모두가 한꺼번에 움직이고 있어요. 관객은 모든 문으로 쏟아져 들어오고, 여러 매표소 창구에서는 같은 티켓 롤에서 표를 팔고, 시사회는 매진에 가까워지고 있으며, 영사실에서는 릴이 계속 돌아가야 해요.

직원들이 공유하는 모든 것은 메모리 주소에 자리 잡고 있고, 테스트는 여러 스레드에서 동시에 함수를 호출해요. 값을 읽고, 바꾸고, 다시 쓰는 과정이 여러 단계로 나뉘어 있으면 그런 부하 속에서 갱신이 유실돼요. 그래서 각 함수는 갱신을 원자적으로 처리해야 해요.

이 연습 문제에서 공유되는 값은 모두 64비트 부호 있는 정수예요.

다섯 가지 과제가 있어요.

Note

이 연습 문제의 갱신은 원자적 명령어로 수행해야 해요. 테스트는 함수를 동시에 실행하면서 갱신이 하나도 유실되지 않는지 확인해요. 따라서 원자적이지 않은 읽기-수정-쓰기는 테스트를 통과하기 어려워요.

1. 입장 인원 세기

문이 열려 있고, 각 문에서는 안내원이 표를 찢고 있어요. 들여보낸 일행은 하나의 공유 입장 집계에 더해져요. 모든 문에서 동시에 세더라도 극장 전체 인원은 정확하게 나와야 해요.

admit_group 함수를 구현해요. 이 함수는 일행의 인원수를 집계에 원자적으로 더해요.

이 함수는 다음 순서로 인자를 받아요.

  • admissions: 공유 입장 집계의 메모리 주소, 64비트 부호 있는 정수예요.
  • group_size: 일행의 인원수, 64비트 부호 있는 정수이며 항상 0보다 커요.
admissions = 120, group_size = 4
// admissions is now 124

테스트는 여러 문을 통해 동시에 일행을 입장시켜요. 최종 집계는 들여보낸 모든 일행의 합과 같아야 해요.

이 함수는 반환값이 없어요. 집계는 제자리에서 갱신되어야 해요.

2. 릴 교체하기

영사기에는 릴이 딱 하나 장착돼요. 다음 릴을 장착하고 현재 릴을 빼내는 일은 한 동작으로 이뤄져야 해요. 영사기 앞에 영사기사가 둘이라면, 두 단계로 나뉘었을 때 같은 릴이 둘 모두에게 넘어가거나 릴 하나를 바닥에 떨어뜨릴 수 있어요.

change_reel 함수를 구현해요. 이 함수는 다음 릴을 원자적으로 장착하고, 장착되어 있던 릴을 반환해요.

이 함수는 다음 순서로 인자를 받아요.

  • mounted: 장착된 릴 식별자의 메모리 주소, 64비트 부호 있는 정수예요.
  • next_reel: 장착할 릴의 식별자, 64비트 부호 있는 정수예요.
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

이 함수는 이전에 장착되어 있던 릴의 식별자를 64비트 부호 있는 정수로 반환해요. 테스트는 여러 스레드가 같은 마운트를 통해 동시에 릴을 교체하게 하고, 넘겨준 모든 릴이 정확히 한 번씩 다시 나오는지 확인해요.

3. 표 한 장 팔기

모든 창구는 하나의 공유 판매 수량에서 표를 팔아요. 문에서처럼 수량을 더하기만 해서는 충분하지 않아요. 각 판매는 자신에게 주어진 번호도 알아내야 해요. 두 판매가 같은 번호를 공유해서는 안 되고, 손님들이 몰려든 뒤에는 집계가 판매된 표의 수와 정확히 같아야 해요.

sell_ticket 함수를 구현해요. 이 함수는 집계에 1을 원자적으로 더하고, 방금 판매한 표의 번호를 반환해요. 표 번호는 이번 판매 _이후_의 집계 값이에요. 집계가 41이었다면 이번 판매는 42번 표예요.

이 함수는 인자를 하나 받아요.

  • tickets_sold: 공유 판매 수량의 메모리 주소, 64비트 부호 있는 정수예요.
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

이 함수는 표 번호를 64비트 부호 있는 정수로 반환해요. 테스트는 여러 창구에서 동시에 표를 팔아요. 반환된 모든 번호는 서로 달라야 하고, 최종 집계는 판매 횟수와 같아야 해요.

4. 시사회 좌석 채우기

시사회에는 정해진 수의 좌석이 있고, 모든 창구에서 그 좌석을 팔아요. 좌석이 남아 있을 때만 판매가 이뤄져야 하고, 모든 창구가 같은 순간 마지막 좌석을 잡으려 해도 상영은 절대 초과 판매되어서는 안 돼요. 집계를 확인한 뒤 더하는 것을 두 단계로 나누면 그 사이에 빈틈이 생겨요. 그래서 확인과 갱신은 함께 성공하거나 함께 실패해야 해요.

claim_seat 함수를 구현해요. 이 함수는 좌석이 아직 남아 있을 때 다음 좌석을 원자적으로 차지해요. 좌석을 차지하면 함수는 좌석 번호를 반환하는데, 이는 이번 차지 이후의 판매된 좌석 수예요. 상영이 매진되면 0을 반환하고 집계는 그대로 둬요.

이 함수는 다음 순서로 인자를 받아요.

  • seats_taken: 이미 판매된 좌석 수의 메모리 주소, 64비트 부호 있는 정수이며 capacity를 넘지 않아요.
  • capacity: 상영의 좌석 수, 64비트 부호 있는 정수이며 항상 0보다 커요.
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

이 함수는 좌석 번호를, 상영이 매진되었을 때는 0을 64비트 부호 있는 정수로 반환해요.

5. 영사실에 신경 쓰기

영사실에는 한 사람만 들어갈 수 있어요. 메모리에 있는 한 워드가 영사실이 사용 중인지 알려줘요. 0은 비어 있음, 1은 사용 중이에요. 직원들은 밤새 찾아오고, 저마다 안에서 할 일이 있어요. 일지에 서명하기, 영사기에 필름 끼우기, 릴 정리하기 같은 일이에요. 일이 무엇이든 영사실의 규칙은 같아요. 그 규칙을 코드로 지켜지게 하는 것이 이 과제예요. 한 번에 한 사람만, 자기 일이 시작될 때부터 끝날 때까지 안에 있어야 해요.

visit_booth 함수를 구현해요. 이 함수는 다음과 같아요.

  1. 영사실이 비어 있을 때까지 기다려요.
  2. 영사실을 차지해요.
  3. 방문자의 일을 실행해요.
  4. 일이 끝난 뒤, 반환하기 전에 영사실을 다시 열어요.

영사실을 다른 사람이 차지하고 있는 동안에는 pause로 예의 바르게 기다려야 해요.

이 함수는 다음 순서로 인자를 받아요.

  • booth: 사용 중 표시 워드의 메모리 주소, 0 또는 1인 64비트 부호 있는 정수예요.
  • task: 방문자의 일, 매개변수를 받지 않고 반환값도 없는 함수예요.

테스트가 실행하는 일들은 영사실과 공유 일지를 들여다봐요. 즉, 영사실은 일이 진행되는 내내 사용 중이어야 하고, 모든 방문은 자기 일을 정확히 한 번만 실행해야 하며, 여러 방문자가 동시에 있어도 일지는 정확하게 기록되어야 해요.

Caution

방문자의 일은 호출 규약을 따르는 평범한 함수예요.

  • caller-saved 레지스터는 무엇이든 덮어쓸 수 있으므로, 일이 끝난 뒤에도 필요한 값은 호출이 건드릴 수 없는 곳에 보관해야 해요.
  • 또한 호출 시점에 스택이 16바이트로 정렬되어 있기를 기대해요.

두 규칙 중 하나라도 어기면 검사 실패가 아니라 크래시로 나타날 수 있어요.

GitHub에서 편집 링크가 새 창이나 탭에서 열려요
x86-64 Assembly Exercism

개막 공연 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.