Python os.sched_setaffinity()로 CPU 친화도 제어하기

멀티코어 시스템에서 프로세스는 기본적으로 아무 CPU에서나 스케줄된다. 대부분은 이게 맞는데, 벤치마크 노이즈를 줄이거나, 특정 워커를 특정 코어에 묶어 캐시 지역성을 살리거나, 컨테이너에서 CPU 예산을 나눠 쓰는 경우엔 프로세스가 실제로 어느 CPU에서 도는지 직접 통제하고 싶어진다. Python의 os.sched_setaffinity()는 이런 CPU 친화도(affinity)를 프로세스 단위로 제어하는 함수다. 이 글에서는 실제로 CPU 집합을 제한해보고, 워커들이 정말 그 안에서만 도는지 직접 관찰한다.

현재 affinity 확인

import os
print("전체 CPU 수:", os.cpu_count())
print("현재 프로세스 affinity:", os.sched_getaffinity(0))
전체 CPU 수: 12
현재 프로세스 affinity: {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}

sched_getaffinity(0)0은 “현재 프로세스”를 뜻한다. 특정 PID를 넣으면 다른 프로세스의 affinity도 조회할 수 있다(권한이 있는 경우).

CPU 집합을 제한하고 실제로 확인하기

워커가 1초간 CPU 바운드 작업을 돌리면서 실행 중이던 CPU 번호를 계속 기록하게 했다. os.sched_getcpu()가 이 환경에는 없어서(아래 주의사항 참고) /proc/self/stat의 39번째 필드(processor)로 대신 확인했다.

import os, sys, time

def current_cpu():
    with open("/proc/self/stat") as f:
        return int(f.read().split()[38])

def worker(worker_id, cpus):
    if cpus is not None:
        os.sched_setaffinity(0, cpus)
    seen = set()
    end = time.time() + 1.0
    x = 0
    while time.time() < end:
        for _ in range(20000):
            x += 1
        seen.add(current_cpu())
    print(f"worker={worker_id} pid={os.getpid()} 실행된 CPU 집합={sorted(seen)}")

if __name__ == "__main__":
    worker_id = int(sys.argv[1])
    cpus = None if sys.argv[2] == "none" else set(int(c) for c in sys.argv[2].split(","))
    worker(worker_id, cpus)
for i in 0 1 2 3; do python3 affinity_worker.py $i none & done; wait
for i in 0 1 2 3; do python3 affinity_worker.py $i 0,1 & done; wait
=== affinity 제한 없음 ===
worker=0 pid=14798 실행된 CPU 집합=[4]
worker=3 pid=14801 실행된 CPU 집합=[8]
worker=2 pid=14800 실행된 CPU 집합=[3]
worker=1 pid=14799 실행된 CPU 집합=[11]

=== affinity를 CPU 0,1로 제한 ===
worker=1 pid=14803 실행된 CPU 집합=[0]
worker=0 pid=14802 실행된 CPU 집합=[1]
worker=2 pid=14804 실행된 CPU 집합=[0, 1]
worker=3 pid=14805 실행된 CPU 집합=[0]

제한이 없을 땐 스케줄러가 4개 워커를 12개 코어 중 4, 8, 3, 11번으로 알아서 흩뿌렸다. {0, 1}로 제한한 뒤에는 네 워커 모두 0번과 1번 밖으로 단 한 번도 나가지 않았다 — worker=2는 실행 중에 0번과 1번을 오갔지만, 그 두 코어를 벗어나지는 않았다.

잘못된 CPU를 지정하면

import os
try:
    os.sched_setaffinity(0, {99})
except OSError as e:
    print(f"에러: {e}")
에러: [Errno 22] Invalid argument

존재하지 않는 CPU 번호를 넣으면 EINVAL로 즉시 실패한다. os.cpu_count() 범위를 벗어난 값이 섞여 있지 않은지 미리 검증하는 게 안전하다.

주의사항

  • os.sched_getcpu()는 Python 3.3부터 있는 표준 함수지만, 이 글을 검증한 WSL2 환경의 Python 3.12에서는 AttributeError가 났다 — 빌드 시 HAVE_SCHED_GETCPU가 꺼져 있으면 이렇게 빠질 수 있다. 코드를 이식성 있게 짜려면 hasattr(os, "sched_getcpu")로 먼저 확인하고, 없으면 /proc/self/stat로 대체하는 게 안전하다.
  • affinity는 “이 집합 안에서만 스케줄하라”는 제약이지 “정확히 코어 하나에 고정”이 아니다. 위 결과에서 worker=2가 0번과 1번을 오간 것처럼, 집합 크기가 1보다 크면 그 안에서는 여전히 스케줄러 재량이다. 코어 하나에 완전히 고정하려면 집합 크기를 1로 준다.
  • cgroup의 cpuset이 이미 사용 가능한 CPU를 제한해 둔 컨테이너 안에서는, sched_setaffinity로 그 밖의 CPU를 요청해도 cgroup 제한을 넘어설 수 없다(문서 기준 사실이며, 이 글에서는 컨테이너 환경으로 직접 검증하지 않았다).
  • WSL2처럼 가상화 계층 위에서는 여기서 보이는 “CPU 번호”가 호스트의 물리 코어와 1:1로 대응한다는 보장이 없다. 하이퍼바이저가 그 위에서 다시 스케줄링하기 때문에, 캐시 지역성 같은 물리적 이득을 노린다면 베어메탈 환경에서 다시 확인해야 한다.

마무리

os.sched_setaffinity()로 지정한 CPU 집합을 실제 워커들이 한 번도 벗어나지 않는다는 걸 직접 확인했다. 다만 affinity는 “그 안에서 스케줄”이라는 느슨한 제약이라 코어 하나에 완전히 못 박고 싶다면 집합 크기를 1로 줘야 하고, 컨테이너·가상화 환경에서는 여기서 본 CPU 번호가 물리 코어와 다르게 매핑될 수 있다는 것도 감안해야 한다.

참고

답글 남기기