쉽게 쓰여진 멀티쓰레딩

번역글: 병행처리의 세계에 한발짝 더 다가서기

April 20, 2019 - 10 minute read -

해당 포스팅은 **internal / pointers에 게재된 A gentle introduction to multithreading의 번역 글입니다. 원작자의 허락을 받고 번역 글을 게재했음을 밝힙니다.

Suitcase image

컴퓨터는 현대에 들어 동시에 여러 개의 연산을 처리하는 것이 가능해졌습니다. 하드웨어의 발전과 더 똑똑해진 운영체제(OS)로 인해 가능해진 이 기능은, 코드 실행의 속도와 반응성 측면에서 당신이 만든 프로그램이 더 빨리 실행되도록 도와줍니다.

이 기능을 활용할 수 있는 소프트웨어를 짜는 것은 매우 흥미로운 일이지만, 동시에 까다롭기도 합니다: 당신이 컴퓨터 속에서 무슨 일이 일어나고 있는지에 대해서 이해해야 하기 때문이죠. 이 첫 번째 에피소드에서는 저는 운영체제가 제공하는 여러 가지의 도구 중에 쓰레드(Threads)에 대해서 설명할 수 있도록 해보겠습니다.



프로세스와 쓰레드: 제대로 알기

현대의 운영체제는 동시에 여러 개의 프로그램을 동시에 실행할 수 있습니다. 이것 덕분에 당신이 웹 브라우저(프로그램)에서 기사를 읽으면서 동시에 뮤직 플레이어(다른 프로그램)에서 음악을 들을 수 있는 것이지요. 이 각각의 프로그램은 실행되고 있는 “프로세스(Process)”라고 불립니다. 운영체제는 이 프로세스가 제한된 환경에서 다른 프로세스들과 조화롭게 같이 실행되는 방법이나, 운영체제가 그 밑의 하드웨어의 기능을 잘 활용할 수 있는 여러 가지의 비법을 알고 있습니다. 어느 방식을 택하든, 결국 당신이 느끼기에는 실행한 모든 프로그램이 동시에 실행된다고 느끼게 될 겁니다.

운영체제에서 여러 개의 연산을 동시에 처리하는 방법은 프로세스를 여러 개 실행시키는 것 말고도 또 있습니다. 각각의 프로세스는 동시에 여러 개의 작업들을 처리할 수 있는데, 이것은 “쓰레드(Thread)”라고 불립니다. 쓰레드는 프로세스의 한 조각이라고 생각해도 좋습니다. 모든 프로세스들은 시작 시점에 최소 한개의 쓰레드를 만드는데, 이것을 메인쓰레드(Main Thread)라고 부릅니다. 그 이후에 해당 프로그램이나 개발자의 필요에 따라서 추가의 쓰레드들이 시작되거나 종료됩니다. “멀티쓰레딩(Multithreading)”은 이 하나의 프로세스 내에서 여러 개의 쓰레드를 처리하는 일을 말합니다.

예를 들어, 당신의 뮤직 플레이어는 여러 개의 쓰레드를 사용할 확률이 높습니다: 하나는 UI를 그리는데(이건 보통 메인쓰레드가 담당합니다), 다른 하나는 음악을 재생하는데 등 말이죠.

운영체제를 여러 개의 프로세스를 담고 있는 하나의 상자로 생각해도 좋습니다. 그 각각의 프로세스들은 여러 개의 쓰레드들을 가지고 있고요. 이 글에서는 필자는 쓰레드에 대해서만 이야기를 할 예정이지만, 이 외의 내용도 몹시 흥미로우며 추가에 더 깊이 다루어질 예정입니다.

Process and threads

운영체제를 여러 개의 프로세스를 담고 있는 하나의 상자로 생각해도 좋습니다. 그 각각의 프로세스들은 여러 개의 쓰레드들을 가지고 있고요.



프로세스와 쓰레드의 차이점

각 프로세스는 운영체제에 의해서 부여된 메모리의 조각을 가지고 있습니다. 기본적으로, 이 메모리 조각은 다른 프로세스들과 공유될 수 없습니다: 예를 들어 당신의 웹 브라우저는 뮤직 플레이어에게 할당된 메모리 조각을 들여다볼 수 없고 반대로도 마찬가지죠. 같은 프로세스의 여러 인스턴스에도 같은 규칙이 적용됩니다. 예를 들어 브라우저를 2번 실행시킬 경우, 운영체제는 각각의 인스턴스를 새로운 프로세스로 취급하고 각각에 메모리 조각을 부여합니다. 따라서, 기본적으로는, 2개 이상의 프로세스들은 서로 데이터를 공유할 방법이 없습니다. 이들이 서로 약속한 방법을 쓰지 않는다면 말이죠. 이 방법은 프로세스 간 통신(Inter-Process Communication), 혹은 IPC 라고 불립니다.

프로세스와 달리, 쓰레드는 부모 프로세스에게서 할당된 메모리 조각을 공유합니다: 뮤직 플레이어의 UI에 노출된 데이터는 쉽게 오디오 엔진이 읽을 수 있고 이 반대로도 마찬가지죠. 따라서 쓰레드들은 서로와 통신하기 훨씬 용이합니다. 게다가, 쓰레드는 프로세스들보다 일반적으로 가볍습니다: 리소스를 덜 차지하고 생성하기 더 빠르죠. 이게 쓰레드가 가벼운 프로세스(lightweight process)로 불리는 이유이기도 합니다.

쓰레드는 당신의 프로그램이 여러 개의 연산을 동시에 처리할 수 있도록 도와주는 매우 간편한 방식입니다. 쓰레드가 없다면, 당신은 아마 한 개의 작업 당 한 개의 프로그램을 짜서, 각각 프로세스로 실행시킨 뒤 운영체제를 통해서 서로 통신을 해야 할 것입니다. 이건 더 어려울 뿐만 아니라(IPC는 까다롭습니다), 더 느릴 것입니다(프로세스는 쓰레드보다 무겁습니다).



Green threads, 혹은 fiber, 혹은 괴짜 쓰레드

지금까지 논한 쓰레드는 운영체제가 지원하는 쓰레드를 의미합니다: 하나의 프로세스가 새로운 쓰레드를 만들려면 운영체제에게 요청을 해야 합니다. 하지만 모든 플랫폼의 운영체제가 쓰레드를 지원하는건 아닙니다. 그린쓰레드(Green Thread) 혹은 파이버(Fiber)라고 불리는 것들은, 멀티쓰레딩을 지원하는 프로그램들이 네이티브 하게 쓰레드를 지원하지 않는 환경에서 멀티쓰레딩을 할 수 있도록 도와주는 가상의 쓰레드입니다. 예를 들어, 가상머신(Virtual Machine)은 기저의 부모 운영체제가 쓰레드를 지원하는지 모르기 때문에 그린쓰레드를 구현해야 할 수도 있습니다.

그린쓰레드는 운영체제와 무관하게 작동하기 때문에 생성하는데 더 빠르며, 관리하기 더 쉽지만, 단점도 있습니다. 이 단점들에 대해서는 이후 글에 대해서 다루겠습니다.

“그린쓰레드”라는 이름은 Sun Microsystems사에서 90년대에 Java의 쓰레드 라이브러리를 디자인한 그린팀(Green Team)에서 왔습니다. 오늘날에는 Java는 더 이상 그린쓰레드를 사용하지 않습니다: 2000년에 다시 네이티브한 쓰레드로 변경했기 때문이죠. 하지만 다른 프로그래밍 언어 - 예를 들어 Go, Haskell, 이나 Rust 등 같은 경우 네이티브 쓰레드 대신 그린쓰레드를 구현해서 사용합니다.



쓰레드, 뭐할 때 쓰나요?

왜 프로세스가 여러 개의 쓰레드를 사용해야 할까요? 위에서 말한 바와 같이, 여러 개의 일을 동시다발적으로 처리할 경우, 시간을 아낄 수 있습니다. 영상 편집 프로그램에서 영화를 렌더링하는 예시를 들어보죠. 사용하는 에디터가 잘 짜여졌다면, 렌더링 작업을 여러 개의 쓰레드로 나누어서 분산처리할 것입니다. 그렇다면 하나의 쓰레드는 최종 렌더링 된 영화의 한 조각을 처리하겠죠. 따라서, 만약 하나의 쓰레드가 작업하면 1시간이 걸릴 작업이 2개의 쓰레드로는 30분; 4개의 쓰레드로는 15분.. 이 걸릴 것입니다.

정말 이렇게 단순할까요? 알아야 할 3가지 중요한 점에 관해서 이야기해보죠.

  1. 모든 프로그램이 멀티쓰레딩을 지원할 필요는 없습니다. 만약 당신의 앱이 순차적으로 진행되는 연산만 필요하거나, 유저가 무엇을 하는 동안 자주 기다린다면 멀티쓰레딩을 지원한다고 해서 그다지 앱의 실행 속도에 혜택이 없을 수도 있습니다.

  2. 어플리케이션에 쓰레드를 더 많이 쓰게 한다고 해서 빨라지는 것도 아닙니다: 하나하나의 작업이 조심스럽게 생각하고 디자인되어야 병렬 처리하는 의미가 있습니다.

  3. 쓰레드들이 동시에 연산들을 처리할지에 대한 100%의 보장이 없습니다: 사실은 이건 하드웨어가 결정하는 부분이거든요.

위 세 가지 중 3번이 가장 중요합니다: 만약 당신의 컴퓨터가 여러 개의 연산을 동시처리 하는걸 지원하지 않는다면, 운영체제는 이걸 “하는 척”을 해야 합니다. 이걸 어떻게 하는지는 아래에 더 이야기하겠지만 지금으로서는 병행(Concurrency)는 작업들은 동시에 처리되는 것처럼 보이는 거라면 병렬(Parallelism)은 실제로 작업이 동시에 처리되는 것을 말하는 것이라고 해두죠.

Concurrency vs Parallelism

병렬(Concurrency)은 병행(Concurrency)에 포함 관계를 가지고 있습니다



병행처리와 병렬처리를 가능케 해주는 것들

당신의 컴퓨터의 CPU(Central Processing Unit)는 실제로 프로그램을 돌리는 역할을 담장하고 있습니다. CPU는 여러가지 부분으로 이루어져 있는데, 그 중 핵심을 코어(Core)라고 부릅니다: 연산이 실제로 이곳에서 처리되죠. 코어는 매 순간에 하나의 연산을 처리할 수 있습니다.

이 조건은 물론 큰 단점입니다! 이를 극복하기 위해 운영체제들은, 특히나 그래픽한 환경에서, 하나의 코어(Single Core)를 가진 머신에서도 다수의 프로세스나 쓰레드를 돌릴 수 있는 복잡한 기법들을 개발해왔습니다. 이 중 가장 중요한 기법은 “선점형 멀티태스킹(Preemptive Multitasking)”인데요, 여기서 “선점형”의 의미는 어떤 작업(task)을 방해하고, 다른 작업으로 넘어간 뒤 일정 시간 이후에 다시 원래의 작업으로 돌아가서 그 작업을 처리할 수 있는 기능을 의미합니다.

따라서, 당신의 CPU가 한 개의 코어를 가지고 있다고 한다면, 당신의 운영체제가 하는 일 중 일부는 하나의 코어가 낼 수 있는 연산 능력을 여러 개의 프로세스나 쓰레드에 적절하게 분배해주는 일일 것입니다. 이 일 덕분에 당신의 여러 개의 프로그램을 동시에 실행한다거나, 하나의 프로그램이 여러 가지 작업(멀티쓰레딩을 지원한다면 말이죠)을 하는 것처럼 보이는 것이죠. 병행처리는 실현되었지만, 실질적인 병렬처리 - 여러 개의 프로세스가 동시에 처리되는 것 - 는 아직 실현되지 않았습니다.

오늘날의 최신식 CPU는 내부에 한순간에 각자 다른 연산을 처리할 수 있는 코어를 여러 개 가지고 있습니다. 2개 이상의 코어를 가진 CPU라면 실제로 병렬 처리를 구현할 수 있다는 이야기죠. 예를 들어, 필자의 Intel Core i7 CPU는 4개의 코어를 가지고 있습니다: 따라서 한순간에 4개의 다른 프로세스나 쓰레드를 동시에 처리할 수 있죠.

운영체제는 CPU 코어의 개수를 확인하고 각각의 코어에 프로세스나 쓰레드를 할당할 수 있습니다. 하나의 쓰레드가 어느 코어에 배정될지는 운영체제의 마음대로이며, 이런 스케줄링(Scheduling)은 실행되는 프로그램에 투명하게 공개됩니다. 추가로, 모든 코어가 바쁘다면 선점형 멀티태스킹이 작동하게 될 겁니다. 이로 인해 당신의 CPU에 있는 실제 코어 수보다 훨씬 더 많은 수의 프로세스나 쓰레드를 실행할 수 있습니다.



싱글 코어 머신에서의 멀티쓰레딩: 말이 되는가?

싱글 코어 머신에서 실제 병렬처리를 구현하기란 불가능합니다. 하지만, 만약 당신이 짜는 프로그램이 멀티쓰레딩을 사용함으로써 성능 향상을 얻을 수 있다면 도입을 고려할만합니다. 프로세스 하나가 여러 개의 쓰레드를 거느리고 있다면, 선점형 멀티태스킹 덕분에 운영체제는 그중 하나의 쓰레드가 느리거나 멈춰버려도 그 어플리케이션을 계속 실행시킬 수 있습니다.

예시를 들어보죠: 굉장히 느린 디스크에서 데이터를 읽는 데스크탑 어플리케이션을 만들고 있다고 해봅시다. 만약 프로그램을 하나의 쓰레드만 사용해서 짠다면, 디스크를 읽는 작업이 시작해서 끝날때 까지 프로그램이 멈춰있을겁니다: 디스크가 깨어날 때 까지 해당 쓰레드에 할당된 CPU의 연산능력은 낭비되는거죠. 물론, 당신의 운영체제는 다른 프로세스들은 잘 실행할 수 있겠지만, 당신이 짠 어플리케이션은 아무것도 할 수 없을겁니다.

당신의 어플리케이션을 멀티쓰레딩을 적용한 방식으로 다시 생각해보죠. 쓰레드 A는 디스크를 읽고/쓰는 일을 담당하고, 쓰레드 B는 UI를 담당한다고 생각해봅시다. 만약 쓰레드 A가 느린 디스크 때문에 멈춰있더라도, 쓰레드 B는 UI를 반응 상태로 유지할 수 있습니다. 이건 물론 운영체제가 2개에게 쓰레드가 주어졌을 때는 CPU의 연산 능력을 2개의 쓰레드에 바꿔가면서 분배할 수 있기 때문이죠.



더 많은 쓰레드, 더 많은 문제들

위에서 말한 것과 같이, 쓰레드는 부모 프로세스와 메모리 조각을 공유해서 사용합니다. 이 덕분에 쓰레드들을 같은 어플리케이션 내에서는 굉장히 쉽게 데이터를 주고받을 수 있죠. 예를 들어: 영상 편집 프로그램은 공유되는 메모리의 굉장히 큰 부분을 영상의 타임라인을 저장하는 데 사용할 수 있습니다. 이 공유되는 메모리는 영상을 파일로 저장하는 일을 하는 여러 개의 일꾼 쓰레드가 읽게 됩니다. 일꾼 쓰레드들은 그 메모리 영역에 대한 주소(예를 들어, 포인터라든지) 값만 주어지면, 메모리에서 읽어 디스크에 렌더링 된 결과물을 기록할 수 있습니다.

다수의 쓰레드가 하나의 메모리 영역으로부터 읽기만 할 때에는 문제가 발생하지 않습니다. 문제는 다른 쓰레드가 메모리에서 읽고 있을 때 하나 이상의 쓰레드가 메모리에 쓰려고 할 때 발생하죠. 이 시점에 2개의 문제가 발생할 수 있는데:

  • 데이터 레이스 (Data Race) - 어떤 쓰레드가 메모리에 어떤 값을 쓰려고 할 때, 다른 쓰레드는 이 메모리 값을 읽을 수 있습니다. 만약 메모리를 수정하고 있는 쓰레드가 작업을 마치지 않았다면, 메모리를 읽는 쓰레드는 잘못된 정보를 읽어올 수 있습니다.

  • 레이스 컨디션 (Race Condiction) - 쓰기용 쓰레드가 작업을 끝나야만 읽기용 쓰레드가 작업을 할 수 있다고 가정해봅시다. 만약 이 순서가 뒤바뀐다면 어떻게 될까요? 데이터 레이스 상황보다 조금 더 복잡한데 레이스 컨디션은 논리적으로 작업 순서가 정해져있는 2개 이상의 쓰레드가 알 수 없는 순서대로 작업을 처리하는걸 의미합니다. 당신의 프로그램은 데이터 레이스을 방지했더라도 레이스 컨디션을 일으킬 수 있습니다.

쓰레드 안정성(Thread safety)

여러 개의 쓰레드들이 한 블록의 코드를 동시에 실행할 때, 위에 나타난 데이터 레이스나 레이스 컨디션이 발생하지 않으며 정상적으로 작동하는 코드를 “쓰레드 세이프(Thread-safe)”하다고 표현합니다. 웹상에서 어떤 라이브러리들이 쓰레드 세이프 하다고 설명하는 것을 보신 적이 있을 겁니다: 만약 당신이 멀티쓰레딩을 지원하는 프로그램을 작성 중이라면, 제3자가 제공한 라이브러리를 사용할때 동시성 문제를 제공할 수 있는 지를 확인하는게 좋습니다.



데이터 레이스(Data race)의 근본적인 이유?

우리는 이미 하나의 CPU 코어가 한순간에 하나의 머신 명령어(Instruction)를 실행할 수 있는걸 알고 있습니다. 이런 CPU 명령어는 더 이상 작은 명령어로 나눌 수 없기 때문에 “원자성을 가진다(Atomic)”라고 표현합니다. 그리스어 “원자(Atom (ἄτομος; atomos)”는 더 이상 나눌 수 없는 이라는 뜻을 가집니다.

더 이상 나눌 수 없는 특성은 원자성을 가진 명령어를 자연스럽게 쓰레드 세이프하게 만들어줍니다. 하나의 쓰레드가 공유된 메모리에 원자성을 가진 쓰기 명령어를 실행할 경우, 어느 누구도 이 쓰기 명령 도중 해당 수정사항을 읽어갈 수 없습니다. 또 반대로, 어떤 쓰레드가 공유된 메모리에 원자성을 가진 읽기 명령을 실행할 경우, 그 순간에 있는 데이터를 통째로 읽어옵니다. 쓰레드는 절대 원자성을 가진 명령어 사이에 끼어들 수 없으며, 고로 데이터 레이스는 발생할 수 없습니다.

나쁜 소식은, 대부분의 명령어는 “원자성을 가지지 않는다(Non-atomic)”는 것입니다. 가장 간단한 변수 할당인 x = 1 조차도 특정 하드웨어에서는 여러 개의 원자성을 띠는 명령어로 구성될 수 있습니다. 따라서 변수 할당 자체가 원자성을 가지지 못하는 것이죠. 따라서 어떤 쓰레드가 x를 읽을 동안 해당 변수에 값을 할당할 경우 데이터 레이스 상황이 발생해버립니다.



레이스 컨디션(Race Condition)의 근본적인 이유?

선점적 멀티테스킹은 운영체제에게 쓰레는 관리에 있어서 모든 권한을 부여합니다: 운영체제는 자체적인 스케줄링 알고리즘을 바탕으로 쓰레드들은 시작하거나, 멈추거나, 일시정지 시킬 수 있습니다. 프로그래머인 당신은 이 실행 여부를 결정할 수 없습니다. 극단적으로, 다음과 같은 코드가 쓰여진 순서대로 실행될지는 알 수 없습니다!

writer_thread.start()
reader_thread.start()

위의 프로그램을 여러 번 실행시킬 경우, 매번 다른 결과가 나온다는 걸 알 수 있을 겁니다: 어떨 때는 writer_thread가 먼저 시작되며, 어떨 때는 reader_thread 가 먼저 시작될 겁니다. 만약 당신이 작성하고자 하는 프로그램이 항상 읽기 전에 써야 한다면 레이스 컨디션에 봉착하게 될 겁니다.

이런 특징은 비결정적(Non-deterministic)이라고 불립니다: 결과값이 매번 바뀌며 당신은 그것을 예상할 수 없는 상태죠. 레이스 컨디션이 있는 프로그램을 디버깅을 하다 보면 문제를 재현하는데 일관된 결과값이 나오지 않기 때문에 디버깅이 굉장히 까다롭습니다.



쓰레드를 잘 관리하는 방법: 동시성 제어(Concurrency Control)

데이터 레이스나 레이스 컨디션은 실제로 프로그램에 발생하는 굉장히 심각한 문제들입니다: 심지어 이런 문제 때문에 목숨을 잃은 사람들도 있습니다. 2개 이상의 동시에 작동하는 쓰레드를 관리하는 방법을 동시성 제어(Concurrency Control) 라고 부릅니다: 운영체제나 프로그래밍 언어들 모두 동시성 제어를 도와주는 여러가지 솔루션을들 제공하죠. 가장 중요한 것들을 꼽아보자면:

  • 동기화(Synchronization) - 리소스들을 한순간에 하나의 쓰레드만 접근할 수 있도록 하는 방법. 동기화는 당신의 코드의 일부분을 protected 으로 선언함으로써 2개 이상의 쓰레드가 동시에 실행해 공유되는 데이터에 비정상적인 값을 넣지 못하게 막는 방법입니다.

  • 원자성을 가진 명령어(Atomic Operations) - 운영체제에서 지원하는 여러 개의 명령어 덕분에 위에서 봤던 변수 할당을 비롯해 여러 개의 원자성을 가지지 않는 명령어를 원자성을 띠게 할 수 있습니다. 이렇다면 공유되는 데이터는 여러 개의 쓰레드가 동시에 읽고 써는 작업을 해도 항상 옳은 값을 가지고 있을 겁니다.

  • 불변 데이터(Immutable data) - 공유된 데이터가 불변(Immutable)하게 선언되었을 경우, 아무것도 그걸 수정할 수 없습니다: 모든 쓰레드는 해당 데이터를 읽기만 할 수 있으며, 고로 동시성과 발생하는 근본적인 원인을 제거해버립니다. 우리와 배운 바와 같이, 아무것도 수정을 시도하지 않는 이상 쓰레드들은 안전하게 같은 메모리 주소에서 읽어올 수 있습니다. 이것이 함수형 프로그래밍(Functional Programming)에 바탕이 되는 철학 중 하나입니다.

필자는 이런 동시성에 관련된 주제들에 대해서 시리즈로 다룰 예정입니다. 다음 에피소드를 기대해주세요!



Reference

8 bit avenue - Difference between Multiprogramming, Multitasking, Multithreading and Multiprocessing
Wikipedia - Inter-process communication
Wikipedia - Process (computing)
Wikipedia - Concurrency (computer science)
Wikipedia - Parallel computing
Wikipedia - Multithreading (computer architecture)
Stackoverflow - Threads & Processes Vs MultiThreading & Multi-Core/MultiProcessor: How they are mapped?
Stackoverflow - Difference between core and processor?
Wikipedia - Thread (computing)
Wikipedia - Computer multitasking
Ibm.com - Benefits of threads
Haskell.org - Parallelism vs. Concurrency
Stackoverflow - Can multithreading be implemented on a single processor system?
HowToGeek - CPU Basics: Multiple CPUs, Cores, and Hyper-Threading Explained
Oracle.com - 1.2 What is a Data Race?
Jaka's corner - Data race and mutex
Wikipedia - Thread safety
Preshing on Programming - Atomic vs. Non-Atomic Operations
Wikipedia - Green threads
Stackoverflow - Why should I use a thread vs. using a process?