Intel 64 어셈블리에서 lock 접두사 명령과 mmap 기반 공유 메모리를 이용해 작업을 여러 프로세스에 분배하는 방법을 살펴본다.
이제 거의 모든 컴퓨터가 어떤 형태로든 멀티프로세싱(즉, 단일 주소 공간을 공유하는 여러 CPU)을 갖추게 되면서, 일부 고급 언어들은 동시성 기능으로 주목을 받기 시작했다. 많은 언어가 이런 기능을 “동시성 프리미티브(concurrency primitives)”라고 부른다. 하지만 이들은 고급 언어이므로, 이런 “프리미티브”도 결국 하드웨어 연산으로 구현되어야 한다는 것을 우리는 안다. C 같은 오래된 고급 언어에는 이런 연산을 위한 내장 지원이 없다. 그 이유는 그런 언어들이 더 저수준이어서가 아니라, 단지 해당 연산들이 C가 발명될 당시에는 존재하지 않았기 때문이다. 어셈블리 언어는 정의상 최신 CPU 기능을 따라가므로1, 오늘날의 동시성 연산의 진짜 본질을 들여다볼 수 있는 최고의 창을 제공해야 한다.
이 글에서는 OSX 또는 Linux에서 실행되는 (비교적) 단순한 동시성 어셈블리 프로그램을 단계적으로 살펴보겠다. 데모는 여기(github)에 있다:
bash-3.2$ time ./concurrency-noprint-x1 foo # single-worker version
real 0m1.458s
user 0m1.445s
sys 0m0.010s
bash-3.2$ # now run two at once
bash-3.2$ time ./concurrency-noprint-x1 foo-2 & ./concurrency-noprint-x1 foo-2
[1] 71366
real 0m0.785s
user 0m0.780s
sys 0m0.001s
[1]+ Done time ./concurrency-noprint-x1 foo-2
bash-3.2$ time ./concurrency-noprint-x4 foo-3 # four-worker version
real 0m0.417s
user 0m0.413s
sys 0m0.003s
bash-3.2$ time ./concurrency-noprint-x7 foo-4 # seven-worker version
real 0m0.295s
user 0m0.283s
sys 0m0.001s
bash-3.2$ diff -s --from-file=foo foo-*
Files foo and foo-2 are identical
Files foo and foo-3 are identical
Files foo and foo-4 are identical
이 프로그램이 실제로 하는 일은 꽤 쓸모없지만 계산량은 적지 않고 쉽게 병렬화할 수 있는 작업이다. 버퍼 시작점으로부터 각 바이트의 오프셋을 65537제곱한 값을 235로 모듈러(mod)한 뒤, 그 값을 다시 각 바이트에 저장한다. 235로 mod를 취하므로 출력은 235바이트마다 반복되어야 한다:
bash-3.2$ hexdump -e '235/1 "%4u" "\n"' -s8 foo
1 37 158 194 35 206 32 128 54 120 146 102 233 9 125 36 122 118 184 210 121 232 33 14 50 161 72 98 164 160 11 157 38 49 180 136 162 228 154 15 76 12 88 124 10 46 47 48 84 205 6 82 18 79 175 101 167 193 149 45 56 172 83 169 165 231 22 168 44 80 61 97 208 119 145 211 207 58 204 85 96 227 183 209 40 201 62 123 59 135 171 57 93 94 95 131 17 53 129 65 126 222 148 214 5 196 92 103 219 130 216 212 43 69 215 91 127 108 144 20 166 192 23 19 105 16 132 143 39 230 21 87 13 109 170 106 182 218 104 140 141 142 178 64 100 176 112 173 34 195 26 52 8 139 150 31 177 28 24 90 116 27 138 174 155 191 67 213 4 70 66 152 63 179 190 86 42 68 134 60 156 217 153 229 30 151 187 188 189 225 111 147 223 159 220 81 7 73 99 55 186 197 78 224 75 71 137 163 74 185 221 202 3 114 25 51 117 113 199 110 226 2 133 89 115 181 107 203 29 200 41 77 198 234 0
*
여기서 나는 hexdump에 이 바이너리 파일을 235바이트씩 한 줄로 표시하라고 요청했다. 한 번에 1바이트씩, 각 바이트를 4글자 필드 폭으로 두고, 부호 없는 정수(10진)로 출력하며, 줄 끝에는 개행을 찍는다. 또한 오프셋 8부터 시작하는데(파일의 첫 8바이트는 동시성 메커니즘이 장부 처리를 위해 사용한다2), 그 때문이다. hexdump 출력의 두 번째 줄에 있는 *는 “이 이후의 모든 줄이 이것과 같다”라는 뜻이므로, 파일은 끝까지 235바이트마다 반복되어야 한다. -v로 *를 억제하고 마지막 4줄을 확인해 보자. 우리가 올바르게 이해하고 있는지 확인하기 위해서다:
bash-3.2$ hexdump -e '235/1 "%4u" "\n"' -s8 -v foo | tail -n4
1 37 158 194 35 206 32 128 54 120 146 102 233 9 125 36 122 118 184 210 121 232 33 14 50 161 72 98 164 160 11 157 38 49 180 136 162 228 154 15 76 12 88 124 10 46 47 48 84 205 6 82 18 79 175 101 167 193 149 45 56 172 83 169 165 231 22 168 44 80 61 97 208 119 145 211 207 58 204 85 96 227 183 209 40 201 62 123 59 135 171 57 93 94 95 131 17 53 129 65 126 222 148 214 5 196 92 103 219 130 216 212 43 69 215 91 127 108 144 20 166 192 23 19 105 16 132 143 39 230 21 87 13 109 170 106 182 218 104 140 141 142 178 64 100 176 112 173 34 195 26 52 8 139 150 31 177 28 24 90 116 27 138 174 155 191 67 213 4 70 66 152 63 179 190 86 42 68 134 60 156 217 153 229 30 151 187 188 189 225 111 147 223 159 220 81 7 73 99 55 186 197 78 224 75 71 137 163 74 185 221 202 3 114 25 51 117 113 199 110 226 2 133 89 115 181 107 203 29 200 41 77 198 234 0
1 37 158 194 35 206 32 128 54 120 146 102 233 9 125 36 122 118 184 210 121 232 33 14 50 161 72 98 164 160 11 157 38 49 180 136 162 228 154 15 76 12 88 124 10 46 47 48 84 205 6 82 18 79 175 101 167 193 149 45 56 172 83 169 165 231 22 168 44 80 61 97 208 119 145 211 207 58 204 85 96 227 183 209 40 201 62 123 59 135 171 57 93 94 95 131 17 53 129 65 126 222 148 214 5 196 92 103 219 130 216 212 43 69 215 91 127 108 144 20 166 192 23 19 105 16 132 143 39 230 21 87 13 109 170 106 182 218 104 140 141 142 178 64 100 176 112 173 34 195 26 52 8 139 150 31 177 28 24 90 116 27 138 174 155 191 67 213 4 70 66 152 63 179 190 86 42 68 134 60 156 217 153 229 30 151 187 188 189 225 111 147 223 159 220 81 7 73 99 55 186 197 78 224 75 71 137 163 74 185 221 202 3 114 25 51 117 113 199 110 226 2 133 89 115 181 107 203 29 200 41 77 198 234 0
1 37 158 194 35 206 32 128 54 120 146 102 233 9 125 36 122 118 184 210 121 232 33 14 50 161 72 98 164 160 11 157 38 49 180 136 162 228 154 15 76 12 88 124 10 46 47 48 84 205 6 82 18 79 175 101 167 193 149 45 56 172 83 169 165 231 22 168 44 80 61 97 208 119 145 211 207 58 204 85 96 227 183 209 40 201 62 123 59 135 171 57 93 94 95 131 17 53 129 65 126 222 148 214 5 196 92 103 219 130 216 212 43 69 215 91 127 108 144 20 166 192 23 19 105 16 132 143 39 230 21 87 13 109 170 106 182 218 104 140 141 142 178 64 100 176 112 173 34 195 26 52 8 139 150 31 177 28 24 90 116 27 138 174 155 191 67 213 4 70 66 152 63 179 190 86 42 68 134 60 156 217 153 229 30 151 187 188 189 225 111 147 223 159 220 81 7 73 99 55 186 197 78 224 75 71 137 163 74 185 221 202 3 114 25 51 117 113 199 110 226 2 133 89 115 181 107 203 29 200 41 77 198 234 0
1 37 158 194 35 206 32 128 54 120 146 102 233 9 125 36 122 118 184 210 121 232 33 14 50 161 72 98 164 160 11 157 38 49 180 136 162 228 154 15 76 12 88 124 10 46 47 48 84 205 6 82 18 79 175 101 167 193 149 45 56 172 83 169 165 231 22 168 44 80 61 97 208 119 145 211 207 58 204 85 96 227 183 209 40 201 62 123 59 135 171 57 93 94 95 131 17 53 129 65 126 222 148 214 5 196 92 103 219 130 216 212 43 69 215 91 127 108 144 20 166 192 23 19 105 16 132 143 39 230
235바이트의 정확한 배수가 아니라는 점에 주목하라. 끝까지 옆으로 스크롤해 보면, 맨 마지막 줄이 중간에서 끝난다는 것을 볼 수 있다. 이는 이 파일이 특정한 235바이트 시퀀스를 루프로 출력해서 만들어진 것이 아니기 때문이다. 대신 매 8바이트 머신 워드가 각각 따로 계산된다. 235바이트 반복 구조는 프로그램이 푸는 문제의 성질 자체에 내재되어 있다(부분적으로는 결과가 그럴듯한지 쉽게 확인할 수 있도록, 내가 그런 문제를 골랐기 때문이다).
동시성 프리미티브가 해결하려는 문제에 대한 개념적 개요부터 시작해 보자3. 단일 프로세스가 어떤 주소 공간에서 동작할 때(즉, 비동시성 프로세스) 우리는 프로세스 실행 중 특정 시점의 전체 주소 공간 상태를 추론할 수 있다. 예를 들어 “이 변수는 4줄 전에 양수임을 확인했고 그 이후로 바꾸지 않았으니 반드시 양수다” 같은 말을 할 수 있다. 동시성 프로세스에서는 이런 추론이 상당 부분 무너진다. 우리 프로세스의 형제(sibling)가 그 사이에 변수를 -1로 바꿨을 수도 있기 때문이다. 우리는 알 방법이 없다. 가능한 상태 전이가 너무 다양해서 강한 주장에 정당성을 부여하기 어렵다. 이 맥락에서 “프로그램이 올바른 출력을 만든다” 같은 주장은 매우 강한 주장이고, 우리는 종종 그런 주장을 하고 싶어 한다(적어도 스스로에게는).
그래서 보안이 어떤 의미에서는 기능을 제한하는 것에 관한 것처럼, 동시성 프리미티브는 여러 프로세서가 공유하는 메모리의 가능한 상태 전이를 제한하는 수단이다. 가장 흔한 골칫거리는 공유 메모리 상태가 “측정”한 시점과 그 측정에 근거해 “행동”을 취하는 시점 사이에 형제에 의해 바뀌었을 수 있다는 점이다. 그러므로 일반적으로 가장 기본적인 동시성 연산은 공유 상태를 측정한 다음, 그 데이터를 이용해 공유 상태를 변경하되, 그 전체 연산 동안 해당 상태에 접근하는 형제를 배제한다. 이렇게 코드의 한 구간이 실행되는 동안, 형제들이 특정 메모리 위치에 접근할 수 없도록 하는 구간을 **임계 구역(critical section)**이라고 부른다.
Haswell(작년에 출하되기 시작했지만 나는 아직 없다) 이전의 모든 Intel CPU에서 “진짜” 임계 구역은 lock 접두사가 붙은 단일 머신 명령으로 제한된다. 더 큰 임계 구역은 이런 단일-명령 프리미티브를 기반으로 에뮬레이션할 수 있다. 오늘은 그 변형을 다룰 것이다.
병렬 컴퓨팅에서 태스크-워커 관점을 정당화하는 특정한 논증을 알고 있는 것은 아니지만, 실전에서는 이것이 코드를 조직하기에 가장 유용했고, 꽤 흔한 방식처럼 보인다. 아이디어는 이렇다. 프로그램의 작업량을 어떤 크기의 **태스크(task)**로 나누고, 여러 개의 서로 교환 가능한 **워커(worker)**가 동시에 실행되면서 각 태스크를 정확히 한 워커가 집어 들어 처리한다. 태스크는 너무 작아서는 안 된다. 태스크를 고르는 데 드는 일이 너무 커지기 때문이다4. 하지만 너무 커서도 안 된다. 어떤 워커가 다른 워커들보다 먼저 끝냈을 때 그 워커가 곧바로 처리할 다음 태스크가 준비되어 있을 가능성이 떨어지기 때문이다.
이 맥락에서 태스크는 임계 구역의 한 종류다. 어떤 태스크를 한 워커가 집어 들면, 다른 워커들은 그 태스크를 건드리지 말아야 한다. 하지만 임계 구역은 아주 작아서 빨리 실행하고 빨리 비켜주는 것이라는 뉘앙스를 가진다. 나는 운 좋게도 대부분의 경우 독립적인 태스크로 쪼갤 수 있는 도메인에서 일해 왔다고 생각한다. (이런 도메인은 종종 embarrassingly parallel이라고 불린다.) 하지만 아래 코드에서는 태스크 대신 짧은 임계 구역으로 조작되는 상태 변수의 예도 하나 보게 될 것이다.
concurrency.asmcontext
%include "os_dependent_stuff.asm"
; Initialize constants.
mov r12, 65537 ; Exponent to modular-exponentiate with
mov rbx, 235 ; Modulus to modular-exponentiate with
mov r15, NPROCS ; Number of worker processes to fork.
mov r14, (SIZE+1)*8 ; Size of shared memory; reserving first
; 64 bits for bookkeeping
처음 두 상수는 꽤 직관적이다. 그저 계산할 작업의 파라미터다. NPROCS와 SIZE는 약간 설명이 필요하다. 이들은 실제로 Makefile에서 정의되고, -D 옵션(예: -DNPROCS=7)으로 nasm에 전달되는 상수다5. SIZE는 실제로 8바이트 머신 워드 단위로 측정되며, 우리가 수행하려는 태스크의 개수다. (앞서 언급했듯, 이 프로그램에서 각 태스크는 출력 파일에서 계산해야 할 8바이트 머신 워드 하나다.)
concurrency.asmcontext
; Check for command-line argument.
cmp qword [rsp], 1
je map_anon
OS가 우리 프로그램에 진입할 때, 커맨드라인은 스택에 있다. [rsp]는 커맨드라인 토큰의 개수(프로그램 이름 포함)이고, [rsp+8]은 프로그램 이름에 대한 포인터, [rsp+2*8]은 첫 번째 커맨드라인 인자(있다면)에 대한 포인터… 이런 식이다. 커맨드라인 인자가 하나도 없으면 토큰 수는 1(프로그램 이름만)이다. 이 경우 우리는 익명(anonymous) 메모리 영역을 요청한다. 반대로 인자가 있으면 커맨드라인에서 지정된 파일을 열고 그것을 매핑한다. 참고: mmap을 처음 본다면 man 페이지를 보라. 내 생각에 이것은 메모리 할당(“익명” 매핑)에도, 파일 I/O에도 “올바른” 방식이다.
open(), ftruncate(), 그리고 mmap()#concurrency.asmcontext
open_file:
; We have a file specified on the command line, so open() it.
mov rax, SYSCALL_OPEN ; set up open()
mov rdi, [rsp+2*8] ; filename from command line
mov rsi, O_RDWR|O_CREAT ; read/write mode; create if necessary
mov rdx, 660o ; `chmod`-mode of file to create (octal)
syscall ; do open() system call
mov r13, rax ; preserve file descriptor in r13
mov rax, SYSCALL_FTRUNCATE ; set up ftruncate() to adjust file size
mov rdi, r13 ; file descriptor
mov rsi, r14 ; desired file size
syscall ; do ftruncate() system call
mov r8, r13
mov r10, MAP_SHARED
jmp mmap
; Ask the kernel for a shared memory mapping.
map_anon:
mov r10, MAP_SHARED|MAP_ANON ; MAP_ANON means not backed by a file
mov r8, -1 ; thus our file descriptor is -1
mmap:
mov r9, 0 ; and there's no file offset in either case.
mov rax, SYSCALL_MMAP ; set up mmap()
mov rdx, PROT_READ|PROT_WRITE ; We'd like a read/write mapping
mov rdi, 0 ; at no pre-specified memory location.
mov rsi, r14 ; Length of the mapping in bytes.
syscall ; do mmap() system call.
test rax, rax ; Return value will be in rax.
js error ; If it's negative, that's trouble.
mov rbp, rax ; Otherwise, we have our memory region [rbp].
concurrency.asmcontext
error:
mov rdi, rax ; In case of error, return code is -errno...
mov rax, SYSCALL_EXIT
neg rdi ; ...so negate to get actual errno
syscall
이 설정을 위해 실제로는 시스템 콜을 세 번 호출해야 한다. 파일을 여는 것(SYSCALL_OPEN), 적절한 크기로 늘리는 것(SYSCALL_FTRUNCATE), 그리고 마지막으로 메모리 매핑을 만드는 것(SYSCALL_MMAP)이다.
lock add#concurrency.asmcontext
lock add [rbp], r15 ; Add NPROCS to the file's first machine word.
; We'll use it to track the # of still-running
; worker processes.
여기서 첫 번째 동시성 프리미티브가 등장한다. 파일의 첫 번째 머신 워드에 NPROCS를 더할 것이다. 파일이 처음 생성될 때 모든 바이트가 0으로 보일 것이라는 사실에 기대고 있다(이는 대부분의 Unix 구현에서 실제로 참이다). 왜 그냥 그 워드를 0으로 _설정_하지 않을까? 이 프로그램의 멋진 특징 중 하나는, 동일한 파일에 대해 이 프로그램을 여러 번 동시에 실행할 수 있고, 그러면 마치 마법처럼 작업을 공유한다는 점이다. 따라서 우리가 프로그램의 두 번째 실행 인스턴스라면, 이 장부 처리 상태를 덮어쓰고 싶지 않다. 그저 워커 풀에 NPROCS명의 워커를 기여하기만 하면 된다.
fork()#concurrency.asmcontext
; Next, fork NPROCS processes.
fork:
mov eax, SYSCALL_FORK
syscall
%ifidn __OUTPUT_FORMAT__,elf64 ; (This means we're running on Linux)
test rax, rax ; We're a child iff return value of fork()==0.
jz child
%elifidn __OUTPUT_FORMAT__,macho64 ; (This means we're running on OSX)
test rdx, rdx ; Apple...you're not supposed to touch rdx here
jnz child ; Apple, what
%endif
dec r15
jnz fork
Apple의 fork() 구현은 약간 엉망이라서, 불행히도 여기에 OS 의존 로직을 넣어야 한다. 하지만 기본 아이디어는 이렇다. (a) 우리가 부모 프로세스이며 새로 fork()된 워커 프로세스가 아닐 때, 그리고 (b) 생성해야 할 프로세스 수가 0이 될 때까지 감소하지 않았을 때에만 fork()를 계속 호출한다.
concurrency.asmcontext
parent:
pause
cmp qword [rbp], 0
jnz parent ; Wait for [rbp], the worker count, to be zero
이제 부모 프로세스는 단순히 활성 워커/자식 프로세스가 하나도 없을 때까지 기다린다(더 이상 할 일이 없으면 워커들은 정상적으로 사라진다). pause 명령은 시스템에 힌트를 주어, 이 루프에서 실제로 많은 에너지를 소모하며 빙글빙글 돌지 않도록 한다.
concurrency.asmcontext
exit_success:
mov eax, SYSCALL_EXIT ; Normal exit
mov edi, 0
syscall
활성 워커 수가 0이 되면, 부모는 성공 코드 0을 반환하며 종료한다.
여기서 워커들이 태스크를 나눠 가진다. 이 프로그램에서 동시성과 관련된 가장 중요한 연산이다:
concurrency.asmcontext
child:
mov rsi, r14 ; Restore rsi from r14 (saved earlier)
mov cl, 0xff ; Set rcx to be nonzero
mov rdi, 8 ; Start from index 8 (past the bookkeeping)
find_work: ; and try to find a piece of work to claim
xor rax, rax
cmp qword [rbp+rdi], 0 ; Check if qword [rbp+rdi] is unclaimed.
jnz .moveon ; If not, move on - no use trying to lock.
lock cmpxchg [rbp+rdi], rcx ; Try to "claim" qword [rbp+rdi] if it is still
; unclaimed.
jz found_work ; If successful, zero flag is set
.moveon:
add rdi, 8 ; Otherwise, try a different piece.
find_work.next:
cmp rdi, rsi ; Make sure we haven't hit the end.
jne find_work
워커는 [rbp] 영역에서 두 번째 8바이트 워드부터(바로 [rbp]의 워드는 워커 수를 나타내기만 하므로) 각 8바이트 워드를 선형으로 스캔하며 0인 워드를 찾는다. 앞서 다뤘듯이, 파일은 처음에는 모두 0으로 시작한다. 나는 이 설정을 머릿속에서 이렇게 상상한다. 파일은 옛 미국 서부처럼 0으로 가득한 황량한 사막으로 시작하고, 워커들은 점유해 살 토지를 찾아 헤맨다. 그들이 발견한 첫 빈 땅에는 “RESERVED”라는 표지판을 세우고(그게 태스크) 집을 짓기 시작한다. 이 경우 RESERVED 표지판이 0xff다. 그러면 다른 워커들은 자기 땅을 찾을 때까지 계속 이동한다. 여기서 핵심은 두 워커가 같은 위치에 RESERVED 표지판을 세우지 못하게 막는 것이다. 그 역할을 하는 것이 **lock cmpxchg**다.
lock cmpxchg (compare-and-swap) #이는 약간 복잡하지만 아름다운 연산이다. 매개변수는 세 가지다:
[rbp+rdi]), 그리고 이 피연산자의 크기는 다음 매개변수의 피연산자 크기에 의해 결정된다(여기서는 다음 매개변수가 8바이트 레지스터이므로 8바이트 머신 워드다)6,rcx이며 0xff를 담고 있는데, 이는 RESERVED를 뜻하는 센티널 값이다),rax이며 암묵적 매개변수다. 여기서는 xor rax, rax로 0으로 만들어져 있는데, 예약되지 않은 워드는 새로 할당된 파일이 0으로 채워지기 때문에 0이기 때문이다).lock cmpxchg가 하는 첫 일은 메모리 위치를 잠그고 기대 값과 비교하는 것이다. 그 다음 결과에 따라 두 가지 중 하나가 일어난다:
cmpxchg는 rax를 우리가 기대한 값이 아니라 지금 메모리에 들어 있는 값으로 덮어쓴다. 불일치를 알리기 위해 zero-flag ZF는 클리어되고, 메모리 위치는 언락된다.ZF가 세트되고, 메모리 위치는 업데이트가 끝나자마자 언락된다.우리 애플리케이션에서의 결론은, 둘 이상의 워커가 같은 태스크를 예약하는 것은 불가능하다는 것이다. 왜냐하면 예약은 항상 원자적(lock된) 연산으로 일어나며, 이 연산은:
lock cmpxchg 전에 일반 cmp를 먼저 수행한다는 점을 눈치챘을지도 모른다. 이는 엄밀히 필요하진 않지만 프로그램의 이 부분을 꽤 빠르게 만든다. 어떤 위치가 이미 오래전에 점유되었다면, 굳이 (중간 정도로 비싼 연산인) lock을 걸고 나서야 알 필요가 없다. 그냥 비어 보이는 것을 찾을 때까지 넘어가면 된다(그리고 비어 보이는 것을 찾았을 때 lock cmpxchg로 정말 비어 있는지 _확인_한다).
concurrency.asmcontext
found_work:
mov r8, 8 ; There are 8 pieces per task.
do_piece: ; This part does the actual work of mod-exp.
mov r13, r12 ; Copy exponent to r13.
mov rax, rdi ; The actual value to mod-exp should start
sub eax, 0x7 ; at 1 for the first byte after the bookkeeping
xor rdx, rdx ; word. This value is now in rax.
div rbx ; Do modulo with modulus.
mov r11, rdx ; Save remainder -- "modded" base -- to r11.
mov rax, 1 ; Initialize "result" to 1.
.modexploop:
test r13, 1 ; Check low bit of exponent
jz .shift
mul r11 ; If set, multiply result by base
div rbx ; Modulo by modulus
mov rax, rdx ; result <- remainder
.shift:
mov r14, rax ; Save result to r14
mov rax, r11 ; and work with the base instead.
mul rax ; Square the base.
div rbx ; Modulo by modulus
mov r11, rdx ; base <- remainder
mov rax, r14 ; Restore result from r14
shr r13, 1 ; Shift exponent right by one bit
jnz .modexploop ; If the exponent isn't zero, keep working
mov byte [rbp+rdi], al ; Else, store result byte.
inc rdi ; Move forward
dec r8 ; Decrement piece counter
jnz do_piece ; Do the next piece if there is one.
jmp find_work.next ; Else, find the next task.
이 글이 이진 거듭제곱에 대한 자세한 산문 설명까지 담기에는 이미 충분히 길다(게다가 이 글의 주제도 아니다). 요지는 이렇다. [rbp] 영역에서 오프셋 rdi가 주어지면, 이 코드 덩어리는 [rbp+rdi]부터 [rbp+rdi+7]까지 각 바이트를 rdi부터 rdi+7 값들의 적절한 모듈러 거듭제곱 결과로 치환한다. 이 코드는 어느 정도 의도적으로 비효율적이다(클럭 사이클을 수십 개씩 잡아먹는 div가 많다). 현실감을 위해서다. 태스크가 의미 있는 시간 동안 수행되길 원한다.
참고: 아래 코드 블록은 순서가 뒤섞여 있으며, 앞의 두 블록 모두와 겹친다.
concurrency.asmcontext
find_work.next:
cmp rdi, rsi ; Make sure we haven't hit the end.
jne find_work
child_exit: ; If we have hit the end, we're done.
lock dec qword [rbp] ; Atomic-decrement the # of active processes.
jmp exit_success
found_work:
더 이상 점유되지 않은 태스크를 찾을 수 없으면, 워커는 성공적으로 종료할 것이다. 하지만 먼저 활성 워커 수를 감소시켜야 한다.
lock dec#이제는 아마 짐작할 수 있겠지만, lock dec는 dec(감소) 연산의 버전으로서, 다른 어떤 워커도 동시에 활성 워커 수 변수를 감소시키지 못하게 보장한다(예: 마지막 두 워커가 값 2를 읽고, 각각 감소시킨 뒤 둘 다 1을 다시 써 넣고 종료해 버려서, 아무도 0으로 만들지 못하는 상황).
(이것이 단지 예제가 아니라면) #
이 특정 문제에 대해서는, 내가 여기서 한 일들 중 많은 것이 사실 그다지 말이 되지 않는다는 점을 짚고 넘어갈 가치가 있다.
하지만 어쨌든 이제 우리는 메모리 매핑 파일을 이용해 동시성 x64 프로그램을 작성하는 방법을 알게 되었다.
어떤 추상화 수준에서 작업하든, Intel 플랫폼에서 동시 처리를 하고 있다면 그 추상화가 이런 개념들로 어떻게 내려오는지 고려해 볼 가치가 있다. 예를 들어 여러분의 플랫폼이 mmap() 같은 것을 노출하는지 확인하고, 여러분의 “동시성 프리미티브”가 개별 lock된 연산으로 어떻게 번역될 수 있는지 생각해 보라. 이는 성능 이슈를 추론하는 데 도움이 될 뿐 아니라, 동시성 프리미티브가 제공하는 보장에 대한 더 깊은 이해도 제공할 것이다.
그리고 물론, 개인용 프로그래밍 환경 점수표의 “동시성 프리미티브가 있는가?” 항목에서 어셈블리에 큰 체크 표시를 해 두는 것도 잊지 말자.
어셈블리를 눈에 띄게 올드스쿨한 프로그래밍 방식으로 생각한다면 이는 직관에 반할 수 있다. 그게 사실이 아니라는 말은 아니다. 하지만 내가 어셈블리 해킹에 사용하는 nasm, DynASM, r2 같은 도구들은 Intel의 어셈블리 언어 명세와 끊임없이 동기화되며, 그 명세는 매번 새로운 CPU 출시 전에 업데이트된다. 다른 도구들이 적응하는 데 훨씬 오래 걸리는 이유는, 글쎄, Intel이 새 기능을 어떻게 활용해야 하는지 정확히 _명시_하지 않기 때문이다. 따라서 실제로 최신 하드웨어는 다른 어떤 곳보다도 먼저 어셈블리에서 지원된다.↩
진지한 작업을 하고 있었다면, 평평한 바이너리 파일 대신 Cap’n Proto를 사용했을 것이다. 그러면 장부 처리 필드가 명확히 구분된다. 어쩌면 이후 글에서 어셈블리로 그걸 하는 방법을 보여줄지도 모르겠다. 그 경우 hexdump 대신 capnp로 데이터를 탐색했을 것이다. 하지만 hexdump는 꽤 다재다능한 도구이고 어쨌든 알아두면 좋다.↩
말장난 의도는 없었다.↩
여기 표시된 코드는 이 규칙을 꽤 심하게 위반한다. 아마도 병렬 실행으로 인한 속도 향상이 이상적인 것보다 눈에 띄게 나쁜 이유가 그것일 텐데, 더 잘하려면 설명이 과하게 복잡해질 것 같아서다.↩
이런 값들을 커맨드라인 인자로 받았어도(받았어야?) 했지만, 인정하자. 커맨드라인 인자 파싱은 어떤 언어에서도 성가시고, 하물며 어셈블리에서는 더 그렇다.↩
단일 원자 연산으로 머신 워드 두 개(종종 포인터 두 개)를 compare-and-swap하고 싶은 애플리케이션도 있다. 이는 lock cmpxchg16b 명령으로 할 수 있다(참고: 16바이트는 여전히 메모리에서 연속이어야 하고, 실제로는 16바이트 정렬이 되어 있어야 한다).↩