하나의 CPU 코어가 실행 중인 프로세스(또는 스레드)를 멈추고, 다른 프로세스로 전환할 때 현재 상태를 저장하고 다음 상태를 불러오는 과정
Context(문맥): CPU가 해당 프로세스를 실행하기 위해 필요한 정보들(레지스터 값, Program Counter, Stack Pointer 등) 이 정보들은 PCB(Process Control Block)에 저장된다.
CPU는 한 번에 하나의 작업만 할 수 있지만, 여러 프로그램이 동시에 돌아가는 것처럼 느끼는 것은 CPU가 아주 짧은 시간 동안 여러 프로세스를 번갈아 가며 실행하기 때문이다.
프로세스 A에서 B로 전환되는 시나리오
- A 정지: 실행 중인 프로세스 A에 인터럽트나 시스템 콜이 발생
- 상태 저장: CPU의 레지스터에 있던 A의 현재 작업 데이터를 PCB_A에 저장
- 다음 프로세스 선택: 스케줄러가 다음에 실행할 프로세스 B를 선택
- 상태 복원: PCB_B에 저장되어 있던 B의 이전 작업 데이터를 CPU 레지스터로 불러옴
- B 실행: 프로세스 B가 중단되었던 지점부터 다시 실행
문맥 교환은 사실 공짜가 아니다. 상태를 저장하고 복원하는 동안 CPU는 실제 유용한 계산 작업을 하지 못하는데 이를 오버헤드라고 한다.
문맥 교환이 너무 자주 일어나면 배보다 배꼽이 더 커져서 시스템 성능이 떨어질 수 있다.
최근 공부했던 코루틴(Coroutine)이나 스레드(Thread)와 연결해서 생각본다면?
프로세스 vs 스레드: 프로세스 간 문맥 교환보다 스레드 간 문맥 교환이 더 빠르다. 스레드는 스택을 제외한 메모리 영역을 공유하기 때문.
코루틴: 코루틴은 OS가 개입하는 '커널 수준의 문맥 교환'이 아니라, 애플리케이션 레벨에서 제어권을 넘기기 때문에 오버헤드가 훨씬 적다.
| 구분 | 프로세스 문맥 교환 | 스레드 문맥 교환 |
|---|---|---|
| 공유 영역 | 거의 없음 (독립된 주소 공간) | 스택 제외 데이터/힙 영역 공유 |
| 메모리 주소 전환 | 가상 메모리 주소(MMU)를 통째로 바꿔야 함 | 주소 공간을 그대로 유지 |
| TLB 비우기 | 캐시의 일종인 TLB를 완전히 비워야 함(Flush) | TLB를 비울 필요가 없음 |
| 비용 | 매우 높음 | 상대적으로 낮음 |
TLB(Translation Lookaside Buffer)란?
가상 주소를 물리 주소로 변환하는 정보를 담은 일종의 주소 변환 캐시. 프로세스가 바뀌면 주소 체계 자체가 달라지므로 이 캐시를 다 지워야 하는데, 이것이 프로세스 문맥 교환의 가장 큰 오버헤드 중 하나
배치 처리 (Batch Processing): 작업을 하나씩 처리하지 않고 모아서 한꺼번에 처리해 교환 횟수를 줄인다.
비동기 I/O (Non-blocking I/O): I/O 대기 시간 동안 프로세스를 Waiting 상태로 보내지 않고 다른 일을 하게 함으로써 불필요한 문맥 교환을 방지한다.
유저 레벨 스레드/코루틴: 운영체제 커널이 개입하지 않고 애플리케이션 수준에서 실행 흐름을 바꾸어 오버헤드를 극단적으로 낮춘다.
자바(Java)나 코틀린(Kotlin)에서 사용하는 일반적인 Thread는 보통 커널 레벨 스레드와 1:1로 매핑된다. 하지만 최근 주목받는 Java의 Project Loom(Virtual Thread)이나 Kotlin Coroutine이 바로 이 유저 레벨 스레드의 개념을 현대적으로 발전시켜 단점(I/O 블로킹 등)을 해결한 형태이다.
문맥 교환은 주로 두 가지 경로를 통해 일어난다.
1) "잠시 비켜주세요" (Running → Ready)
2) "결과 나올 때까지 쉬다 올게요" (Running → Waiting)
- Ready (준비): CPU를 할당받기 위해 줄을 서서 기다리는 상태. 메모리에 로드되어 실행 준비를 마쳤지만, CPU가 다른 일을 하고 있는 상황
- Running (실행): CPU를 할당받아 실제로 명령어를 처리하고 있는 상태
- Waiting / Blocked (대기/차단): I/O 작업(파일 읽기, 네트워크 응답 등)이나 특정 이벤트가 발생하기를 기다리는 상태. 이때 CPU는 이 프로세스를 붙잡고 있지 않고 다른 프로세스로 넘겨버림