멀티코어 환경에서 동작하는 프로그램을 구상하다 보면, '컴퓨터는 어떻게 수많은 일들을 동시에 처리하는가'라는 근본적인 질문과 마주하게 된다. 단순히 작성한 코드가 하나의 프로그램으로 실행될 때조차, 운영체제 내부에서는 수많은 프로세스와 스레드가 시시각각 CPU 자원을 나눠 쓰고 있다. 이 정교한 시스템의 핵심 원리를 제대로 이해하지 않고는 좋은 아키텍처를 설계할 수 없다는 생각이 들어, 운영체제의 가장 핵심적인 역할인 프로세스와 스레드, 그리고 동시성(Concurrency)에 대해 다시 한번 정리해 보기로 했다.
1. 프로세스와 가상화: 독립된 실행 단위의 탄생
모든 시작은 프로세스(Process)이다. 이는 '실행 중인 프로그램의 인스턴스'로 정의된다. 단순히 디스크에 저장된 프로그램 코드 덩어리가 아니라, 메모리에 적재되어 생명을 얻은 실체인 것이다.
프로세스의 핵심적인 추상화는 두 가지이다.
제어 흐름 (Control Flow): CPU가 실행하는 명령어의 순차적인 흐름이다. 각 프로세스는 자신만의 프로그램 카운터(PC)와 레지스터 상태를 가지며 독립적으로 실행된다.
메모리 주소 공간 (Address Space): 프로세스가 접근할 수 있는 메모리 영역의 집합이다. 운영체제는 각 프로세스에게 다른 프로세스와 겹치지 않는 고유의 주소 공간을 할당한다.
운영체제는 이 두 가지 추상화를 통해 각 프로세스에게 가상화(Virtualization)라는 강력한 환상을 제공한다. 모든 프로세스는 마치 CPU와 메모리 전체를 혼자 독점하여 사용하는 것처럼 행동할 수 있다. 이것이 우리가 여러 프로그램을 동시에 실행해도 서로 충돌하지 않는 이유이다.
2. CPU 통제권과 문맥 교환
그런데 한 가지 의문이 생긴다. 특정 프로세스가 CPU에서 실행 중일 때는 운영체제 코드는 멈춰있다. 만약 프로세스가 CPU를 놓아주지 않는다면? 이때 운영체제가 CPU 통제권을 되찾아오는 핵심적인 장치가 바로 사용자/커널 모드 전환과 타이머 인터럽트이다.
사용자/커널 모드: 애플리케이션은 사용자 모드에서, OS는 커널 모드에서 동작한다. I/O 요청과 같은 민감한 작업은 반드시 시스템 콜(System Call)을 통해 커널 모드로 전환하여 OS에게 위임해야 한다.
타이머 인터럽트: OS는 주기적으로 인터럽트를 발생시키는 하드웨어 타이머를 설정한다. 이 인터럽트가 발생하면 현재 실행 중인 프로세스는 강제로 멈추고, 제어권은 OS에게 넘어온다.
이렇게 제어권을 되찾은 OS는, 이제 어떤 프로세스를 다음에 실행할지 결정한다. 이것이 바로 스케줄링이다.
3. 스케줄링 정책에 대한 간단한 회고
과거 포스팅에서 스케줄링 정책은 자세히 다룬 적이 있다. 스케줄러의 목표는 성능(Tturnaround
STCF(Shortest Time to Completion First)는 평균 턴어라운드 시간을 최소화하여 시스템 전체의 처리량을 높이는 데 유리하다. 반면 RR(Round Robin)은 모든 프로세스에게 짧은 시간 조각(time slice)을 번갈아 할당하여 상호작용이 잦은 프로그램의 응답 시간을 줄이는 데 효과적이다. 현대의 MLFQ나 Linux의 CFS 같은 스케줄러들은 이 둘의 장점을 결합하려는 복잡하고 정교한 시도들이다. 멀티코어 환경에서는 캐시 친화성(Cache Affinity)을 유지하기 위해 CPU 코어별로 큐를 두는 MQMS 방식도 중요한 고려사항이 된다.
4. 스레드와 동시성: 공유지의 비극
지금까지의 논의는 독립된 주소 공간을 가진 프로세스에 대한 이야기였다. 하지만 현대 애플리케이션은 더 가벼운 실행 단위, 즉 스레드(Thread)를 통해 하나의 프로세스 내에서 여러 작업을 동시에 처리하고자 하는 요구가 크다.
프로세스: 독립된 주소 공간을 가진다. 보호의 단위. 생성 및 문맥 교환 비용이 크다.
스레드: 같은 프로세스의 주소 공간(특히 힙 영역)을 공유한다. 동시성의 단위. 생성 및 문맥 교환이 가볍다.
이 '주소 공간 공유'라는 특성은 스레드의 가장 큰 장점이자, 동시에 동시성 프로그래밍을 어렵게 만드는 주된 원인이다. 둘 이상의 스레드가 공유 데이터에 동시에 접근할 때, 스케줄러의 변덕스러운 실행 순서에 따라 결과가 매번 달라지는 경쟁 조건(Race Condition)이 발생할 수 있기 때문이다.
경쟁 조건은 코드 내에 임계 구역(Critical Section), 즉 공유 자원에 접근하는 부분이 존재하기 때문에 발생한다. 다음 코드를 보자.
// 전역 변수 counter
counter = counter + 1;
이 한 줄의 코드는 원자적으로(atomically) 실행되지 않는다. 실제로는 다음과 같은 저수준의 명령어들로 나뉘어 실행된다.
메모리에서
counter
의 현재 값을 CPU 레지스터로 가져온다. (load)레지스터의 값을 1 증가시킨다. (increment)
레지스터의 새로운 값을 다시 메모리의
counter
위치에 쓴다. (store)
만약 스레드 A가 2번까지 실행한 직후, 타이머 인터럽트가 발생해 스레드 B로 문맥 교환이 일어났다고 가정해 보자. 스레드 B는 아직 변경 전의 counter
값을 메모리에서 읽어와 1, 2, 3번을 모두 마칠 것이다. 그 후 다시 스레드 A로 제어권이 돌아와 3번 명령을 실행하면, 스레드 B의 작업은 그대로 덮어쓰여 사라지게 된다. 두 번의 연산이 있었지만 결과는 한 번만 증가하는, 데이터 부정합(inconsistency) 문제가 발생한 것이다.
상호 배제와 뮤텍스
이 문제를 해결하기 위한 유일한 방법은 임계 구역을 상호 배제(Mutual Exclusion) 원칙에 따라 실행하도록 보장하는 것이다. 즉, 한 번에 오직 하나의 스레드만 임계 구역에 진입할 수 있도록 강제해야 한다.
가장 대표적인 상호 배제 기법이 바로 뮤텍스(Mutex, Mutual Exclusion) 락이다.
lock_t mutex; // 뮤텍스 변수 선언
...
Pthread_mutex_lock(&mutex); // 임계 구역 시작: 락을 잠근다
counter = counter + 1;
Pthread_mutex_unlock(&mutex); // 임계 구역 종료: 락을 푼다
스레드는 임계 구역에 진입하기 전 lock()
을 시도한다. 만약 다른 스레드가 락을 점유하고 있지 않다면, 락을 획득하고 임계 구역으로 진입한다. 만약 락이 이미 점유된 상태라면, 해당 락이 unlock()
될 때까지 그 자리에서 대기(block)한다. 이 간단한 장치를 통해, 여러 줄의 명령어로 구성된 임계 구역 코드 전체가 마치 하나의 원자적 명령어처럼 실행되는 것을 보장할 수 있다.
결론
결국 운영체제의 역사는 '어떻게 제한된 자원을 효율적으로 나눠 쓸 것인가'에 대한 고민의 역사이다. 프로세스와 가상 메모리를 통해 실행 환경을 분리하고, 스케줄러를 통해 CPU 시간을 분배했다. 이제는 스레드를 통해 하나의 프로세스 내부에서마저 실행 흐름을 나누어 쓰는 시대가 되었다.
스레드의 데이터 공유는 강력한 무기이지만, 경쟁 조건이라는 치명적인 위험을 내포하고 있다. 이를 제어하기 위한 뮤텍스와 같은 동기화 기법의 이해는 이제 동시성 프로그래밍의 필수 소양이 되었다. 이번 정리를 통해 지난 시간 배워온 프로세스부터 스레드, 그리고 동기화 문제까지 이어지는 개념의 흐름을 명확히 할 수 있었다. 다음에는 이 뮤텍스 락이 하드웨어나 운영체제 수준에서는 구체적으로 어떻게 구현되는지에 대해 더 깊이 파고들어 볼 생각이다.
추천글:
[운영체제] CPU 가상화 | Logical Control Flow에서 Context Switch까지
[운영체제] 운영체제란? | Virtualization, Concurrency, Persistence 핵심 개념 이해
[운영체제] CPU 스케줄링 | FIFO에서 MLFQ까지, 스케줄링 정책의 발전과정 정리
[운영체제] CPU 스케줄링 심화 | Proportional Share Scheduler 와 CFS, EEVDF