멀티스레드 구현 발전사
단일 태스크와 멀티 태스크
- 컴퓨터가 처음 등장했을 때는 단일 태스크(하나의 프로그램을 순차적으로 실행하며, CPU가 다음 작업을 처리하려면 이전 작업이 끝나기를 기다림)만을 처리할 수 있었습니다.
- 이는 I/O bound 작업에서 CPU가 오래 idle 상태로 있다는 자원 낭비가 있습니다.
- 1960년대에 이르러 멀티프로그래밍과 멀티태스킹 개념이 도입되었습니다.
- 예를 들어, 유닉스(UNIX)가 1970년대에 등장하면서 프로세스 단위의 멀티태스킹을 지원했습니다.
- 하지만 프로세스는 메모리와 자원을 독립적으로 할당받아 무겁고, 문맥 교환(context switching) 비용이 크다는 제약이 있었습니다.
멀티스레드
개요
- 1980년대에 들어서면서 프로세스보다 더 가벼운 실행 단위가 필요하다는 인식이 생겼습니다. 프로세스 내에서 여러 작업을 병렬적으로 실행할 수 있다면, 자원 공유가 쉬워지고 문맥 교환 비용도 줄어들 것이라는 아이디어였죠.
- 여기서 스레드(thread)가 등장합니다. 스레드는 하나의 프로세스 내에서 독립적인 실행 흐름을 가지며, 같은 메모리 공간을 공유하기 때문에 프로세스보다 훨씬 가볍고 효율적이었습니다.
- 최초의 스레드 개념은 학계와 연구실에서 논의되었고, 1980년대 중반부터 OS에 본격적으로 도입되기 시작했습니다.
- 예를 들어, Mach 운영체제(1985년 개발 시작)는 스레드를 기본 실행 단위로 삼아 멀티스레딩을 지원한 초기 사례 중 하나입니다.
C 에서의 구현
-
1980년대에는 아직 표준화된 스레드 API가 없었고, 스레드 구현은 OS마다 달랐습니다. 그러나 1990년대 초반에 이르러 POSIX 스레드(약칭 Pthreads)가 등장하면서 C 언어에서 멀티스레드를 다루는 표준이 마련되었습니다.
-
Pthreads는 OS 가 제공하는 스레드(OS 스레드)를 사용하는 저수준 API로서, 스레드의 생성, 종료, 동기화 등을 직접 관리해야 합니다. 이는 유연성을 제공하지만, 동시에 개발자가 실수할 가능성을 높이고, 특히 스레드 자원이 적절히 해제되지 않으면 스레드 고갈과 같은 문제가 발생할 수 있다는 어려움이 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg) {
printf("스레드 실행 중: %d\n", *(int*)arg);
return NULL;
}
int main() {
pthread_t thread;
int arg = 42;
// 스레드 생성
if (pthread_create(&thread, NULL, thread_func, &arg) != 0) {
perror("스레드 생성 실패");
return 1;
}
// 스레드 종료 대기
pthread_join(thread, NULL);
printf("메인 스레드 종료\n");
return 0;
}
Java 에서의 구현
-
Java는 1995년 출시 초기부터 멀티스레드를 언어 수준에서 지원하며, OS 스레드를 사용하는 추상화된 API를 제공했습니다.
-
Java의 스레드 모델은 플랫폼 독립성을 위해 JVM이 OS 스레드를 매핑해 감싸고 제어하는 방식이었으며, 개발자는 저수준 OS API를 직접 다룰 필요가 없었습니다.
-
다만 가상 스레드 방식이 아니라 여전히 OS 스레드를 그대로 사용하기 때문에 동기화 문제(데드락, 레이스 컨디션 등)와 자원 소모라는 한계를 드러냈습니다.
-
1
2
3
4
5
6
7
8
9
10
11
12
class MyThread extends Thread {
public void run() {
System.out.println("스레드 실행 중");
}
}
public class Main {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start(); // JVM이 OS 스레드를 시작
}
}
가상 스레드
OS 스레드를 직접 사용하는 구현의 한계
- 동기화 문제
- OS 스레드를 사용하는 경우, 여러 스레드가 동일한 자원에 접근할 때 동기화 문제가 발생할 수 있습니다. 대표적인 문제로는 데드락(Deadlock)과 레이스 컨디션(Race Condition)이 있습니다. 이러한 문제를 해결하기 위해서는 적절한 동기화 메커니즘을 사용해야 하며, 이는 개발자에게 추가적인 복잡성을 가져옵니다.
- 자원 소모
-
OS 스레드는 생성 및 관리에 상당한 시스템 자원을 소모합니다. 특히, 많은 수의 스레드를 생성할 경우, 시스템의 메모리와 CPU 자원을 많이 사용하게 되어 성능에 영향을 미칠 수 있습니다. 이는 고성능 서버 애플리케이션이나 대규모 병렬 처리가 필요한 경우에 문제가 될 수 있습니다.
-
구체적으로 OS 스레드는 다음과 같은 이유로 자원 소모가 큽니다.
-
스레드 생성 비용: 운영 체제 스레드를 생성하는 것은 상대적으로 무거운 작업입니다. 각 스레드는 운영 체제 커널에서 관리되며, 스레드 생성 시 운영 체제는 스레드의 스택 메모리와 관련된 커널 구조체를 할당해야 합니다. 이는 메모리와 CPU 자원을 소모하게 됩니다.
-
컨텍스트 스위칭 비용: 운영 체제 스레드 간의 컨텍스트 스위칭은 상당한 비용이 듭니다. 컨텍스트 스위칭은 CPU가 한 스레드의 실행 상태를 저장하고 다른 스레드의 실행 상태를 복원하는 과정으로, 이 과정에서 CPU 레지스터, 메모리 맵핑, 캐시 등이 변경됩니다. 이러한 작업은 CPU 시간을 소모하며, 스레드 수가 많아질수록 성능에 부정적인 영향을 미칠 수 있습니다.
-
메모리 사용량: 운영 체제 스레드는 각 스레드마다 고정된 크기의 스택 메모리를 할당받습니다. 일반적으로 이 스택은 수백 KB에서 수 MB까지 할당되며, 많은 수의 스레드를 생성할 경우 메모리 사용량이 급격히 증가할 수 있습니다.
-
운영 체제 자원 제한: 운영 체제는 동시에 실행할 수 있는 스레드 수에 제한을 두고 있습니다. 이 제한은 시스템의 메모리와 CPU 자원에 따라 달라지며, 많은 수의 스레드를 생성할 경우 운영 체제의 자원 제한에 도달할 수 있습니다.
-
-
가상 스레드: Go의 고루틴(goroutine)
-
Go는 이런 문제를 해결하기 위해 경량 스레드 모델을 도입했는데, 그 중심에 고루틴이 있습니다. 고루틴은 OS 스레드와 1:1로 매핑되지 않고, Go 런타임이 관리하는 가상 스레드로서, 기존 스레드 모델의 한계를 뛰어넘는 새로운 접근법을 제시했습니다.
-
고루틴이 가상 스레드로 불리는 이유는 OS 커널이 아닌 Go 런타임이 스케줄링을 담당하기 때문입니다. Go는 M:N 스케줄링 모델을 사용합니다. 즉, M개의 고루틴을 N개의 OS 스레드에 매핑해 실행하며, 런타임이 자체 스케줄러로 작업을 분배합니다.
-
고루틴은 Go에서 병렬 작업을 수행하는 기본 단위로, OS 스레드보다 훨씬 가볍고 생성 및 관리 비용이 적습니다. OS 스레드가 수 메가바이트의 메모리를 필요로 하는 데 비해, 고루틴은 초기 스택 크기가 2KB(필요 시 동적으로 확장)로 매우 작아 수십만 개의 고루틴을 동시에 실행할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d 시작\n", id)
time.Sleep(time.Second) // 작업 시뮬레이션
fmt.Printf("Worker %d 종료\n", id)
}
func main() {
for i := 1; i <= 3; i++ {
go worker(i) // 고루틴 생성
}
time.Sleep(2 * time.Second) // 메인 고루틴이 종료되지 않도록 대기
fmt.Println("메인 종료")
}
-
고루틴은 가상 스레드의 개념을 대중화하며 이후 다른 언어와 런타임에도 영향을 미쳤습니다. 예를 들어 JDK 19(2022년)의 Project Loom은 고루틴과 유사한 가상 스레드를 도입했고, Kotlin의 코루틴(coroutine) 또한 Go의 고루틴에서 영감을 받아 경량 스레드 모델을 구현했습니다.
-
또한 고루틴은 채널 개념을 통해 동시성을 혁신적으로 다룹니다. 채널은 고루틴 간 안전한 데이터 교환을 위한 메커니즘으로, 특히 채널 읽기(
<-)는 작업 완료를 기다리고 결과를 받는 직관적인 방법을 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import (
"fmt"
"time"
)
func worker(id int, ch chan string) {
time.Sleep(time.Second) // 작업 시뮬레이션
ch <- fmt.Sprintf("Worker %d 완료", id) // 채널에 결과 전송
}
func main() {
ch := make(chan string, 2) // 버퍼 크기 2인 채널
go worker(1, ch)
go worker(2, ch)
result1 := <-ch // 첫 번째 결과 읽기
result2 := <-ch // 두 번째 결과 읽기
fmt.Println(result1)
fmt.Println(result2)
fmt.Println("메인 종료")
}
- 채널은 Go 특유의 공유 메모리로 동기화하지 말고, 통신으로 동기화하라는 설계 원칙이 잘 드러나는 개념으로, 멀티스레드 프로그래밍의 복잡성을 줄이는 데 큰 기여를 했습니다.
C#의 경우: 가상 스레드 없이 OS 스레드 관리 추상화
초기
- C#은 2002년 .NET Framework 1.0과 함께 처음 등장했으며, 이때부터 멀티스레드를 지원했습니다. 초기에는
System.Threading.Thread클래스를 통해 OS 스레드를 직접 생성하고 관리하는 방식이 기본이었습니다. 이는 Java의Thread클래스와 유사한 방식으로, 개발자가 스레드의 생명주기를 수동으로 제어해야 했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using System.Threading;
class Program
{
static void Worker(object id)
{
Console.WriteLine($"Worker {id} 시작");
Thread.Sleep(1000);
Console.WriteLine($"Worker {id} 종료");
}
static void Main()
{
Thread t1 = new Thread(Worker);
Thread t2 = new Thread(Worker);
t1.Start(1);
t2.Start(2);
t1.Join();
t2.Join();
Console.WriteLine("메인 종료");
}
}
.NET Framework 2.0과 ThreadPool 도입
-
.NET Framework 2.0(2005년)에서
ThreadPool이 도입되며 멀티스레드 프로그래밍이 한 단계 발전했습니다. 스레드 풀은 미리 생성된 OS 스레드를 재사용해 비용을 줄이고, 스레드 수를 시스템이 자동 관리하게 했습니다. -
이는 Java의
ExecutorService와 비슷한 수준의 추상화로, 스레드 생성을 줄였지만 여전히 작업 완료 추적이나 결과를 반환받는 기능이 부족했습니다. 따라서 이때는 작업 상태를 추적하거나 복잡한 흐름을 관리하려면ManualResetEvent나AutoResetEvent같은 동기화 도구를 추가로 써야 했습니다. 이런 코드는 작성과 디버깅이 번거롭고, 실수하기 쉬웠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Threading;
class Program
{
static void Worker(object id, ManualResetEvent doneEvent)
{
Console.WriteLine($"Worker {id} 시작");
Thread.Sleep(1000);
Console.WriteLine($"Worker {id} 종료");
doneEvent.Set();
}
static void Main()
{
ManualResetEvent[] events = { new ManualResetEvent(false), new ManualResetEvent(false) };
ThreadPool.QueueUserWorkItem(state => Worker(1, events[0]));
ThreadPool.QueueUserWorkItem(state => Worker(2, events[1]));
WaitHandle.WaitAll(events);
Console.WriteLine("메인 종료");
}
}
.NET Framework 4.0과 Task의 도입
- C#의 멀티스레드 추상화는 .NET Framework 4.0(2010년)에 도입된 Task Parallel Library(TPL)와 Task 클래스로 새로운 차원에 도달했습니다. Task는 OS 스레드와 스레드 풀을 기반으로 하면서도, 개발자가 스레드 자체를 신경 쓰지 않고 작업 단위(task)로 사고할 수 있게 하는 고수준 추상화를 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using System.Threading.Tasks;
class Program
{
static void Worker(object id)
{
Console.WriteLine($"Worker {id} 시작");
Task.Delay(1000).Wait(); // 동기 대기
Console.WriteLine($"Worker {id} 종료");
}
static void Main()
{
Task task1 = Task.Factory.StartNew(Worker, 1);
Task task2 = Task.Factory.StartNew(Worker, 2);
Task.WaitAll(task1, task2); // 모든 작업 완료 대기
Console.WriteLine("메인 종료");
}
}
.NET 4.5와 async/await의 도입
-
.NET Framework 4.0에서는 서로 다른
Task간의 비동기 작업을 순차적으로 처리하기 위해ContinueWith메서드를 사용하여 후속 작업을 정의해야 했습니다. -
후속 작업은
ContinueWith메소드에 콜백함수 형태로 전달됐는데, 이것이 다중으로 중첩되는 경우 가독성이 떨어지고, 에러 처리가 복잡해졌습니다. 따라서 많은 사람들이 이를 콜백 지옥(callback hell)이라 불러 어려움을 호소했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.Threading.Tasks;
class Program
{
static Task<string> WorkerAsync(int id)
{
return Task.Run(() =>
{
Task.Delay(1000).Wait(); // 작업 시뮬레이션
return $"Worker {id} 완료";
});
}
static void Main()
{
Task<string> task1 = WorkerAsync(1);
Task<string> task2 = WorkerAsync(2);
task1.ContinueWith(t1 =>
{
Console.WriteLine(t1.Result);
task2.ContinueWith(t2 =>
{
Console.WriteLine(t2.Result);
Console.WriteLine("메인 종료");
});
});
Task.WaitAll(task1, task2); // 메인 블록
}
}
- C# 5.0(.NET 4.5, 2012년)에 도입된
async/await는 콜백 지옥을 근본적으로 해결했습니다.Task를 기반으로 하되, 비동기 작업을 동기 코드처럼 직선적으로 표현할 수 있게 했죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Threading.Tasks;
class Program
{
static async Task<string> WorkerAsync(int id)
{
await Task.Delay(1000); // 비동기 대기
return $"Worker {id} 완료";
}
static async Task Main()
{
Task<string> task1 = WorkerAsync(1);
Task<string> task2 = WorkerAsync(2);
string result1 = await task1;
string result2 = await task2;
Console.WriteLine(result1);
Console.WriteLine(result2);
Console.WriteLine("메인 종료");
}
}
Python의 멀티스레드와 GIL
Python의 멀티스레드
-
Python 또한 멀티스레드를 추상화하는 구현이 있습니다. 예를 들어 Python 3.2(2011년)부터 도입된
concurrent.futures모듈은 스레드 풀을 제공합니다. -
그러나 Python은 GIL이라는 설계 철학의 이슈로 프로그램이 여러 OS 스레드를 잡고 있어도 이를 활용하지 못해 실질적으로 싱글스레드로 동작합니다.
-
멀티프로세스 모듈을 활용하여 멀티코어를 활용하는 방법이 있으나, 이는 메모리 공유와 통신 비용이 커져 간단한 작업에 비효율적입니다.
GIL 이란
-
GIL은 CPython(기본 Python 구현체)에서 인터프리터가 동시에 하나의 네이티브 스레드만 실행하도록 보장하는 뮤텍스(mutex)입니다. 즉, Python 프로그램이 여러 OS 스레드를 잡고 있어도 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있습니다.
-
이는 멀티코어 CPU에서 병렬 처리를 제한하며, Python의 멀티스레드가 CPU-bound 작업에서 비효율적인 이유입니다.
GIL 의 도입 이유
-
CPython 구현체의 단순성
-
Python의 공개 시점인 1991년 당시 초기 목표는 단순하고 사용하기 쉬운 인터프리터였습니다. 당시 C로 작성된 CPython은 객체 참조 카운팅(reference counting)을 메모리 관리 방식으로 사용했는데, 이는 멀티스레드 환경에서 경쟁 조건(race condition)을 피하기 위해 동기화가 필요했습니다.
-
GIL은 모든 스레드가 인터프리터에 접근할 때 단일 락을 걸어 참조 카운팅의 안정성을 보장하는 간단한 해결책이었습니다.
-
-
C 확장 모듈과의 호환성
- Python은 C로 작성된 확장 모듈(예: NumPy)을 쉽게 통합할 수 있도록 설계되었습니다. GIL은 이런 모듈들이 스레드 안전성(thread safety)을 신경 쓰지 않아도 되게끔 해주었죠. 복잡한 락 메커니즘 대신 GIL 하나로 모든 것을 관리했습니다.
-
단일 스레드 성능
- 1990년대에는 멀티코어 CPU가 드물었고, 단일 스레드 성능이 더 중요했습니다. GIL은 단일 스레드 작업에서 오버헤드를 줄이는 데 유리했으며, 당시로선 합리적인 선택이었어요.
-
즉, GIL은 단순성과 호환성을 우선시한 초기 설계의 산물이었고, 멀티스레드 병렬성을 희생한 대신 안정성과 개발 편의성을 택한 결정이었습니다.
GIL의 미래
-
GIL을 없애려는 노력은 여러 차례 있었지만 성공하지 못했습니다.
- 1999년, Greg Stein의 시도: GIL을 제거한 “Freethreading Python”을 제안했으나, 단일 스레드 성능이 40% 느려져 채택되지 않았습니다.
- Jython/IronPython: GIL 없는 대체 구현체지만, CPython과의 호환성 부족으로 주류가 되지 못했어요.
- PyPy: JIT 컴파일러를 사용하는 대안 인터프리터로 GIL 제거를 시도 중이지만, 아직 CPython의 대체로는 부족합니다.
-
장기적으로는 GIL이 제거될 거라는 전망이 우세합니다. 이미 GIL을 선택적으로 비활성화하는 옵션을 추가하는 PEP 703(“Making Python GIL-less”)이 주목받고 있으며, 이러한 시도 끝에 Python 4.0쯤에서 GIL이 기본적으로 제거될 가능성이 있습니다. 하지만 C 확장 모듈의 호환성을 위해 GIL 모드를 유지하는 하위 호환성 옵션이 남을 수 있어요.
JavaScript와 Node.js
JavaScript와 Google Chrome
-
JavaScript는 원래 브라우저 환경에서 DOM 조작과 이벤트 처리를 위해 설계된 언어로, V8 엔진 또한 JS 코드를 실행할 때 기본적으로 단일 스레드에서 동작, 단일 코어에서 실행됩니다.
-
멀티스레드 환경에서 DOM 같은 공유 자원을 다룰 때 발생할 수 있는 복잡한 동기화 문제를 피하기 위해 싱글스레드 모델로 구현되었습니다. 싱글스레드 모델은 간단하고 예측 가능하다는 장점이 있습니다.
-
구체적으로, 크롬의 렌더링과 UI 처리는 각 탭마다 할당된 하나의 렌더러 프로세스(Render Process)에서 이루어지는데, 이 프로세스 내에서 JavaScript 실행, DOM 조작, CSS 스타일 계산, 레이아웃, 페인팅 같은 작업은 모두 메인 스레드(Main Thread)에서 처리됩니다. 그러니까 UI 관련 작업은 싱글스레드 기반이고, 결과적으로 각 탭에서의 처리는 단일 코어에 의존한다고 할 수 있습니다.
-
크롬 전체는 멀티코어를 잘 활용하는데, 예를 들어 탭마다 프로세스가 다르니까 여러 탭을 동시에 띄우면 각 프로세스가 다른 코어에 할당될 수 있습니다. 하지만 한 탭 안의 UI 작업은 그 탭의 메인 스레드에서만 돌아가니까, 단일 코어의 성능에 의존적입니다. 메인 스레드가 무거운 JavaScript 작업(예: 복잡한 연산)으로 블록되면 UI가 멈추는 “Jank” 현상이 생깁니다.
-
JavaScript는 이처럼 싱글 스레드를 전제하는 언어이지만, CPU-bound 작업으로 인해 UI가 멈추는 경우를 해결하기 위해 Web Worker라는 개념이 2014년 HTML5 표준에서 제시되었습니다.
-
웹 워커는 브라우저에서 자바스크립트 코드를 메인 스레드와 별도의 백그라운드 스레드에서 실행할 수 있게 해주는 기술입니다. JS 코드에서
Worker객체를 생성하여 메시지를 전송하면, V8 인스턴스가 이 객체에 등록된 콜백함수(별도 파일 구현)를 실행하기 위한 OS 스레드를 하나 할당받아 이 스레드에서 등록된 콜백함수를 실행합니다. -
웹 워커는 별도의 스레드에서 실행되며, DOM이나 window 객체에 직접 접근할 수 없고 UI 조작이 불가능합니다.
postMessage()와onmessage이벤트를 통해 메인 스레드와 워커 간 데이터 교환이 이루어집니다. -
이는 JS에서의 일종의 멀티 스레드 구현이라고 할 수 있지만, 워커 스레드에서 DOM 등에 접근할 수 없다는 등 제약이 있으므로 일반적인 형태의 멀티 스레드와는 개념상 차이가 있습니다.
-
1
2
3
4
5
6
7
8
9
10
11
12
13
// worker.js
self.onmessage = function (event) {
const data = event.data;
let result = 0;
// 무거운 계산 예시: 1부터 입력값까지 더하기
for (let i = 1; i <= data; i++) {
result += i;
}
// 결과를 메인 스레드로 전송
self.postMessage(result);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// main.js
if (window.Worker) {
// 워커 인스턴스 생성
const myWorker = new Worker('worker.js');
// 워커에 메시지 전송
myWorker.postMessage(1000000);
// 워커로부터 결과 수신
myWorker.onmessage = function (event) {
console.log('워커로부터 받은 결과:', event.data);
};
// 오류 처리
myWorker.onerror = function (error) {
console.error('워커 오류:', error.message);
};
} else {
console.log('브라우저가 웹 워커를 지원하지 않습니다.');
}
Node.js
-
Node.js도 V8 엔진에 기반하기에 기본적으로 싱글스레드에서 실행되지만, I/O 작업은 libuv라는 C 라이브러리를 통해 비동기적으로 백그라운드 스레드 풀에서 처리됩니다. 예를 들어 파일 읽기나 네트워크 요청은 이벤트 루프 밖에서 실행되고, 결과가 콜백이나 Promise로 메인 스레드에 돌아옵니다. 따라서 이러한 작업에서는 Node.js는 멀티스레드로 동작합니다.
-
2019년 Node.js 12가 발표되어 Worker Threads 기능이 정식으로 추가돼, HTML5의 Web Workers와 유사한 방식으로 이 기능을 사용할 수 있습니다.
- Worker Threads는 Web Worker와 마찬가지로 메시지 패싱으로 통신하지만, DOM 접근 제한 같은 브라우저 환경의 제약이 없어 더 유연하게 사용할 수 있습니다. 다만, 전통적인 멀티스레드 언어의 기능과는 여전히 차이가 있어, 완전한 멀티스레드라기보다는 JavaScript에 최적화된 병렬 처리 도구라 할 수 있습니다.