본문 바로가기
programming

프로세스와 스레드 그리고 GIL

by AtoN 2023. 5. 17.

프로세스와 스레드 그리고 GIL

파이썬에서 스레드를 여러 개 만들어도 CPU를 많이 쓰는 작업이 빨라지지 않는 경우가 있습니다. 반면 같은 Python 프로그램에서도 NumPy 같은 네이티브 라이브러리의 연산은 여러 CPU 코어를 동시에 사용할 수 있습니다.

차이는 단순히 스레드냐 프로세스냐만으로 설명되지 않습니다. 프로세스와 스레드가 메모리와 실행 상태를 어떻게 공유하는지, 그리고 실제 계산이 Python interpreter에서 실행되는지 C/C++ 같은 네이티브 코드에서 실행되는지를 함께 봐야 합니다.

일반적인 CPython에서는 GIL(Global Interpreter Lock) 때문에 한 프로세스 안에서 여러 스레드가 동시에 Python bytecode를 실행하는 데 제약이 있습니다. 하지만 네이티브 라이브러리가 긴 연산 동안 GIL을 해제하면 다른 스레드도 실행될 수 있고, 라이브러리 내부에서 자체적으로 여러 CPU 코어를 사용하는 것도 가능합니다.

프로세스와 스레드가 무엇을 공유하고 무엇을 따로 가지는지부터 시작해, Linux가 이 둘을 어떻게 구현하고 CPython의 GIL이 그 위에서 어떤 제약을 만드는지 살펴봅니다. 마지막에는 CPU-bound와 I/O-bound 작업에서 각각 스레드, 프로세스, 비동기 방식 중 무엇을 선택해야 하는지 정리합니다.


1장. 이 개념이 생긴 이유

컴퓨터가 프로그램 하나만 돌리던 시절에는 이 구분이 필요 없었습니다. 문제는 여러 프로그램을 동시에 돌리고 싶어지면서 시작됐습니다.

두 프로그램이 같은 메모리를 쓰면 A가 쓴 값을 B가 덮어쓰고 B의 버그가 A를 죽입니다. 그래서 운영체제는 프로그램마다 자기만의 메모리 공간이 있는 것처럼 속이기로 했습니다. 이것이 프로세스입니다.

그런데 격리를 하고 나니 새 문제가 생겼습니다. 한 프로그램 안에서 여러 일을 동시에 하고 싶은데, 프로세스를 추가하면 별도의 주소 공간과 커널 관리 단위가 생기고 프로세스 간 통신도 필요합니다. fork()는 copy-on-write 덕분에 처음부터 모든 메모리를 물리적으로 복사하지는 않지만, 주소 공간을 공유하는 스레드보다 격리와 통신 비용이 큽니다. 그래서 주소 공간을 공유하되 실행 흐름만 나눈 단위가 필요해졌고 이것이 스레드입니다.

프로세스는 격리, 스레드는 같은 주소 공간 안의 공유와 가벼운 동시 실행에 초점을 둔다는 것이 좋은 출발점입니다. 다만 실제 차이는 주소 공간뿐 아니라 파일·시그널·스케줄링 단위처럼 어떤 자원을 공유하느냐까지 함께 봐야 합니다.


2장. 프로세스의 메모리 구조

프로세스는 실행 중인 프로그램입니다. 디스크에 있는 실행 파일은 프로그램이고, 그것이 메모리에 올라가 CPU를 할당받으면 프로세스가 됩니다.

높은 주소
   Stack    지역 변수, 함수 호출 프레임
            아래로 자람

   (빈 공간)

   Heap     동적 할당
            위로 자람

   Data     전역 변수, static

   Text     실행 코드 (읽기 전용)
낮은 주소

여기에 더해 PCB라는 자료구조에 프로세스 상태를 저장합니다. PID, 프로그램 카운터, 레지스터 값, 열린 파일 목록, 페이지 테이블 포인터 같은 것들이고 리눅스에서는 task_struct 입니다.

핵심은 주소 공간이 독립적이라는 점입니다. 프로세스 A의 포인터 0x7fff1234 와 프로세스 B의 0x7fff1234 는 물리적으로 다른 메모리를 가리킵니다. MMU와 페이지 테이블이 이 환상을 만듭니다.

이 독립성은 한 실행 단위의 충돌이 다른 실행 단위로 번지는 범위를 줄이는 데 쓰입니다. Chromium도 browser·renderer·GPU 등 여러 프로세스를 나누고 Site Isolation으로 서로 다른 사이트를 별도 renderer process에 격리해 안정성과 보안을 높입니다. 다만 탭 하나가 항상 프로세스 하나와 정확히 대응하는 구조는 아닙니다.


3장. 스레드가 공유하는 것

스레드는 프로세스 안의 실행 흐름입니다. 같은 프로세스의 스레드들은 Text·Data·Heap과 열린 파일 같은 많은 자원을 공유합니다. 반면 각 스레드는 자기 Stack, 프로그램 카운터와 레지스터, thread-local storage, signal mask 같은 실행 상태를 따로 가집니다.

프로세스

  공유하는 것
     Text, Data, Heap
     열린 파일, 시그널 핸들러

  스레드마다 따로 갖는 것
     Thread 1     Stack, PC, 레지스터
     Thread 2     Stack, PC, 레지스터
     Thread 3     Stack, PC, 레지스터

Stack만 따로인 이유. 스택은 함수 호출 이력입니다. 스레드마다 지금 어느 함수를 실행 중인지가 다르니 스택도 달라야 합니다. 반면 힙에 올린 데이터는 같이 쓰는 것이 목적이라 공유합니다.

여기서 장점과 단점이 동시에 나옵니다. 데이터를 그냥 같이 쓰면 되므로 프로세스 간 통신처럼 파이프나 공유 메모리를 따로 설정할 필요가 없고 생성 비용도 훨씬 쌉니다.

단점도 같은 성질에서 나옵니다. 두 스레드가 같은 변수를 동시에 고치면 레이스 컨디션이 생기고, 한 스레드가 세그폴트를 내면 프로세스 전체가 죽습니다.

리눅스에서의 구분. 리눅스 커널은 프로세스와 POSIX 스레드를 모두 task_struct로 표현합니다. 저수준의 clone()/clone3()는 새 task가 주소 공간, 파일 디스크립터 테이블, 시그널 처리 상태 등을 어느 범위까지 공유할지 플래그로 지정할 수 있습니다.

// 스레드 구현에서 함께 쓰이는 대표 공유 플래그의 개념
CLONE_VM       // 주소 공간 공유
CLONE_FILES    // 파일 디스크립터 테이블 공유
CLONE_SIGHAND  // 시그널 disposition 공유
CLONE_THREAD   // 같은 thread group에 포함

CLONE_VM은 주소 공간 공유를 뜻하지만 이 플래그 하나만으로 POSIX 스레드가 되는 것은 아닙니다. 실제 스레드는 여러 공유 플래그와 스레드 그룹 관계가 함께 설정됩니다. 핵심은 리눅스에서 프로세스와 스레드의 차이가 완전히 별개의 커널 객체라기보다 어떤 실행 자원을 공유하느냐에 크게 걸려 있다는 점입니다.

공유 여부를 한 표로.

자원 프로세스 간 같은 프로세스의 스레드 간
코드 (Text) 독립 공유
전역, 정적 변수 (Data) 독립 공유
힙 (Heap) 독립 공유
열린 파일 디스크립터 별도 테이블이 기본이지만 상속·공유 가능 같은 테이블 공유
스택 독립 독립
프로그램 카운터, 레지스터 독립 독립
스레드 로컬 저장소 독립 독립
시그널 핸들러 독립 공유

4장. 컨텍스트 스위칭 비용

운영체제 스케줄러가 다루는 단위는 실행 가능한 task이고, 하나의 논리 CPU(hardware thread)는 한 시점에 하나의 task를 실행합니다. 여러 task가 한 논리 CPU를 공유하면 컨텍스트 스위칭으로 번갈아 실행됩니다. SMT가 있는 물리 코어는 여러 논리 CPU를 가질 수 있으므로 “물리 코어 하나에는 실행 흐름 하나뿐”이라고 단정하면 안 됩니다.

전환할 때 하는 일은 이렇습니다.

1. 현재 실행 중인 것의 레지스터와 PC를 저장
2. 다음에 실행할 것의 상태를 복원
3. 실행 재개

주소 공간이 다른 task로 전환하면 페이지 테이블 컨텍스트도 바뀌므로 주소 변환 캐시와 CPU 캐시의 locality를 잃을 가능성이 커집니다. 같은 주소 공간의 스레드 전환은 이 비용을 일부 피할 수 있습니다.

TLB는 가상 주소에서 물리 주소로의 변환을 캐시합니다. 다만 현대 CPU의 PCID/ASID 같은 기능은 주소 공간이 바뀌어도 TLB 엔트리를 무조건 전부 버리지 않게 해 줍니다. 따라서 프로세스 전환마다 TLB를 전부 비운다고 외우면 틀립니다.

배수를 단정하지 않는 편이 안전합니다. 실제 비용은 CPU, 커널, working set, 캐시 상태에 따라 달라집니다. 스레드 전환이 보통 더 가볍다는 경향은 있지만, 고정된 몇 배 수치보다 주소 공간과 캐시 locality가 어떻게 달라지는지를 이해하는 편이 낫습니다.

선택 기준.

상황 선택 이유
한 부분이 죽어도 전체가 살아야 함 프로세스 크래시 격리
신뢰할 수 없는 코드 실행 프로세스 메모리 격리, 샌드박싱
큰 데이터를 자주 주고받음 스레드 통신 없이 직접 접근
생성과 소멸이 잦음 스레드 생성 비용이 낮음
CPU 코어를 다 쓰고 싶음 언어에 따라 다름 GIL 참고

실제 시스템은 두 방식을 섞어 씁니다. Chromium은 여러 종류의 프로세스로 격리하면서 renderer 내부에서는 여러 스레드를 사용하고, 사이트·프레임 관계에 따라 renderer process를 공유하거나 분리합니다. Nginx도 여러 worker process와 event-driven I/O를 결합합니다.


5장. 파이썬 GIL

기본 GIL-enabled CPython에는 Global Interpreter Lock이 있습니다. 한 인터프리터 안에서는 한 시점에 한 스레드만 Python bytecode를 실행하게 하는 락입니다. Python 3.14부터는 별도의 free-threaded build도 공식 지원되므로, 이제는 사용 중인 빌드를 구분해야 합니다.

전통적인 CPython은 객체와 인터프리터 내부 상태를 단순하고 빠르게 보호하기 위해 GIL에 크게 의존해 왔습니다. 참조 카운팅도 중요한 이유 중 하나지만 그것만이 전부는 아닙니다. GIL 덕분에 많은 C API와 객체 구현이 모든 접근에 세밀한 락을 두지 않고 동작할 수 있었습니다.

결과가 중요합니다.

# CPU 바운드 - 스레드를 늘려도 빨라지지 않는다
def crunch(n):
    return sum(i*i for i in range(n))

# 4스레드로 돌려도 1스레드와 비슷하거나 오히려 느림 (GIL 경합)
# I/O 바운드 - 스레드가 효과적이다
def fetch(url):
    return requests.get(url)   # 네트워크 대기 중에 GIL을 놓는다

# 여러 요청의 I/O 대기를 겹칠 수 있어 처리량이 늘 수 있음. 정확한 배수는 네트워크와 서버 병목에 따라 달라짐

GIL-enabled CPython도 blocking I/O를 기다릴 때는 GIL을 놓습니다. C/C++ 확장도 구현이 명시적으로 GIL을 해제한 구간에서는 다른 파이썬 스레드가 진행할 수 있습니다. 모든 C 확장이 자동으로 GIL을 놓는 것은 아닙니다.

NumPy의 많은 수치 연산과 PyTorch, ONNX Runtime 같은 네이티브 런타임은 계산의 상당 부분을 C/C++/CUDA 쪽에서 수행하고 내부 스레드 풀이나 GPU를 사용합니다. 그래서 파이썬 호출부에 GIL이 있어도 실제 커널이 병렬로 실행될 수 있습니다. 어느 연산이 GIL을 해제하는지는 라이브러리 구현을 확인해야 합니다.

반면 GIL-enabled CPython에서 순수 파이썬 CPU 루프는 여러 스레드로 코어를 동시에 쓰기 어렵습니다. 이럴 때는 벡터화·네이티브 연산으로 옮기거나 multiprocessing을 고려합니다. 프로세스 방식은 직렬화와 IPC 비용이 붙으므로 큰 배열을 자주 주고받으면 이득이 줄 수 있습니다.

Free-threaded CPython. PEP 703에 따라 Python 3.13부터 GIL을 끈 별도 빌드가 제공됐고, Python 3.14에서는 이 free-threaded build가 실험 단계를 벗어나 공식 지원되는 선택 옵션이 됐습니다. 하지만 기본 CPython 빌드는 여전히 GIL-enabled이고, free-threaded 환경에서는 일부 C 확장이 GIL을 다시 활성화할 수 있습니다. 따라서 “Python에는 이제 GIL이 없다”가 아니라 GIL 없는 빌드를 선택할 수 있게 됐다가 정확합니다.


6장. C++에서는

C++의 std::thread는 일반적인 구현에서 OS native thread에 매핑됩니다. Python의 GIL 같은 언어 전역 락은 없으므로 여러 runnable thread가 여러 코어에서 병렬 실행될 수 있고, 대신 공유 데이터의 race condition과 동기화를 직접 관리해야 합니다.

std::mutex mtx;
std::vector<float> results;

void worker(const Tensor& input) {
    auto out = infer(input);              // 락 없이 병렬
    std::lock_guard<std::mutex> lk(mtx);  // 공유 자원만 보호
    results.push_back(out);
}

ONNX Runtime의 intra_op_num_threads와 inter_op_num_threads가 이런 병렬성을 조절하는 대표적인 설정입니다. 전자는 operator 내부 병렬성을 제어합니다. inter_op_num_threads는 graph의 여러 operator를 병렬 실행할 때 쓰는 thread 수인데, execution_mode=ORT_PARALLEL일 때 생성되는 inter-op thread pool에 적용됩니다. 기본 ORT_SEQUENTIAL에서는 같은 의미로 동작하지 않으므로 두 값을 함께 볼 때 execution mode도 확인해야 합니다.


7장. 정리

프로세스는 격리를, 스레드는 같은 주소 공간 안의 값싼 동시 실행을 제공합니다. 리눅스에서는 둘 다 task로 표현되며, 주소 공간·파일·시그널 상태 등을 얼마나 공유하는지가 중요한 차이입니다.

컨텍스트 스위칭 비용은 레지스터 저장·복원뿐 아니라 주소 공간 전환과 TLB·CPU 캐시 locality의 영향까지 받습니다. 현대 CPU가 PCID/ASID로 일부 비용을 줄이므로 고정 배수를 외우지 않는 편이 안전합니다.

Python에서는 사용 중인 CPython 빌드와 실제 작업의 실행 위치를 함께 봐야 합니다. GIL-enabled 빌드의 순수 파이썬 CPU 작업은 프로세스·벡터화가 유리한 경우가 많고, I/O 대기나 GIL을 해제하는 네이티브 연산은 스레드도 효과적입니다. Free-threaded 빌드는 이 판단을 다시 바꾸고 있습니다.


용어 정리

용어 한 줄 뜻
프로세스 독립된 주소 공간을 가진 실행 단위
스레드 주소 공간을 공유하는 실행 흐름
PCB 프로세스 상태를 저장하는 커널 자료구조
clone() 리눅스에서 무엇을 공유할지 플래그로 지정해 태스크를 만드는 시스템 콜
TLB 가상 주소를 물리 주소로 바꾸는 캐시
레이스 컨디션 여러 스레드가 같은 데이터를 동시에 고쳐 결과가 깨지는 현상
GIL 한 시점에 하나의 스레드만 파이썬 바이트코드를 실행하게 하는 전역 락
CPU 바운드 / I/O 바운드 연산이 병목 / 대기가 병목

참고자료

'programming' 카테고리의 다른 글

가상 메모리와 mmap  (0) 2023.09.13
HTTP와 스트리밍 (SSE)  (0) 2023.05.10
트랜잭션과 격리 수준  (0) 2023.05.09
IO 모델과 비동기  (0) 2023.03.09
[PY] line_profiler (@profile)  (0) 2023.03.04

댓글