본문 바로가기

Coding

WaitForMultipleObjects vs epoll: OS가 파이썬 성능에 미치는 영향

 

[윈도우 vs 리눅스, 멀티프로세싱의 보이지 않는 벽] 똑같은 파이썬 코드인데 왜 윈도우에서만 오류가 날까요? 운영체제(OS) 레벨의 차이, 특히 프로세스 핸들 수 제한의 비밀을 파헤쳐 드립니다.

파이썬으로 멀티프로세싱 코드를 열심히 작성했는데, 내 PC(리눅스)에서는 잘만 돌아가던 코드가 동료의 윈도우 PC에서는 뜬금없는 오류를 뿜어내는 당황스러운 경험, 다들 한 번쯤 있으신가요? 분명 코드 한 줄 건드리지 않았는데 말이죠. 저도 최근에 이런 문제로 반나절을 헤맨 적이 있는데요, 원인은 바로 눈에 보이지 않는 운영체제(OS)의 차이에 있었습니다. 😊

 

윈도우의 숨겨진 제약: WaitForMultipleObjects() 🤨

문제의 핵심은 윈도우의 WaitForMultipleObjects라는 API에 있습니다. 이 함수는 여러 개의 이벤트나 프로세스 핸들이 완료되기를 기다리는 역할을 하는데요. 이름 그대로 여러 객체를 '기다리는' 아주 중요한 친구입니다. 파이썬의 multiprocessing 모듈도 내부적으로 이 API를 사용하여 여러 워커 프로세스들이 작업을 마칠 때까지 기다리죠.

하지만 이 친구에겐 치명적인(?) 제한이 하나 있습니다. 바로 한 번에 기다릴 수 있는 핸들의 수가 64개(정확히는 MAXIMUM_WAIT_OBJECTS 매크로 값)로 제한된다는 점입니다. 즉, 64개보다 많은 프로세스를 만들어 동시에 제어하려고 하면 "나 64명까지만 받을 수 있어!"라며 오류를 뱉어내는 거죠. 우리가 Pool(processes=100) 같은 코드를 짰을 때 윈도우에서 문제가 생기는 이유가 바로 이것 때문입니다.

💡 알아두세요!
이 64개 제한은 파이썬만의 문제가 아니라, 윈도우 API 자체의 제약입니다. 따라서 C++, C# 등 다른 언어로 윈도우 프로그램을 개발할 때도 동일하게 마주칠 수 있는 문제입니다.

 

자유로운 리눅스: select, poll, epoll의 세계 🐧

반면에 리눅스는 어떨까요? 리눅스는 프로세스 간 통신(IPC)이나 동기화에 WaitForMultipleObjects와는 다른 방식을 사용합니다. 바로 select, poll, epoll 같은 I/O 멀티플렉싱 기술이죠.

이 기술들은 여러 파일 디스크립터(File Descriptor, 리눅스에서는 프로세스, 소켓 등 모든 것을 파일처럼 다룹니다)의 상태 변화를 한 번에 감지하고 처리하는 방식입니다. 덕분에 윈도우와 같은 '64개'라는 인위적인 제한에서 훨씬 자유롭습니다.

항목 핸들 수 제한 특징
select() 기본 1024개 가장 오래되고 호환성이 좋지만, 성능 저하 이슈가 있음
poll() 시스템 메모리에 따름 select()의 단점을 개선, 더 많은 핸들 처리 가능
epoll() 시스템 메모리에 따름 매우 효율적인 최신 방식으로, 수만 개 이상의 핸들도 거뜬함

특히 최신 리눅스 커널에서 주로 사용하는 epoll은 사실상 시스템의 메모리가 허용하는 한까지 핸들을 다룰 수 있어, 수십, 수백 개의 프로세스를 관리하는 데 전혀 문제가 없습니다.

 

리눅스 vs 윈도우: 코드로 비교하기 🧪

간단한 파이썬 코드로 두 운영체제의 차이를 직접 확인해볼까요?

📝 워커 100개 생성 테스트 코드

from multiprocessing import Pool
import time

def work(x):
    # 각 워커가 0.1초 동안 어떤 작업을 수행한다고 가정
    time.sleep(0.1)
    return x * x

# 워커 프로세스를 100개 생성
if __name__ == '__main__':
    # 윈도우에서는 이 숫자가 64 이상이면 문제가 될 수 있습니다.
    num_processes = 100 
    
    with Pool(processes=num_processes) as pool:
        results = pool.map(work, range(1000))

    print(f"{num_processes}개의 워커로 작업 완료!")
    print(f"결과 (앞 10개): {results[:10]}")

위 코드를 리눅스 환경에서 실행하면 아무런 문제 없이 100개의 워커 프로세스가 생성되고 작업이 완료됩니다. 하지만 윈도우에서는 Pool 객체가 내부적으로 64개 이상의 프로세스 핸들을 기다려야 하는 상황이 오면 ValueError가 발생할 수 있습니다.

⚠️ 주의하세요!
리눅스라고 해서 워커 수를 무한정 늘릴 수 있는 것은 아닙니다. OS 레벨의 '제한'은 없지만, 시스템의 물리적인 자원(CPU 코어 수, 메모리 용량)에 따라 성능이 좌우됩니다. 너무 많은 프로세스는 오히려 잦은 컨텍스트 스위칭으로 성능을 저하시키거나, 메모리 부족(Out of Memory)으로 시스템 전체를 마비시킬 수 있습니다.

 

💡

윈도우 vs 리눅스 멀티프로세싱 핵심 요약

Windows의 제약: WaitForMultipleObjects() API의 64개 핸들 제한 때문에 워커 수에 직접적인 영향을 받습니다.
Linux의 자유: select/poll/epoll 방식으로 동작하여 OS 레벨의 워커 수 제한이 사실상 없습니다.
현실적 한계: 리눅스도 결국 CPU, 메모리 등 시스템 자원이 실질적인 워커 수의 한계가 됩니다.
개발자 팁: 크로스플랫폼 호환성을 위해서는 Windows의 64개 제한을 고려하여 코드를 설계하는 것이 안전합니다.

 

자주 묻는 질문 ❓

Q: 왜 윈도우는 이런 64개 제한을 계속 유지하나요?
A: 가장 큰 이유는 '하위 호환성'입니다. 오래전에 만들어진 API 설계를 변경하면 수많은 기존 프로그램들이 오작동할 수 있기 때문이죠. 또한, 커널 레벨에서 고정된 크기의 배열로 핸들을 관리하는 것이 더 간단하고 예측 가능한 성능을 보장했던 시절의 기술적 배경도 있습니다.
Q: 그럼 윈도우에서 64개 이상의 작업을 동시에 처리하려면 어떻게 해야 하나요?
A: 여러 가지 방법이 있습니다. 64개 미만의 워커 그룹을 여러 개 만들어 순차적으로 실행하거나, 비동기 I/O(asyncio)와 스레드를 조합하는 방식을 고려해볼 수 있습니다. 혹은 처음부터 64개 이하의 워커 풀로 작업을 관리하는 것이 가장 간단하고 안정적인 해결책입니다.
Q: 리눅스에서 적절한 워커 프로세스 수는 어떻게 정하나요?
A: 정답은 없지만, 일반적으로 'CPU 코어 수' 또는 'CPU 코어 수 x 2'를 기준으로 시작하는 것이 좋습니다. 작업의 종류(CPU-bound vs I/O-bound)에 따라 최적의 숫자는 달라지므로, 실제 환경에서 테스트를 통해 최적의 워커 수를 찾아가는 과정이 중요합니다.

결론적으로, 리눅스는 멀티프로세싱 워커 수에 대한 OS 제한이 거의 없는 반면, 윈도우는 64개라는 명확한 제한이 존재합니다. 따라서 여러 플랫폼에서 동작해야 하는 프로그램을 개발한다면, 이러한 보이지 않는 '벽'을 항상 염두에 두고 가장 보수적인 환경(윈도우)을 기준으로 설계하는 것이 안전합니다. 😊