Preempt RT 환경에서 실시간 애플리케이션을 설계할 때, 동시성(concurrency) 문제는 매우 중요한 이슈이다. 여러 개의 스레드가 동시에 실행되면서 동일한 자원에 접근할 때 발생할 수 있는 문제를 해결하는 것이 핵심이다. 이러한 문제를 방지하기 위해서는 동시성 관리와 레이스 컨디션(race condition)을 효과적으로 제어해야 한다.
동시성 문제의 개요
동시성은 여러 스레드가 동시에 실행되는 상태를 의미하며, 실시간 시스템에서는 작업의 우선순위를 기반으로 다양한 스레드가 동시에 실행될 수 있다. 이 때, 자원의 비효율적인 사용이나 데이터의 불일치를 방지하기 위해 동기화 메커니즘이 필요하다.
동시성의 예시
동시성 문제를 설명하기 위해 두 개의 스레드가 하나의 공유 변수 x를 업데이트하는 상황을 고려해 봅시다.
// 스레드 A
x = x + 1;
// 스레드 B
x = x * 2;
위의 코드에서 스레드 A와 스레드 B가 동시에 실행되면, 예상하지 못한 결과가 발생할 수 있다. 만약 스레드 A가 변수 x에 1을 더하는 도중에 스레드 B가 x에 2를 곱하게 된다면, 결과는 실행 순서에 따라 달라질 수 있다.
레이스 컨디션
레이스 컨디션은 여러 스레드가 동시에 실행될 때, 특정 변수의 최종 값이 실행 순서에 따라 달라지는 상황을 말한다. 이러한 문제는 예측 불가능한 결과를 초래할 수 있으며, 특히 실시간 시스템에서는 치명적일 수 있다.
레이스 컨디션 예시
위의 예제를 수학적으로 표현하면, 스레드 A와 스레드 B가 다음과 같은 순서로 실행될 수 있다:
- 초기값: x_0
- 스레드 A: x_1 = x_0 + 1
- 스레드 B: x_2 = x_1 \times 2
이 경우, x_2는 2(x_0 + 1)이 된다. 그러나 실행 순서가 달라져 스레드 B가 먼저 실행된다면, 다음과 같은 결과가 나온다:
- 초기값: x_0
- 스레드 B: x_1 = x_0 \times 2
- 스레드 A: x_2 = x_1 + 1
이 경우 x_2는 2x_0 + 1이 된다. 이처럼, 실행 순서에 따라 전혀 다른 결과가 나올 수 있는 상황이 레이스 컨디션이다.
동기화 메커니즘
동시성 문제를 해결하기 위해 가장 일반적으로 사용되는 방법은 동기화 메커니즘을 도입하는 것이다. 동기화는 여러 스레드가 자원에 접근하는 순서를 제어하여, 레이스 컨디션을 방지하는 역할을 한다.
뮤텍스 (Mutex)
뮤텍스(Mutex, Mutual Exclusion)는 동기화의 대표적인 방법 중 하나이다. 뮤텍스는 하나의 스레드만이 특정 코드 블록에 접근할 수 있도록 하여, 동시에 여러 스레드가 동일한 자원에 접근하는 것을 방지한다.
pthread_mutex_t lock;
// 스레드 A
pthread_mutex_lock(&lock);
x = x + 1;
pthread_mutex_unlock(&lock);
// 스레드 B
pthread_mutex_lock(&lock);
x = x * 2;
pthread_mutex_unlock(&lock);
위의 코드에서 스레드 A와 스레드 B는 뮤텍스를 통해 동기화된다. 따라서 두 스레드 중 하나가 공유 변수 x를 업데이트하는 동안 다른 스레드는 대기하게 되어, 레이스 컨디션이 발생하지 않는다.
세마포어 (Semaphore)
세마포어(Semaphore)는 뮤텍스와 유사하지만, 다수의 스레드가 동시에 접근할 수 있는 자원 수를 제한하는 데 사용된다. 세마포어는 자원의 사용 가능 여부를 나타내는 카운터와 같은 역할을 하며, 특정 자원의 접근을 제한한다.
sem_t semaphore;
sem_init(&semaphore, 0, 1);
// 스레드 A
sem_wait(&semaphore);
x = x + 1;
sem_post(&semaphore);
// 스레드 B
sem_wait(&semaphore);
x = x * 2;
sem_post(&semaphore);
위의 예제에서 세마포어는 자원 접근을 하나로 제한하고, 뮤텍스와 비슷한 방식으로 동작한다. 그러나 세마포어는 카운터를 통해 여러 자원에 대한 접근을 제어할 수 있다는 점에서 뮤텍스와 차이가 있다.
동시성 제어에서의 문제점과 주의사항
동시성 제어를 위해 뮤텍스와 세마포어와 같은 메커니즘을 사용하는 것은 필수적이지만, 잘못된 사용은 새로운 문제를 야기할 수 있다. 이러한 문제를 이해하고 피하는 것이 중요하다.
데드락 (Deadlock)
데드락은 두 개 이상의 스레드가 서로 자원을 기다리면서 무한히 대기하는 상황을 말한다. 예를 들어, 스레드 A는 자원 R_1을 잠그고 자원 R_2를 기다리고 있으며, 스레드 B는 자원 R_2를 잠그고 자원 R_1을 기다리는 경우, 두 스레드는 영원히 대기 상태에 빠지게 된다.
이를 방지하기 위해서는 다음과 같은 방법을 사용할 수 있다:
- 자원 할당 순서 고정: 모든 스레드가 자원을 동일한 순서로 요청하도록 규칙을 설정한다. 예를 들어, 항상 먼저 R_1을 잠그고, 그 후에 R_2를 잠그도록 한다.
- 타임아웃 설정: 스레드가 자원을 일정 시간 동안 획득하지 못하면 잠금을 해제하고 나중에 다시 시도하도록 한다.
- 교착 상태 회피 알고리즘: 예를 들어, 뱅커 알고리즘과 같은 교착 상태 회피 알고리즘을 적용하여 자원 할당을 관리할 수 있다.
라이브락 (Livelock)
라이브락은 데드락과 비슷하지만, 스레드가 무한히 서로의 상태를 변경하면서 진전이 없는 상황을 말한다. 예를 들어, 두 스레드가 동시에 자원을 요청하고 서로 양보하면서 계속 자원을 획득하지 못하는 경우 라이브락이 발생할 수 있다.
라이브락을 피하기 위해서는 다음과 같은 전략을 사용할 수 있다:
- 무작위 지연: 자원을 양보한 후, 무작위 시간 동안 대기한 후 다시 시도하게 한다. 이렇게 하면 두 스레드가 동시에 다시 시도할 확률이 낮아진다.
- 재시도 횟수 제한: 특정 횟수 이상 자원 획득에 실패하면 다른 대안을 선택하도록 한다.
우선순위 역전 (Priority Inversion)
우선순위 역전은 우선순위가 낮은 스레드가 자원을 잠근 상태에서 우선순위가 높은 스레드가 해당 자원을 필요로 할 때 발생하는 문제이다. 이 경우, 우선순위가 낮은 스레드가 자원을 해제할 때까지 우선순위가 높은 스레드는 대기해야 하므로 시스템의 실시간 특성이 저하될 수 있다.
우선순위 역전을 해결하기 위한 방법으로는 우선순위 상속(priority inheritance) 메커니즘이 있다. 이 방법은 낮은 우선순위의 스레드가 자원을 잠갔을 때, 해당 자원을 필요로 하는 높은 우선순위의 스레드의 우선순위를 일시적으로 상속받아 작업을 완료할 수 있도록 한다.
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&lock, &attr);
위의 코드에서 pthread_mutexattr_setprotocol
함수를 사용하여 우선순위 상속 프로토콜을 설정함으로써 우선순위 역전을 방지할 수 있다.
원자적 연산 (Atomic Operations)
동시성 문제를 해결하기 위한 또 다른 방법은 원자적 연산을 사용하는 것이다. 원자적 연산은 중단되지 않고 한 번에 완료되는 연산으로, 스레드가 동시에 실행되는 경우에도 안전하게 사용할 수 있다.
원자적 연산의 예시
#include <stdatomic.h>
atomic_int x = 0;
void increment() {
atomic_fetch_add(&x, 1);
}
위의 코드에서 atomic_fetch_add
함수는 변수 x에 1을 더하는 연산을 원자적으로 수행한다. 이 함수는 어떤 스레드도 이 연산을 중단하거나 간섭할 수 없도록 보장하므로, 동시성 문제를 방지할 수 있다.
스핀락 (Spinlock)
스핀락(Spinlock)은 자원을 기다리는 동안 스레드가 대기 상태에 들어가지 않고, 반복적으로 잠금이 해제될 때까지 자원을 확인하는 방법이다. 이는 짧은 시간 내에 잠금이 해제될 것으로 예상될 때 유용하며, 스레드가 컨텍스트 스위치로 인한 오버헤드를 피할 수 있도록 한다.
스핀락의 예시
#include <pthread.h>
pthread_spinlock_t spinlock;
void init_spinlock() {
pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
}
void thread_function() {
pthread_spin_lock(&spinlock);
// 공유 자원 접근
pthread_spin_unlock(&spinlock);
}
void destroy_spinlock() {
pthread_spin_destroy(&spinlock);
}
스핀락은 짧은 작업에 적합하며, CPU를 적극적으로 사용하여 잠금이 해제될 때까지 기다린다. 하지만 잠금이 오랫동안 유지될 경우, CPU 자원을 낭비할 수 있기 때문에 사용에 주의가 필요하다.
락-프리 프로그래밍 (Lock-Free Programming)
락-프리 프로그래밍은 락(뮤텍스, 세마포어 등)을 사용하지 않고도 안전하게 동시성을 관리할 수 있는 방법을 말한다. 이는 높은 성능과 낮은 지연을 요구하는 실시간 시스템에서 특히 유용하다.
CAS (Compare-And-Swap)
락-프리 프로그래밍의 핵심 기술 중 하나는 CAS(Compare-And-Swap)이다. CAS는 특정 변수의 값이 예상한 값과 동일한지 확인하고, 동일한 경우에만 새로운 값으로 변경하는 원자적 연산이다.
#include <stdatomic.h>
void lock_free_increment(atomic_int *x) {
int old_value = atomic_load(x);
while (!atomic_compare_exchange_weak(x, &old_value, old_value + 1)) {
// old_value가 다른 스레드에 의해 변경된 경우 반복
}
}
위의 코드에서 atomic_compare_exchange_weak
는 x의 현재 값이 old_value
와 동일한지 확인하고, 동일하면 x에 새로운 값을 설정한다. 이 방식은 다른 스레드가 값을 변경한 경우에도 안전하게 동작한다.
메모리 배리어 (Memory Barrier)
메모리 배리어는 컴파일러와 CPU가 메모리 연산의 순서를 최적화하는 것을 방지하여, 동시성 문제를 해결하는 데 중요한 역할을 한다. 메모리 배리어는 특정 연산이 다른 연산 전에 반드시 수행되도록 보장하며, 이를 통해 예측 가능한 동작을 유지할 수 있다.
메모리 배리어의 사용
메모리 배리어는 일반적으로 어셈블리 수준에서 사용되며, 특정 컴파일러 확장을 통해 접근할 수 있다. 예를 들어, GCC에서는 __sync_synchronize()
함수를 사용하여 전체 메모리 배리어를 삽입할 수 있다.
__sync_synchronize(); // 전체 메모리 배리어
이 함수는 이후의 모든 메모리 연산이 이전의 메모리 연산이 완료된 후에 실행되도록 보장한다.
실시간 시스템에서의 동시성 관리 전략
실시간 시스템에서 동시성 관리는 특히 중요하다. 예측 가능한 응답 시간과 높은 신뢰성이 요구되기 때문에, 동시성 제어는 실시간 시스템의 성능과 안정성에 큰 영향을 미친다.
우선순위 기반 락 관리
실시간 시스템에서는 우선순위 기반 락 관리가 중요한 전략 중 하나이다. 이는 우선순위가 높은 스레드가 자원에 빠르게 접근할 수 있도록 보장하며, 우선순위 역전과 같은 문제를 피할 수 있도록 설계된다.
커널 지원 동기화
Preempt RT와 같은 실시간 커널에서는 고성능의 동기화 프리미티브를 제공하여 동시성 관리를 지원한다. 이러한 프리미티브는 실시간 요구사항을 충족하기 위해 지연 시간을 최소화하도록 설계되어 있다.
예를 들어, Preempt RT는 높은 우선순위의 작업이 빠르게 실행될 수 있도록 최적화된 뮤텍스와 스핀락을 제공하며, 이들 메커니즘은 일반적인 리눅스 커널보다 더 짧은 지연 시간을 제공한다.
커스텀 동기화 메커니즘
특정 애플리케이션의 요구사항에 따라, 커스텀 동기화 메커니즘을 설계하는 것도 고려할 수 있다. 예를 들어, 특정 작업 패턴에 맞춘 경량화된 락 또는 분산된 자원 관리 기법을 도입하여 성능을 극대화할 수 있다.
이와 같이 동시성 관리와 레이스 컨디션 방지는 실시간 시스템에서 매우 중요한 설계 요소로, 적절한 동기화 메커니즘을 선택하고 구현하는 것이 필수적이다.