#프로그래밍 언어 시리즈
- 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
- 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
- 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타 프로그래밍
- 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
- 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가지로 나눌 수 있습니다.
- 파일 읽기/쓰기, 네트워크 송신 등 I/O 작업이 포함되어있어서
- 순수하게 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
비동기 프로그래밍을 배울 때 가장 먼저 Promise와 Future에 대해서 알아보아야 합니다. 쓰레드를 돌릴 때 대부분의 경우 실행 결과도 필요합니다. 이때 사용하는 것은 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를 가져옵니다. promise에 set_value 함수가 호출되었을 때 future의 wait가 끝나는 것입니다.
물론 이렇게 직접 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는 완전히 새로 만들어진 기능이 아닌 Promise의 then을 이용해서 만들어진 것입니다. 물론 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++;
}
}