"당신이 다뤄본 프로그래밍 개념 중 가장 어려운 개념은 무엇이었나요?"
학부 초입이라면 C 언어의 포인터를, 학부 중반이라면 자료구조나 혹은 다형성 및 상속 등 객체지향적 특성들을 얘기할 수 있겠지만, 최종적으로 나름 유망하거나 실력있는 프로그래머들조차 입을 모아 진저리치는 개념이 있는데요.
"저는 멀티 스레딩이 제일 어려웠던 것 같습니다."
오늘 해볼 이야기는 바로 스레드 - 그중에서도 멀티 스레드와 락(lock)에 관한 이야기입니다.
프로그램을 실행하는 장치는 CPU입니다. 물론 GPU 등의 장치에서도 가능하지만, 지금은 고전적인 프로그래밍의 시선으로 보자구요.
더 빠른 컴퓨터를 위해, 즉 CPU의 성능을 올리기 위해 사람들은 갖은 노력을 기울여 왔는데, 단순하게는 클락 스피드를 높이는 것부터 시작해서 다중 코어에 이르기까지 긴 시간을 기울여 왔죠.
자동차를 생각해보세요. 자동차를 더 빠르게 굴리는 방법은 뭐가 있을까요?
단순하게는 자동차 엔진을 더 폭발적으로 돌리는 것. 즉, 더 많은 연료와 더 시끄러운 엔진일 것입니다. 이는 그다지 효율적이지 않고 한계가 명확해 보이죠.
빠른 속도의 엔진을 감당하기 위해서 취할 수 있는 방법 중에서는 이보다 세련된 방법이 있습니다. 바로 엔진 그 자체를 증축하는 것. 즉 엔진을 여러개 붙이는 것입니다.
늘어난 엔진은 분명 더 빠른 속도를 감당할 수 있겠지만, 두 엔진이 별도로 동작하는 것은 자동차가 마치 서로 몸을 지배하려고 싸우는 쌍두룡처럼 움직이게 할지도 모릅니다.
비상용 서브 엔진이 필요한 경우가 아니라면, 엔진 그 자체를 두 개나 싣고 다니는 자동차는 거의 존재하지 않죠.
보다 현실적인 방법은 엔진을 완전히 두개로 늘리기보단, 엔진의 핵심 코어를 늘려 엔진의 스펙을 향상시키는 것.
우리가 흔히 6기통, 8기통이라고 칭하는 N기통의 N이 엔진의 핵심 코어 수라고 볼 수 있습니다.
신기하게도 CPU도 이와 비슷한 발전을 겪어 왔는데요.
컴퓨터가 프로그램을 처리하기 위해서는 단순히 폭발적인 엔진 -CPU의 클락 스피드 증가- 을 가질 수도 있고, 엔진의 코어 수 증가 -CPU의 코어 수 증가- 를 통해 프로그램을 여러 코어가 나누어서 처리하여 성능 향상을 기대해 볼 수도 있습니다.
"잠깐, 프로그램을 쪼갠다고요? 대체 어떻게 그게 가능하죠?"
만약 당신이 멀티 스레드를 이미 알고 계시다면, 작업 분할로 성능을 향상 시킨다는 대목에서 거부감이 느껴졌을 수도 있고, 만약 처음 보는 개념이었다면 스레드라는 새로운 개념을 위해 눈 빛이 반짝거리고 계실지도 모르겠네요.
스레드라는 말을 방금 했던 거 같은데 스레드는 대체 무엇일까요? 스레드에 대해 알기 전 프로세스가 무엇인지 생각해 봅시다.
프로세스는 논리적으로 현재 실행되고 있는 하나의 프로그램입니다. 윈도우에서 작업관리자를 열어보면, 수많은 현재 실행중인 프로그램 - 즉, 수 많은 프로세스를 보실 수 있는데요.
대체로 대부분의 실체적 프로그램 하나는 논리적으로 하나의 프로세스를 가지게 됩니다. - 물론 근래의 프로그램은 극단적인 성능 향상을 위해서 다중 프로세스로 프로그래밍 된 경우가 많이 있습니다. 찾아보는 것도 재미있을 것 같네요!
예외적으로 웹 브라우저 정도가 탭의 개념을 통해 하나의 탭당 하나의 프로세스를 생성하는 멀티 프로세스 개념을 구현하고 있으며, 그렇기 때문에 실제로 웹 브라우저, 대표적으로 크롬 등은 무거운 프로그램에 속합니다.
멀티 프로세스로 프로그램을 쪼개게 되면, 각 프로세스마다 여유있는 CPU의 코어를 점유하여 프로그램을 좀 더 빠르고 효율적으로 구동하는 것이 가능해집니다.
멀티 프로세스는 프로그램을 쪼개는 가장 기본적인 아이디어이지만 몇가지 단점이 있는데, 우선은 각 프로세스는 완전히 독립적이라 자원을 서로 공유하기가 힘들다는 점이며, 두번째는 프로세스마다 갖게되는 자원을 분할된 프로세스도 갖기 때문에 프로그램이 너무 무거워진다는 점이죠.
그렇기 때문에 하나의 프로세스 내에서 프로세스 자원은 함께 공유하는 형태로, 완전히 독립적이라기 보다는 프로세스 내에서 부분 독립적으로 실행되는, 프로그램 내의 일종의 갈래가 존재하는데 이를 스레드라고 부릅니다.
스레드는 멀티 프로세스 프로그램처럼 프로그램의 규모를 크게 키우지 않으면서도, 프로그램의 성능을 최대로 이끌어 낼 수 있다는 장점 덕에 많은 프로그래머가 사랑하는 개념이면서도 동시에 그 악랄함에 혐오하는 개념이기도 한데요.
멀티 스레드로 성능을 향상한다는 것은 필연적으로 자원 공유의 문제가 발생하기 때문입니다.
컴퓨터 공학에서 자원이란 하드웨어의 구성(메모리, CPU, 네트워크)을 의미하며, 특히 지금 이야기하려는 공유하는 자원은 대체로 메모리를 의미합니다.
좋아요. 1~100까지 수의 합을 단순 순회하여 구하는 프로그램을 작성해 봅시다.
물론 우리는 가우스의 방식을 따를 수 있겠지만 지금은 예시를 위해 루프를 사용하겠습니다.
만약 C를 사용해서 코딩한다면 다음과 같은 코드가 짜일 것입니다.
#include <stdio.h>
void add(int* sum, int i) {
*sum += i;
}
int main() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
add(&sum, i);
}
printf("%d", sum);
}
위의 코드를 자원 공유 문제를 설명하기 위해 게시판에 숫자를 계속 더하는 사람의 이야기로 설명해보겠습니다.
처음에는 게시판에 0이 적혀있고, 한 사람이 게시판의 숫자를 보고, 다음 숫자를 더해 게시판의 숫자를 지우고 계산한 숫자를 써넣습니다.
이 과정을 100번 반복하다보면, 분명 계산이 끝날 것이고 5050이 적혀있겠죠.
만약 멀티 스레딩 즉, 프로그램을 분할한다면 두 명의 사람이 숫자를 계산하는 꼴이 될 것이고, 잘 분배된 작업이라면 이는 분명 평균적으로 두배의 성능 향상을 보여줄 것입니다.
가정으로, A는 홀수를 계속 더하고, B는 짝수를 계속 더한다고 생각해봅시다.
위 예시에서는 두 사람이 일을 하고 있는데, 이를 프로그램으로 구현한다면 결과는 얼마가 나올까요?
결과는 경우에 따라 다르겠지만, 대부분의 경우 5050이 나오지 않게 됩니다. 그 이유가 무엇일까요?
이것이 앞서 말한 자원 공유의 문제점입니다.
A가 게시판에서 0을 읽었습니다. 뒤돌아서 종이에 계산을 하고, 0 + 1을 하여 1을 계산했죠. A는 게시판을 지우고 1을 씁니다.
이번에는 B가 게시판에서 1을 읽었습니다. B도 종이에 계산을 하고, 1 + 2 = 3을 계산하여, 게시판의 4를 지우고 3을 씁니다.
잠깐, 뭔가 이상한데요?
왜 게시판에는 4가 써있던 거죠? B가 뒤돌아 종이에 계산을 하는 사이, 좀 더 계산이 빠른 A가 다음 숫자인 3을 B가 2를 더하기 전에 미리 더해서 1 + 3 = 4를 써버린 겁니다.
심지어 B는 그것을 눈치채지 못하고 A가 쓴 값을 지우고 자신이 계산한 결과를 써버리게 됩니다.
이런 과정이 누적되다 보면 마지막 순간에는 목표치인 5050보다 낮은 값이 높은 확률로 등장할 수 밖에 없게 됩니다.
이게 자원 공유의 문제입니다. 내가 값을 수정하는 동안에는, 누구도 이 숫자를 건드려서는 안된다는 것이죠.
일련의 작업 묶음이 적어도 이 자원에 대해서 다른 프로세서의 접근없이 안전하게 실행하는 것을 원자적(Atomical)으로 실행한다고 하며,
이러한 원자적 실행은 단순히 코드가 한 줄이라고 해도 그 안의 실제 구현부 기계어 코드는 여러줄일 수도 있어, 한줄의 코드도 적절한 조치가 없다면 쪼개져서 실행될 수 있음을 의미합니다.
위에서 필요한 "적절한 조치"라는 개념을 프로그램에서 구현한 것을 락이라고 부릅니다.
그럼 락을 사용해 봅시다. 두 사람이 여전히 게시판에 숫자를 더하고 있습니다.
한명이 게시판을 잠그고 열쇠를 가진 채로 뒤돌아서 종이에 계산을 하고 게시판 문을 열고 수정을 한 뒤 열쇠를 게시판 옆 열쇠 걸이에 걸어둡니다.
열쇠가 없어서 다른 사람은 계산을 끝마쳐도 게시판을 수정할 수 없었습니다. 열쇠가 열쇠 걸이에 걸리기 전까지는요.
열쇠가 놓이고, 이제야 다른 사람이 열쇠를 가지고 게시판을 수정할 권리가 생겼네요. 다음 사람도 앞 사람처럼 일을 하고, 이를 반복합니다.
좋아요. 열쇠를 사용해서 게시판의 값이 의도된대로 흘러가게 만들었습니다. 근데 성능은요?
한 사람이 일하는 동안 다른 사람은 아무런 일도 하지 못합니다. 그저 기다릴 뿐이죠. 이런 상황이라면 두명을 고용한 이유 자체가 없어집니다.
심지어 열쇠를 열고 잠그는 불필요한 시간이 생기게 됩니다. 실제 프로그램에서도 락을 너무 자주 사용하면 그에 따른 오버헤드가 필연적으로 발생합니다.
이것이 멀티 스레딩이 어려운 이유입니다.
여러명이 일을 처리해야 할 때 적절히 일을 분배해 주지 않는다면 안하니만 못한 결과를 만들어 내니까요.
문제는 이 일을 "적절히" 분배하는 것에 나름의 노하우가 필요하다는 사실입니다.
위 문제에서 두배의 성능을 보고 싶다면 게시판을 세개 만들고, 두 사람이 서로 간섭없이 일을 한 다음에, 일이 끝나면 남아있는 하나의 공유 게시판으로 가서 숫자 더해서 씁니다.
다음 사람의 일이 끝난다면 다음 사람도 공유 게시판으로 가서 그 숫자에 자신의 숫자를 더해서 다시 쓰면 되겠죠.
여기서는 공유 게시판 앞에 열쇠를 놓아야 합니다. 현실적으로 두 사람이 동시에 게시판에 글을 쓰지는 않겠지만, 컴퓨터는 충분히 두 작업이 동시에 이루어질 수 있고, 이는 원하지 않는 값을 만들 수 있기 때문입니다.
열쇠로 게시판을 열고 잠그는 오버헤드가 조금 있겠지만, 단 두번의 락 과정은 충분히 봐줄만 합니다.
특히 위와 같은 예시는 문제를 더 작은 문제로 쪼갠 뒤 나중에 큰 문제로 합쳐서 풀어내는 DP(Dynamic Programming)의 한 예시가 될 수도 있습니다.
Quiz
N개의 숫자를 더하는데 N명이 함께 계산을 한다면 가장 효율적인 로직은 무엇일까요? 힌트는 이진 트리입니다.
만약 1000개의 *.txt 파일을 전부 읽어서 한 텍스트로 합치는 코드를 짠다면 어떨까요?
혹은 텍스트 하나를 1000명에게 전송하는 코드는요?
이런 경우에는 성능 향상을 위해서는 멀티 스레드가 필연적으로 필요할 수 밖에 없을 겁니다.
오늘 스레드에 대한 이야기, 어떠셨나요?
운영체제를 이미 배우신 분이라면 기억이 새록새록 떠오를지도, 아니라면 저게 무슨 X소리인가 싶으실 수도 있으실겁니다.
프로그래머에게 반드시 필요한 개념이니만큼 잘 기억이 안난다면 다시 한번, 이제 막 배울 차례라면 주의 깊게 들으시길 추천드릴께요!
재미있고 유용하셨다면 다음에도 찾아주세요! 그럼 안녕~