프로그래밍 관련 블로그를 보면 뮤텍스와 세마포어의 가장 큰 차이는 보호하려는 자원의 개수 차이이며, 화장실 칸 비유로 설명하는 글을 많이 볼 수 있다.
하지만 해당 설명만으로는 두 동기화 기법을 언제, 어떤 용도로 사용하는 지 목적을 이해하기 어렵다. 이번 글에서는 뮤텍스와 세마포어의 사용 용도 차이를 중심으로 두 동기화 기법의 차이를 정리해보려고 한다.
Mutex와 Semaphore의 차이
| 뮤텍스 (Mutex) | 세마포어 (Semaphore) | |
| 주 사용 용도 | 공유 자원 보호 | 생산자/소비자 간 동기화 (이벤트 통지 or 자원 풀 관리) |
| 동기화의 목적 | 자원 접근 순서 조정 (상호 배제, Mutual Exclusion) |
Task 간 실행 순서 조정 (Task Synchronization) |
| 자원 개수 | 1개 | 1~N개 (1: 바이너리 세마포어, N: 카운팅 세마포어) |
| 소유권 (Ownership) | 있음 (소유자만 unlock 가능) |
없음 (누구나 take/wait, give/post 가능) |
| PI(Priority Inheritance) | 지원 (우선순위 역전 방지) | 미지원 |
뮤텍스는 “내가 잠깐 이 자원 쓸게"하고 lock하고, "다 썼으니 이제 누가 써도 돼”하고 unlock는 식으로 lock과 unlock을 통해 공유 데이터를 보호하는 용도로 쓰인다. 뮤텍스를 누가 잠갔으면 잠근 Task가 직접 풀어야 하는 구조라서 소유 개념이 있다.
반면 세마포어는 생산자-소비자 관계인 Task간 동기화 용도로 많이 쓰인다. 소비자는 세마포어를 획득하려고 기다리는 쪽이며, wait / take로 누군가 나를 깨울 때까지 기다린다. 한편, 생산자는 세마포어를 증가시켜 대기 중인 Task를 깨우는 쪽이며, give / post로 어떤 이벤트가 발생했다거나, 버퍼나 큐에 데이터가 쌓였음을 알리며 소비자가 깨서 처리할 수 있도록 통지하는 역할을 한다.
뮤텍스 사용 예시 (POSIX)
다음은 POSIX(Linux/UNIX에서 사용하는 표준 API) pthread mutex의 사용 예시이다.
두 스레드(Task A, Task B)가 같은 전역 변수 shared_data를 사용할 때, 뮤텍스로 임계 영역을 보호해서 동시에 접근하지 못하게 한 코드이다.
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
// PTHREAD_MUTEX_INITIALIZER를 사용하여 정적으로 초기화하는 경우 pthread_mutex_init을 호출하지 않아도 된다.
pthread_mutex_t lock;
int shared_data = 0;
void* worker(void* arg) {
pthread_mutex_lock(&lock);
// 임계영역 시작
printf("[%s] acquired lock\n", (char*)arg);
shared_data++;
sleep(1);
printf("[%s] releasing lock, shared_data=%d\n", (char*)arg, shared_data);
// 임계영역 끝
pthread_mutex_unlock(&lock);
return NULL;
}
int main() {
pthread_mutex_init(&lock, NULL);
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, "Task A");
pthread_create(&t2, NULL, worker, "Task B");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&lock);
return 0;
}
위 코드에서 pthread_mutex_lock(&lock), pthread_mutex_unlock(&lock) 사이가 임계 영역이 된다. 임계 영역이란 여러 스레드(또는 프로세스)가 동시에 접근하면 안 되는 공유 자원을 다루는 코드 영역을 말한다.
다음은 세마포어 사용 예시이다.
그런데 세마포어는 임베디드 OS 환경에서는 흔하게 쓰이지만, 리눅스에서는 프로세스 간 통신(IPC)에서 좀 쓰이고, 스레드 간 동기화에 쓰이는 것은 거의 본 적이 없다.. 아마도 그 이유는 POSIX에 이미 condition variable같은 더 고수준의 동기화 API가 존재하기 때문이지 않을까 싶다. 그래서 아래에서도 FreeRTOS 예시를 들었다.
바이너리 세마포어 사용 예시 (FreeRTOS)
바이너리 세마포어를 이벤트 통지 용도로 사용한 예시이다. 센서 데이터가 들어오면 ISR이 신호를 주고, Task가 깨어나서 데이터를 읽는다.
SemaphoreHandle_t xBinSem;
// 생산자 (ISR)
void ADC_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 데이터 준비 완료임을 소비자에게 알림
// ISR에서 더 높은 우선순위의 Task를 깨웠다면 두 번째 인자인 출력 파라미터가 pdTrue로 바뀜
xSemaphoreGiveFromISR(xBinSem, &xHigherPriorityTaskWoken);
// pdTrue를 인자로 넘기면 바로 그 Task로 컨텍스트 스위칭 하도록 지시함
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 소비자 (Task)
void SensorTask(void *pvParam) {
for (;;) {
// 신호 올 때까지 대기
// pdTrue를 리턴하면 정상적으로 세마포어를 얻은 상황이고 토큰이 소비되서 세마포어는 다시 0이됨
if (xSemaphoreTake(xBinSem, portMAX_DELAY) == pdTRUE) {
read_sensor_data();
}
}
}
카운팅 세마포어 사용 예시 (FreeRTOS)
카운팅 세마포어를 버퍼의 자원관리 용도로 사용하는 예시이다. 생산자가 버퍼에 데이터를 채워 넣고, 소비자가 꺼내서 처리한다.
#define BUFFER_SIZE 4
SemaphoreHandle_t xCountSem;
void ProducerTask(void *pvParam) {
for (;;) {
// 버퍼에 데이터를 채워 넣고 세마포어 카운트를 1 증가함
produce_data();
xSemaphoreGive(xCountSem);
}
}
void ConsumerTask(void *pvParam) {
for (;;) {
// 세마포어 카운트가 0이면 대기하고, 1이상이면 1감소시키면서 버퍼에더 데이터를 꺼내 처리함
// 소비자 Task의 우선순위가 더 높기 때문에 다시 대기상태로 들어가기 전까지는 생산자 Task로 스위칭이 안일어남
// 만약 두 Task의 우선순위가 같으면 틱마다 라운드 로빈으로 번갈아 실행될 것임
if (xSemaphoreTake(xCountSem, portMAX_DELAY) == pdTRUE) {
consume_data();
}
}
}
int main(void) {
xCountSem = xSemaphoreCreateCounting(BUFFER_SIZE, 0);
// 소비자 Task의 우선순위가 생산자 Task보다 높음. 네 번째 인자인 uxPriority 숫자가 높을 수록 높음
xTaskCreate(ProducerTask, "P", 512, NULL, 1, NULL);
xTaskCreate(ConsumerTask, "C", 512, NULL, 2, NULL);
vTaskStartScheduler();
}
뮤텍스의 소유권(Ownership) 개념
위에서 뮤텍스와 세마포어의 차이를 표로 설명할 때 뮤텍스에만 소유권 개념이 존재한다고 했는데, 이에 대해 좀 더 자세히 알아보도록 하자.
뮤텍스는 잠근 Task가 그 뮤텍스의 owner가 되고, owner만이 해당 뮤텍스를 unlock할 수 있다. 그 이유는 뮤텍스가 공유 데이터 보호 용으로 쓰이기 때문이다. 예를 들어, 어떤 공유 변수 g_value를 여러 스레드가 접근한다고 가정해보자. taskA가 g_value를 수정 중인데 풀기도 전에, taskB가 unlock 해버리면 보호의 의미가 없다.
그래서 반드시 잠근 주체가 직접 풀어야 하는 구조가 뮤텍스의 소유권 개념이다.
void taskA(void) {
mutex_lock(&m);
g_value++;
mutex_unlock(&m);
}
void taskB(void) {
mutex_unlock(&m); // 불가능
mutex_lock(&m);
g_value++;
mutex_unlock(&m);
}
반면 세마포어는 소유권 개념이 없기 때문에 누가 give하고, 누가 take하든 상관없다. 오히려 Task 간 동기화에 사용되는 세마포어의 용도를 사용할 때 서로 다른 주체 간에 give와 take를 주고 받는게 자연스럽다.
뮤텍스의 우선순위 상속 (Priority Inheritance) 개념
위에서 뮤텍스와 세마포어의 차이를 표로 설명할 때 뮤텍스에만 우선순위 상속 개념이 존재한다고 설명했었다. 이 부분에 대해서도 좀 더 자세히 알아보도록 하자.
우선순위 상속(Priority Inheritance)에 대해 이해하려면, 먼저 우선순위 역전(Priority Inversion)을 알아야 한다.
다음과 같이 세개의 스레드가 존재하고, T1과 T2만 동일한 뮤텍스를 공유해서 쓴다고 가정하자.
- T1 (우선순위 높음)
- T2 (우선순위 낮음)
- T3 (우선순위 중간)
- 낮은 우선순위인 T2가 뮤텍스를 lock 했다.
- 높은 우선순위인 T1이 같은 뮤텍스를 lock하려고 하지만 T2가 먼저 잡고 있어서 대기 상태로 진입한다.
- 그 사이 중간 우선순위인 T3이 다른 일로 CPU를 계속 점유하고 있어서, T2가 unlock을 못하고 결국 우선순위가 제일 높은 T1이 깨어나지도 못한다.
이게 바로 낮은 우선순위의 작업이 높은 우선순위의 실행을 막는 우선순위 역전 상황이다.
그리고 이에 대한 해결책으로 존재하는 것이 우선순위 상속이다.
우선순위 상속은 낮은 우선순위의 Task가 어떤 뮤텍스를 잡고 있고, 더 높은 우선순위 Task가 해당 뮤텍스를 기다리기 위해 블록되면, 뮤텍스 소유자의 우선순위를 대기자들 중 가장 높은 우선순위까지 자동으로 끌어올리고, 소유자가 그 뮤텍스를 풀면 자동으로 원래 우선순위로 복귀시키는 것이다.
위와 같은 상황에서 뮤텍스에 PI 기능이 구현되어 있고 해당 기능을 사용하도록 활성화했다면, 커널이 자동으로 T2의 우선순위를 T1 수준으로 상승시키고, T2가 빨리 실행되서 unlock을 수행한 뒤엔 다시 원래 우선순위로 복귀시켜서 우선순위 역전을 방지했을 것이다.
다음 코드는 POSIX pthread API를 사용해서 뮤텍스를 우선순위 상속 속성을 가지도록 설정하는 방법을 보여주는 예시다.
// 속성을 직접 지정하며 뮤텍스 초기화시
pthread_mutex_t mtx;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
// 우선순위 상속 설정 켬
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&mtx, &attr);
// ~ 뮤텍스 사용 ~
// 속성 지정한 뮤텍스 자원 정리 시
pthread_mutex_destroy(&lock);
pthread_mutexattr_destroy(&attr);
위와 같이 PTHREAD_PRIO_INHERIT 속성을 켜면 필요할 때만 커널이 우선순위를 올려주고, 뮤텍스를 unlock하면 자동으로 원래 우선순위로 복귀한다.
'프로그래밍 > 리눅스 시스템 프로그래밍' 카테고리의 다른 글
| Linux ] rename이 atomic한 이유 (리눅스 파일의 참조 구조) (0) | 2025.11.24 |
|---|---|
| Linux ] 파일 append는 정말 atomic 할까? (0) | 2025.11.12 |
| Linux ] dup2() 함수를 사용한 표준입출력 리다이렉션 (0) | 2025.11.03 |
| Linux ] fork()를 통한 프로세스 생성 (0) | 2025.11.01 |
| Linux에서 현재 프로세스가 모니터가 연결된 GUI 세션인지 확인하는 법 (0) | 2025.10.24 |