개요
bpy를 멀티프로세스 환경에서 사용할 때 spawn 방식에서는 _bpy 로드 실패, fork 방식에서는 초기화 충돌이 발생한다. spawn 방식의 실패는 비교적 이해하기 쉽지만, fork 방식에서 왜 충돌이 발생하는지는 직관적으로 이해하기 어렵다. 이 글에서는 구체적인 비유와 예시를 통해 이 문제를 정리한다.
fork는 무엇을 복제하는가
fork는 부모 프로세스의 메모리를 Copy-on-Write 방식으로 복제한다.
메인 프로세스 (PID 100)
├── 메모리 (변수, 객체 등)
├── 파일 디스크립터
├── bpy 로드 상태
└── 전역 변수
↓ fork
자식 프로세스 (PID 101)
├── 메모리 복제
├── 파일 디스크립터 복제
├── bpy 로드 상태 복제
└── 전역 변수 복제
얼핏 보면 bpy가 이미 로드된 상태로 복제되니 문제없을 것 같다. 하지만 여기서 중요한 포인트가 있다.
복제된 값 ≠ 유효한 값
복제된 것은 메모리에 저장된 값이지, 그 값이 가리키는 실제 OS 자원이 아니다.
열쇠 복사 비유
원본 열쇠 (메인 프로세스, PID 100)
→ 자물쇠 회사(OS)가 PID 100에게 발급한 열쇠
→ 이 열쇠로 문(OpenGL, 메모리 풀 등)을 열 수 있음
복사된 열쇠 (자식 프로세스, PID 101)
→ 열쇠 모양(메모리 값)은 동일하게 복사됨
→ 하지만 자물쇠 회사(OS)는 PID 101에게 이 열쇠를 발급한 적 없음
→ 문을 열려고 하면 OS가 거부
Blender 전역 상태가 복제될 때 무슨 일이 일어나는가
Blender 초기화 시 설정되는 것들
bpy 초기화 (메인 프로세스 PID 100)
→ Lock 파일 생성
/tmp/blender_lock (PID 100이 실행 중임을 표시)
→ 전역 메모리 풀 할당
메모리 주소 0x1234에 Blender 컨텍스트 저장
→ OpenGL 핸들 획득
OS로부터 핸들 #001 발급 (소유자: PID 100)
fork 후 자식 프로세스 상태
자식 프로세스 (PID 101) — fork 직후
→ Lock 파일 내용 복제
/tmp/blender_lock에 PID 100 기록되어 있음
→ 전역 메모리 주소 복제
0x1234에 동일한 컨텍스트 값 있음
→ OpenGL 핸들 값 복제
핸들 #001 값을 가지고 있음
BUT 이 핸들의 소유자는 여전히 PID 100
자식 프로세스에서 bpy 함수 호출 시
자식 프로세스 (PID 101)에서 mesh_to_pyg() 호출
→ bpy 내부에서 전역 컨텍스트 확인
→ "이 컨텍스트는 PID 100이 만든 것"
→ "나(PID 101)는 초기화를 한 적이 없음"
→ bpy가 두 가지 중 하나를 시도
[시도 1] 재초기화
→ Lock 파일 확인
→ "이미 PID 100이 실행 중" → 재초기화 거부 → 실패
[시도 2] 기존 컨텍스트 그대로 사용
→ OpenGL 핸들 #001 사용 시도
→ OS: "이 핸들은 PID 100 소유, PID 101 사용 불가"
→ crash
spawn 방식과의 차이
spawn 방식
새 프로세스 (PID 101)
→ 완전히 새로 시작
→ bpy 재초기화 시도
→ 컨테이너 환경에서 _bpy 네이티브 모듈 로드 실패
→ ModuleNotFoundError: No module named '_bpy'
fork 방식
자식 프로세스 (PID 101)
→ 복제된 상태로 시작
→ bpy 재초기화 없이 기존 컨텍스트 사용 시도
→ 복제된 핸들/락이 유효하지 않아 충돌
→ bpy init 실패
두 방식 모두 결국 bpy가 단일 프로세스 전용으로 설계되어 있어서 발생하는 문제다. 단지 실패하는 시점과 방식이 다를 뿐이다.
왜 일반 라이브러리는 이 문제가 없는가
numpy, torch 같은 라이브러리는 처음부터 멀티프로세스 환경을 고려하여 설계됐다.
numpy, torch
→ 전역 상태 최소화
→ 프로세스별 독립적인 상태 관리
→ OS 자원(핸들, 락)을 프로세스 단위로 관리
→ fork 후 자식 프로세스에서 재초기화 가능
bpy
→ 전역 상태에 강하게 의존
→ 단일 프로세스 기준으로 설계
→ OS 자원을 프로세스 간 공유 불가
→ fork 후 자식 프로세스에서 재사용 불가
해결책 — subprocess로 완전 격리
이 문제의 해결책은 bpy를 완전히 독립된 새 프로세스에서만 실행하는 것이다.
fork / spawn (실패)
메인 프로세스
└── 워커 프로세스 (부모로부터 파생)
└── bpy 사용 시도 → 충돌
subprocess (성공)
메인 프로세스 (bpy 없음)
└── 완전히 새로운 독립 프로세스
└── bpy 초기화 → 성공
└── STL 파일 처리 → 성공
└── 프로세스 종료
subprocess는 fork / spawn과 달리 부모 프로세스와 완전히 독립된 새 프로세스를 생성한다. 따라서 bpy가 깨끗한 환경에서 초기화되어 정상 동작한다.
# sqs_consumer.py — bpy 임포트 없음
def process_message(message: dict):
subprocess.run(
['python', '/app/inference_runner.py', json.dumps(body)],
check=True
)
# inference_runner.py — 완전히 독립된 프로세스에서 bpy 초기화
from libs.OcclusalplaneUtils import mesh_to_pyg # bpy 사용 → 성공
정리
| 방식 | 동작 | bpy 결과 | 실패 원인 |
|---|---|---|---|
| spawn | 새 인터프리터 시작 | ❌ | _bpy 네이티브 모듈 로드 실패 |
| fork | 부모 메모리 복제 | ❌ | 복제된 OS 자원 핸들 무효 |
| subprocess | 완전히 독립 프로세스 | ✅ | — |
핵심은 복제된 값과 유효한 값은 다르다는 점이다. fork로 메모리를 복제해도 OS가 관리하는 자원(OpenGL 핸들, 락 파일 등)은 원래 프로세스 소유로 남아 있어 자식 프로세스에서 사용할 수 없다. bpy는 이런 OS 자원에 강하게 의존하기 때문에 fork 후 자식 프로세스에서 정상 동작을 보장할 수 없다.