빙응의 공부 블로그

[OSTEP 병행성(concurrency)]개요 본문

CS/운영체제

[OSTEP 병행성(concurrency)]개요

빙응이 2025. 4. 28. 22:02

📝서론 

지금까지 운영체제가 다루는 기본 개념들의 발전 과정을 살펴보았다. 하나의 물리적 CPU를 다수의 가상 CPU로 확장하여 마치 여러 개의 프로그램이 동시에 실행하는 듯한 착시를 만들었고 개별적인 프로세스가 모두 독립적으로 많은 가상 메모리를 가지는 것처럼 만들었다.

 

이번 장에서는 프로세스를 위한 새로운 개념인 쓰레드를 소개한다.

프로그램에서 한 순간에 하나의 명령어만 실행하는 관점에서 벗어나 멀티 쓰레드 프로그램은 하나 이상의 실행 지점을 가진다.

 

차이점 1, 쓰레드의 주소 공간 공유와 동일한 값 접근 가능

문맥 교환을 통해 실행중인 쓰레드가 교체할 때 쓰레드 제어 블록( thread control block, TCB)

쓰레드 간의 문맥 교환은 주소 공간을 공유한다. 즉, 같은 주소 공간을 공유한 상태로, 레지스터, 스택 포인터, 프로그램 카운터 등만 교체한다. 이때 필요한 정보를 담아두는 것이 쓰레드 제어 블록이다.

 

또한 주소 공간을 공유하기 때문에 같은 데이터 접근이 가능하며 이것으로 인한 오류는 나중에 후술하겠다.

차이점 2, 스택 영역의 활용

고전적 프로세스 주소 공간 모델은 하나의 스택만 존재하며 주로 주소 공간 하부에 위치한다.

반면에 멀티 쓰레드 프로세스의 경우에는 각 쓰레드가 독립적으로 실행되며 쓰레드가 실행하기 위해 여러 루틴을 호출한다.

주소 공간에는 하나의 스택이 아니라 쓰레드마다 스택이 할당된다. 

 

📝쓰레드의 데이터 공유 

쓰레드는 따로 제한을 명시하지 않는다면 실행 순서는 스케줄러의 동작에 따라 바뀔 수 있다. 그렇기에 실행 결과가 달라질 수 있는 오류가 생긴다. 

 

해당 코드를 확인해보자

public class ConcurrencyIssueExample {
    private static int counter = 0; // 공유 자원

    public static void main(String[] args) throws InterruptedException {
        // 스레드 2개 생성
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter++; // 동시성 문제 발생 가능
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter++; // 동시성 문제 발생 가능
            }
        });

        t1.start();
        t2.start();

        t1.join(); // t1 스레드가 끝날 때까지 기다림
        t2.join(); // t2 스레드가 끝날 때까지 기다림

        System.out.println("최종 counter 값: " + counter);
    }
}

 

이 코드는 읽기 -> 계산 -> 쓰기를 거친다. 이 과정에서 두 스레드가 동시에 counter를 읽으면 서로 덮어쓰는 일이 발생하여 최종 값이 달라질 수 있다.

최종 counter 값: 18342

 

 

📝제어 없는 스케줄링

이러한 현상은 그렇다면 왜 발생할까?

상황

  • counter라는 공유 변수가 있다.
  • counter++를 스레드 두 개가 동시에 실행한다고 가정하자!

 

근데 사실 counter++는 진짜로 한 번에 실행되는 것이 아니다.

사실 컴퓨터는 이걸 3단계로 나눠서 실행한다.

  1.  counter 메모리 값을 읽어오기
    • eax 레지스터에 저장(mov 명령어)
  2. eax 레지스터에 1 더하기
    • add 명령어
  3. eax 값을 다시 counter에 저장하기 
    • move 명렁어

 

이 과정이기에 쓰레드에 따로 제한이 없다면 문제가 발생합니다.

 

  • 스레드1이 1, 2번 까지 하고 있을 때 (counter=50, eax=51)
  • 갑자기 인터럽트 발생 → 운영체제가 스레드1을 멈춤
  • 스레드2가 실행됨 → 같은 counter를 읽음 (여전히 50)
  • 스레드2는 50+1 해서 51을 counter에 저장
  • 다시 스레드1이 이어서 실행 → 스스로 가지고 있던 51을 counter에 다시 저장

 

이러한 상황을 경쟁 조건이라고 합니다.
스레드들이 서로 경쟁해서 예상치 못한 결과가 나오는 것입니다.

 

컴퓨터의 작동에서 일반적으로 발생하는 결정적 결과와 달리 어떠한 결과가 나올지 알 수 없는 것을 비결정적인 결과라고 부릅니다.

  • 경쟁 조건 : 명령어의 실행 순서에 따라 결과가 달라지는 상황
  • 결정적 결과 : 어떤 상황에서 동일한 값
  • 비결정적 결과 : 어떤 상황에서 결과가 다른 경우

 

멀티 쓰레드 같은 코드를 실행할 대 경쟁 조건이 발생하기 때문에 이러한 코드 부분을 임계 영역이라고 부릅니다.

공유 변수를 접근하고 하나 이상의 스레드가 동시에 실행되면 안되는 코드를 임계 영역이라 부릅니다.

  • 임계 영역 : 하나 이상의 스레드가 동시에 실행되면 값이 바뀌기에 안되는 영역

 

이러한 코드에 필요한 것은 상호 배제로 하나의 스레드가 임계 영역 내의 코드를 실행 중일 때 다른 스레드가 실행할 수 없도록 보장하는 것을 말합니다.

  • 상호 배제 : 하나의 스레드가 임계 영역 내의 코드를 실행 중일 때 다른 스레드가 실행할 수 없도록 보장

📝원자성에 대한 바람

임계 영역 문제에 대한 해결 방법 중 하나로 강력한 명령어 한 개로 의도한 동작을 수행하여, 인터럽트 발생 가능성을 원천적으로 차단하는 것이다. 말이 좀 어려운데 쉽게 말하면 1개의 동작에 필요한 명령어들을 묶어서 완전히 실행하도록 하는 것이다.

이 명령어 세 줄이 원자성, 즉 한 번에 실행되게 보장하면 임계 영역 문제를 걱정하지 않아도 된다.

이것을 하드웨어적으로 동기화 함수(synchronization primitives) 라 한다. 이러한 몇 개의 하드웨어 동기화 명령어와 운영체제의 지원으로 한 번에 하나의 쓰레드만 임계 영역에서 실행하도록 구성된 프로그램을 작성할 수 있다.

 

물론 문제는 존재한다. 

 

📝또 다른 문제 : 상대 기다리기

우리는 병행성에서 단순히 공유 변수 접근 문제뿐만 아니라 다른 것도 생각해야 한다.

어떤 스레드가 다른 쓰레드의 작업이 끝나기를 기다려야 하는 경우이다.

 

예를 들어보자

 

  • 스레드 A가 디스크에 데이터를 요청했어.
  • 디스크는 바로 응답하지 못해.
  • 그럼 스레드 A는 잠들어(sleep) 있고
  • 디스크가 작업을 끝낸 다음, A를 깨워서(wakeup) 이어서 실행시켜야 해.

이후에서는 원자성 지원을 위한 동기화 함수 제작에 대한 내용과 멀티 쓰레드 프로그램에서 흔한 잠자기/깨우기 동작에 관한 지원 기법에 대해 다룬다.

 

 

📝정리

 

  • 쓰레드와 주소 공간
    • 쓰레드는 하나의 물리적 CPU에서 여러 개의 가상 CPU로 실행되며, 각 쓰레드는 주소 공간을 공유한다.
    • 쓰레드 간의 문맥 교환은 레지스터, 스택 포인터, 프로그램 카운터만 교체하고 주소 공간을 공유하기 때문에 같은 데이터를 접근할 수 있다.
  • 스택 영역
    • 고전적 프로세스는 하나의 스택을 사용하지만, 멀티 쓰레드 프로세스는 각 쓰레드마다 독립적인 스택을 할당하여 실행한다.
  • 쓰레드의 데이터 공유 및 동시성 문제
    • 멀티 쓰레드 환경에서 공유 자원을 동시에 접근하면 동시성 문제가 발생한다. 예를 들어, counter++와 같은 코드에서 여러 스레드가 동시에 접근하면 경쟁 조건(race condition)이 발생할 수 있다.
    • 경쟁 조건: 명령어 실행 순서에 따라 결과가 달라지는 상황.
    • 임계 영역: 공유 자원에 동시에 접근해서 값이 바뀌는 것을 막아야 하는 코드 영역.
  • 상호 배제 (Mutual Exclusion)
    • 임계 영역에서 동시에 여러 스레드가 실행되지 않도록 상호 배제를 보장해야 한다. 이를 위해서는 하나의 스레드만 임계 영역 내에서 실행되도록 보장해야 한다.
  • 원자성 (Atomicity)
    • 원자성은 여러 명령어를 하나의 동작으로 묶어 중간에 인터럽트가 발생하지 않도록 보장하는 것이다. 이로 인해 임계 영역 문제를 해결할 수 있다.
    • 하드웨어 동기화 함수(synchronization primitives)와 운영체제의 지원을 통해 임계 영역에서 한 번에 하나의 스레드만 실행되도록 할 수 있다.
  • 상대 기다리기 (Blocking)
    • 어떤 스레드가 다른 스레드의 작업이 끝나기를 기다려야 하는 상황도 존재한다.
    • 예를 들어, 스레드 A가 디스크 I/O를 요청하고, 응답이 오면 A가 다시 실행되는 방식이다. 이는 잠자기/깨우기 동작을 통해 구현된다.