스레드 동기화는 실시간 시스템에서 매우 중요한 역할을 한다. Preempt RT에서 실시간 스레드 프로그래밍을 할 때, 여러 스레드가 공유 자원에 접근하거나, 특정 순서대로 작업을 처리해야 할 때 동기화가 필수적이다. 적절한 동기화 기법을 사용하지 않으면 우선순위 역전, 데드락, 경쟁 상태 등의 문제가 발생할 수 있다.
뮤텍스(Mutex)
뮤텍스는 Mutual Exclusion의 약자로, 여러 스레드가 공유 자원에 동시 접근하지 못하도록 하는 동기화 객체이다. 뮤텍스는 다음과 같은 특징을 갖는다:
- 소유권: 뮤텍스는 특정 스레드만이 잠글 수 있으며, 잠금을 해제할 수 있다. 이는 자원의 소유권을 명확하게 정의할 수 있게 한다.
- 블로킹: 어떤 스레드가 이미 뮤텍스를 잠갔다면, 다른 스레드가 같은 뮤텍스를 잠그려고 할 때, 해당 스레드는 블로킹된다.
- 우선순위 상속: Preempt RT에서는 우선순위 역전 문제를 해결하기 위해 뮤텍스에 우선순위 상속(Priority Inheritance) 메커니즘이 포함될 수 있다. 이를 통해 낮은 우선순위의 스레드가 뮤텍스를 소유하고 있을 때, 높은 우선순위의 스레드가 그 뮤텍스를 요청하면, 낮은 우선순위의 스레드의 우선순위가 상속되어 잠시 동안 높아진다.
뮤텍스 사용 예제
다음은 Preempt RT에서 뮤텍스를 사용한 간단한 C 코드 예제이다:
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t mutex;
void* thread_function(void* arg) {
pthread_mutex_lock(&mutex);
// 공유 자원 접근
printf("Thread %ld: critical section\n", (long)arg);
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_mutex_init(&mutex, NULL);
pthread_create(&thread1, NULL, thread_function, (void*)1);
pthread_create(&thread2, NULL, thread_function, (void*)2);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
위 예제에서 pthread_mutex_lock
함수를 통해 뮤텍스를 잠그고, 임계 영역을 보호한다. pthread_mutex_unlock
함수는 뮤텍스를 해제하여 다른 스레드가 자원에 접근할 수 있도록 한다.
세마포어(Semaphore)
세마포어는 카운터 개념을 기반으로 한 동기화 메커니즘으로, 특정 자원의 접근을 제한하는 데 사용된다. 세마포어는 다음과 같은 특징을 갖는다:
- 카운터: 세마포어는 내부적으로 카운터 값을 유지하며, 이 값이 0보다 크면 자원에 접근할 수 있고, 0이면 접근할 수 없다.
- 다중 스레드 접근: 세마포어는 여러 스레드가 동시에 자원에 접근할 수 있는 개수를 제어할 수 있다. 예를 들어, 카운터 값이 3이면 최대 3개의 스레드가 동시에 자원에 접근할 수 있다.
- 블로킹: 세마포어의 카운터 값이 0이면, 자원을 요청하는 스레드는 블로킹된다. 다른 스레드가 세마포어를 해제하면 블로킹된 스레드가 깨어난다.
이진 세마포어(Binary Semaphore)
이진 세마포어는 카운터 값이 0 또는 1만 가질 수 있는 세마포어이다. 사실상 뮤텍스와 유사한 역할을 하지만, 소유권 개념이 없고, 같은 세마포어를 여러 스레드가 해제할 수 있다.
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
sem_t semaphore;
void* thread_function(void* arg) {
sem_wait(&semaphore);
// 공유 자원 접근
printf("Thread %ld: critical section\n", (long)arg);
sem_post(&semaphore);
return NULL;
}
int main() {
pthread_t thread1, thread2;
sem_init(&semaphore, 0, 1);
pthread_create(&thread1, NULL, thread_function, (void*)1);
pthread_create(&thread2, NULL, thread_function, (void*)2);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
sem_destroy(&semaphore);
return 0;
}
위 코드에서 sem_wait
은 세마포어를 잠그는 함수이며, 세마포어의 카운터 값을 감소시킨다. sem_post
는 세마포어를 해제하여 카운터 값을 증가시킨다.
세마포어의 유형
세마포어는 주로 두 가지 유형으로 나뉜다: 이진 세마포어와 카운팅 세마포어. 이미 이진 세마포어에 대해 논의했으므로, 이제 카운팅 세마포어에 대해 설명하겠다.
카운팅 세마포어(Counting Semaphore)
카운팅 세마포어는 카운터의 값이 0 이상의 정수 범위를 가지며, 동시에 여러 스레드가 자원에 접근할 수 있는 개수를 제어할 수 있다. 예를 들어, 특정 자원에 동시에 5개의 스레드만 접근하도록 허용하려면 세마포어를 5로 초기화한다.
카운팅 세마포어는 다음과 같이 동작한다:
- 초기화: 세마포어의 초기값을 자원의 개수로 설정한다.
- 카운터 감소: 자원에 접근하려는 스레드는 세마포어의 카운터를 감소시키며, 카운터가 0이면 자원이 모두 사용 중이므로 해당 스레드는 블로킹된다.
- 카운터 증가: 자원의 사용이 끝나면 세마포어의 카운터를 증가시켜 다른 스레드가 자원에 접근할 수 있게 한다.
카운팅 세마포어 예제
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
#define NUM_RESOURCES 3
sem_t semaphore;
void* thread_function(void* arg) {
sem_wait(&semaphore);
// 공유 자원 접근
printf("Thread %ld: using resource\n", (long)arg);
sleep(1); // 자원 사용 시뮬레이션
printf("Thread %ld: releasing resource\n", (long)arg);
sem_post(&semaphore);
return NULL;
}
int main() {
pthread_t threads[5];
sem_init(&semaphore, 0, NUM_RESOURCES);
for (long i = 0; i < 5; i++) {
pthread_create(&threads[i], NULL, thread_function, (void*)i);
}
for (int i = 0; i < 5; i++) {
pthread_join(threads[i], NULL);
}
sem_destroy(&semaphore);
return 0;
}
이 예제에서 NUM_RESOURCES
는 자원의 개수를 나타내며, 최대 3개의 스레드가 동시에 자원을 사용할 수 있다. 각 스레드는 세마포어의 sem_wait
을 호출하여 자원에 접근하고, 사용 후 sem_post
를 호출하여 자원을 해제한다. 세마포어 카운터는 이 과정을 통해 동적으로 변화하며, 자원의 동시 접근을 제어한다.
우선순위 역전 문제와 해결 방법
스레드 동기화와 관련된 중요한 문제 중 하나는 우선순위 역전(Priority Inversion)이다. 우선순위 역전은 낮은 우선순위를 가진 스레드가 높은 우선순위의 스레드보다 먼저 실행되어야 하는 상황에서 발생한다. 이는 실시간 시스템에서 치명적인 문제로 이어질 수 있다.
우선순위 역전의 발생
우선순위 역전은 다음과 같은 시나리오에서 발생할 수 있다:
- 높은 우선순위 스레드 \mathbf{T_H}는 공유 자원에 접근하기 위해 뮤텍스 잠금을 요청한다.
- 낮은 우선순위 스레드 \mathbf{T_L}가 이미 그 자원에 대한 뮤텍스를 소유하고 있다.
- 중간 우선순위를 가진 스레드 \mathbf{T_M}가 실행되며, 높은 우선순위 스레드 \mathbf{T_H}는 자원을 기다리며 블로킹 상태에 있다.
- 이 경우, \mathbf{T_H}는 \mathbf{T_M}에 의해 역전되고, \mathbf{T_L}이 자원을 해제하기 전까지 실행되지 않는다.
우선순위 상속 기법
우선순위 역전을 해결하기 위해 Preempt RT에서는 우선순위 상속(Priority Inheritance) 기법을 사용한다. 이 기법의 주요 아이디어는 다음과 같다:
- 낮은 우선순위의 스레드 \mathbf{T_L}가 높은 우선순위 스레드 \mathbf{T_H}의 요청을 받은 경우, \mathbf{T_L}의 우선순위가 일시적으로 \mathbf{T_H}의 우선순위로 상속된다.
- \mathbf{T_L}가 공유 자원을 해제하면, \mathbf{T_L}의 우선순위는 원래의 우선순위로 되돌아간다.
이 방식은 우선순위 역전의 시간을 최소화하며, 실시간 시스템의 신뢰성을 보장하는 데 필수적이다.
우선순위 상속 기법의 구현 예제
다음은 우선순위 상속이 적용된 뮤텍스의 사용을 시뮬레이션한 C 코드 예제이다. 이 코드는 일반적으로 우선순위 역전이 발생할 수 있는 상황을 설명한다.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sched.h>
#include <unistd.h>
pthread_mutex_t mutex;
void* low_priority_thread(void* arg) {
struct sched_param param;
param.sched_priority = 10;
pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);
pthread_mutex_lock(&mutex);
printf("Low priority thread: locked mutex\n");
// 긴 작업 시뮬레이션
sleep(2);
printf("Low priority thread: releasing mutex\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
void* high_priority_thread(void* arg) {
struct sched_param param;
param.sched_priority = 90;
pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);
// 짧은 지연 후 뮤텍스 요청
sleep(1);
printf("High priority thread: trying to lock mutex\n");
pthread_mutex_lock(&mutex);
printf("High priority thread: locked mutex\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
void* medium_priority_thread(void* arg) {
struct sched_param param;
param.sched_priority = 50;
pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);
// 중간 작업 시뮬레이션
printf("Medium priority thread: doing work\n");
sleep(2);
printf("Medium priority thread: done with work\n");
return NULL;
}
int main() {
pthread_t low, high, medium;
pthread_mutexattr_t mutex_attr;
pthread_mutexattr_init(&mutex_attr);
pthread_mutexattr_setprotocol(&mutex_attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&mutex, &mutex_attr);
pthread_create(&low, NULL, low_priority_thread, NULL);
pthread_create(&medium, NULL, medium_priority_thread, NULL);
pthread_create(&high, NULL, high_priority_thread, NULL);
pthread_join(low, NULL);
pthread_join(medium, NULL);
pthread_join(high, NULL);
pthread_mutex_destroy(&mutex);
pthread_mutexattr_destroy(&mutex_attr);
return 0;
}
이 코드는 Preempt RT에서 제공하는 우선순위 상속 기능을 시뮬레이션한다.
- Low priority thread는 가장 낮은 우선순위를 가지고 있으며, 먼저 뮤텍스를 잠급니다.
- High priority thread는 높은 우선순위를 가지고 있으며, 나중에 뮤텍스를 요청한다.
- Medium priority thread는 중간 우선순위를 가지고 있으며, 자원에 접근하지 않지만 스레드 스케줄링에 영향을 줄 수 있다.
우선순위 상속이 적용된 경우, Low priority thread는 High priority thread의 우선순위를 상속받아 더 빨리 실행되며, Medium priority thread가 블로킹되지 않도록 한다.
스핀락(Spinlock)
스핀락은 뮤텍스와 유사한 동기화 메커니즘이지만, 블로킹 없이 잠금이 해제될 때까지 루프를 돌며 기다리는 방식으로 동작한다. 스핀락은 다음과 같은 상황에서 유용할 수 있다:
- 짧은 시간 동안의 잠금: 잠금이 짧은 시간 안에 해제될 것이 확실한 경우, 스핀락은 컨텍스트 스위칭 오버헤드를 피할 수 있어 유리한다.
- 멀티코어 시스템: 스핀락은 멀티코어 시스템에서 사용될 때 효과적이다. 한 코어에서 잠금이 해제되는 동안, 다른 코어는 빠르게 잠금을 획득할 수 있다.
하지만, 스핀락은 단일 코어 시스템에서는 비효율적일 수 있으며, 장시간의 잠금에서는 CPU 자원을 낭비할 수 있다.
스핀락 사용 예제
다음은 간단한 스핀락 사용 예제이다.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
pthread_spinlock_t spinlock;
void* thread_function(void* arg) {
pthread_spin_lock(&spinlock);
printf("Thread %ld: acquired spinlock\n", (long)arg);
// 작업 시뮬레이션
sleep(1);
printf("Thread %ld: releasing spinlock\n", (long)arg);
pthread_spin_unlock(&spinlock);
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
pthread_create(&thread1, NULL, thread_function, (void*)1);
pthread_create(&thread2, NULL, thread_function, (void*)2);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_spin_destroy(&spinlock);
return 0;
}
이 예제에서 pthread_spin_lock
과 pthread_spin_unlock
을 사용하여 스핀락을 잠그고 해제한다. 스핀락을 사용하는 동안, 다른 스레드는 루프를 돌며 잠금이 해제되기를 기다린다.
스핀락은 짧은 시간 동안의 잠금을 효율적으로 처리할 수 있지만, 장시간의 잠금이 예상될 경우에는 뮤텍스와 같은 다른 동기화 기법이 더 적절할 수 있다.