9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍

지민규

프로그래밍 언어 시리즈

  1. 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
  2. 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
  3. 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍
  4. 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
  5. 9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍

서론

안녕하세요, 쿠키런 킹덤의 서버팀에 속해있는 지민규입니다. CPU 코어의 성능이 한계점에 가까워지고 보급형 CPU도 4코어 이상인 요즘 시대에 높은 반응성 및 성능을 요구하는 프로그램을 만들려면 동시성 프로그래밍은 필수입니다. 이러한 추세에 많은 프로그래밍 언어들도 각종 동시성 기능을 내장해가고 있습니다. 이 글에서는 각 동시성 기능 간의 관계와 몇몇 언어들의 특이한 동시성 기능들을 소개하려 합니다.

이전 편들이 하나의 프로그래밍 언어를 깊게 이해하고 있는 분들을 대상으로 쓴 것처럼, 이번 동시성 프로그래밍 편에서는 동시성 프로그래밍에 대해 어느 정도 이해하고 있는 분들을 대상으로 작성했습니다.

용어 비교

시작하기에 앞서 먼저 동시성 프로그래밍 용어들을 정리해볼 필요가 있습니다

병렬처리(Parallel Computing)

병렬처리는 간단히 말해서 여러 명령을 같은 순간에 처리하는 것입니다. 일반적으로 하나의 CPU 코어는 한 번에 하나의 명령을 처리합니다. 만약 하나의 프로그램에서 다중 코어를 이용해 여러 명령을 한 번에 처리한다면 병렬 처리라고 할 수 있습니다. 반드시 CPU 코어를 사용해야만 병렬처리를 할 수 있는 것은 아닙니다. 수백, 수천 개의 코어로 이루어진 그래픽카드(GPU)를 활용하여 병렬처리를 하는 방법도 있습니다.

멀티쓰레딩(Multithreading)

운영체제가 제공하는 쓰레드를 이용하여 병렬처리를 하는 기법입니다. 일반적으로 컴퓨터 프로그램은 하나 이상의 프로세스들로 이루어져 있고 프로세스는 하나 이상의 쓰레드로 이루어져 있습니다. 하나의 쓰레드는 로컬 변수들을 저장할 스택 메모리와 실행 중인 명령의 위치 등으로 이루어져 있습니다. 같은 프로세스 내의 쓰레드끼리는 서로 힙 메모리를 공유합니다.

동시성 프로그래밍(Concurrent Programming)

동시성 프로그래밍은 기존의 순서대로 명령들이 실행되는 구조에서 벗어나 명령들이 불규칙한 순서대로 실행될 수 있게 해주는 프로그래밍 기법입니다. 멀티쓰레딩은 동시성 프로그래밍의 한 방법이지만, 동시성 프로그래밍이 꼭 병렬처리 및 멀티쓰레딩을 의미하지는 않습니다.

비동기 프로그래밍(Asynchronous Programming)

비동기 프로그래밍은 Future, Promise 및 Async-Await와 같은 추상화된 기능들을 이용하여 동시성 프로그래밍을 하는 방법입니다. 마찬가지로 비동기 프로그래밍을 사용한다고 해서 꼭 병렬처리 및 멀티쓰레딩인 것은 아닙니다.

쓰레드

쓰레드(Thread)가 명령 흐름을 관리하는 기본 단위인 만큼 가장 대표적인 병렬처리 방식입니다. 쓰레드는 운영체제가 제공하기 때문에 운영체제마다 사용해야 하는 라이브러리, 쓰레드 생성 방식, 세부적인 기능들이 미묘하게 다릅니다. 다행히도, 대부분의 프로그래밍 언어는 운영체제를 신경쓰지 않아도 되도록 추상화한 쓰레딩 라이브러리를 제공합니다.

// C# 코드
// 쓰레드가 돌릴 함수
static void SayHi(string message) {
  Console.WriteLine("Hi, " + message);
}

// 쓰레드 생성
Thread thread = new Thread(() => SayHi("Mango"));

// 쓰레드 시작
// "Hi, Mango"를 출력한다
thread.Start();

// 쓰레드 종료 기다리기
thread.Join();

쓰레드를 생성할 때 운영체제와 소통하고 메모리를 할당해야 하므로 시간과 메모리 리소스가 들어갑니다. 쓰레드 개수가 너무 많아져도 문제인데, 운영체제가 관리해야 하는 쓰레드의 수가 늘어나고 코어를 더 많은 쓰레드들이 나눠서 사용해야 하며, CPU 코어를 사용하는 쓰레드가 교체될 때 레지스터를 교체하는 Context Switching이 더 자주 발생하여 성능도 하락하게 됩니다.

쓰레드 풀

과도하게 잦은 쓰레드 생성과 Context Switching을 피하는 방법 중 하나는 쓰레드 풀(Thread Pool)을 사용하는 것입니다. 쓰레드가 필요할 때마다 매번 생성하는 것이 아닌, 쓰레드 풀에 저장된 쓰레드를 할당받아 사용하고 쓰레드 사용이 끝났을 때 다시 쓰레드 풀로 반환하는 방식입니다. 이때 쓰레드 풀로 넘겨진 함수를 일반적으로 태스크(Task)라고 부릅니다.

// C# 코드
// 기본 제공되는 쓰레드 풀, SayHi 함수를 실행하는 태스크를 전달한다.
ThreadPool.QueueUserWorkItem((_) => SayHi("Mango"));

다만 쓰레드 풀이 장점만 있는 것은 아닙니다. 리소스만 충분하다면 즉각적으로 생성되고 실행되는 쓰레드와 달리, 쓰레드 풀은 남은 쓰레드가 없다면 실행 중인 태스크 중 하나가 끝나 쓰레드를 반환할 때까지 시작하지 않습니다. 그렇기 때문에 무거운 태스크들로 쓰레드들을 소진시켰다면 다음 태스크는 아무리 가볍더라도 무거운 태스크 중 하나가 끝날 때까지 시작하지 않습니다. 이로 인해 반응성이 떨어집니다.

태스크가 무거워지는 이유는 크게 2가지로 나눌 수 있습니다.

  1. 파일 읽기/쓰기, 네트워크 송신 등 I/O 작업이 포함되어있어서
  2. 순수하게 CPU 연산이 많아서

1번 케이스로 인해 쓰레드 풀의 반응성이 떨어지는 경우는 CPU 성능의 문제가 아닙니다. I/O 작업은 CPU가 연산을 하느라 느려지는 것이 아니라 하드디스크/SSD와 같은 하드웨어의 작업을 기다리고 있거나 네트워크 응답을 기다리고 있는 것이기 때문입니다. 이 경우, 운영체제에서 쓰레드를 대기 상태로 설정하여 CPU를 다른 쓰레드에게 양보하기 때문에 쓰레드 개수가 늘어나더라도 Context Switching 횟수가 크게 늘지 않아 CPU 성능에 큰 문제가 발생하지 않습니다. 그렇기 때문에 I/O 작업을 처리하는 쓰레드 풀은 남은 쓰레드가 없으면 새 쓰레드를 만들어 쓰레드 풀에 포함시키고 오랫동안 사용하지 않은 쓰레드를 자동으로 제거하는 방식으로 쓰레드 개수를 가변적으로 운용하여 반응성을 높이는 것이 일반적입니다.

2번 케이스는 CPU 성능의 문제입니다. 일반적으로 CPU 작업을 위한 쓰레드 풀은 CPU 코어의 개수에 비례해서 쓰레드 개수를 고정으로 운용하는데, 만약 더 많은 쓰레드를 생성하면 Context Switching의 횟수가 증가하여 전체적인 성능 하락이 발생합니다. 이 경우에는 태스크 자체를 더 작은 단위로 쪼개서 다른 태스크가 돌아갈 기회를 주는 방법 밖에는 없습니다.

// C# 코드
// 기존 태스크
static void OriginalTask() {
  /* 매우 무거운 CPU 작업 1 */
  DoTheDishes();
  /* 매우 무거운 CPU 작업 2 */
  DoTheLaundry();
}

// 쪼개진 태스크들
static void CpuTask1() {
  DoTheDishes();
  ThreadPool.QueueUserWorkItem((_) => CpuTask2());
}

static void CpuTask2() {
  DoTheLaundry();
}

이렇게 I/O 작업이 있는 태스크와 CPU 작업 위주인 태스크는 서로 다른 쓰레드 풀을 사용해야 하며 태스크를 쪼개야 하는 경우도 있다는 것을 보았습니다. 만약 한 태스크가 CPU 작업과 I/O 작업을 함께 할 경우 각 작업은 서로 다른 쓰레드 풀에 할당되어야 하므로 계속해서 태스크를 쪼개야 합니다.

// C# 코드
// 기존 태스크
static void OriginalTask() {
  /* 매우 무거운 CPU 작업 1 */
  DoTheDishes();
  /* 매우 무거운 I/O 작업 */
  MakeDoctorsAppointment();
  /* 매우 무거운 CPU 작업 2 */
  DoTheLaundry();
}

// 쪼개진 태스크들
static void CpuTask1() {
  DoTheDishes();
  ioThreadPool.QueueUserWorkItem((_) => IoTask());
}

static void IoTask() {
  MakeDoctorsAppointment();
  cpuThreadPool.QueueUserWorkItem((_) => CpuTask2());
}

static void CpuTask2() {
  DoTheLaundry();
}

위 코드에서도 보이듯이, 태스크를 직접 쪼개는 건 비직관적입니다. 이러한 비직관성을 회피하는 방법 중 하나는 바로 경량쓰레드를 사용하는 것입니다.

경량쓰레드

경량쓰레드(Light-weight Thread)는 운영체제가 아닌 프로그램 레벨에서 구현된 쓰레드입니다. 생성하고 제거할 때 운영체제와 소통할 필요가 없고 리소스의 사용량도 최적화하여 훨씬 가볍기 때문에 경량쓰레드라고 부릅니다. 경량쓰레드를 언어 차원에서 제공하는 대표적인 언어는 Go입니다.

// Go 코드
func task() {
  /* 매우 무거운 CPU 작업 1 */
  doTheDishes()
  /* 매우 무거운 I/O 작업 */
  makeDoctorsAppointment()
  /* 매우 무거운 CPU 작업 2 */
  doTheLaundry()
}

// 경량쓰레드 위에서 실행
go task()

경량쓰레드는 쓰레드 풀과 깊은 연관성이 있습니다. 사실 경량쓰레드는 쓰레드 풀 위에서 작동하고, 프로그래밍 언어가 태스크를 대신 쪼개주는 것이기 때문입니다.

위에서 보이는 task 함수는 어떤 면에서는 하나의 태스크가 아니라 세 개로 나눠진 태스크라고도 볼 수 있습니다. 나눠진 지점마다 다른 경량쓰레드가 작동할 기회를 주는 것입니다. 쓰레드가 명령의 흐름을 추적한다면 경량쓰레드는 태스크의 흐름을 추적한다고 할 수 있습니다. 그렇기 때문에 Context Switching의 비용 또한 운영체제의 쓰레드에 비하면 훨씬 낮습니다.

경량쓰레드를 사용하면 태스크를 어느 쓰레드 풀로 넘겨야 하는지, 어떻게 쪼갤지, 쓰레드가 충분한지에 대한 고민을 크게 덜 수 있습니다.

비동기 프로그래밍

비동기 프로그래밍은 태스크 쪼개기 문제를 해결하는 방법 중 하나입니다. 비동기 프로그래밍은 태스크 쪼개기를 최대한 자연스럽게 만들어 코드의 직관성을 살립니다.

다만 비동기 프로그래밍이 꼭 멀티쓰레딩을 동반하지 않습니다. 태스크를 별도의 쓰레드나 쓰레드 풀로 넘긴다면 멀티쓰레딩을 활용하는 비동기 프로그래밍이고, 하나의 쓰레드가 모두 처리한다면 병렬성이 없는 비동기 프로그래밍입니다.

Promise와 Future

비동기 프로그래밍을 배울 때 가장 먼저 PromiseFuture에 대해서 알아보아야 합니다. 쓰레드를 돌릴 때 대부분의 경우 실행 결과도 필요합니다. 이때 사용하는 것은 Promise와 Future입니다.

// C++ 코드
// 피보나치 함수
long fibonacci(long n) {
  if (n <= 1)
    return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// 피보나치 계산 결과를 promise로 전달하는 태스크
void runFibonacci(long n, promise<long> fibonacciPromise) {
  long result = fibonacci(n);
  fibonacciPromise.set_value(result);
}

// 결과 값을 받아줄 promise 생성
promise<long> fibonacciPromise;

// 결과 값을 조회할 수 있는 future 생성
future<long> fibonacciFuture = fibonacciPromise.get_future();

// 쓰레드 생성 후 태스크 전달
thread t = thread(runFibonacci, 50, move(fibonacciPromise));

// future에 값이 도달할 때까지 기다리기
long result = fibonacciFuture.get();

Promise는 값을 받을 수 있고 Future는 값을 조회할 수 있습니다. 위에서 보이듯이 C++에서는 Promise를 먼저 만든 뒤 Promise에 대응되는 Future를 가져옵니다. promiseset_value 함수가 호출되었을 때 futurewait가 끝나는 것입니다.

물론 이렇게 직접 Promise를 다뤄야 하는 경우는 많지 않습니다. 일반적으로는 Promise는 라이브러리 내부에서 만들어지고, Future만 받아서 처리하는 경우가 많습니다.

// C++ 코드
long fibonacci(long n) {
  if (n <= 1)
    return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// 새 쓰레드를 만들고 그 위에 태스크를 굴려서 결과를 future로 받는다.
future<long> fibonacciFuture = async(fibonacci, 50);

// future에 값이 도달할 때까지 기다리기
long result = fibonacciFuture.get();

Promise를 직접 다뤘을 때는 set_value를 호출하는 것을 잊어버리면 wait 함수가 영원히 끝나지 않겠지만 async 함수를 활용하여 간접적으로 사용하면 이러한 위험성을 피할 수 있습니다. async를 통해 만들어낸 Future와 직접 Promise로부터 만들어낸 Future는 안전성 측면에서 크게 다릅니다.

그렇기 때문에 많은 언어는 주인 없는 Future가 존재하지 않도록 Future를 만들 때부터 태스크를 묶고 실행하는 경우가 많습니다.

// Java 코드
// 태스크를 기본 쓰레드 풀에 실행시켜 결과를 future로 받는다.
CompletableFuture<Long> fibonacciFuture =
  CompletableFuture.supplyAsync(() -> {
    return fibonacci(50);
  });

// future에 값이 도달할 때까지 기다리기
Long result = fibonacciFuture.get();

C#에서는 아예 Future를 Task<T>라고 부르고 있습니다.

// C# 코드
// 태스크를 기본 쓰레드 풀에 실행시켜 결과를 future로 받는다.
Task<long> fiboncciFuture =
  Task<long>.Run(() => return Fibonacci(50));

// future에 값이 도달할 때까지 기다리기
long result = fiboncciFuture.Result;

재밌는 것은 Javascript/Typescript에서는 이런 Future들을 Promise라고 부른다는 것입니다.

// Typescript
// 태스크를 실행시켜 결과를 future로 받는다.
let fiboncciFuture: Promise<number> = new Promise(resolve => {
  resolve(fibonacci(50))
})

태스크가 묶인 Future의 특징 중 하나는 태스크 연계가 가능하다는 것입니다. Future의 결과에 새로운 태스크를 묶어서 또 다른 Future를 만드는 것입니다. 이것 잘 활용하면 태스크가 자연스럽게 쪼개지게 되어 반응성 문제를 어느 정도 회피할 수 있습니다.

// Typescript 코드
// Future들을 출력하는 함수들
function getHired(): Promise<Job> {
  /* ... */
}

function earnMoney(job: Job): Promise<Money> {
  /* ... */
}

function buyHouse(money: Money): Promise<House> {
  /* ... */
}

// Promise의 then 메소드를 이용해서 Future들을 합친다
let houseFuture: Promise<House> =
  getHired()
    .then(job => earnMoney(job))
    .then(money => buyHouse(money))

이러한 방식으로 Future 합치는 것에 큰 단점이 있습니다. 각 태스크의 의존 관계가 복잡해지면 Future 중첩이 복잡해져 가독성이 떨어집니다.

// Typescript 코드
let future: Promise<[Job, Money, House]> =
  getHired()
    .then(job =>
      earnMoney(job)
        .then(money =>
          buyHouse(money)
            .then(house =>
              [job, money, house]
            )
        )
    )

이러한 중첩 문제를 해결할 방법 중 하나는 Async-Await 기능을 이용하는 것입니다.

Async-Await

Async-Await은 Future의 중첩으로 인한 가독성 하락 문제를 해결해줍니다. Typescript의 Async-Await 기능은 async 키워드와 await 키워드를 사용합니다. 먼저 async에 대해서 배워봅시다.

// Typescript 코드
async function getHired(): Promise<Job> {
  /* ... */
}

async function earnMoney(job: Job): Promise<Money> {
  /* ... */
}

async function buyHouse(money: Money): Promise<House> {
  /* ... */
}

먼저 async 함수는 반드시 Promise를 출력해야 한다는 제약 조건이 있습니다. Typescript에서는 출력 타입에 Promise<Job>과 같은 형식으로 적지만 Promise 부분을 생략하는 프로그래밍 언어도 몇몇 존재합니다.

async 함수를 사용하면 await 키워드를 사용할 수 있게 해줍니다. await 키워드를 활용하면 중첩 문제를 해결할 수 있습니다.

// Typescript 코드
async function collectAllFutures(): Promise<[Job, Money, House]> {
  let job: Job = await getHired()
  let money: Money = await earnMoney(job)
  let house: House = await buyHouse(money)

  return [job, money, house]
}

// 위 코드는 아래와 동일
// fucntion runAll(): Promise<[Job, Money, House]> {
//   getHired()
//     .then(job =>
//       earnMoney(job)
//         .then(money =>
//           buyHouse(money)
//             .then(house =>
//               [job, money, house]
//             )
//         )
//     )
// }

Async-Await는 완전히 새로 만들어진 기능이 아닌 Promisethen을 이용해서 만들어진 것입니다. 물론 then으로 직접 중첩하는 것과는 세세한 차이점이 존재하지만 조금 넓게 보면 일종의 문법적 설탕(Syntatic Sugar)입니다. 문법적 설탕은 기존에 존재하는 기능에 새로운 문법을 추가하여 가독성을 높인 언어 기능을 뜻합니다.

그렇기 때문에 어떻게 보면 Async-Await는 눈속임입니다. 위 runAll의 3개의 함수 호출은 전부 다른 태스크에 속해있습니다. await를 하는 지점마다 다른 태스크가 실행될 기회를 주는 것이고 사실 한 함수의 몸체 내에서 모두 호출하는 것이 아닙니다. 이런 면에서 경량쓰레드랑 어느 정도 비슷한 점이 존재한다고 볼 수 있습니다. 경량쓰레드는 문법을 추가하지 않고 이러한 문제를 해결한다면, Async-Await는 새로운 키워드와 문법을 추가하여 해결합니다.

Kotlin에서는 Async-Await 대신 코루틴(Coroutine)을 사용하는데, yield등 몇 가지 특수한 기능들이 제공되는 것을 제외하면 Async-Await와 동일하기 때문에 이번 글에서는 다루지 않습니다.

IO모나드

무시무시한 단어가 등장했습니다. 모나드(Monad)입니다. 모나드는 함수형 프로그래밍 패러다임의 개념 중 가장 이해하기 어려운 개념으로 악명 높습니다. 이 글은 동시성 프로그래밍에 관한 글이기 때문에 분량상 모나드에 대해서 전부 설명할 수는 없지만, IO모나드를 대략적으로 이해하는데 필요한 수준까지 설명합니다.

먼저 IO모나드에 해당하는 Scala의 Future[T]를 살펴봅시다.

// Scala 코드
// 대략적인 Future 인터페이스
trait Future[T] {
  /* ... */
  def flatMap[U](f: T => Future[U]): Future[U]
}

// 대략적인 Future 정적 객체
object Future {
  /* ... */
  // Future의 정적 함수
  def successful[T](result: T): Future[T] = /* ... */
}

어떠한 타입이 모나드가 되려면 정확히 2개의 함수가 필요합니다. 대상 타입이 F이고. 타입 파라미터 T를 받는다면 T ⇒ F[U] 타입의 함수를 입력받아 F[U]를 출력할 수 있는 메소드와 T를 입력받아 F[T]를 출력할 수 있는 정적 함수가 필요합니다. Future의 경우 Future[T].flatMap 메소드가 전자에 해당하고 Future.sucessful 함수가 후자에 해당합니다.

이 두 함수를 가진 타입들을 가졌다면 해당 타입을 모나드라고 할 수 있습니다. 모든 모나드는 Typescript의 Promise와 같이 중첩된 형태로 함수를 호출하는 상황이 발생할 수 있습니다.

// Typescript 코드
// Typescript에서는 이렇게 중첩된다
fucntion collectAllFutures(): Promise<[Job, Money, House]> {
  getHired()
    .then(job =>
      earnMoney(job)
        .then(money =>
          buyHouse(money)
            .then(house =>
              [job, money, house]
            )
        )
    )
}

// Scala 코드
// Typescript의 Promise가 then을 사용한다면
// Scala의 Future는 flatMap을 사용한다
def collectAllFutures(): Future[(Job, Money, House)] =
  getHired()
    .flatMap(job =>
      earnMoney(job)
        .flatMap(money =>
          buyHouse(money)
              .flatMap(house =>
                Future.successful((job, money, house))
              )
         )
    )

여러 Future의 결과를 모아서 출력하는 경우 Typescript의 Promise가 그러했듯 Scala의 Future도 그런 문제를 겪게 됩니다. Scala의 Nullable 타입에 해당하는 Option도 모나드이기 때문에 마찬가지의 문제를 겪을 수 있습니다.

// Scala 코드
trait Option[T] {
  // 현재 값이 null일 때는 null을 출력한다
  // 현재 값이 null이 아닐 때는 f를 실행한 출력 값을 출력한다
  def flatMap[U](f: T => Option[U]): Option[U]
}

object Option {
  def some[T](value: T): Option[T] = /* ... */
}

// 마찬가지로 중첩된 형태로 함수를 호출한다
// 여기서 각각의 getXXX 함수는 Option을 출력한다고 가정한다
def collectAllOptions(): Option[(Job, Money, House)] =
  getJob()
    .flatMap(job =>
      getMoney(job)
        .flatMap(money =>
          getHouse(money)
            .flatMap(house =>
              Option.some((job, money, house))
            )
        )
    )

함수형 프로그래밍에서 모나드가 널리 쓰이는 만큼 함수형 프로그래밍에 굉장히 고전적인 문제라서 모나드를 위한 문법적 설탕이 이미 존재합니다. 이를 Scala에서는 For-Comprehension이라고 부릅니다.

// Scala 코드
def collectAllFutures(): Future[(Job, Money, House)] =
  for {
    job   <- getHired()
    money <- earnMoney(job)
    house <- houseMoney(job)
  } yield (job, money, house)

def collectAllOptions(): Option[(Job, Money, House)] =
  for {
    job   <- getJob()
    money <- getMoney(job)
    house <- getHouse(job)
  } yield (job, money, house)

// Typescript 코드
// 비교를 위해 가져온 Typescript의 Async-Await 코드
async function collectAllFutures(): Promise<[Job, Money, House]> {
  let job = await getHired()
  let money = await earnMoney(job)
  let house = await buyHouse(money)

  return [job, money, house]
}

뭔가 익숙하지 않으십니까? Async-Await와 상당 부분 비슷합니다. 차이점은 Async-Await는 비동기 처리 맥락에서만 사용할 수 있는 문법적 설탕이지만, For-Comprehension은 모나드들에 전반적으로 적용할 수 있는 문법적 설탕입니다. 제 동료가 집필한 게임 서버 개발에 스칼라 사용하기에서 비동기 처리가 전혀 없는 비즈니스 로직에 For-Comprehension을 사용한 것을 볼 수 있습니다.

메모리 공유

동시성 프로그래밍을 할 때 생겨나는 본격적 고민은 메모리 공유에서 시작됩니다. 동시성 프로그래밍은 기존 순서대로 진행되는 코드의 형태에서 벗어나 명령들이 불규칙한 순서로 실행된다고 했었습니다.

명령 처리 순서가 불규칙해지면 어떠한 문제가 발생할까요? 예를 들어 전역 변수에 1씩 100,000번 더하는 함수가 있다고 합시다.

// C++ 코드
int globalValue = 0;

void add100_000() {
  for (int i = 0; i < 100000; i++) {
    globalValue++;
  }
}

이 함수를 2개의 쓰레드에 돌려서 200,000번 더해봅시다.

// C++ 코드
// 함수를 2개에 쓰레드에 나눠서 호출한다
thread thread1 = thread(add100_000);
thread thread2 = thread(add100_000);

thread1.join();
thread2.join();

// 과연 무엇이 출력될까?
cout << globalValue << endl;

같은 전역 변수에 200,000번 더했으니 200,000이 될 것 같지만, 실제로는 100,000과 200,000 사이의 숫자가 출력됩니다.

// 위 프로그램을 10번 실행한 결과
// 참고: 실행 환경에 따라 결과가 크게 다를 수 있음
108725
104778
102279
103750
100470
101885
102690
102263
102678
102362

어째서 이런 일이 발생하는 것일까요? 일단 add100_000 함수를 확인해봅시다. CPU 명령어 관점에서 globalValue++는 3개의 명령으로 이루어져 있습니다.

// C++ 코드
int globalValue = 0;

void add100_000() {
  for (int i = 0; i < 100000; i++) {
    // globalValue++; 는 다음 세 명령으로 변환된다.
    int oldValue = globalValue; // 1번
    int newValue = oldValue + 1; // 2번
    globalValue = newValue; // 3번
  }
}

이 3개의 명령을 2개의 쓰레드, A 쓰레드와 B 쓰레드에서 동시에 실행한다고 생각해봅시다. 쓰레드는 운영체제가 관리하므로 언제 명령을 수행하고 언제 CPU 코어를 양보할지 모릅니다. 만약 A 쓰레드에서 1, 2, 3번 명령을 완료한 뒤 B 쓰레드에서 1, 2, 3번 명령을 수행하면 결과는 예상대로 이루어집니다.

// C++ 코드
int globalValue = 0;

// 쓰레드 A에서 명령 1, 2, 3번 수행
// 전역 변수로부터 0을 읽는다
int threadA_oldValue = globalValue;
// 0에 1을 더해 1이라는 계산 결과가 나온다
int threadA_newValue = threadA_oldValue + 1;
// 1이라는 결과를 전역 변수에 대입한다
globalValue = threadA_newValue;

// 쓰레드 B에서 명령 1, 2, 3번 수행
// 전역 변수로부터 1을 읽는다
int threadB_oldValue = globalValue;
// 1에 1을 더해 2라는 계산 결과가 나온다
int threadB_newValue = threadB_oldValue + 1;
// 2이라는 결과를 전역 변수에 대입한다
globalValue = threadB_newValue;

// 2가 출력된다
cout << globalValue << endl;

하지만 만약 A 쓰레드에서 1, 2번째 명령을 수행한 뒤 3번째 명령을 수행하기 전에 B 쓰레드가 3개의 명령을 수행하면 어떻게 될까요?

// C++ 코드
int globalValue = 0;

// 쓰레드 A에서 명령 1, 2번 수행
// 전역 변수로부터 0을 읽는다
int threadA_oldValue = globalValue;
// 0에 1을 더해 1이라는 계산 결과가 나온다
int threadA_newValue = threadA_oldValue + 1;

// 쓰레드 B에서 명령 1, 2, 3번 수행
// 전역 변수로부터 0을 읽는다
int threadB_oldValue = globalValue;
// 0에 1을 더해 1라는 계산 결과가 나온다
int threadB_newValue = threadB_oldValue + 1;
// 1이라는 결과를 전역 변수에 대입한다
globalValue = threadB_newValue;

// 쓰레드 A에서 명령 3번 수행
// 1이라는 쓰레드A의 결과를 전역 변수에 대입한다
globalValue = threadA_newValue;

// 1이 출력된다
cout << globalValue << endl;

아직 A 쓰레드에서 1이라는 값을 대입하지 않았기 때문에 B 쓰레드에서도 1이라는 결과가 도출됩니다. 1이 아닌 다른 결과가 나왔더라도 어차피 A 쓰레드의 3번 명령에서 1로 덮어씌워질 것입니다.

이렇게 똑같은 코드임에도 명령의 실행 순서에 따라 결과가 달라지는 것을 경쟁 상태(Race Condition)라고 합니다. 두 쓰레드가 경쟁하여 어느것이 먼저 수행되는지에 따라 결과가 달라지는 것이죠.

위 예시에서는 쓰레드간 작업 전환이 일어날 수 있었기 때문에 경쟁 상태가 발생합니다. 만약 병렬성이 없는, 싱글쓰레드 환경이였다면 발생하지 않습니다. 싱글쓰레드 환경에서는 반드시 태스크 단위로 실행되기 때문에 A 쓰레드의 1, 2, 3번 명령 사이에 B 쓰레드의 1, 2, 3번 명령이 끼어들 수 없습니다. 대신 연산 및 대입이 여러 태스크에 걸쳐서 일어난다면 경쟁 상태가 발생할 수 있습니다.

// Typescript 코드
async function add100_000() {
  for (int i = 0; i < 100000; i++) {
    let oldValue = globalValue;
    let newValue = oldValue + 1;
    await fireGlobalValueChangeEvent(); // 태스크를 나누는 지점
    globalValue = newValue;
  }
}

Compare and Swap

현대 프로그래밍에서 경쟁 상태 해결 방법은 Compare and Swap에서 시작합니다. Compare and Swap은 계산한 뒤 변수에 대입하여 확정하기 전에 변수의 값을 한 번 더 확인해서 기존 값과 같은지 확인하고 나서 대입합니다.

// C++ 코드
// 변경할 변수와, 계산에 사용했었던 변수의 값, 그리고 새 값을 넘긴다
bool compareAndSwap(int* target, int oldValue, int newValue) {
  // 변경할 변수의 값을 가져온다
  int targetValue = *target;

  // 변경할 변수의 값이 처음 계산에 사용했던 값과 같다면 변경한 뒤 성공으로 출력한다
  if (targetValue == oldValue) {
    *target = newValue;
    return true;
  }

  // 계산한 사이에 값이 바뀌었다면 실패로 출력한다.
  return false;
}

만약 계산하는 데 처음 사용했던 값과 현재 변수의 값이 다르다면 중간에 다른 태스크가 값을 변경한 것이기 때문에 다시 처음부터 계산해야 합니다. 만약 계산에 사용한 값과 현재 변수 값이 같다면 값이 변경되지 않았다고 가정하고 진행해도 되는 것입니다.

compareAndSwap 함수도 여러 명령으로 이루어져 있기 때문에 결국 경쟁 상태에 영향받지 않을까 생각될 수 있는데, 대부분의 현대 CPU에서는 compareAndSwap이 하나의 CPU 명령으로써 제공되기 때문에 이러한 문제에서 자유롭습니다. compareAndSwap 실행 도중 다른 쓰레드가 target의 값을 변경할 일은 없다는 것입니다.

compareAndSwap 명령을 활용하여 add100_000를 다시 구현한 것은 다음과 같습니다.

// C++ 코드
void add100_000() {
  for (int i = 0; i < 100000; i++) {
    // compareAndSwap이 실패하면 다시 시도한다
    do {
      int oldValue = globalValue;
      int newValue = oldValue + 1;
      // globalValue = newValue; 대신 compareAndSwap을 활용한다
    } while (!compareAndSwap(&globalValue, oldValue, newValue))
  }
}

이런 경우에는 A 쓰레드에서 1, 2번 명령을 수행하고 B 쓰레드에서 1, 2, 3번 명령을 수행했을 경우 A 쓰레드의 3번 명령은 실패하게 되어 다시 1, 2, 3번 명령을 재시도합니다. 이렇게 함수나 타입에서 경쟁 상태를 제거했다면 Thread-safe하다고 표현합니다.

Compare and Swap이 가진 단점이 두 가지 있는데, 첫 번째로는 재시도를 기반으로 사용하기 때문에 성능이 조금 떨어질 수 있다는 것이며, 두 번째로는 재시도할 명령들에 부수 효과(Side-effect)이 있으면 의도되지 않은 결과가 발생할 수 있습니다.

부수 효과란 쉽게 말해 함수 외부에 있는 환경에 영향을 끼치는 것입니다, 대표적인 부수 효과로는 네트워크 통신, 콘솔 출력이 있습니다. 만약 위의 do-while에 콘솔 출력이 있다면 compareAndSwap을 실패할 때마다 추가로 콘솔 출력을 합니다.

// C++ 코드
do {
  int oldValue = globalValue;
  int newValue = oldValue + 1;
  // 부수 효과! compareAndSwap이 실패하면 여러번 출력함!
  cout << "Changing globalValue to " << newValue << endl;
} while (!compareAndSwap(&globalValue, oldValue, newValue))

Atomic

변수를 수정하는 작업에 Compare and Swap을 사용하는 것을 일반화한 것이 Atomic입니다. Atomic은 타입을 감싸서 Compare and Swap을 활용하여 Thread-safe한 메소드만을 노출시킵니다.

// Java 코드
AtomicInt atomicInt = new AtomicInt(10);
// Thread-safe한 함수들
int value1 = atomicInt.get();
int value2 = atomicInt.getAndIncrement();
int value2 = atomicInt.addAndGet(5);

Atomic에 단점이 몇 가지 있는데 값 타입에는 atomic을 사용할 수 없다는 것입니다. Compare and Swap 명령어는 int, bool이나 참조 타입(포인터)과 같은 기초적인 타입만을 넘길 수 있기 때문입니다.

Atomic 또한 Compare and Swap을 기반으로 만들었기 때문에 부수 효과를 포함하는 수정은 할 수 없습니다.

// C++ 코드
void add100_000() {
  for (int i = 0; i < 100000; i++) {
    int oldValue = globalValue;
    int newValue = oldValue + 1;
    // 부수 효과가 있어서 globalValue를 atomic으로 만들 수 없다!
    cout << "Changing globalValue to " << newValue << endl;
    globalValue = newValue;
  }
}

이런 경우에 사용하는 것이 뮤텍스(Mutex)입니다.

뮤텍스

뮤텍스는 특정 코드 영역을 여러 쓰레드가 동시에 접근할 수 없게 만드는 데 사용합니다. add100_000에서 경쟁 상태가 발생하는 코드 부분만을 뮤텍스로 감싼 코드는 다음과 같습니다.

// C++ 코드
int globalValue = 0;
mutex m;

void add100_000() {
  for (int i = 0; i < 100000; i++) {
    m.lock();
    // 이 영역에는 동시에 하나의 쓰레드만 진입할 수 있다
    int oldValue = globalValue;
    int newValue = oldValue + 1;
    globalValue = newValue;
    m.unlock();
  }
}

뮤텍스를 활용할 경우 A 쓰레드의 1, 2, 3번 명령 사이에 B 쓰레드의 명령이 끼어들 수 없기 때문에 Thread-safey를 얻습니다. A 쓰레드가 먼저 lock을 호출하고 진입했다면 B 쓰레드는 A가 모든 작업을 끝내고 unlock을 호출할 때까지 대기합니다.

Atomic과 달리 재시도를 하지 않기 때문에 부수 효과가 있는 코드가 포함되어 있어도 문제없이 작동합니다.

// C++ 코드
int globalValue = 0;
mutex m;

void add100_000() {
  for (int i = 0; i < 100000; i++) {
    m.lock();
    int oldValue = globalValue;
    int newValue = oldValue + 1;
    // 부수 효과가 있어도 괜찮다!
    cout << "Changing globalValue to " << newValue << endl;
    globalValue = newValue;
    m.unlock();
  }
}

뮤텍스의 구현 방식은 여러 가지가 있는데, 가장 간단한 것은 스핀락(Spinlock)입니다. 스핀락은 Compare and Swap 명령을 이용하여 구현할 수 있습니다.

// C++ 코드
// 참고: 실제 스핀락은 더 최적화 되어있음
class SpinLock {
public:
  bool isLocked = false;

  void lock() {
    // isLocked가 false라면 true를 대입한다.
    // isLocked가 true라면 false가 될 때까지 재시도한다.
    while (compareAndSwap(&isLocked, false, true)) {}
  }

  void unlock() {
    isLocked = false;
  }
}

스핀락의 단점은 락이 해제될 때까지 반복적으로 명령을 수행하기 때문에 CPU를 낭비한다는 것입니다. 그렇기 때문에 보편적으로 사용하는 뮤텍스는 compareAndSwap 시도가 실패하면 운영체제와 소통하여 쓰레드를 대기 상태로 설정합니다. 재우는 동안에는 CPU를 점거하지 않으며 unlock이 호출되었을 때 일어나서 다시 compareAndSwap을 시도합니다. 단점으로는 운영체제와 소통하는 시간이 들어가기 때문에 스핀락보다 반응성이 조금 떨어지고 락이 풀리는데 걸리는 시간이 짧을 경우 스핀락보다 성능 면에서도 더 떨어질 수도 있습니다.

이러한 이유로 lock 함수 자체가 어느 정도 성능 또는 반응성을 희생하기 때문에 lock을 너무 자주 해제하고 거는 것은 좋지 않습니다. add100_000 함수의 뮤텍스는 사실 좀 더 넓게 잡아주는 게 좋습니다.

// C++ 코드
int globalValue = 0;
mutex m;

void add100_000() {
  // For 루프 시작 지점에 걸어서 100,000번이 아닌 1번만 lock을 건다
  m.lock();
  for (int i = 0; i < 100000; i++) {
    int oldValue = globalValue;
    int newValue = oldValue + 1;
    globalValue = newValue;
  }
  m.unlock();
}

뮤텍스가 장점만 있는 것은 아닙니다. 장점만큼 단점도 꽤 많습니다.

첫 번째로는 이미 lock이 걸렸을 때 unlock이 될 때까지 쓰레드가 대기해야 하기 때문에 CPU 쓰레드 풀의 쓰레드는 뮤텍스를 대기해서는 안 됩니다. 뮤텍스를 대기하는 순간 쓰레드 풀의 쓰레드 하나를 쓰지 못하는 것이기 때문에 CPU 코어를 제대로 활용할 수 없게 됩니다.

두 번째 문제는 락을 이용한 프로그래밍은 어렵다는 것입니다. 락을 걸어야할 필요가 있는지, 락을 얼마나 넓게 잡아야 할지 철저히 고민하면서 프로그래밍해야 하고 락을 거는 내부의 코드가 어떤 경쟁 상태를 만드는지도 꿰고 있어야 합니다.

세 번째 문제가 가장 큰 문제입니다, 바로 데드락(Deadlock)이 발생할 수 있다는 것입니다. 데드락은 두 개의 쓰레드가 서로의 뮤텍스가 해제되는 것을 기다릴 때 발생합니다.

// C++ 코드
mutex m1;
mutex m2;

void task1() {
  // 뮤텍스 1, 2에 락을 건다
  m1.lock();
  m2.lock();

  m2.unlock();
  m1.unlock();
}

void task2() {
  // 뮤텍스 1, 2에 락을 건다, 다만 2부터 건다
  m2.lock();
  m1.lock();

  m1.unlock();
  m2.unlock();
}

thread t1 = thread(task1);
thread t2 = thread(task2);

t1.join();
t2.join();

만약 각 쓰레드가 동시에 m1m2 뮤텍스에 락을 걸면 데드락 현상이 발생합니다.

// C++ 코드

// t1 쓰레드에서 m1 뮤텍스에 락을 건다
m1.lock();

// t2 쓰레드에서 m2 뮤텍스에 락을 건다
m2.lock();

// t1 쓰레드에서 m2 뮤텍스에 락을 건다
// 하지만 t2 쓰레드가 이미 걸어버려서 대기한다
m2.lock();

// t2 쓰레드에서 m1 뮤텍스에 락을 건다
// 하지만 t1 쓰레드가 이미 걸어버려서 대기한다
m2.lock();

두 쓰레드 다 서로의 락이 풀릴 때까지 기다리게 됩니다. 아무런 진전 없이 영원히 대기하게 되는 것입니다. 프로그램이 제대로 작동하지 않게 됩니다.

데드락을 약간이나마 예방하는 방법은 여러 뮤텍스를 언제나 순서대로 락을 거는겁니다. 위 예시에서는 t1 쓰레드는 m1 뮤텍스에 락을 건 뒤 m2 뮤텍스에 락을 걸었고 t2 쓰레드는 m2, 그다음 m1 뮤텍스에 락을 걸어서 발생합니다. 만약 두 쓰레드 모두 뮤텍스를 같은 순서대로 걸었다면 데드락 현상은 발생하지 않습니다.

하지만 이 예방 방법은 단점이 있습니다. 앞서서 락을 걸어야하기 때문에 락을 조건부로 거는 방법은 없습니다. m1, m2 순서대로 락을 걸도록 정책을 잡으면 m2에 락을 걸고 나서 m1을 조건부로 거는 방법은 없습니다. m2 락을 걸고 나서 뒤늦게 조건 체크하여 m1 락을 거는 것은 정책을 위반한 것이기 때문이죠. 여기서 m1 뮤텍스도 실제 필요한 순간보다 앞서서 락을 걸기 때문에 락을 거는 범위가 늘어나 쓰레드의 대기시간이 늘어난다는 단점도 있습니다.

그래도 뮤텍스가 부수 효과 있는 코드 영역에서 안전하게 경쟁 상태를 제거할 수 있는 몇 안 되는 방법이기에 뮤텍스를 완전히 배제할 수는 없습니다. 그나마 할 수 있는 것은 뮤텍스의 사용을 최소화하고 가능하면 락이 없는 메모리 공유 방식을 사용하는 것입니다.

동시성 자료구조

동시성 자료구조(Concurrent Data Structures)는 Thread-safe한 자료구조들을 일컫습니다. 존재하는 동시성 자료구조들의 예시는 다음과 같습니다.

  • 스택(Stack)
  • 큐(Queue)
  • 리스트(List)
  • 맵(Map, aka. Dictionary)

각각의 자료구조들은 다양한 구현 방식이 존재하고 그에 따라 장점과 단점이 나눠집니다. 특히 맵의 경우 락을 이용해 방법이 있고 락 없이 구현한 방법도 있습니다. Java의 ConcurrerntHashMap 경우 락을 이용해서 구현되었습니다.

락 없이 구현된 동시성 자료구조들은 주로 Compare and Swap을 이용해서 구현되었습니다. 그렇기 때문에 많은 쓰레드가 동시에 접근하고 자료구조를 수정한다면 Compare and Swap이 더 자주 실패하기 때문에 성능이 조금 떨어질 수 있습니다.

동시성 자료구조 중에 가장 중요한 자료구조를 뽑아보라고 한다면 큐(Queue)라고 할 수 있습니다. 큐를 활용하면 쓰레드 간의 소통을 할 수 있습니다. 그중에서도 채널(Channel)은 큐에 더 이상 요소를 추가할 수 없도록 닫는 기능과 채널의 메시지를 대기하는 기능을 추가한 것입니다. 채널을 활용하면 비동기적으로 처리되는 스트림을 구현할 수 있습니다.

// Rust 코드
// 주어진 디렉토리에 있는 파일들을 읽어서 채널로 보내주는 함수
fn stream_files_in_directory(path: &'static str, sender: Sender<String>) -> io::Result<()> {
  // 디렉토리 내 디렉토리/파일들 나열하기
  let entries = fs::read_dir(path)?;

  // 디렉토리/파일들 순회
  for entry in entries {
    // 파일이면 읽고 string으로 변환해서 채널에 삽입
    let entry = entry?;
    if entry.file_type()?.is_file() {
      let bytes = fs::read(entry.path())?;
      sender.send(String::from_utf8(bytes).unwrap()).unwrap();
    }
  }

  // 작업 완료 (채널은 자동으로 닫힘)
  Ok(())
}

fn main() {
  // 채널 만들기
  let (sender, receiver) = channel();

  // 생성 후 실행
  let thread = thread::spawn(|| stream_files_in_directory("src", sender));
  
  // 채널이 닫힐때까지 파일 데이터를 받는다
  for bytes in receiver.iter() {
    // 파일 데이터를 콘솔로 출력한다
    println!("{bytes}")
  }
}

이외에도 쓰레드 풀에 태스크를 넘길 때 사용되는 것이 큐입니다. 태스크를 넘길 때 태스크 대기열에 들어가게 되는데 이때 Thread-safey를 가진 큐를 사용하여 여러 쓰레드가 동시에 태스크를 추가해도 문제없도록 구현합니다. 어떤 면에서는 쓰레드 풀의 큐도 쓰레드간 소통을 위해 사용한다고 볼 수 있습니다.

큐의 대표적 사용처가 한 가지 더 있는데, 바로 액터(Actor)입니다

액터

액터는 메모리를 아예 공유하지 않고 쓰레드 하나에 완전히 맡겨버리는 방법입니다. 만약 다른 쓰레드에서 메모리에 접근하거나 수정하고 싶다면 반드시 해당 쓰레드와 소통하여 조회하거나 수정하게 해야 합니다. add100_000를 액터를 활용해서 구현한 코드는 다음과 같습니다.

// Rust 코드
// 참고: 9가지 언어 중에 내장 라이브러리만으로
// 액터를 간단하게 구현할 수 있는 언어가 Rust밖에 없었음

// 액터에게 보낼 수 있는 명령들
enum Command {
  Increment, // 값 1 올리기
  Get(Sender<i32>), // 값 조회하기
}

// 액터 명령을 받아줄 쓰레드용 함수
fn counter_actor(command_receiver: Receiver<Command>) {
  let mut value = 0;

  // 무한 반복
  loop {
    // 명령을 받아서...
    let command = command_receiver.recv().unwrap();
    match command {
      // Increment면 value를 1 상승시킨다
      Command::Increment => value += 1,
      // Get이면 같이 온 채널에 값을 전달한다
      Command::Get(sender) => sender.send(value).unwrap(),
    }
  }
}

fn add100_000(actor_sender: Sender<Command>) {
  for _ in 0..100000 {
    // 값을 직접 올리는 것이 아닌, 액터에게 값을 올리는 명령을 전달한다 일을 시킨다
    actor_sender.send(Command::Increment).unwrap();
  }
}

fn main() {
  // 액터 명령용 채널 생성
  let (command_sender, command_receiver) = channel();

  // 액터 쓰레드 돌리기
  thread::spawn(|| counter_actor(command_receiver));

  // add100_000를 돌릴 2개의 쓰레드
  let t1 = {
    let command_sender = command_sender.clone();
    thread::spawn(|| add100_000(command_sender))
  };
  let t2 = {
    let command_sender = command_sender.clone();
    thread::spawn(|| add100_000(command_sender))
  };

  // add100_000 작업이 끝날 때까지 기다리기
  t1.join().unwrap().unwrap();
  t2.join().unwrap().unwrap();

  // 조회 결과를 받을 채널 생성
  let (result_sender, result_receiver) = channel();

  // 액터에게 조회 명령을 내린다
  command_sender.send(Command::Get(result_sender)).unwrap();

  // 조회 결과는 채널에서 받는다
  let result = result_receiver.recv().unwrap();

  // 200000이 출력된다
  println!("{result}");
}

실제 액터를 사용할 때는 직접 구현하지 않고 라이브러리를 통해서 액터를 사용하는 것이 일반적입니다. 그리고 액터를 실행할 때 쓰레드를 사용하면 액터의 수와 쓰레드의 수가 비례하므로 운영체제 쓰레드가 아닌 경량쓰레드를 사용하는 것이 일반적입니다.

메모리를 공유하지 않으면 경쟁 상태가 발생할 일도 없습니다. 액터에 명령이 쌓이면 내부의 쓰레드가 명령들을 하나씩 순서대로 처리하여 값을 수정합니다. 그런 이유로 뮤텍스도 필요 없고 액터 내에서 Compare and Swap을 할 필요가 없기 때문에 부수 효과가 있는 명령들을 수행해도 괜찮습니다.

액터의 가장 빛날 때는 액터에 응답이 필요 없는 명령을 내리는 경우입니다. 위 예시에서는 Increment 명령이 그러한데요, Increment의 경우 기존 뮤텍스 기반 방식과 달리 쓰레드 대기가 전혀 필요없기 때문입니다. 그런 면에서 가장 CPU 사용 효율이 좋다고 할 수 있습니다.

이처럼 액터가 가진 강점이 여러 가지이기 때문에 액터가 언어에 내장되어 제공되기도 합니다. 그중 하나는 Swift입니다.

// Swift 코드
// 액터 정의
actor CounterActor {
  var value: Int = 0

  func increment() {
    self.value += 1
  }

  func get() -> Int {
    return self.value
  }
}

func add100_000(counterActor: CounterActor) async {
  for _ in 0..<100000 {
    // 그냥 함수 호출하듯이 사용하면 된다
    await counterActor.increment();
  }
}

// 액터 생성
let counterActor = CounterActor()

// 쓰레드 풀에 태스크 전달
let task1 = Task {
  await add100_000(counterActor: counterActor)
}
let task2 = Task {
  await add100_000(counterActor: counterActor)
}

// 태스크들이 끝날 때까지 대기
await task1.value
await task2.value

// 조회 및 출력
let value = await counterActor.get()
print(value)

액터를 자체 지원하는 다른 두 언어는 Erlang과 Elixir입니다. 두 언어 모두 BEAM이라는 VM(Virtual Machine)으로 실행하는데, 이 VM의 특징은 운영체제 쓰레드 대신 경량쓰레드만 존재한다는 것과 모든 경량쓰레드에 Mailbox라는 채널이 달려있다는 것입니다. Elixir에서 액터를 구현하는 방법은 다음과 같습니다.

# Elixir 코드
# 액터 정의
defmodule CounterActor do
  use GenServer

  def init(_init_arg) do
    {:ok, 0}
  end

  def handle_cast(:increment, value) do
    {:noreply, value + 1}
  end

  def handle_call(:get, _from, value) do
    {:reply, value, value}
  end
end

defmodule Test do
  def add100_000(actor_pid) do
    Enum.each(1..100000, fn (_) -> GenServer.cast(actor_pid, :increment) end)
  end  
end

# 액터 생성
{:ok, actor_pid} = GenServer.start_link(CounterActor, [])

# 경량쓰레드 생성하여 태스크 전달
task1 = Task.async(fn -> Test.add100_000(actor_pid) end)
task2 = Task.async(fn -> Test.add100_000(actor_pid) end)

# 태스크들이 끝날 때까지 대기
Task.await(task1)
Task.await(task2)

# 조회 및 출력
result = GenServer.call(actor_pid, :get)
IO.puts(result)

하지만 응답이 필요한 요청을 보냈을 때 응답을 받으려면 액터의 쓰레드가 요청을 처리할 때까지 대기해야하므로 어떻게 보면 액터 자체가 락으로써 작용한다고 할 수 있습니다. 이러한 응답 대기가 꼬이면 뮤텍스처럼 액터도 데드락이 발생할 수 있다는 것이 단점입니다.

// Swift 코드
// 액터 정의
actor CounterActor {
  // 짝지어진 액터 변수 추가
  var otherActor: CounterActor!
  var value: Int = 0

  func setOtherActor(otherActor: CounterActor) {
    self.otherActor = otherActor
  }

  func increment() {
    self.value += 1
  }

  func get() -> Int {
    return self.value
  }

  // 짝지어진 액터 변수의 값을 가져와서 평균을 계산하여 출력하는 함수
  func getAverage() async -> Double {
    // 짝지어진 액터에게 요청하고 응답을 기다림
    let otherValue = await self.otherActor.get()
    return Double(otherValue + value) / 2.0
  }
}

// 액터 2개 생성
let actor1 = CounterActor()
let actor2 = CounterActor()
// 액터 짝지어주기
await actor1.setOtherActor(otherActor: actor2)
await actor2.setOtherActor(otherActor: actor1)

// 만약 양쪽 액터에 동시에 getAverage를 호출한다면..
async let asyncResult1 = Task { actor1.getAverage() }
async let asyncResult2 = Task { actor2.getAverage() }

// 과연 응답이 돌아올까?
let result1 = await asyncResult1
let result2 = await asyncResult2

만약 양쪽 액터가 getAverage을 동시에 실행하면 두 액터는 서로에게 get 명령을 전달하게 될 것입니다. 이 get 명령은 getAverage가 완료되었을 때 실행합니다. 하지만 getAverage가 완료되려면 서로의 get 함수가 수행되어 값을 전달받아야 한다는 모순에 빠지게 됩니다. 데드락에 걸리는 것입니다.

Swift는 이 데드락 문제를 Reentrancy로 해결했습니다. 액터에서의 Reentrancy는 액터 쓰레드가 대기할 상황이 생기면 대기하는 대신 다른 명령들을 실행하는 기능입니다. 위 예시에서는 다른 액터의 get을 호출하면 응답을 기다리는 게 아니라 큐에 들어온 명령을 틈틈이 수행하게 됩니다. 이 과정에서 큐에 있었던 다른 액터가 보낸 get 명령을 처리하게 되고 데드락은 완전히 제거됩니다.

다만 이 방법은 단점이 없지는 않은데, 다른 명령을 틈틈히 수행하는 과정에서 필드가 수정되어 상태가 변화할 가능성도 있다는 것입니다. await 전후로 상태가 바뀌는 것입니다. 이러한 이유로 await마다 상태가 바뀔 수도 있음을 감안하고 로직을 구현해야하기 때문에 로직 구현 난이도가 급상승합니다.

STM

STM(Software Transactional Memory)은 데이터베이스의 트랜잭션과 유사한 방법으로 메모리 공유 문제를 해결하는 방법입니다.

데이터베이스의 트랜젝션은 조회 및 수정 이후 커밋 처리하면 성공 또는 롤백으로 응답하게 되는데, 성공이 되었으면 여러 데이터베이스 테이블에 대한 수정이 한 번에 적용되며 실패했으면 롤백 되어 수정 사항을 반영하지 않습니다.

STM도 비슷하게 여러 STM 변수와 STM 전용 자료구조를 수정한 뒤 일종의 커밋 과정을 거쳐 갑니다. 이때 조회 또는 수정한 자료구조들이 다른 쓰레드에 의해서 수정되었다면 실패 처리하고 처음부터 다시 수정 및 커밋 시도하는 구조입니다.

// Scala 코드
// ZIO 라이브러리를 이용해서 작성된 STM 코드
// 주의: 실제로는 동작하지 않는 코드

// STM 기초 타입들/자료구조들
val playerIds: TSet[String]
val lastJoinedPlayerId: TRef[Option[String]]
val log: TQueue[String]

// STM 조회 및 수정사항 정의
def notifyPlayerJoined(newPlayerId: String): USTM[Unit] =
  // 아래의 모든 요청들은 전부 한 번에 수정된다
  for {
    _                <- playerIds.put(newPlayerId)
    previousPlayerId <- lastJoinedPlayerId.getAndUpdate(Some(newPlayerId))
    _                <- previousPlayerId match {
                          case Some(previousPlayerId) =>
                            log.offer(s"$previousId is no longer the last player")
                          case None =>
                            STM.unit
                        }
    _                <- log.offer(s"$newPlayerId is the new last player")
  } yield ()

// commit으로 수정사항 확정
notifyPlayerJoined("Mango").commit

이런 면에서 Atomic과 유사한 점이 많은데, Atomic은 Compare and Swap으로 대입이나 덧셈 뺄셈과 같은 기본적인 연산들에 대한 Thread-saftey를 제공한다면 STM은 캐 여러 개의 자료구조나 변수에 대한 수정에 대한 Thread-safety를 제공합니다. 또 양쪽 기법 모두 재시도 기반으로 작동하기 때문에 부수 효과가 존재하면 안 된다는 점도 비슷합니다.

STM이 가진 단점은 몇 가지가 더 있는데 재시도 기반이라서 쓰레드간 수정 경쟁이 치열하다면 성능이 하락합니다. 특히 수정 및 조회가 많아서 영향 끼치는 자료구조가 많거나 커밋을 하기 전 계산이 오래 걸린다면 수정 경쟁이 일어날 확률이 더 높아져 효율성이 더 떨어질 수도 있습니다.

결론

이번 동시성 프로그래밍 편은 작성하다 보니 프로그래밍 시리즈 5편 중 가장 긴 편이 되었습니다. 그럼에도 분량상 빠트린 내용도 이것저것 많이 있어서 아쉽습니다. 동시성 프로그래밍은 확실히 완벽한 해결책이 존재하지 않아 어렵습니다. 그래도 다행인 건 반드시 한 프로그램에서 한 가지 동시성 기법을 사용해야 하는 건 아닙니다. 어떤 동시성 프로그래밍 기법이 적절한지 판단하여 높은 성능과 반응성을 가진 아키텍처를 만드는 것에 이번 글, 모자라지만 조금이라도 도움이 되었으면 좋겠습니다.

여담으로 이번 편에서 사실 Haskell과 Zig이라는 언어도 소개하려고 했었는데 안 그래도 높은 글의 난이도가 더 높아질 것 같아서 Swift와 Elixir로 참았습니다. 언젠가 기회가 되신다면 Zig의 Colorblind Async-Await에 대해서 찾아보시는 것을 추천해 드립니다.

이렇게 프로그래밍 언어 시리즈가 끝났습니다. 프로그래밍 언어 시리즈는 사실 제 친구들이나 쿠키런 킹덤 서버팀 동료들과 프로그래밍 언어에 대해서 논쟁을 하면서 나왔었던 주제들을 총집합해서 엮어낸 시리즈입니다. 제 친구들과 동료들이 없었으면 작성할 수 없었을 것입니다. 제 친구들과 쿠키런 킹덤 서버팀에게 이 시리즈를 바칩니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 [쿠키런: 킹덤] 게임 서버 소프트웨어 엔지니어를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
게임서버Scala함수형

© 2024 Devsisters Corp. All Rights Reserved.