CPU 코어 하나가 여러 프로그램을 번갈아 실행할 때 일어나는 컨텍스트 스위칭을 정리했습니다. PCB에 무엇을 저장하는지, 언제 전환이 일어나고 왜 비용이 드는지, 리눅스에서 직접 세어 보는 방법까지 살펴봅니다.
"코어는 네 개뿐인데, 어떻게 프로그램 수백 개가 동시에 돌아가는 걸까?"
지난번 프로세스와 스레드를 정리하면서 컨텍스트 스위칭이라는 말을 짧게 짚고 넘어갔습니다. 그런데 막상 "그래서 정확히 뭘 저장하고 뭘 꺼내는데?", "그게 왜 비싸다는 거지?"라고 스스로 물어보니 대답이 흐릿했습니다.
그래서 이번에는 전환이 일어나는 순간을 처음부터 끝까지 따라가 보기로 했습니다. 무엇을 어디에 저장하는지, 언제 전환이 일어나는지, 그리고 그 비용이 서버에서 어떤 모습으로 나타나는지까지 정리했습니다.

CPU(Central Processing Unit) 코어 하나는 한 순간에 한 실행 흐름만 처리합니다. 여러 프로그램이 동시에 도는 것처럼 보이는 건 OS(Operating System, 운영체제)가 아주 짧은 간격으로 실행 대상을 바꿔 주기 때문입니다.
이때 지금 실행 중이던 흐름의 상태를 저장하고, 다음에 실행할 흐름의 상태를 불러오는 작업을 컨텍스트 스위칭(Context Switching)이라고 합니다. 순서는 크게 세 단계입니다.
처음엔 컨텍스트라는 단어가 너무 막연했습니다. 여기서 컨텍스트는 이 흐름을 나중에 똑같이 이어서 실행하려면 기억해 둬야 하는 모든 것을 말합니다.
책을 읽다가 잠깐 덮을 때 책갈피를 끼우고, 어디까지 이해했는지 메모해 두는 것과 비슷합니다. 다시 펼쳤을 때 그 책갈피와 메모만 있으면 바로 이어서 읽을 수 있습니다. CPU 입장에서 책갈피는 다음에 실행할 명령어 위치이고, 메모는 계산하던 중간값입니다.
OS는 프로세스마다 관리 정보를 담은 구조체를 하나씩 둡니다. 이걸 PCB(Process Control Block, 프로세스 제어 블록)라고 하고, 컨텍스트 스위칭 때 저장하는 값도 여기에 들어갑니다.

리눅스에서는 이 역할을 task_struct라는 구조체가 맡습니다. 리눅스는 스레드도 같은 구조체로 관리해서, 스레드마다 하나씩 있다고 보면 됩니다.
레지스터는 CPU 안에 있는 아주 작고 빠른 저장 칸입니다. 그런데 코어마다 한 벌뿐이라서, 다음 흐름이 실행되는 순간 그 값이 덮어써집니다.
그래서 떠나는 흐름의 레지스터 값을 메모리에 옮겨 적어 두지 않으면, 나중에 돌아왔을 때 계산하던 값이 전부 사라집니다. 처음엔 "메모리에 있는 데이터는 그대로인데 뭘 저장하지?" 싶었는데, 저장 대상이 메모리가 아니라 CPU 안의 값이라는 걸 알고 나니 이해가 됐습니다.
프로세스는 실행되는 동안 몇 가지 상태를 오갑니다. 가장 많이 쓰는 구분은 준비, 실행, 대기 세 가지입니다.

준비 상태에서 실행 상태로 올리는 걸 디스패치(Dispatch)라고 합니다. 그림은 입출력을 기다리는 경우만 그렸고, 할당 시간을 다 써서 실행에서 곧장 준비로 돌아가는 길도 있습니다. 어느 쪽이든 실행 상태에서 빠지는 순간마다 컨텍스트 스위칭이 일어납니다.

실행에서 빠지는 계기는 크게 둘로 나눌 수 있습니다.
sleep()을 호출해도 여기에 해당합니다.할당 시간이 정확히 몇 ms인지는 OS와 스케줄러 설정에 따라 다릅니다. 리눅스 스케줄러는 고정된 시간 조각을 쓰지 않고 실행 중인 작업 수와 가중치에 따라 몫을 계산해서, 하나의 숫자로 말하기 어렵습니다.
인터럽트(Interrupt)는 하드웨어나 소프트웨어가 CPU에게 "지금 하던 거 잠깐 멈추고 이것부터 처리해 주세요"라고 보내는 신호입니다. CPU는 신호를 받으면 하던 일을 멈추고 커널의 처리 코드로 넘어갑니다.
수업 중에 교실 문을 두드리는 것과 비슷합니다. 선생님은 하던 설명을 멈추고 문을 열어 본 뒤, 별일 아니면 하던 설명으로 돌아오고 급한 일이면 다른 일을 먼저 합니다. 타이머 인터럽트는 일정 간격으로 문을 두드려서 OS가 "이제 다른 프로세스 차례인가?"를 판단할 기회를 만들어 줍니다.
이 부분이 제일 헷갈렸습니다 🤔 시스템 콜(System Call)을 부르면 커널로 들어가니까, 그게 곧 컨텍스트 스위칭인 줄 알았습니다.

알고 보니 둘은 구분해야 했습니다.
getpid()처럼 바로 답이 나오는 시스템 콜은 커널에 들어갔다가 같은 프로세스로 돌아옵니다. 실행 대상은 바뀌지 않습니다.read()로 디스크를 읽는데 데이터가 아직 없으면, 기다리는 동안 CPU를 놀릴 수 없으니 커널이 다른 프로세스로 전환합니다.구분 기준을 한 줄로 정리하면, 실행 중인 흐름이 바뀌었는지입니다. 모드 전환은 컨텍스트 스위칭의 한 부분으로 일어나지만, 모드 전환이 일어났다고 항상 컨텍스트 스위칭이 일어나는 것은 아닙니다.
CPU에는 권한 단계가 있습니다. 우리가 짠 프로그램은 권한이 낮은 사용자 모드에서 돌고, 하드웨어를 직접 만지거나 다른 프로세스의 메모리를 건드리는 일은 커널 모드에서만 할 수 있습니다.
은행 창구로 비유하면, 손님(프로그램)은 금고에 직접 들어갈 수 없고 창구 직원(커널)에게 요청해야 합니다. 시스템 콜은 창구에 번호표를 내미는 행동이고, 직원이 금고에 다녀오는 동안 손님이 잠깐 자리를 비워도 되면 다음 손님을 먼저 받는 것이 컨텍스트 스위칭입니다.
컨텍스트 스위칭이 비싸다고 하는데, 처음엔 레지스터 몇 개 저장하는 게 뭐가 비싸나 싶었습니다. 비용은 크게 두 종류였습니다.

이 시간 동안 CPU는 커널 코드를 실행할 뿐, 사용자 프로그램의 일은 전혀 진행되지 않습니다.
실제로 더 큰 건 전환한 뒤에 천천히 드러나는 비용이었습니다.
요즘 CPU는 TLB 항목에 주소 공간 번호를 붙여 두는 기능이 있어서, 프로세스를 바꿀 때마다 TLB를 통째로 비우지 않아도 됩니다. 그래도 새 프로세스의 변환 결과가 다시 채워지기 전까지는 느릴 수밖에 없습니다.
같은 프로세스의 스레드끼리는 주소 공간이 같습니다. 그래서 페이지 테이블을 갈아 끼울 필요가 없고, TLB에 담긴 변환 결과도 그대로 쓸 수 있습니다. 힙 데이터를 함께 쓰니 캐시가 살아 있을 가능성도 큽니다.
다만 레지스터를 저장하고 복원하는 직접 비용은 스레드 전환에도 똑같이 듭니다. 스레드 전환이 공짜인 게 아니라, 주소 공간을 바꾸는 비용이 빠질 뿐이라는 점을 기억해 두려고 합니다.
리눅스는 프로세스마다 컨텍스트 스위칭 횟수를 세어 둡니다. 클라우드 리눅스 환경(코어 4개)에서 직접 확인해 봤습니다.
# 1) 0.05초씩 20번 잠드는 프로세스
bash -c 'for i in $(seq 20); do sleep 0.05; done; grep ctxt /proc/self/status'
# 2) 코어 4개에 쉬지 않고 도는 프로세스 8개
for i in $(seq 8); do timeout 4 bash -c 'while :; do :; done' & done
grep ctxt /proc/<PID>/status
# 1) 결과
voluntary_ctxt_switches: 21
nonvoluntary_ctxt_switches: 0
# 2) 결과 (3초쯤 지난 뒤, 그중 한 프로세스)
voluntary_ctxt_switches: 0
nonvoluntary_ctxt_switches: 383
/proc/<PID>/status: 리눅스가 프로세스 정보를 파일처럼 보여 주는 곳입니다. /proc/self는 지금 이 명령을 실행하는 프로세스 자신을 뜻합니다.voluntary_ctxt_switches: 자발적 전환 횟수입니다. 1번은 sleep을 기다릴 때마다 스스로 CPU를 내려놓아서 이 숫자가 올라갔습니다.nonvoluntary_ctxt_switches: 비자발적 전환 횟수입니다. 2번은 할 일이 계속 남아 있는데 코어보다 프로세스가 두 배 많아서, 3초 만에 수백 번 CPU를 빼앗겼습니다.처음엔 2번도 0이 나와서 당황했습니다. 프로세스를 하나만 돌렸더니 놀고 있는 코어가 있어서 아무도 빼앗을 이유가 없었던 것이었습니다. 코어 수보다 많이 돌리고 나서야 숫자가 올라가는 걸 보니 확실히 기억에 남았습니다.
시스템 전체의 초당 전환 횟수는 vmstat 1의 cs 열에서 볼 수 있습니다. sysstat 패키지가 있다면 pidstat -w 1로 프로세스별 초당 자발적(cswch/s), 비자발적(nvcswch/s) 전환 수도 볼 수 있습니다.
스프링 부트 서버는 JVM(Java Virtual Machine) 프로세스 하나 안에서 요청마다 스레드를 배정합니다. 내장 톰캣의 기본 최대 스레드 수는 200개이고, 이 스레드는 OS 스레드와 1대1로 대응합니다.
이 구조에서 컨텍스트 스위칭은 이렇게 드러납니다.
Java 21에 정식으로 들어온 가상 스레드(Virtual Thread)는 이 전환 비용을 줄이려는 방법입니다.

가상 스레드는 OS가 아니라 JVM이 관리합니다. JVM은 코어 수 정도의 플랫폼 스레드(캐리어 스레드)를 두고, 그 위에 가상 스레드를 올렸다 내렸다 합니다. 가상 스레드가 DB 응답을 기다리면 캐리어 스레드에서 내려오고, 그 자리에 다른 가상 스레드가 올라탑니다.
이 전환은 커널을 거치지 않고 JVM 안에서 일어나서, OS 스레드끼리 전환하는 것보다 가볍습니다. 스프링 부트 3.2부터는 spring.threads.virtual.enabled=true 설정 하나로 요청 처리에 가상 스레드를 쓸 수 있습니다.
다만 캐리어 스레드 자체는 여전히 OS가 코어에 배정하니, OS 수준의 컨텍스트 스위칭이 사라지는 건 아닙니다.
read()처럼 기다려야 하는 호출일 때 커널이 다른 프로세스로 전환하면서 컨텍스트 스위칭이 일어납니다.