[Operating System] 프로세스 동기화(Synchronization)

남태일·2023년 12월 8일

Operating System

목록 보기
6/9
post-thumbnail

지금까지 우리는 프로세스가 동시에 또는 병렬적으로 실행될 수 있다는 것을 알았다.

프로세스들이 동시에 또는 병렬로 실행될 때, 여러 프로세스가 공유하는 데이터의 일관성이 깨질 수 있다.

오늘은 이러한 환경에서 발생하는 문제들과 해결 방법에 대해서 알아보자.


경쟁 상황(Race Condition)

프로세스들이 공유 자원을 동시에 사용하는 상황을 말한다.

아래 코드를 보자

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

int num = 0;

void* producer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
		num++;
	}

	return NULL;
}

void* consumer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
		num--;
	}

	return NULL;
}

int main()
{
	pthread_t threads[2];

	pthread_create(&(threads[0]), NULL, producer, NULL);
	pthread_create(&(threads[1]), NULL, consumer, NULL);

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

	printf("num = %d\n", num);

	return 0;
}

두 개의 스레드를 만들고 각 스레드가 producer(), consumer() 함수를 실행한다.

producer 함수는 전역변수 num 값을 1씩 50000번 더한다.
consumer 함수는 전역변수 num 값을 1씩 50000번 뺀다.

우리가 예측할 수 있는 num 값의 결과는 0이다.

하지만 결과를 보면, 0 값이 아닌 아래와 같이 이상한 값이 나온다.

왜 그럴까?
num++가 기계어로 컴파일되면, 실제로 아래와 같은 단계를 거치게 된다.

register = num
regitser = register + 1
num = register

또한 num--는 아래와 같이 컴파일 된다.

register = num
regitser = register - 1
num = register

register는 CPU 코어에서 사용하는 임시 저장을 위한 레지스터이다.

자, 여기서 중요한 점은 스레드들이 실행될 때 스케줄링에 의해 번갈아가면서 실행된다는 점이다.

num = 0 상태로 두 스레드가 시작되었고, 각각 두 번째 줄 까지 실행을 완료한 뒤 문맥 교환이 되었다고 가정하면
producer 스레드는 num에 1을 저장할 것이며, consumer 스레드는 num에 -1을 저장할 것이다.

따라서, num의 값이 일관성 없이 저장되며, 둘 중 하나의 값을 사용하게 되면서 꼬이게 되는 것이다.

전역변수 num은 스레드들의 공유 자원이라고 볼 수 있다.

스레드들이 이러한 공유 자원을 동시에 사용하는 상황을 "경쟁 상황(Race Condition)"이라고 한다.

이러한 Race Condition을 방지하기 위해, 우리는 한 순간에 하나의 스레드만 변수 num을 사용하도록 보장해야 한다.
이러한 보장을 위해, 동기화 기법이 사용된다.


임계 구역(Critical Section)

공유 자원에 접근하는 코드가 있는 영역이다.
즉, 경쟁 상태가 발생할 수 있는 코드 영역이다.

공유 자원의 일관성 적용 및 프로세스간 동기화를 위해서는 임계 구역에 하나의 프로세스만 들어가도록 하면 된다.

임계 구역에 들어가기 위한 요청을 하는 코드 부분을 진입 구역(Entry Section)이라고 부른다.
임구 구역에서 나가는 코드 부분을 퇴출 구역(Exit Section)이라고 부른다.

임계 구역에 대해서 다음의 세 가지 조건을 충족해야 한다.

  • 상호 배제(Mutual Exclusion)
    • 특정 프로세스가 임계 구역에 들어가 있다면, 다른 프로세스는 임계 구역에 들어가지 못한다.
  • 진행(Progress)
    • 임계 구역에서 실행되는 프로세스가 없으면, 임계 구역 진입을 기다리는 프로세스 중 하나가 임계 구역에 들어간다.
  • 한정 대기(Bounded Waiting)
    • 임계 구역 진입을 기다리는 프로세스는 언젠가 들어가야 한다.

하드웨어적 동기화

현대의 시스템은 한 워드(Word)의 내용을 검사하고 변경하거나, 두 워드의 내용을 원자적으로(Atomically) 교환할 수 있는(인터럽트 되지 않는) 하나의 단위로서, 특별한 하드웨어 동기화 기법을 제공한다.

원자적 변수(Atomic Variable)

int 및 bool과 같은 기본 자료형에 대한 원자적 연산을 제공한다.

race condition 예시에서 정수 값을 증가시키거나 감소시키면 race condition이 발생할 수 있다는 것을 알 수 있다.

원자적 변수는 단일 변수에 대한 데이터 경쟁이 있을 수 있는 상황에서 상호 배제를 보장하는 데 사용될 수 있다.

아래 예제는 num 전역 변수를 원자적 변수로 변경한 예제이다.

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

_Atomic int num = 0;

void* producer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
		num++;
	}

	return NULL;
}

void* consumer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
		num--;
	}

	return NULL;
}

int main()
{
	pthread_t threads[2];

	pthread_create(&(threads[0]), NULL, producer, NULL);
	pthread_create(&(threads[1]), NULL, consumer, NULL);

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

	printf("num = %d\n", num);

	return 0;
}

전역 변수 num이 원자성을 가지게되어, data race가 일어나지 않도록 보장된다.

원자적 변수는 속도가 느리기 때문에, 남용하면 안된다.
또한, 제한 사항이 있다.


소프트웨어적 동기화

운영체제는 임계 구역 문제를 해결하기 위해 소프트웨어 도구들 제공한다.

Mutex Lock

가장 간단한 소프트웨어적 동기화 도구이다.

mutex는 mutual exclusion(상호 배제)의 약자이다.
임계 구역을 보호하기 위해 mutex를 사용한다.

프로세스는 임계 구역에 들어가려면 lock을 획득해야하며, 나올 때에는 lock을 반납해야 한다.

lock을 획득하기 위해 내부적으로 사용되는 함수는 acquitre() 이며, 아래와 같다.

acquire()
{
	while (!available)
    	; /* busy wait */
    available = false;
}

lock을 반납하기 위해 내부적으로 사용되는 함수는 release() 이며, 아래와 같다.

release()
{
	available = true;
}

available 변수는 lock의 사용 여부를 나타낸다.
acquitre(), release() 함수는 원자적으로 수행된다.

mutex를 사용하는 예제는 아래와 같다.

lock을 획득한 하나의 프로세스만 임계 구역에 있을 수 있게된다.

바쁜 대기(Busy Waiting)

프로세스가 임계 구역에 들어가기 위해 반복해서 lock을 획득하려고 시도하는 것을 말한다.

지금까지 설명한 구현 방식의 단점은 바쁜 대기를 해야 한다는 것이다.
임계 구역에 하나의 프로세스가 들어가 있을때, 임계 구역에 들어가기를 원하는 다른 프로세스들은 반복문을 계속 실행해야 한다.

바쁜 대기는 다른 프로세스가 생산적으로 사용할 수 있는 CPU 주기를 낭비한다.

스핀락(Spinlock)

우리가 설명한 mutex lock 유형을 "스핀락"이라고 한다.
lock 을 사용할 수 있을 때까지 프로세스가 반복문을 수행하며 "회전(spin)"하기 때문이다.

스핀락은 문맥 교환이 필요하지 않다는 장점이 있다.

최신 멀티 코어 시스템에서 스핀락은 많은 운영체제에서 사용된다.

mutex lock 예제

아래 코드는 producer, consumer 문제를 mutex lock을 사용하여 해결한 예제이다.

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

int num = 0;

void* producer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
    	// 임계 구역 시작
		pthread_mutex_lock(arg);
		num++;
		pthread_mutex_unlock(arg);
        // 임계 구역 끝
	}

	return NULL;
}

void* consumer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
    	// 임계 구역 시작
		pthread_mutex_lock(arg);
		num--;
		pthread_mutex_unlock(arg);
        // 임계 구역 끝
	}

	return NULL;
}

int main()
{
	pthread_t threads[2];
	pthread_mutex_t mutex;

	pthread_mutex_init(&mutex, NULL);

	pthread_create(&(threads[0]), NULL, producer, &mutex);
	pthread_create(&(threads[1]), NULL, consumer, &mutex);

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

	printf("num = %d\n", num);

	pthread_mutex_destroy(&mutex);

	return 0;
}

임계 구역의 시작과 끝을 mutex_lock(), mutex_unlock()을 사용하여 스레드간 상호 배제를 시켜주었다.

결과

POSIX mutex 동작 방식

mutex 설명에서는 스핀락을 예제로 설명했지만, CPU 자원 낭비와 같은 문제를 해결하기 위해 실제로 mutex는 다른 방식으로 구현될 수 있다.

POSIX API의 mutex lock 동작 방식은 아래와 같다.
POSIX API의 pthread_mutex_lock() 함수를 사용하여 lock을 얻으려고 할때, 이미 lock이 사용 중일 경우, 스레드는 해당 mutex의 lock을 기다리는 전용 "대기 큐"에 들어가서 대기한다.

pthread_mutex_unlock() 함수가 호출되어 lock이 사용 가능해지면, 스케줄러가 대기 큐에서 대기 중인 스레드 중 하나를 선택하여 깨워주고 스레드는 임계 구역에 진입한다.

[POSIX 공식 문서]
https://pubs.opengroup.org/onlinepubs/9699919799/functions/pthread_mutex_lock.html


Semaphore

좀 더 정교하게 동기화할 수 있는 동기화 기법이다.

mutex는 임계 구역에 하나의 프로세스만 진입할 수 있다.

semaphore는 임계 구역에 하나의 프로세스 뿐만 아니라, 여러 개의 프로세스가 진입할 수 있도록 한다.

여러 개의 스레드가 사용 가능한 공유 자원은 semaphore를 사용하여 효율적으로 여러 개의 스레드가 해당 자원을 사용할 수 있다.

semaphore는 임계 구역에 진입한 프로세스의 개수를 나타내는 정수 변수 S를 사용한다.
semaphore는 내부적으로 원자적 함수 wait(), signal() 을 사용하여 구현된다.

wait() 함수는 아래와 같다.

wait(S)
{
	while (S <= 0)
    	; /* busy wait */
    S--;
}

signal() 함수는 아래와 같다.

signal(S)
{
	S++;
}

wait(), signal() 함수에서 정수 값 변경은 반드시 원자적으로 수행되어야 한다.
또한, wait()의 경우, S의 값을 검사하는 작업(S <= 0)과 변경하는 작업(S--)을 하는 동안 인터럽트 되지 않고 실행되어야 한다.

semaphore 예제

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

int num = 0;

void* producer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
    	// 임계 구역 시작
		sem_wait(arg);
		num++;
		sem_post(arg);
        // 임계 구역 끝
	}

	return NULL;
}

void* consumer(void* arg)
{
	for (int i = 0; i < 50000; i++)
	{
    	// 임계 구역 시작
		sem_wait(arg);
		num--;
		sem_post(arg);
        // 임계 구역 끝
	}

	return NULL;
}

int main()
{
	pthread_t threads[2];
	sem_t *semaphore;

	semaphore = sem_open("semaphore", O_CREAT, 0644, 1);

	pthread_create(&(threads[0]), NULL, producer, semaphore);
	pthread_create(&(threads[1]), NULL, consumer, semaphore);

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

	printf("num = %d\n", num);

	sem_close(semaphore);

	return 0;
}

procuder, consumer 문제를 semaphore 기법을 사용해 해결했다.

결과

semaphore 동작 방식

semaphore도 설명에서는 바쁜 대기를 통해 설명했지만, POSIX mutex 동작 방식처럼 스레드를 일시 정지 하는 로직으로 변경해서 구현할 수도 있다.

0개의 댓글