프로세스 동기화

2024. 5. 7. 18:14CS/운영체제

동기화란

동기화는 특정 자원에 접근할 때 한 개의 프로세스만 접근하게 하거나, 프로세스를 올바른 순서대로 실행하게 하는 것을 의미한다.

프로세스를 올바른 순서대로 실행하는 것을 실행 순서 제어를 위한 동기화라고 하고, 동시에 접근해서는 안되는 자원에 하나의 프로세스만 접근하도록 하는 것을 상호 배제를 위한 동기화라고 한다.

 

실행 순서 제어를 위한 동기화

Book.txt라는 파일이 있다. Writer 프로세스는 텍스트 파일에 데이터를 저장하는 프로세스이고, Reader 프로세스는 데이터를 읽어오는 프로세스이다. 순서가 보장되지 않는다면 데이터를 저장하는 Writer 프로세스가 실행되기 전에 Reader 프로세스가 실행되어 올바른 저장값을 읽어오지 못할 수 있다. 

상호 배제를 위한 동기화

#include <iostream>
#include <queue>
#include <thread>

void produce();
void consume();

int sum = 0;

int main() {

    std::cout << "초기 합계: " <<  sum << std::endl;
    std::thread producer(produce);
    std::thread consumer(consume);

    producer.join();
    consumer.join();
    
    std::cout << "producer, consumer 스레드 실행 이후 합계: " <<  sum << std::endl;
    
    return 0;
}

void produce() {
    for(int i = 0; i < 100000; i++) {
        sum++;
    }
}

void consume() {
    for(int i = 0; i < 100000; i++) {
        sum--;
    }
}

 

produce 프로세스와 consume 프로세스를 100,000번 실행했기 때문에 결과값이 0이 나올 거 같지만 그렇지 않다.

 

왜냐하면 생산자와 소비자가 총합이라는 데이터를 동시에 접근하게 되는데 소비자가 생산자의 작업이 끝나기 전에 데이터를 수정하고 반대로 생산자가 소비자의 작업이 끝나기 전에 데이터를 수정하기 때문이다.

 

즉, 동시에 접근해서는 안되는 자원에 동시에 접근했기에 발생하는 문제이다.

 

이렇게 프로세스가 동시에 접근하는 공동의 자원을 공유 자원이라고 한다.

공유 자원은 전역 변수가 될 수 있고, 파일이 될 수 있으며 입출력장치나 보조기억장치가 될 수도 있다.

그리고 공유 자원에 접근하는 코드 중, 동시에 실행하면 문제가 발생하는 코드 영역을 임계 구역이라고 한다.

그리고 임계 구역의 코드가 실행되는 상황을 레이스 컨디션이라고 한다. 위의 코드가 레이스 컨디션의 예시이다.

 

레이스 컨디션이 발생하는 근본적인 이유는 무엇일까?

바로 컴퓨터가 고급언어를 저급언어로 변환해서 실행하는 과정에 있다.

 

그림처럼 문맥교환(context switching)이 일어나면 각 프로세스의 작업이 현재 총합에 저장되지 않기 때문에 결과적으로 기댓값인 10이 아니라 9가 나오는 것을 알 수 있다. 

 

따라서 상호 배제를 위한 동기화를 위해서는 세 가지 원칙이 반드시 지켜져야한다.

  • 상호 배제 : 한 프로세스가 임계 구역에 진입했다면 다른 프로세스는 들어올 수 없다.
  • 진행 : 임계 구역에 어떤 프로세스도 진입하지 않았다면 임계 구역에 진입하고자 하는 프로세스는 들어갈 수 있다.
  • 유한 대기 : 한 프로세스가 임계 구역에 진입하고 싶다면 그 프로세스는 언젠가는 임계 구역에 들어올 수 있어야 한다.

동기화 기법

뮤텍스 락

뮤텍스 락은 동시에 접근해서는 안되는 자원에 동시에 접근하지 않도록 만드는 도구, 다시 말해 상호 배제를 위한 동기화 도구이다.

뮤텍스 락의 가장 단순한 형태는 하나의 전역 변수와 두 개의 함수로 구현할 수 있다.

  • 자물쇠 역할 : 프로세스들이 공유하는 전역 변수 lock
  • 임계 구역을 잠그는 역할 : acquire 함수
  • 임계 구역의 잠금을 해제하는 역할 : release 함수
acquire() {
    while(lock == true)
    	;
    lock = true;
}

release() {
	lock = false;
}

 

acquire 함수는 임계 구역에 들어가기 전에 호출하는 함수다. 만약 임계 구역이 잠겨있다면 임계 구역이 열릴 때까지(lock = false) 반복적으로 확인한다. 그리고 임계 구역이 열렸다면 임계 구역을 잠근다.(lock = true)

 

📢 여기서 임계 구역에 들어갈 수 있는지 반복적으로 확인하는 과정을 바쁜 대기(busy wait)라고 한다.

즉, 하나의 프로세스가 임계 구역에 진입했을 때 다른 프로세스가 임계 구역에 진입하려는 시도를 무한 반복하는 것이다.

이러한 유형을 프로세스가 lock의 해제를 기다리는 동안 회전한다고 해서 스핀락 이라고 부르기도 한다.

 

release 함수는 임계 구역에서의 작업이 끝나고 호출하는 함수이다. 현재 잠겨있는 임계 구역을 열어주는(lock = false)함수이다.

 

 acquire 함수와 release 함수를 임계 구역 전후로 호출함으로써 하나의 프로세스만 임계 구역에 진입할 수 있다.

acquire();
// 임계 구역에서 작업중...🔨
release();

 

조금 더 구체적인 예시를 살펴보자.

#include <stdio.h>
#include <pthread.h>

#define NUM_THREADS 4

int shared = 0;

void *foo(void *arg){
    for(int i = 0; i < 10000; ++i){
        shared += 1;
    }
    return NULL;
}

int main(){
    pthread_t threads[NUM_THREADS];

    for(int i = 0; i < NUM_THREADS; ++i){
        pthread_create(&threads[i], NULL, foo, NULL);
    }

    for(int i = 0; i < NUM_THREADS; ++i){
        pthread_join(threads[i], NULL);
    }

    printf("final result is %d\n", shared);

    return 0;
}

 

위의 코드는 4개의 스레드를 생성해서 foo 함수를 4번 호출하는 것이다.

예상대로라면 40000이 나와야하지만 실행할때마다 36547, 32775... 이렇게 값이 다르게 나온다.

이유는 코드가 동기화되어있지 않기 때문이다. 따라서 임계 구역을 한번에 하나의 스레드만 실행하도록 변경해야한다.

뮤텍스 락을 사용해보자.

 

#include <stdio.h>
#include <pthread.h>

#define NUM_THREADS 4
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //뮤택스라는 자물쇠를 선언하고 초기화

int shared = 0;

// 이 부분이 임계구역
void *foo(void *arg){
    pthread_mutex_lock(&mutex); // 자물쇠를 잠그고 임계구역 진입
    for(int i = 0; i < 10000; ++i){
        shared += 1;
    }
    pthread_mutex_unlock(&mutex); // 작업을 종료하고 자물쇠 잠금 해제
    return NULL;
}

int main(){
    pthread_t threads[NUM_THREADS];

    for(int i = 0; i < NUM_THREADS; ++i){
        pthread_create(&threads[i], NULL, foo, NULL);
    }

    for(int i = 0; i < NUM_THREADS; ++i){
        pthread_join(threads[i], NULL);
    }

    printf("final result is %d\n", shared);

    return 0;
}

 

각 스레드는 임계 구역을 진입하기 위해서 mutex 변수를 확보해야하고, 그렇지 못했다면 임계 구역 앞에서 대기한다.

이제는 몇 번 반복해서 실행해도 40000이 나온다.

 

세마포

세마포는 공유 자원이 여러 개 있는 상황에서도 적용이 가능한 동기화 도구이다. 

예를 들어 하나의 탈의실에는 한 명의 사람만 들어갈 수 있어도 여러 개의 탈의실에서는 다수의 사람이 들어갈 수 있다.

세마포는 뮤텍스 락과 비슷하게 하나의 변수와 두 개의 함수로 단순하게 구현할 수 있다.

  • 임계 구역에 진입할 수 있는 프로세스의 개수(사용 가능한 공유 자원의 개수)를 나타내는 전역변수 S
  • 임계 구역에 들어가도 좋은지, 기다려야되는지 알려주는 wait 함수
  • 임계 구역 앞에서 기다리는 프로세스에게 이제 들어가도 좋다고 신호를 주는 signal 함수

세마포도 뮤텍스 락과 마찬가지로 임계 구역 진입 전후로 wait와 signal 함수를 호출한다.

wait()
// 임계 구역
signal()

 

wait 함수는 다음과 같은 구조로 만들 수 있다.

wait() {
  while(S <= 0) // 만약 임계 구역에 진입 가능한 프로세스의 갯수가 0이하라면
  ; // 사용할 수 있는 자원이 있는지 반복적으로 확인
  S-- // 임계 구역에 진입 가능한 프로세스 갯수가 하나 이상이면 S를 1 감소시키고 임계 구역 진입
}

 

signal 함수는 다음과 같이 만들 수 있다.

signal() {
  S++; // 임계 구역에서의 작업을 마친 뒤 S를 1 증가
}

 

예를 들어 세 개의 프로세스가 두 개의 공유 자원에 순서대로 접근한다고 가정한다면 다음과 같은 순서로 실행된다.

 

하지만 한가지 문제가 있다. 바로 바쁜 대기이다. 뮤텍스 락에도 해당하는 문제이지만 사용할 수 있는 공유자원이 없으면 프로세스는 무작정 무한 반복하며 S를 확인해야한다. 이것은 CPU의 주기를 낭비한다.

 

따라서 실제로는 wait 함수에서 만일 사용할 수 있는 자원이 없다면 해당 프로세스 상태를 대기 상태로 만들고, 그 프로세스의  PCB를 세마포를 위한 대기 큐에 집어넣는다. 그리고 다른 프로세스가 임계 구역에서의 작업이 끝나고 signal 함수를 호출하면 signal 함수는 대기중인 프로세스를 대기 큐에서 제거하고, 프로세스 상태를 준비상태로 변경한 후 준비 큐로 옮겨준다.

 

wait() {
  S--;
  if(S < 0) {
    add this process to Queue; // 해당 프로세스 PCB를 대기 큐에 삽입
    sleep(); // 프로세스를 대기 상태로 변경
  }
}
signal() {
  S++;
  if(S <= 0) {
    remove a process p from Queue; // 대기 큐에 있는 프로세스 p를 제거
    wakeup(p); // 프로세스 p를 대기 상태에서 준비 상태로 만든다.
  }
}

 

 

지금까지는 동시에 접근해서는 안되는 자원에 동시에 접근하지 않도록 제어하는 기법을 알아보았다.

이번에는 세마포를 활용해서 프로세스의 순서를 제어하는 방법에 대해 알아보자.

방법은 세마포의 변수 S를 0으로 두고 먼저 실행할 프로세스 뒤에 signal 함수를 붙이고, 다음에 실행할 프로세스 앞에 wait 함수를 붙이면 된다.

 

왜 이렇게 하면 순서 보장이 가능할까?

P1이 먼저 실행되면 P1이 먼저 임계 구역에 진입하는 것은 당연한 것이다.

그렇다면 P2가 먼저 실행이 된다면 S가 0이고 wait가 실행되기 때문에 P1이 P2보다 먼저 임계 구역에 진입하게 된다.

그리고 P1이 임계 구역에서 작업이 끝나고 signal을 호출하면 P2는 임계 구역에 진입할 수 있다.

즉, P1이 먼저 실행되든 P2가 먼저 실행되든 반드시 P1, P2 순서대로 실행된다.

 

모니터

세마포어는 동시성 문제를 해결하는데 유용하지만 사용하기 복잡하고 프로그래머의 실수를 유발할 수 있다. 때문에 모니터라는 기법이 개발되었다. 모니터는 다수의 프로그래밍 언어에 지원이 되는데 대표적으로 자바의 Synchronized 키워드가 있다.

세마포어는 개발자가 직접 signal과 wait 함수를 사용해서 동기화를 하는 반면 모니터는 내부적으로 동기화를 관리하기 때문에 사용방법이 간단하다는 장점이 있다.

 

모니터는 그림과 같이 공유자원과 공유 자원에 접근하기 위한 인터페이스를 묶어 관리한다.

그리고 프로세스는 반드시 인터페이스를 통해서만 공유자원에 접근해야한다. 이때 이 인터페이스를 프로시저라고 한다.

한번에 하나의 프로세스만 모니터 개체 내의 프로시저를 사용할 수 있다. 모니터에 접근하지 못한 프로세스는 큐에서 대기하고 있는다. 이것이 모니터를 활용한 상호 배제 동기화 기법이다.

 

또한 모니터는 실행 순서 제어를 위한 동기화도 제공한다. 조건변수를 활용하는 것인데 조건변수는 프로세스나 스레드의 실행 순서를 제어하기 위해 사용되는 변수이다.

 

 

조건 변수에는 wait와 signal 두 가지 연산이 있다.

만약 모니터에 진입한 어떤 프로세스가 조건변수 x의 wait를 호출했다면 그 프로세스는 조건변수 x에 대한 큐에 삽입된다. 그리고 이 프로세스는 다른 프로세스가 signal을 호출할 때까지 차단된다. 그렇게 되면 모니터 내부에 진입한 프로세스는 없게 되고 다른 프로세스가 모니터 내부로 들어올 수 있다.

 

그리고 어떤 프로세스가 조건변수 x에 대한 signal을 호출하게 되면 조건 변수 x에 대해 대기 상태에 있던 프로세스를 깨워 모니터 안으로 들어오게 할 수 있다.

 

이때 모니터 내부의 프로세스가 signal을 호출해서 다른 프로세스를 깨우면 모니터 내부에 두 개의 프로세스가 동시에 실행될 수 있다.

따라서 이를 해결하기 위해 wait를 호출했던 프로세스가 signal을 호출한 프로세스가 모니터를 떠날 때까지 기다렸다가 실행하거나, signal을 호출한 프로세스의 실행을 일시 중단하고 자신을 실행한 뒤 다시 signal을 호출한 프로세스의 수행을 재개한다.

 

중요한 점은 모니터가 조건 변수를 이용해서 프로세스 실행 순서 제어를 위한 동기화를 제공한다는 것이다.

  • 특정 프로세스가 아직 실행될 조건이 되지 않았을 때에는 wait을 통해 실행을 중단한다.
  • 특정 프로세스가 실행될 조건이 충족되었을 때에는 signal을 통해 실행을 재개한다.

'CS > 운영체제' 카테고리의 다른 글

가상 메모리와 페이징  (0) 2024.05.15
교착 상태와 해결 방법에 대한 이론  (1) 2024.05.15
CPU 스케줄링 알고리즘  (2) 2024.05.02
CPU 스케줄링 개요  (0) 2024.05.02
스레드  (0) 2024.05.02