싱글코어만으로 프로그램을 실행시키는 시대는 이제 저물고 있다. 최근에는 AMD, Intel 너나할거 없이 프로세서의 코어를 늘리고 있다. 코어의 클럭을 올리는데 한계에 도달했기 때문이다. 일반적으로 프로그램을 만들면, 프로그램은 싱글스레드로 작동한다. 즉, 프로세서가 수많은 코어를 가지고 있다 하더라도, 단 하나의 코어만 활용해서 동작할 수 있다는 뜻이다. 어떻게 하면 그 수많은 코어를 활용할 수 있을까? 일반적으로는 async/non-blocking으로 동작하는 라이브러리를 사용함으로써 간단하게 적용할 수 있다. 하지만 여기서 좀 더 깊이 들어가서, 그러한 라이브러리들은 또 어떻게 돌아가는지, CPU 레벨에서는 어떤 일이 일어나는지 간단히 알아보고자 한다. 또, 실제로 이러한 특성을 활용하여 어떻게 프로그래밍할 수 있는지 lock free 자료구조를 통해 소개할 예정이다.
언어는 Rust를 주로 활용하여 설명할 것이다. Rust를 전혀 모르더라도, 코드 상의 키워드를 보고 유추하거나 pseudo code로 생각하면 어렵지 않을 것이다. 어셈블리 레벨을 보여주려고 할 때에는 Compiler Explorer의 Rust disassembly가 영 좋지 않기 때문에 C++ 코드로 보여줄 것이다.
멀티코어를 활용하는 프로그래밍을 알아보기 전에 간단하게 용어를 정리하고자 한다. 이러한 프로그래밍을 한다고 하면, concurrency(동시성)이나 parallelism(병렬성)이라는 용어가 자주 등장한다. concurrency는 논리적으로 동시에 실행시키는 것, parallelism은 물리적으로 동시에 실행시킨다고 생각하면 될 것 같다. 즉, 싱글코어 프로세서라 하더라도 concurrency는 가능하며, parallelism은 실제로 멀티코어에서 동작할 때 일어난다고 보면 된다. 프로그래밍을 할 때에는 concurrency하게 짜고 멀티코어에서 프로그램을 돌리면, OS단에서 스케줄러가 각 코어에 프로세스/쓰레드를 잘 할당해서 parallelism을 보여준다고 생각하면 될 거 같다. 따라서 앞으로는 concurrency을 바탕으로 설명하도록 하겠다.
자, 이제 동시성 프로그래밍을 해보도록 하자. 먼저, 아주 간단한 사례를 살펴보면 좋을 거 같다. 하나의 변수에 두 개의 쓰레드가 1,000번씩 +1해주는 상황을 생각해보자. 코드로 짜보면 아래와 같다.
use std::thread;
struct Counter(*mut i32);
unsafe impl Send for Counter {}
fn main() {
let mut threads = Vec::new();
let mut counter = 0;
for _ in 0..2 {
let counter = Counter(&mut counter as *mut _);
threads.push(thread::spawn(move || {
for _ in 0..1_000 {
unsafe {
*(counter.0) += 1;
}
}
}));
}
for t in threads {
t.join().unwrap();
}
println!("{}", counter);
}
일반적으로는 당연히 2000이 나올 것이라 생각하지만, 실제로 여러 번 돌려보면 2000보다 작거나 같은 수가 나온다. AMD/Intel CPU에서는 거의 대부분 2000이 나오고, ARM CPU에서는 꽤 많이 2000보다 작은 수가 나온다. 왜 이런 결과가 나오는 것일까? counter에 +1을 할 때 어떤 일이 일어나는지 자세하게 살펴보도록 하자. 덧셈하여 더하는 과정을 아래와 같이 나열해볼 수 있다.
단순히 코드 상으로는 1-3의 과정이 한 번에 일어날 것 같지만, 실제로 어셈블리를 까보면 그렇지 않다. 위의 코드에서 덧셈하는 부분(+=)만 C++로 작성해보면 아래와 같다.
int counter = 0;
void add()
{
counter += 1;
}
어셈블리로 컴파일하면 아래와 같은 결과가 나온다.
x86-64 clang 12.0.1 -O0
add():
push rbp
mov rbp, rsp
mov eax, dword ptr [counter]
add eax, 1
mov dword ptr [counter], eax
pop rbp
ret
위에 나열한 순서처럼 1, 2, 3이 나뉘어져 있음을 알 수 있다.
즉, 다음과 같은 일이 일어날 수 있다.
Thread A
Thread B
위의 상황에서 A-1, A-2, B-1, B-2, A-3, B-3의 순서로 실행된다면 A가 더했던 1은 날라가버리는 것이다. 이러한 상황을 막기 위해 atomic이라는 개념이 나온다. 돌턴의 원자설에서 원자는 쪼개질 수 없는 가장 작은 단위인 것처럼, atomic operation은 위의 과정과는 달리 쪼개지지 않는다는 의미이다.
잠시 하나를 짚고 넘어가보도록 하자. 위의 코드를 -O3로 컴파일하면 설명했던 것과는 달리 1개의 명령어로만 이뤄진다. 이때 설령 명령어가 1개일지라도 atomic하지 않기 때문에 결국 위에 설명한 것처럼 문제가 있다.