1차 PBL : pthread
User Threads and Kernel Threads

사용자 수준 스레드 (User-level threads)는 운영체제 위에서 실행됩니다.
이러한 스레드들은 커널에게 보이지 않습니다.
블로킹을 방지하는 특별한 시스템 호출 라이브러리에 연결됩니다.
오버헤드가 적습니다.
CPU 집중적인 스레드가 다른 스레드들을 블록킹할 수 있습니다.
한 번에 하나의 프로세서만 사용할 수 있습니다.

커널 수준 스레드 (Kernel-level threads)는 운영체제의 일부분입니다.
커널은 프로세스를 스케줄링하는 것처럼 스레드를 스케줄링할 수 있습니다.
하나의 프로세스 내의 여러 스레드가 여러 CPU에서 동시에 실행될 수 있습니다.
스레드 간 동기화는 프로세스 간 동기화보다 효율적이지만, 사용자 수준 스레드보다는 덜 효율적입니다.

하이브리드 모델에서는 하나의 프로세스의 스레드들이 여러 개의 커널 엔티티를 공유할 수 있습니다.
POSIX 스레드는 이러한 모델들을 모두 지원할 수 있습니다.
스레드는 경쟁 범위 (contentionscope)를 가지며, PTHREAD_SCOPE_PROCESS 또는 PTHREAD_SCOPE_SYSTEM중 하나입니다.
주어진 시스템에서 둘 중 하나 또는 둘 다 지원될 수 있습니다.
Thread Management
| 함수 이름 | 설명 | 주요 특징 |
|---|---|---|
pthread_cancel |
특정 스레드에 취소 요청을 보냅니다. | 스레드가 취소 가능한 상태여야 하며, 취소 시점을 제어할 수 있습니다. |
pthread_create |
새로운 스레드를 생성합니다. | 스레드가 실행할 함수와 인자를 지정해야 합니다. 생성된 스레드의 ID를 반환합니다. |
pthread_detach |
스레드를 분리 상태로 만듭니다. | 분리된 스레드는 종료 시 자동으로 자원을 반환하며, 다른 스레드가 pthread_join을 호출하여 기다릴 수 없습니다. |
pthread_equal |
두 개의 스레드 ID를 비교하여 같은 스레드인지 확인합니다. | 두 스레드 ID가 같으면 0이 아닌 값을, 다르면 0을 반환합니다. |
pthread_exit |
호출한 스레드를 종료하고 종료 상태를 반환합니다. | 스레드의 종료 코드를 다른 스레드가 pthread_join을 통해 얻을 수 있습니다. |
pthread_kill |
특정 스레드에 시그널을 보냅니다. | 주로 에러 처리나 스레드 간 통신에 사용될 수 있습니다. 보낼 수 있는 시그널은 표준 시그널입니다. |
pthread_join |
특정 스레드가 종료될 때까지 호출한 스레드를 기다리게 합니다. | 기다리는 스레드의 종료 상태를 얻을 수 있습니다. 분리되지 않은 스레드에 대해서만 호출할 수 있습니다. |
pthread_self |
현재 호출한 스레드의 ID를 반환합니다. | 스레드 자신을 식별하는 데 사용됩니다. 반환되는 값은 pthread_t 타입입니다. |
POSIX 함수는 성공시 0을 리턴, 실패했을 때 0이 아닌 값을 리턴한다.
대부분 함수는 errno 값을 설정하지 않는다.
Referencing the threads by ID
POSIX 스레드 프로그래밍에서 각 스레드는 pthread_t 타입의 고유한 식별자를 갖는다. 대부분의 시스템에서 이는 정수형으로 구현되나, 반드시 그럴 필요는 없다. 스레드는 pthread_self() 함수를 통해 자신의 식별자를 획득할 수 있으며, pthread_equal() 함수를 사용하여 두 스레드 ID의 동일성을 비교할 수 있다. 이러한 식별자는 스레드 관리 및 상호작용의 기본 요소로 활용된다.
pthread_t pthread_self(void);
int pthread_equal(thread_t t1, pthread_t t2);
Creating a thread
POSIX 스레드 라이브러리에서 새로운 스레드는 pthread_create() 함수를 통해 생성된다. 이 함수는 다음과 같은 인자를 필요로 한다.
int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr,
void *(*start_routine) (void *), void *restrict arg);
첫째, thread 인수는 생성될 스레드의 식별자를 저장할 메모리 위치의 주소를 제공해야 한다. 호출하는 프로세스(또는 스레드)는 이 식별자를 통해 생성된 스레드를 관리할 수 있다.
둘째, attr 인수는 생성될 스레드의 속성을 명시하는 속성 객체를 가리키는 포인터이다. 스레드 속성에는 스케줄링 정책, 스택 크기, 분리 상태 등이 포함된다. 만약 기본 속성을 사용하고자 할 경우에는 NULL 포인터를 전달하면 된다. 스레드 속성에 대한 자세한 내용은 추후 논의될 것이다.
셋째, void *(*) (void *) 인수는 새로운 스레드가 실행할 함수의 포인터를 나타낸다. 이 인수를 통해 생성된 스레드가 어떤 작업을 수행할지 정의한다. 새로운 스레드는 이 함수를 호출하여 실행을 시작한다. void 포인터여서 자유로운 형 변환이 가능하기 때문에 알맞은 형 변환을 이용해서 사용할 수 있다.
넷째, void *는 start_routine으로 전달될 인수의 포인터이다. 어떤 타입의 데이터든 void * 타입으로 캐스팅하여 전달할 수 있다. 생성된 스레드가 실행할 함수(start_routine)에 필요한 데이터를 전달하는 데 사용된다. 만약 전달할 인수가 없다면 NULL을 전달할 수 있다.
예제 1 - pthread_basic.c
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int glob_var = 6;
void *t_function(void *data)
{
int id;
int i=0;
pthread_t t_id;
id = *((int *)data);
glob_var++;
t_id = pthread_self();
printf("pid = %d, t_id = %lu, id = %d, i=%d, glob_var = %d\n",
getpid(), t_id, id, i, glob_var);
sleep(2);
return (void *)(id*id);
}
int main()
{
pthread_t p_thread[2];
int err;
int status;
int a = 1;
int b = 2;
printf("before pthread_create() pid = %d, glob_var = %d\n", getpid(), glob_var);
if((err = pthread_create(&p_thread[0], NULL, t_function, (void*)&a)) < 0)
{
perror("thread create error : "); exit(1);
}
if((err = pthread_create (&p_thread[1], NULL, t_function, (void*)&b)) < 0)
{
perror("thread create error : ");
exit(2);
}
pthread_join(p_thread[0], (void **)&status); // p_thread[0]이 종료될 때 까지 대기,
// status는 스레드 종료값 받는 인수
printf("thread join : %d\n", status); // 1
pthread_join(p_thread[1], (void **)&status);
printf("thread join : %d\n", status); // 4(2*2)
printf("after pthread_create() glob_var = %d\n", glob_var);
return 0;
}
같은 그룹의 스레드들은 같은 메모리 공간을 공유한다. 메인 스레드와 생성된 스레드들은 같은 공간을 사용하고 따라서 전역 변수는 공유된다. 프로세스는 복제의 개념이여서 다르다. fork()를 통해 생성한 자식 프로세스와 부모 프로세스는 메모리 스페이스(물리 주소)가 아예 다르다.
Q. 해당 코드가 없다면?
pthread_join(p_thread[0], (void **)&status); pthread_join(p_thread[1], (void **)&status);
- 메인 스레드의 빠른 종료: 메인 스레드는 생성한 자식 스레드들이 작업을 완료할 때까지 기다리지 않고
main함수의 마지막에 도달하여 종료될 가능성이 높다. - 자식 스레드의 잠재적인 미완료: 자식 스레드들은 메인 스레드가 종료될 때까지 계속 실행될 수도 있고, 운영체제의 스케줄링 방식이나 다른 요인에 따라 중간에 강제로 종료될 수도 있다. 이는 자식 스레드가 수행하려던 작업이 완료되지 않을 수 있음을 의미한다.
- 자원 누수 가능성: 분리되지 않은(detached되지 않은) 스레드의 경우, 메인 스레드가
pthread_join을 호출하여 명시적으로 종료 상태를 회수해야 해당 스레드가 사용했던 자원(예: 스레드 자체의 메모리)이 시스템에 반환된다.pthread_join이 없다면 이러한 자원들이 회수되지 않고 남아있어 잠재적인 자원 누수(resource leak)를 발생시킬 수 있다. - 출력 순서의 예측 불가능성: 자식 스레드 내의
printf함수 호출은 메인 스레드의printf함수 호출과 독립적으로 실행된다.pthread_join이 없다면 메인 스레드가 먼저 종료되어 "after pthread_create() glob_var = %d\n"가 먼저 출력될 수도 있고, 자식 스레드들의 출력이 먼저 나오거나 섞여서 나올 수도 있다. 실행 순서가 예측하기 어려워진다. - 스레드 종료 상태 확인 불가:
pthread_join함수를 통해 우리는 자식 스레드가 반환한 종료 상태 값(status)을 확인할 수 있다.pthread_join이 없다면 자식 스레드가 어떤 결과를 반환했는지 알 수 없게 된다.
결론적으로, 주어진 코드에서 pthread_join 호출을 제거하면 메인 스레드는 자식 스레드의 완료를 기다리지 않고 먼저 종료될 가능성이 높으며, 자식 스레드가 미처 작업을 끝내지 못하거나 자원 누수가 발생할 수 있고, 스레드 종료 상태를 확인할 수 없게 된다. 일반적으로 메인 스레드가 생성한 스레드의 결과를 기다리거나, 스레드가 사용한 자원을 정리해야 하는 경우에는 pthread_join을 사용하는 것이 중요하다.
Detaching and joining
스레드는 자신이 실행하는 함수가 return 문을 통해 종료될 때 소멸한다. 그러나 프로세스와 유사하게, 스레드는 pthread_exit() 함수 호출 또는 예외 발생과 같은 다른 종료 방식을 가질 수 있다. 스레드가 종료되더라도 일부 시스템 자원은 즉시 반환되지 않고 유지되며, 이는 해당 스레드를 다른 스레드가 pthread_join() 함수를 통해 기다리거나, 스레드를 포함하는 프로세스가 종료될 때까지 지속된다. 따라서 pthread_join() 함수는 종료된 스레드의 자원 회수 및 종료 상태 확인을 위해 필수적으로 사용된다. 이를 통해 자원 누수를 방지하고 프로그램의 안정성을 확보할 수 있다.
int pthread_join(pthread_t thread, void **value_ptr);
// 해당 thread를 기다리고 value_ptr은 해당 thread의 return값을 받아온다.
스레드가 종료될 때 시스템 자원을 자동으로 반환하도록 설정하는 방법이 존재하며, 이를 detached thread (분리된 스레드)라고 한다. 분리된 스레드는 종료 후 별도의 자원 회수 과정 없이 운영체제에 의해 자동으로 관리되므로, 다른 스레드가 pthread_join() 함수를 호출하여 그 종료를 기다릴 수 없다.
스레드를 분리 상태로 만드는 한 가지 방법은 pthread_detach() 함수를 사용하는 것이다. 이 함수를 호출하면 특정 스레드는 분리되어, 종료 시 자동으로 관련 자원을 해제하게 된다. 따라서 분리된 스레드에 대해서는 pthread_join() 함수를 호출할 수 없다는 점을 유념해야 한다. 스레드를 분리할지, 아니면 다른 스레드가 종료를 기다리도록 할지는 프로그램의 설계 및 스레드의 역할에 따라 신중하게 결정해야 한다.
int pthread_detach(pthread_t thread);
// 해당 thread를 분리한다.
thread가 종료하더라도 모든 자원을 반납하지 않는다. 대부분은 정리가 되겠지만 main thread가 return값 등의 정보를 얻어가기 위해 모두 지우지 않는다. 프로세스도 마찬가지이다. 이렇게 되면 자원이 낭비될 수 있다. 이런 자원 낭비가 아까운 경우 detach하면 된다. 다만 join은 하지 못한다.
int error;
int *exitcodep;
pthread_t tid;
if(error = pthread_join(tid, &exitcodep))
fprintf(stderr, "Failed to join thread: %s\n", strerror(error));
else
fprintf(stderr, "The exit code was %d\n", *exitcodep);
void *processfd(void *arg);
int error;
int fd pthread_t tid;
if(error = pthread_create(&tid, NULL, processfd, &fd))
fprintf(stderr, "Failed to create thread: %s\n", strerror(error));
else if(error = pthread_detach(tid))
fprintf(stderr, "Failed to detach thread: %s\n", strerror(error));
Thread 종료값
스레드는 자신의 실행 결과를 다른 스레드에게 반환 값을 통해 전달할 수 있다. 이는 마치 부모 프로세스가 자식 프로세스의 종료 상태를 통해 값을 얻는 것과 유사한 개념이다. 그러나 스레드의 반환 값은 프로세스의 종료 상태와 달리 작은 정수 값으로 제한되지 않는다.
스레드의 반환 값은 임의의 데이터에 대한 포인터 형태로 전달될 수 있다. 스레드들은 동일한 주소 공간을 공유하므로, 이러한 포인터 기반의 데이터 공유는 비교적 간단하게 이루어진다. 스레드가 return 문을 사용하거나 pthread_exit() 함수를 호출하면서 반환하고자 하는 데이터의 주소를 전달하면 된다.
이러한 반환 값을 얻기 위해서는 pthread_join() 함수를 사용해야 한다. pthread_join() 함수는 호출한 스레드를 특정 스레드가 완료될 때까지 일시적으로 중단시킨다. 기다리는 스레드는 pthread_join() 함수의 두 번째 인자를 통해 종료된 스레드의 반환 값(void 포인터)을 받을 수 있다. 따라서 스레드 간의 데이터 교환 및 작업 결과 동기화에 pthread_join() 함수와 스레드의 반환 값을 효과적으로 활용할 수 있다.
Exiting and cancellation
프로세스는 다음과 같은 상황에서 종료될 수 있다.
- 프로세스 내에서
exit()함수를 직접 호출하는 경우(exit()은 프로세스를 종료하는 system call) - 프로세스 내의 어떤 스레드든
exit()함수를 호출하는 경우 - 메인 함수(
main)에서return문을 실행하여 종료되는 경우 - 프로세스가 특정 시그널을 수신하는 경우
이러한 어떤 경우에든, 해당 프로세스 내의 모든 스레드는 함께 종료된다. 즉, 하나의 스레드가 프로세스를 종료시키는 동작을 수행하면, 그 프로세스 내의 다른 모든 스레드 역시 강제적으로 종료된다는 점을 이해해야 한다. 이는 프로세스와 그 내부 스레드들이 하나의 실행 단위를 공유하기 때문에 발생하는 현상이다.
스레드는 작업 완료 시 pthread_create 함수에 의해 실행되는 자신의 메인 함수에서 return 문을 호출하거나, 명시적으로 pthread_exit() 함수를 호출하여 종료될 수 있다.
void pthread_exit(void *value_ptr);
// 함수를 실행한 스레드가 종료
또한, 한 스레드는 pthread_cancel() 함수를 사용하여 다른 스레드에게 종료를 요청할 수 있다.
int pthread_cancel(pthread_t thread);
// 인자에 들어간 thread가 종료. 단, 해당 thread가 cancel을 enable설정을 해야 함.
pthread_cancel() 함수는 종료 요청을 수행한 직후에 반환되므로, 성공적인 반환이 반드시 대상 스레드의 즉각적인 종료를 의미하는 것은 아니다. 실제로 종료 요청이 받아들여져 대상 스레드가 결국 종료될지 여부 또한 보장되지 않는다. 이는 대상 스레드가 취소 요청을 처리하는 방식(취소 가능 상태, 취소점 등)에 따라 달라지기 때문이다. 따라서 pthread_cancel()은 종료를 "요청"하는 메커니즘이며, 강제적인 즉시 종료를 보장하지 않는다는 점을 이해해야 한다.
취소 요청의 결과는 대상 스레드의 취소 가능 상태(cancellation state)와 타입에 따라 달라진다. 스레드의 취소 가능 상태는 다음과 같이 두 가지로 구분된다.
- 활성화 (act on request):
PTHREAD_CANCEL_ENABLE: 이 상태에서는 취소 요청이 접수되면 스레드는 취소 동작을 수행할 수 있다. 이것이 스레드의 기본 설정 상태이다. default 값이다. - 보류 (pending):
PTHREAD_CANCEL_DISABLE: 이 상태에서는 취소 요청이 접수되더라도 스레드는 즉시 취소되지 않고, 취소 가능 상태가 다시 활성화될 때까지 보류된다.
스레드는 pthread_setcancelstate() 함수를 사용하여 자신의 취소 가능 상태를 변경할 수 있다. 이 함수의 원형은 다음과 같다.
int pthread_setcancelstate(int state, int *oldstate);
여기서 state 인자는 설정하고자 하는 새로운 취소 가능 상태(PTHREAD_CANCEL_ENABLE 또는 PTHREAD_CANCEL_DISABLE)를 지정하며, oldstate 인자는 이전의 취소 가능 상태 값을 저장할 포인터이다. 이전 상태 값을 저장함으로써 스레드는 필요한 경우 자신의 취소 가능 상태를 이전 상태로 복원할 수 있다.
특정 블로킹 함수를 호출하면 스레드의 취소점 (cancellation point)이 발생한다. 취소점이란 스레드를 취소할 수 있는 특별한 지점을 뜻한다. POSIX 스레드 표준에서는 다음과 같은 함수들을 취소점으로 명시하고 있다.
pthread_joinpthread_cond_waitpthread_cond_timedwait(3)pthread_testcancelsem_waitsigwait
POSIX 규약에 따르면, 블로킹될 가능성이 있는 대부분의 시스템 호출과, 이러한 시스템 호출을 내부적으로 호출할 수 있는 라이브러리 함수들 역시 취소점에 해당한다.
프로그래머는 pthread_testcancel() 함수를 사용하여 명시적으로 스레드 내에 취소점을 설정할 수도 있다. pthread_testcancel() 함수가 호출되면, 해당 스레드에 보류 중인 취소 요청이 있는지 확인하고, 만약 있다면 스레드는 취소된다. 취소 요청이 없다면 이 함수는 아무런 동작도 하지 않고 반환된다. 따라서 pthread_testcancel()을 적절한 위치에 삽입함으로써 스레드의 취소 동작 시점을 제어할 수 있다.
스레드의 취소 타입 (cancellation type)은 pthread_setcanceltype() 함수를 사용하여 설정할 수 있다. 취소 타입은 취소 요청이 접수되었을 때 스레드가 언제 실제로 취소 동작을 수행할지를 결정한다. 가능한 취소 타입은 다음과 같다.
- 비동기 취소 (
PTHREAD_CANCEL_ASYNCHRONOUS): 이 타입으로 설정되면, 취소 요청이 접수되는 즉시 스레드는 취소될 수 있다. 즉, 스레드가 특정 취소점에 도달할 때까지 기다리지 않고, 어느 시점에서든 갑작스럽게 종료될 수 있다. - 지연된 취소 (
PTHREAD_CANCEL_DEFERRED): 이 타입으로 설정되면, 취소 요청이 접수되더라도 스레드는 즉시 취소되지 않는다. 스레드는 미리 정의된 취소점(cancellation point)에 도달했을 때 비로소 취소 요청을 확인하고 취소 동작을 수행한다. 이것이 스레드의 기본 취소 타입이다.
스레드의 취소 타입을 설정하는 함수 pthread_setcanceltype()의 원형은 다음과 같다.
int pthread_setcanceltype(int type, int *oldtype);
type 인자는 설정하고자 하는 새로운 취소 타입(PTHREAD_CANCEL_ASYNCHRONOUS 또는 PTHREAD_CANCEL_DEFERRED)을 지정하며, oldtype 인자는 이전의 취소 타입을 저장할 포인터이다. 이전 타입을 저장함으로써 스레드는 필요에 따라 자신의 취소 타입을 이전 값으로 복원할 수 있다.
앞서 언급된 pthread_testcancel() 함수는 스레드 내에서 명시적으로 취소점을 설정하는 데 사용된다. 이 함수는 다음과 같은 원형을 가진다.
void pthread_testcancel(void);
pthread_testcancel()이 호출되면, 스레드는 보류 중인 취소 요청이 있는지 확인한다. 만약 취소 요청이 있고, 스레드의 취소 가능 상태가 활성화되어 있으며, 취소 타입이 지연된 취소(PTHREAD_CANCEL_DEFERRED)라면, 스레드는 이 지점에서 취소된다. 만약 취소 요청이 없거나, 취소 가능 상태가 비활성화되어 있거나, 취소 타입이 비동기 취소(PTHREAD_CANCEL_ASYNCHRONOUS)라면, pthread_testcancel() 함수는 즉시 반환된다.
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void *theThread(void *parm)
{
int oldstate;
printf("Thread: Entered\n");
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &oldstate);
while(1){
printf("Thread: Looping or long running request\n");
pthread_testcancel();
sleep(1);
}
return NULL;
}
int main()
{
pthread_t thread;
int rc = 0;
int status;
void *status;
printf("Create/start a thread\n");
rc = pthread_create(&thread, NULL, theThread, NULL);
printf("Wait a bit until we 'realize' the thread needs to be canceled\n");
sleep(3); // main thread와 생성된 thread 간의 동작 순서를 맞추기 위해 sleep -> DISABLE로 설
rc = pthread_cancel(thread);
printf("Wait for the thread to complete, and release its resources\n");
rc = pthread_join(thread, &status);
printf("Thread status indicates it was canceled\n");
if(status != PTHREAD_CANCELED){
printf("Unexpected thread status\n");
}
return 0;
}
Passing parameters to thread and returning values
스레드를 생성할 때 배열과 같은 포인터를 전달함으로써 여러 매개변수를 스레드에 전달할 수 있다.
반환 값을 받을 때 주의를 기울여야 한다. 종료되는 스레드는 조인하는 스레드에 포인터를 전달한다. 이들은 동일한 주소 공간을 공유한다. 따라서 반환 값은 스레드가 종료된 후에도 유효해야 한다.
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#define PERMS (S_IRUSR | S_IWUSR)
#define READ_FLAGS O_RDONLY
#define Write_FLAGS (O_WRONLY | O_CREAT | O_TRUNC)
void *copyfilemalloc(void *arg);
int main(int argc, char *argv[]){
/* copy fromfile to tofile*/
int *bytesptr;
int error;
int fds[2];
pthread_t tid;
if(argc != 3){
fprintf(stderr,"Usage: %s fromfile tofile\n", argv[0]);
return 1;
}
if(((fds[0] = open(argv[1], READ_FLAGS)) == -1 ||
((fds[1] = open(argv[2], WRITE_FLAGS, PERMs)) == -1)){
perror("Failed to open the files");
return 1;
}
if(error = pthread_create(&tid, NULL, copyfilemalloc, fds)){
fprintf(stderr, "Failed to create thread: %s\n", strerror(error));
return 1;
}
if(error = pthread_join(tid, (void **)&&bytesptr)){
fprintf(stderr, "Failed to join thread: %s\n", strerror(error));
}
printf("Number of byte copied: %d\n", *bytesptr);
return 0;
}
Thread attributes
속성은 생성되거나 소멸될 수 있는 객체와 유사하게 동작한다.
- 속성 객체를 생성합니다 (기본 속성으로 초기화).
- 속성 객체의 속성을 수정.
- 해당 속성 객체를 사용하여 스레드를 생성.
- 속성 객체는 스레드에 영향을 주지 않고 변경되거나 재사용 가능.
- 속성 객체는 스레드 생성 시에만 영향.
| 기능 | 함수 | 설명 |
|---|---|---|
| 속성 객체 관리 | int pthread_attr_init(pthread_attr_t *attr); |
스레드 속성 객체를 기본값으로 초기화합니다. |
int pthread_attr_destroy(pthread_attr_t *attr); |
스레드 속성 객체에 할당된 자원을 해제하고 객체를 파괴합니다. | |
| **Detach 상태 | ||
| (state)** | int pthread_attr_setdetachstate(...) |
스레드가 생성될 때 detach될지 (자동으로 자원 반환) joinable할지 (다른 스레드가 종료를 기다릴 수 있음) 여부를 설정합니다. |
int pthread_attr_getdetachstate(...) |
스레드 속성 객체의 detach 상태 값을 가져옵니다. | |
| 스케줄링 정책 | int pthread_attr_setschedpolicy(...) |
스레드의 스케줄링 정책 (FIFO, RR, OTHER 등)을 설정합니다. |
int pthread_attr_getschedpolicy(...) |
스레드 속성 객체의 스케줄링 정책 값을 가져옵니다. | |
| 스케줄링 파라미터 | int pthread_attr_setschedparam(...) |
스레드의 스케줄링 파라미터 (주로 우선순위)를 설정합니다. |
int pthread_attr_getschedparam(...) |
스레드 속성 객체의 스케줄링 파라미터 값을 가져옵니다. | |
| 스케줄링 상속 | int pthread_attr_setinheritsched(...) |
새로운 스레드가 생성한 스레드의 스케줄링 속성을 상속받을지 여부를 설정합니다. |
int pthread_attr_getinheritsched(...) |
스레드 속성 객체의 스케줄링 상속 속성 값을 가져옵니다. | |
| 경합 범위 (확장) | int pthread_attr_setscope(...) |
스레드의 경합 범위 (시스템 레벨 또는 프로세스 레벨)를 설정합니다 (POSIX 확장). |
int pthread_attr_getscope(...) |
스레드 속성 객체의 경합 범위 값을 가져옵니다 (POSIX 확장). | |
| 스택 크기 | int pthread_attr_setstacksize(...) |
스레드의 스택 크기를 설정합니다. |
int pthread_attr_getstacksize(...) |
스레드 속성 객체의 스택 크기 값을 가져옵니다. | |
| 스택 주소 (고급) | int pthread_attr_setstackaddr(...) |
스레드가 사용할 스택 메모리 주소를 명시적으로 설정합니다 (주의해서 사용해야 함). |
int pthread_attr_getstackaddr(...) |
스레드 속성 객체의 스택 메모리 주소 값을 가져옵니다. | |
| 가드 영역 크기 | int pthread_attr_setguardsize(...) |
스택 오버플로우 감지를 위한 가드 영역의 크기를 설정합니다. |
int pthread_attr_getguardsize(...) |
스레드 속성 객체의 가드 영역 크기 값을 가져옵니다. |
POSIX Synchronization Functions
뮤텍스(Mutex)는 상호 배제(mutual exclusion)의 약자로, 동시 프로그래밍에서 여러 스레드나 프로세스가 공유 리소스에 동시에 접근하는 것을 방지하기 위해 사용되는 동기화 메커니즘이다. 임계 구역(critical section)이라고 불리는 코드 영역에 대한 접근을 제어하여, 한 번에 하나의 스레드나 프로세스만 해당 리소스를 사용할 수 있도록 한다.
| 동기화 메커니즘 | 함수 | 설명 |
|---|---|---|
| 뮤텍스 (Mutex) | int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); |
뮤텍스를 초기화합니다. 속성 객체를 지정하거나 NULL을 전달하여 기본 속성으로 초기화할 수 있습니다. |
int pthread_mutex_destroy(pthread_mutex_t *mutex); |
뮤텍스를 파괴하고 할당된 자원을 해제합니다. | |
int pthread_mutex_lock(pthread_mutex_t *mutex); |
뮤텍스를 잠금니다. 만약 뮤텍스가 이미 잠겨 있다면, 호출 스레드는 뮤텍스가 해제될 때까지 블록됩니다. | |
int pthread_mutex_trylock(pthread_mutex_t *mutex); |
뮤텍스를 잠금 시도합니다. 만약 뮤텍스가 이미 잠겨 있다면, 즉시 오류를 반환하고 블록되지 않습니다. | |
int pthread_mutex_unlock(pthread_mutex_t *mutex); |
뮤텍스를 해제합니다. 뮤텍스를 잠근 스레드만이 해제할 수 있습니다. | |
int pthread_mutexattr_init(pthread_mutexattr_t *attr); |
뮤텍스 속성 객체를 기본값으로 초기화합니다. | |
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr); |
뮤텍스 속성 객체를 파괴하고 할당된 자원을 해제합니다. | |
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type); |
뮤텍스 타입을 설정합니다 (PTHREAD_MUTEX_NORMAL, PTHREAD_MUTEX_RECURSIVE, PTHREAD_MUTEX_ERRORCHECK, PTHREAD_MUTEX_DEFAULT). |
|
int pthread_mutexattr_gettype(const pthread_mutexattr_t *attr, int *type); |
뮤텍스 타입 값을 가져옵니다. | |
| 조건 변수 (Condition Variable) | int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr); |
조건 변수를 초기화합니다. 속성 객체를 지정하거나 NULL을 전달하여 기본 속성으로 초기화할 수 있습니다. |
int pthread_cond_destroy(pthread_cond_t *cond); |
조건 변수를 파괴하고 할당된 자원을 해제합니다. | |
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); |
조건 변수가 시그널될 때까지 호출 스레드를 블록합니다. 호출 시 뮤텍스는 잠겨 있어야 하며, 대기 상태에서 뮤텍스가 해제되고 시그널을 받은 후 다시 잠깁니다. | |
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime); |
지정된 시간 동안 조건 변수가 시그널될 때까지 호출 스레드를 블록합니다. 시간 초과 시 오류를 반환합니다. | |
int pthread_cond_signal(pthread_cond_t *cond); |
대기 중인 스레드 중 하나를 깨웁니다. 어떤 스레드가 깨어날지는 스케줄링 정책에 따라 다릅니다. | |
int pthread_cond_broadcast(pthread_cond_t *cond); |
대기 중인 모든 스레드를 깨웁니다. | |
int pthread_condattr_init(pthread_condattr_t *attr); |
조건 변수 속성 객체를 기본값으로 초기화합니다. | |
int pthread_condattr_destroy(pthread_condattr_t *attr); |
조건 변수 속성 객체를 파괴하고 할당된 자원을 해제합니다. | |
| 읽기-쓰기 잠금 (Read-Write Lock) | int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr); |
읽기-쓰기 잠금을 초기화합니다. 속성 객체를 지정하거나 NULL을 전달하여 기본 속성으로 초기화할 수 있습니다. |
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); |
읽기-쓰기 잠금을 파괴하고 할당된 자원을 해제합니다. | |
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); |
읽기 잠금을 획득합니다. 다른 읽기 잠금은 동시에 획득할 수 있지만, 쓰기 잠금이 걸려 있다면 블록됩니다. | |
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); |
읽기 잠금을 시도합니다. 쓰기 잠금이 걸려 있다면 즉시 오류를 반환하고 블록되지 않습니다. | |
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); |
쓰기 잠금을 획득합니다. 다른 어떤 잠금 (읽기 또는 쓰기)도 걸려 있다면 블록됩니다. | |
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); |
쓰기 잠금을 시도합니다. 다른 어떤 잠금 (읽기 또는 쓰기)이 걸려 있다면 즉시 오류를 반환하고 블록되지 않습니다. | |
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); |
읽기 또는 쓰기 잠금을 해제합니다. | |
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr); |
읽기-쓰기 잠금 속성 객체를 기본값으로 초기화합니다. | |
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr); |
읽기-쓰기 잠금 속성 객체를 파괴하고 할당된 자원을 해제합니다. | |
| 배리어 (Barrier) | int pthread_barrier_init(pthread_barrier_t *barrier, const pthread_barrierattr_t *attr, unsigned count); |
배리어를 초기화합니다. count는 배리어에서 기다려야 하는 스레드 수입니다. 속성 객체를 지정하거나 NULL을 전달하여 기본 속성으로 초기화할 수 있습니다. |
int pthread_barrier_destroy(pthread_barrier_t *barrier); |
배리어를 파괴하고 할당된 자원을 해제합니다. | |
int pthread_barrier_wait(pthread_barrier_t *barrier); |
호출 스레드를 배리어에 도착할 때까지 블록합니다. count 번째 스레드가 도착하면 모든 스레드가 동시에 해제됩니다. |
|
int pthread_barrierattr_init(pthread_barrierattr_t *attr); |
배리어 속성 객체를 기본값으로 초기화합니다. | |
int pthread_barrierattr_destroy(pthread_barrierattr_t *attr); |
배리어 속성 객체를 파괴하고 할당된 자원을 해제합니다. |
Mutex Locks
뮤텍스는 특별한 변수로서 두 가지 상태를 가진다.
- 잠금(locked) 상태: 특정 스레드가 뮤텍스를 소유하고 있는 상태.
- 해제(unlocked) 상태: 어떤 스레드도 뮤텍스를 소유하지 않은 상태.
뮤텍스는 대기 중인 스레드들을 위한 큐를 가지고 있다. 스레드 스케줄링 정책과 관련하여 POSIX 표준은 특정 정책을 요구하지 않는다.
뮤텍스는 공유 자원에 대한 독점적인 접근을 보장하고, 임계 영역(critical section)을 보호하기 위해 사용된다.
- 뮤텍스는 짧은 시간 동안만 점유하도록 설계되었다.
- 데이터 구조의 상태가 일시적으로 불일치할 수 있는 변경 작업을 수행하는 데 이상적이다.
Creating and initializing a mutex
pthread_mutex_t 타입의 변수는 뮤텍스 잠금을 나타내는 데 사용된다. 뮤텍스 변수는 사용하기 전에 반드시 초기화되어야 한다.
- 정적으로 할당된 변수의 경우:
PTHREAD_MUTEX_INITIALIZER를 할당하여 간단하게 초기화할 수 있다. - 동적으로 할당된 변수의 경우:
pthread_mutex_init함수를 호출해야 한다.mutex: 초기화할 뮤텍스 객체의 주소를 가리키는 포인터이다.attr: 뮤텍스 속성 객체의 주소를 가리키는 포인터이다. 기본 속성으로 초기화하려면NULL을 전달한다.- 함수가 성공적으로 완료되면 0을 반환한다.
#include <pthread.h> int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); pthread_mutex_t my_mutex = PTHREAD_MUTEX_INITIALIZER;
Destroying a mutex
pthread_mutex_destroy 함수는 인자로 전달된 뮤텍스를 파괴한다.
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *restrict mutex);
mutex: 파괴할 뮤텍스 객체의 주소를 가리키는 포인터이다.
함수가 성공적으로 완료되면 0을 반환한다.
POSIX 표준은 파괴된 뮤텍스를 스레드가 참조하는 경우의 동작은 정의되어 있지 않다고 명시적으로 규정하고 있다. 따라서 파괴된 뮤텍스를 다시 사용하거나 참조하는 것은 피해야 한다.
Locking and unlocking a mutex
POSIX 표준에서는 뮤텍스를 획득하기 위하여 다음 두 가지 함수를 사용한다.
pthread_mutex_lock함수:- 본 함수는 인자로 주어진 뮤텍스가 현재 잠겨 있지 않은 경우 즉시 잠금을 획득한다. 만약 뮤텍스가 이미 다른 스레드에 의하여 잠겨 있다면, 해당 함수를 호출한 스레드는 뮤텍스가 해제될 때까지 실행을 멈추고 대기 상태에 놓인다. 뮤텍스가 해제되면, 대기 중인 스레드 중 하나가 뮤텍스를 획득하여 블로킹 상태에서 벗어나게 된다.
pthread_mutex_trylock함수:- 본 함수는 뮤텍스를 잠그려고 시도하되, 즉시 반환하는 특징을 가진다. 만약 뮤텍스가 이미 다른 스레드에 의하여 잠겨 있다면, 해당 함수는 블록되지 않고
EBUSY라는 오류 코드를 반환한다. 뮤텍스 잠금에 성공한 경우에는 0을 반환한다.
획득한 뮤텍스의 잠금을 해제하기 위하여는 pthread_mutex_unlock 함수를 사용한다.
pthread_mutex_unlock함수:- 본 함수는 인자로 지정된 뮤텍스의 잠금을 해제한다. 일반적으로 뮤텍스를 성공적으로 잠근 스레드만이 이 함수를 호출하여 잠금을 해제하여야 한다. 함수가 정상적으로 완료되면 0을 반환한다.
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *restrict mutex);
int pthread_mutex_trylock(pthread_mutex_t *restrict mutex);
int pthread_mutex_unlock(pthread_mutex_t *restrict mutex);
주의할 점으로, 스레드가 소유하지 않은 뮤텍스를 해제하거나, 동일한 뮤텍스를 중복하여 해제하는 행위는 그 결과가 정의되어 있지 않으므로 반드시 피하여야 한다.
counter.c
#include <pthread.h>
static int count = 0;
static pthread_mutex_t countlock = PTHREAD_MUTEX_INITIALIZER;
int increment(void){
/* increment the counter */
int error;
if(error = pthread_mutex_lock(&countlock)){
return error;
}
count ++;
return pthread_mutex_unlock(&countlock);
}
int decrement(void){
/* decrement the counter */
int error;
if(error = pthread_mutex_lock(&countlock)){
return error;
}
count --;
return pthread_mutex_unlock(&countlock);
}
int getcrement(void){
/* retrieve the counter */
int error;
if(error = pthread_mutex_lock(&countlock)){
return error;
}
*countp = count;
return pthread_mutex_unlock(&countlock);
}
At-Most-Once and At-Least-Once Execution
POSIX 표준은 특정 초기화 루틴이 프로그램 실행 중 단 한 번만 수행되도록 보장하는 pthread_once 함수를 제공한다. 이는 전역적으로 공유되는 자원을 초기화하는 데 유용하다.
pthread_once 함수를 사용하기 위하여는 pthread_once_t 타입의 제어 변수가 필요하며, 이 변수는 정적으로 초기화되어야 한다.
#include <pthread.h>
int pthread_once(pthread_once_t *restrict once_control, void (*init_routine)(void));
pthread_once_t once_control = PTHREAD_ONCE_INIT;
pthread_once 함수의 형태는 다음과 같다.
once_control: 초기화 상태를 제어하는pthread_once_t타입의 변수에 대한 포인터이다.init_routine: 단 한 번 실행될 초기화 함수의 포인터이다.
pthread_once 함수가 처음으로 호출되면, init_routine이 실행된다. 이후에 동일한 once_control 변수를 사용하여 pthread_once 함수가 호출되더라도 init_routine은 다시 호출되지 않는다.
함수가 성공적으로 완료되면 0을 반환한다.
주의할 점은 초기화 함수(init_routine)는 어떠한 파라미터도 받지 않는다는 것이다. 따라서 초기화에 필요한 정보가 있다면 전역 변수 등을 통해 접근해야 한다.
#include <pthread.h>
#include <stdio.h>
static pthread_once_t initonce = PTHREAD_ONCE_INIT;
int var;
static void initialization(void){
var = 1;
printf("The variable was initialized to %d\n" var);
}
int printinitonce(void){
/* call initialization at most once */
return pthread_once(&initonce, initialization);
}
mutex lock으로 똑같이 구현이 가능하다.
#include <pthread.h>
#include <stdio.h>
int printinitmutex(int *var, int value){
static int done = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int error;
if(error = pthread_mutex_lock(&lock)){
return error;
}
if(!done){
*var = value;
printf("The variable was initialized to %d\n", value);
done = 1;
}
return pthread_mutex_unlock(&lock);
}
Semaphores
E. W. Dijkstra는 상호 배제와 동기화의 고수준 관리를 위해 세마포어 추상화를 제안하였다.
세마포어는 두 가지 원자적 연산(wait 및 signal)을 갖는 정수 변수이다.
- wait: down, P, lock 연산이라고도 불린다. 세마포어 값이 0보다 크면 값을 1 감소시키고 진행한다. 만약 세마포어 값이 0이면, 세마포어 값이 0보다 커질 때까지 호출 스레드를 블록시킨다.
- signal: up, V, unlock, post 연산이라고도 불린다. 세마포어 값을 1 증가시킨다. 만약 세마포어를 기다리는 스레드가 있다면, 그 중 하나를 깨운다.
세마포어는 자원의 개수를 나타내는 데 사용될 수 있으며, 여러 스레드가 공유 자원에 대한 접근을 제어하는 데 효과적이다. 뮤텍스가 상호 배제를 위한 이진 세마포어의 특수한 형태라고 볼 수 있다.
// Sudo Code
void wait(semaphore_t *sp){
if(sp->value > 0){
sp->value --;
}
else{
/* add this process to sp->list */
/* block */
}
}
void signal(semaphore_t *sp){
if(sp->list != NULL){
/* remove a process from sp->list and put in ready state */
}
else{
sp->value++;
}
}
Unnamed Semaphore
프로세스용 세마포어, 스레드용 세마포어가 있다. 스레드는 자원을 공유하기 때문에 스레드용 세마포어가 더 간단하게 구현이 가능하다.
POSIX 세마포어는 sem_t 타입의 변수로서, 값을 초기화, 증가, 감소시키는 원자적 연산과 관련되어 있다.
구현 환경이 POSIX 세마포어를 지원하는지 여부는 unistd.h 헤더 파일에 _POSIX_SEMAPHORES 매크로가 정의되어 있는지 확인하여 알 수 있다.
다음 코드 조각은 sem이라는 이름의 세마포어 변수를 선언하는 예시이다.
#include <semaphore.h>sem_t sem;
POSIX 세마포어는 사용하기 전에 반드시 초기화되어야 한다. sem_init 함수를 사용하여 세마포어를 초기화할 수 있다.
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem: 초기화할 세마포어 변수의 주소를 가리키는 포인터이다.pshared: 세마포어의 공유 범위를 지정하는 플래그이다. 이름 없는 세마포어의 경우 0으로 설정한다.pshared가 0이면, 해당 세마포어는 세마포어를 초기화한 프로세스 내의 스레드들 사이에서만 사용될 수 있다.- 세마포어를 생성한 후 자식 프로세스를 생성하더라도, 자식 프로세스는 해당 세마포어에 접근할 수 없다.
value: 세마포어의 초기 값을 설정한다.
함수가 성공적으로 완료되면 0을 반환하고, 오류가 발생하면 -1을 반환한다.
sem_destroy 함수는 이전에 초기화된 이름 없는 세마포어를 파괴한다.
#include <semaphore.h>
int sem_destroy(sem_t *sem);
sem: 파괴할 세마포어 변수의 주소를 가리키는 포인터이다.
파괴된 세마포어를 다시 사용하려면 sem_init 함수를 사용하여 재초기화해야 한다. 사용 중인 세마포어를 파괴하는 것은 정의되지 않은 동작이므로 피해야 한다.
sem_post 함수는 고전적인 세마포어 시그널링을 구현한다. 이 함수는 세마포어의 값을 1 증가시킨다. 만약 세마포어를 기다리는 스레드가 있다면, 그 중 하나를 깨운다.
이 함수는 시그널 안전(signal-safe) 함수이므로, 시그널 핸들러 내에서도 안전하게 호출될 수 있다.
#include <semaphore.h>
int sem_post(sem_t *sem);
sem: 시그널을 보낼 세마포어 변수의 주소를 가리키는 포인터이다.
함수가 성공적으로 완료되면 0을 반환하고, 오류가 발생하면 -1을 반환한다.
sem_trywait 함수와 sem_wait 함수는 고전적인 세마포어 wait 연산을 구현한다. 이 함수들은 세마포어의 값을 1 감소시키려고 시도한다.
sem_trywait함수:sem_trywait함수는 세마포어의 값이 0보다 크면 값을 1 감소시키고 즉시 0을 반환한다. 만약 세마포어의 값이 0이라면, 호출 스레드를 블록시키지 않고 즉시 -1을 반환하며errno를EAGAIN으로 설정한다.#include <semaphore.h> int sem_trywait(sem_t *sem);sem_wait함수:sem_wait함수는 세마포어의 값이 0보다 크면 값을 1 감소시키고 0을 반환한다. 만약 세마포어의 값이 0이라면, 세마포어의 값이 0보다 커질 때까지 호출 스레드를 블록시킨다. 블록된 스레드는 다른 스레드가sem_post함수를 호출하여 세마포어 값을 증가시키면 깨어난다.#include <semaphore.h> int sem_wait(sem_t *sem);
두 함수 모두 성공적으로 세마포어 값을 감소시키면 0을 반환하고, 오류가 발생하면 -1을 반환한다. sem_wait 함수는 시그널에 의해 인터럽트될 수 있으며, 이 경우 -1을 반환하고 errno를 EINTR로 설정할 수 있다.
semshared.c
#include <errno.h>
#include <semaphore.h>
static int shared = 0;
static sem_t sharedsem;
int initshared(int val){
if(sem_init(&sharedsem, 0, 1) == -1){
return -1;
}
shared = val;
return 0;
}
int getshared(int *sval){
while(sem_wait(&sharedsem) == -1){
if(errno != EINTR){
return -1;
}
}
*sval = shared;
return sem_post(&sharedsem);
}
int incshared(int *sval){
while(sem_wait(&sharedsem) == -1){
if(errno != EINTR){
return -1;
}
}
shared++;
return sem_post(&sharedsem);
}
threadcritical.c
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#define BUFSIZE 1024
#define TEN_MILLION 10000000L
/* ARGSUSED */
void *threadout(void *args){
char buffer[BUFSIZE];
char *c;
struct timespec sleeptime;
sleeptime.tv_sec = 0;
sleeptime.tv_nsec = TEN_MILLION;
snprintf(buffer, BUFSIZE, "This is a thread from process %;d\n", (long)getpid());
c = buffer;
/* start of critical section */
while(*c != '\0'){
fputc(*c, stderr); // critical section이 아니여서 여러개의 스레드들이 스케줄링이 되면서 출력한다.
c++;
nanosleep(&sleeptime, NULL);
}
/* end of critical section */
return NULL;
}
maincritical.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void *threadout(void *args);
int main(int argc, char *argv[]){
int error;
int i;
int n;
pthread_t *tids;
if(argc != 2){
/* check for valid number of command-line arguments */
fprintf(stderr, "Usage: %s numthreads\n", argv[0]);
return 1;
}
n = atoi(argv[1]);
tids = (pthread_t *)calloc(n, sizeof(pthread_t));
if(tids = NULL){
perror("Failed to allocate memory for thread IDs");
return 1;
}
for(i = 0; i < n; i++){
if(error = pthread_create(tids+i, NULL, threadout, NULL)){
fprintf(stderr, "Failed to create thread: %s\n", strerror(error));
return 1;
}
}
for(i = 0; i < n; I++){
if(error = pthread_join(tids[i], NULL)){
fprintf(stderr, "Failed to join thread: %s\n", strerror(error));
return 1;
}
}
return 0;
}
threadcriticalsem.c
#include <errno.h>
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
#define BUFSIZE 1024
#define TEN_MILLION 10000000L
void *threadout(void *args){
char buffer[BUFSIZE];
char *c;
sem_t *semlockp;
struct timespec sleeptime;
semlockp = (sem_t *)args;
sleeptime.tv_sec = 0;
sleeptime.tv_nsec = TEN_MILLION;
snprintf(buffer, BUFSIZE, "This is a thread from process %;d\n", (long)getpid());
c = buffer;
/* entry section */
while(sem_wait(semlockp) == -1){
if(errno != EINTR){
fprintf(stderr, "Thread failed to lock semaphore\n");
}
}
/* start of critical section */
while(*c != '\0'){
fputc(*c, stderr);
c++;
nanosleep(&sleeptime, NULL);
}
/* exit section */
if(sem_post(semlockp) == -1){
/* Exit section */
fprintf(stderr, "Thread failed to unlock semaphore\n");
}
/* remainder section */
return NULL;
}
maincriticalsem.c
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void *threadout(void *args);
int main(int argc, char *argv[]) {
int error;
int i;
int n;
sem_t semlock;
pthread_t *tids;
if (argc != 2) {
/* Check for valid number of command-line arguments */
fprintf(stderr, "Usage: %s numthreads\n", argv[0]);
return 1;
}
n = atoi(argv[1]);
if (n <= 0) {
fprintf(stderr, "Number of threads must be greater than zero\n");
return 1;
}
/* Initialize semaphore */
if (sem_init(&semlock, 0, 1) == -1) {
perror("Failed to initialize semaphore");
return 1;
}
tids = (pthread_t *)calloc(n, sizeof(pthread_t));
if (tids == NULL) {
perror("Failed to allocate memory for thread IDs");
sem_destroy(&semlock);
return 1;
}
for (i = 0; i < n; i++) {
if ((error = pthread_create(tids + i, NULL, threadout, &semlock))) {
fprintf(stderr, "Failed to create thread: %s\n", strerror(error));
free(tids);
sem_destroy(&semlock);
return 1;
}
}
for (i = 0; i < n; i++) {
if ((error = pthread_join(tids[i], NULL))) {
fprintf(stderr, "Failed to join thread: %s\n", strerror(error));
free(tids);
sem_destroy(&semlock);
return 1;
}
}
/* Destroy semaphore */
sem_destroy(&semlock);
free(tids);
return 0;
}
2차 PBL : Semaphore Monitor
모니터(Monitor)란?
모니터는 고수준의 동기화 도구로, 상호배제(Mutual Exclusion)와 조건 동기화(Condition Synchronization)를 추상화하여 제공하는 구조이다. 모니터 내에 정의된 함수들은 오직 하나의 스레드만이 진입 가능하며, wait() 과signal()을 통해 조건이 만족될 때까지 대기하거나 다른 쓰레드를 깨울 수 있다.

어떤 스레드가 들어와서 한 기능이라도 사용한다면, 다른 스레드는 모니터에 들어오지 못하고 기다려야 한다.
x.signal() 함수는 조건 x를 기다리고 있는 정확히 하나의 프로세스를 깨운다. 만약 일시 중지된 프로세스가 없다면, signal() 연산은 아무런 효과가 없다. 이는 세마포어에서의 signal() 연산과는 다른 모습이다. 세마포어에서의 signal() 연산은 항상 세마포어의 값을 증가시킨다.
Semaphore Monitor: Signal and Wait
1. 모니터의 기본 구조 및 상호 배제
각 모니터마다 다음과 같은 세마포어와 변수가 사용된다.
semaphore mutex; // 초기값 = 1
semaphore next; // 초기값 = 0
int next_count = 0; // 초기값 = 0
semaphore mutex;- 스레드가 모니터 함수를 실행하기 직전에
wait(mutex)연산을 수행하여mutex값을 0으로 만들고 진입한다. 만약 다른 프로세스가 이미 모니터 내부에 있어서mutex가 0이라면, 해당 프로세스는mutex가 1이 될 때까지 대기한다. 모니터에서 나갈 때는signal(mutex)연산을 수행하여mutex값을 다시 1로 만들어 다른 스레드가 진입할 수 있도록 한다.
- 스레드가 모니터 함수를 실행하기 직전에
mutex는 상호 배제를 위한 이진 세마포어임. 초기값이 1인 이유는 모니터 내의 임계 구역(Critical Section, 즉 모니터 내부 코드)에는 한 번에 하나의 스레드만 진입할 수 있도록 하기 위함임.semaphore next;next는 Hoare 스타일 모니터에서 신호를 보낸 스레드(P_signaler)들이 모니터 밖에서 대기하기 위한 큐.- 구체적인 동작 과정
- 스레드 A가 모니터 내에서 실행 중
- 스레드 A가
x.signal()호출 → 조건변수 x에서 대기 중인 스레드 B를 깨움 - 스레드 A는
wait(next)를 호출하여 즉시 실행을 중단하고next세마포어(큐)에서 대기 - 스레드 B가 모니터 내에서 실행 시작
- 스레드 B가 모니터를 떠나면
next세마포어(큐)에서 대기 중인 스레드 A를signal(next)를 호출해 깨워서 실행 재개
- 우선순위 관리2순위:
next세마포어에서 대기 중인 프로세스(signal을 보낸 스레드) - 3순위: 모니터 진입을 기다리는 새로운 프로세스(Entry Queue)
- 1순위: 조건변수에서 깨어난 프로세스(signal을 받은 스레드)
- 초기값이 0인 이유
- 처음엔 signal을 보내서 대기 중인 스레드가 없다는 의미이다. 세마포어의 값은 현재 대기 중인 signaler 스레드의 수를 나타낸다. counting semaphore로 구현 된 이유는 여러 스레드가 동시에 같은 조건을 기다릴 수 있어야 하기 때문이다.
- 구체적인 동작 과정
next세마포어는 Hoare 스타일 모니터에서 중요한 역할을 함. Hoare스타일의 모니터 내에서x.signal()을 호출하여 다른 스레드(P_waiter)를 깨운 스레드(P_signaler)가 즉시 실행권을 포기하고 깨워진 스레드가 바로 실행됨. 이는 깨워진 스레드에게 lock과 processor를 모두 넘겨주는 방식임.int next_count = 0;- 모니터 함수가 끝날 때,
next_count가 0보다 크면next큐에 대기 중인 프로세스가 있다는 의미이므로signal(next)를 호출하여 그 중 하나를 깨운다. 그렇지 않으면signal(mutex)를 호출하여 외부에서 모니터 진입을 기다리는 스레드가 들어올 수 있도록 한다.
- 모니터 함수가 끝날 때,
next_count는next세마포어에서 대기 중인 스레드(즉,x.signal()을 호출하고wait(next)를 실행한 스레드)의 수를 기록하는 정수형 변수임.
// procedure F
wait(mutex); // 모니터 진입 전 mutex 획득
....
body of F // 모니터 내에서 실행되는 함수 F
....
if (next_count > 0) // next 큐에 대기 중인 스레드가 있다면
signal(next); // 해당 스레드를 깨움(우선권 부여)
else
signal(mutex); // 없다면 외부 대기 중인 프로세스가 들어올 수 있도록 mutex 해제
해당 구조를 통해 모니터 내에서 상호 배제가 보장된다.
2. 조건 변수(Condition Variables) 구현
모니터는 조건 변수를 통해 특정 조건이 만족될 때까지 스레드가 대기하고 조건이 만족되면 다른 스레드에 의해 깨어날 수 있도록 한다.
semaphore x_sem; // 초기값 = 0
int x_count = 0; // 초기값 = 0
semaphore x_sem;- 스레드가
x.wait()을 호출하면wait(x_sem)을 통해 이 세마포어에서 대기 상태가 된다. 다른 프로세스가x.signal()을 호출하여signal(x_sem)을 실행하면 대기 중인 스레드 중 하나가 깨어난다. - counting semaphore로 구현 된 이유는 여러 스레드가 동시에 같은 조건을 기다릴 수 있어야 하기 때문이다.
- 스레드가
- 조건 변수
x에 대한 세마포어임. 특정 조건 x가 충족되기를 기다리는 스레드들이 이 세마포어에서 대기함. 위next세마포어와 비슷한 이유로 처음엔 대기 중인 스레드가 없기 때문에 초기값 0을 가진다. int x_count = 0;x.wait()시x_count를 증가시키고x.signal()시x_count가 0보다 클 때만 시그널링 작업을 수행한다. 스레드가x_sem에서 깨어나면x_count는 감소한다.- 시그널링: 조건 변수를 통해 대기 중인 스레드를 깨우는 작업
x_sem세마포어에서 대기 중인 즉, 조건 x가 충족되기를 기다리는 스레드의 수를 기록하는 정수형 변수임.
x.wait() 연산의 구현
스레드가 조건 x가 만족될 때까지 기다리고자 할 때 x.wait()을 호출한다.
x_count++; // 조건 x를 기다리는 스레드 수 증가
if(next_count > 0)
signal(next); // next 큐에 대기 중인 스레드(이전에 signal을 했던 스레드)에 모니터 제어권 전달
else
signal(mutex) // 외부에서 모니터 진입을 기다리는 스레드에게 제어권 전달
wait(x_sem); // 현재 프로세스는 x_sem에서 대기(조건 x가 충족되기를 기다림)
x_count--; // x_sem에서 깨어난 후 조건 x를 기다리는 스레드 수 감소
x_count++: 현재 스레드가 조건x를 기다릴 것이므로x_count를 1 증가if(next_count > 0) signal(next); else signal(mutex);:- 현재 x.wait()을 호출한 스레드는 x_sem에서 대기 상태로 들어갈 것이다. 그 전에 모니터의 잠금(mutex)을 해제하여 다른 스레드가 모니터를 사용할 수 있도록 해야 한다.
- 만약
next_count > 0이라면 이전에x.signal()을 호출하고wait(next)로 인해next큐에서 대기 중인 스레드가 있다는 의미다. 이 경우signal(next)를 호출하여 그 스레드에게 모니터 제어권을 넘겨준다. next_count == 0이라면,next큐에 대기 중인 프로세스가 없으므로signal(mutex)를 호출하여 모니터 외부에서 새로운 프로세스가 진입할 수 있도록 한다.
wait(x_sem);: 현재 스레드는x_sem세마포어에서 대기한다. 다른 스레드가x.signal()을 호출하여signal(x_sem)을 실행시켜줄 때까지 여기서 블록된다.x_count--;:x_sem에서 깨어난 후(즉, 다른 스레드가x.signal()을 호출해 준 후) 조건x를 기다리던 스레드 목록에서 빠져나왔으므로x_count를 1 감소시킨다. 이 시점에서 스레드는 모니터 내부에 있으며mutex를 암묵적으로 다시 획득한 상태로 간주되어 계속 실행된다.
x.signal() 연산의 구현
스레드가 조건 x를 만족시켰음을 대기 중인 모든 스레드에게 알릴 때 x.signal()을 호출한다.
if(x_count > 0) { // 조건 x를 기다리는 스레드가 있을 경우에만 실행
next_count++; // signal을 호출한 현재 스레드가 곧 next 큐에서 대기할 것이므로 증가
signal(x_sem); // x_sem을 signal하여 조건 x를 기다리던 스레드 중 하나를 깨움
wait(next); // 현재 스레드(signaler)는 next 큐에서 대기(깨어난 스레드가 작업을 마칠 때 까지)
next_count--; // next 큐에서 깨어난 후 next_count 감
}
if(x_count > 0): 조건x를 기다리는 스레드가 한 개 이상 있을 때만 시그널링 로직을 수행한다.next_count++;:signal을 호출한 현재 스레드(P_signaler)는 잠시 후wait(next)를 통해next큐에서 대기할 것이므로,next_count를 1 증가시킨다.signal(x_sem);:x_sem세마포어에signal연산을 수행하여 조건x를 기다리며x_sem에서 대기 중이던 스레드(P_waiter) 중 하나를 깨운다.wait(next);: P_signaler는next세마포어에서 대기한다. Hoare 스타일 모니터이므로 P_waiter가 모니터 사용을 마치고 떠나거나 또는 P_waiter가 다시 다른 조건에서 대기 상태가 될 때까지 P_signaler는 대기한다.next_count--;: P_signaler가next세마포어에서 깨어난 후 (즉, P_waiter가signal(next)를 호출해준 후),next_count를 1 감소시킨다.
이미지로 이해하기

- 스레드 A가 모니터 안으로 진입Body of F 실행
wait(mutex)로 모니터 mutex lock- 스레드 A가 조건
x를 기다리기 위해x_sem큐에 진입(사진에서는 Wait Queue)큐에 들어가기 전에 현재next_count조건 검사를 통해next큐(사진에서는 Signal Queue)에 스레드가 있는지 검사진입을 풀고wait(x_sem)을 통해x_sem큐에 진입 후 대기 - 없으니
signal(mutex)로 Entry Queue에서 진입 가능할 수 있도록 mutex unlock - 먼저
x_sem에 들어갈 예정이므로x_count++ - Entry Queue에 있는 스레드 B가 모니터 진입
- 스레드 B가
x자원을 생성하는 작업 수행 - 스레드 B가 다른 스레드들에게 자원을 사용할 수 있다고
signal을 보냄있다면 현재 실행중인 스레드 B는next큐에 들어가야 하므로next_count++스레드 B는wait(next)을 통해next큐에 진입 후 대기 signal(x_sem)을 보내wait(x_sem)에서 대기중인 스레드 A를 깨움- 먼저
x_count조건을 검사하여x_sem큐에 스레드가 있는지 검사 - 스레드 A 모니터 재진입
x_count--를 통해x_sem큐의 대기 스레드 수를 수정- 스레드 A 실행 종료있다면
signal(next)로 깨우고 없다면signal(mutex)로 모니터 mutex unlock을 함. next_count조건을 검사하여next큐에 스레드가 있는지 검사- 스레드 B 모니터 재진입
next_count--를 통해next큐의 대기 스레드 수를 수정- 스레드 B 종료 후 다른 스레드 진입을 위해
signal을 보냄.있다면signal(next)로 깨우고 없다면signal(mutex)로 모니터 mutex unlock을 함. next_count조건을 검사하여next큐에 스레드가 있는지 검사
Semaphore Monitor : Signal and Continue
1. 모니터의 기본 구조 및 상호 배제
각 모니터마다 다음과 같은 세마포어와 변수가 사용된다.
semaphore mutex; // 초기값 = 1
semaphore mutex;
mutex는 상호 배제를 위한 이진 세마포어임. 초기값이 1인 이유는 모니터 내의 임계 구역(Critical Section, 즉 모니터 내부 코드)에는 한 번에 하나의 스레드만 진입할 수 있도록 하기 위함임.
- 스레드가 모니터 함수를 실행하기 직전에
wait(mutex)연산을 수행하여mutex값을 0으로 만들고 진입한다. 만약 다른 프로세스가 이미 모니터 내부에 있어서mutex가 0이라면, 해당 프로세스는mutex가 1이 될 때까지 대기한다. 모니터에서 나갈 때는signal(mutex)연산을 수행하여mutex값을 다시 1로 만들어 다른 스레드가 진입할 수 있도록 한다.
// procedure F
// 모니터 함수 실행
wait(mutex);
-- body of F --
signal(mutex); // 단순히 mutex만 해제
해당 구조를 통해 모니터 내에서 상호 배제가 보장된다.
2. 조건 변수(Condition Variables) 구현
모니터는 조건 변수를 통해 특정 조건이 만족될 때까지 스레드가 대기하고 조건이 만족되면 다른 스레드에 의해 깨어날 수 있도록 한다.
semaphore x_sem; // 초기값 = 0
int x_count = 0; // 초기값 = 0
semaphore x_sem;- 스레드가
x.wait()을 호출하면wait(x_sem)을 통해 이 세마포어에서 대기 상태가 된다. 다른 프로세스가x.signal()을 호출하여signal(x_sem)을 실행하면 대기 중인 스레드 중 하나가 깨어난다.
- 스레드가
- 조건 변수
x에 대한 세마포어임. 특정 조건 x가 충족되기를 기다리는 스레드들이 이 세마포어에서 대기함. 처음엔 대기 중인 스레드가 없고, 만족된 조건이 없기 때문에 때문에 초기값 0을 가진다. int x_count = 0;- 다른 스레드가 조건을 만족 시키고
x.signal()을 호출할 때, ‘이 조건을 기다리는 스레드가 있는지’ 확인 후, 대기 중인 스레드가 없다면x_count를 확인해서 대기 스레드가 있는단
- 다른 스레드가 조건을 만족 시키고
x_sem세마포어에서 대기 중인 즉, 조건 x가 충족되기를 기다리는 스레드의 수를 기록하는 정수형 변수임.
x.wait() 연산의 구현
스레드가 조건 x가 만족될 때까지 기다려야하지만, 모니터를 계속 점유하면 안 된다.
while (조건이 거짓) {
x_count++;
signal(mutex); // 모니터 해제하고 대기
wait(x_sem); // 조건 변수에서 대기
wait(mutex); // 깨어나면 다시 모니터 진입
x_count--;
}
- While 루프: Signal and Continue에서는 신호를 받아도 즉시 실행되지 않기 때문에 조건을 걸어서 프로세스가 조건을 만족하는지 확인. Signal & Continue의 핵심이다.
x_count++: 현재 스레드가 조건x를 기다릴 것이므로x_count를 1 증가signal(mutex): 프로세스가 조건을 기다리면서 모니터를 해제한다. 이것으로 다른 프로세스가 모니터에 진입하게 되고, 조건을 만족시킬 가능성이 생긴다.- Signal and Wait이랑 다른 점은, "조건이 만족되었다"는 보장이 아니라 "조건을 다시 확인해보라"는 의미
wait(x_sem);: 현재 스레드는x_sem세마포어에서 대기한다. 다른 스레드가x.signal()을 호출하여signal(x_sem)을 실행시켜줄 때까지 여기서 블록된다.wait(mutex);: 프로세스가 깨어나면 즉시 모니터에 재진입을 시도한다. 만약 다른 프로세스가 모니터를 사용 중이라면 여기서 다시 블록될 수 있다. 메모리의 상태가 달라져 있을 수 있어 조건을 다시 검사하는 것이 필수적이다.x_count--;:x_sem에서 깨어난 후(즉, 다른 스레드가x.signal()을 호출해 준 후) 조건x를 기다리던 스레드 목록에서 빠져나왔으므로x_count를 1 감소시킨다. 이 시점에서 스레드는 모니터 내부에 있으며mutex를 암묵적으로 다시 획득한 상태로 간주되어 계속 실행된다.
x.signal() 연산의 구현
공유 데이터를 수정해서 다른 스레드들 기다리고 있던 조건이 거짓에서 참으로 변할 때 호출된다.
// 대기 중인 프로세스가 있으면 힌트만 제공
if (x_count > 0) {
signal(x_sem); // 대기 중인 스레드에게 힌트 제공
// 하지만 즉시 제어권을 넘기지 않음
}
if(x_count > 0): 조건x를 기다리는 스레드가 실제로 존재하는지 확인한다.signal(x_sem);: 조건x에서 대기 중인 프로세스 중 하나를 깨운다.
이미지로 이해하기

- 스레드 A가 모니터 안으로 진입
wait(mutex)로 모니터 mutex lock. Body of F 실행 - 스레드 A가 조건
x를 기다리기 위해x_sem큐에 진입(사진에서는 Wait Queue)
먼저x_sem에 들어갈 예정이므로x_count++
큐에 들어가기 전에 Mesa 스타일에서는 Signal Queue가 없으므로next_count검사 없이 바로signal(mutex)로 Entry Queue에서 진입 가능할 수 있도록 mutex unlock
진입을 풀고wait(x_sem)을 통해x_sem큐에 진입 후 대기 - Entry Queue에 있는 스레드 B가 모니터 진입
- 스레드 B가
x자원을 생성하는 작업 수행 및 신호 전달
먼저x_count조건을 검사하여x_sem큐에 스레드가 있는지 검사
있다면signal(x_sem)을 보내wait(x_sem)에서 대기중인 스레드 A에게 힌트 제공
Mesa 스타일의 핵심: 스레드 B는 Hoare와 달리 Signal Queue에 들어가지 않고 계속 실행됨
5. 스레드 B가 모니터에서 계속 실행 후 종료
Mesa 스타일에서는 신호를 보낸 후에도 현재 프로세스가 계속 실행될 수 있음
모든 작업을 완료한 후 signal(mutex)로 모니터 mutex unlock
6. 스레드 A가 Entry Queue를 거쳐 모니터 재진입signal(x_sem)으로 깨어난 스레드 A는 즉시 모니터에 진입하지 못하고 Entry Queue로 이동
다른 프로세스들과 함께 모니터 진입을 위해 경쟁wait(mutex)를 통해 모니터에 재진입x_count--를 통해 x_sem큐의 대기 스레드 수를 수정
7. 스레드 A가 조건 재검사 후 실행
Mesa 스타일에서는 신호가 힌트일 뿐이므로 while 루프를 통해 조건을 다시 확인
조건이 여전히 만족되는지 검사 후 작업 진행
8. 스레드 A 실행 종료
Mesa 스타일에서는 Signal Queue가 없으므로 단순히 signal(mutex)로 모니터 mutex unlock
9. Entry Queue에서 다른 프로세스 진입 가능
모니터가 해제되어 Entry Queue의 다른 프로세스가 진입할 수 있음
Signal & Wait vs Signal & Continue 비교
| 비교 항목 | Hoare Semantics | Mesa Semantics |
|---|---|---|
| 기본 철학 | 신호 = 보장 (즉시 제어권 전달) | 신호 = 힌트 (지연된 실행) |
| 큐 구조 | • Entry Queue • Signal Queue (우선순위 높음) |
• Entry Queue만 존재 • 단순한 구조 |
| signal() 호출 시 제어권 전달 | 즉시 전달 - 신호 보낸 프로세스가 Signal Queue로 이동 | 지연 전달 - 현재 프로세스가 계속 실행 |
| 조건 검사 방식 | if (조건) - 조건 변경 보장으로 한 번만 검사 |
while (조건) - 조건 재검사 필수 |
| 깨어난 프로세스의 모니터 진입 | 즉시 진입 - Signal Queue에서 우선순위 보장 | Entry Queue 경쟁 - 다른 프로세스들과 공정 경쟁 |
| 구현 복잡도 | 복잡 • next_count 관리 • 우선순위 메커니즘 • 두 개 큐 동기화 |
단순 • 기본적인 mutex만 필요 • 단일 큐 구조 |
| 컨텍스트 스위칭 | 빈번함 - signal 시마다 즉시 전환 | 적음 - 프로세스 완료 후 전환 |
| 조건 불변성 | 강력한 보장 - 깨어난 시점에 조건 확실히 만족 | 약한 보장 - 조건 재확인 필요 |
| Spurious Wakeup 처리 | 별도 처리 필요 - 예상치 못한 깨어남 시 문제 발생 가능 | 자연스러운 처리 - while 루프로 자동 해결 |
| 성능 특성 | • ✅ 조건 재검사 불필요 • ❌ 잦은 컨텍스트 스위칭 • ❌ 메모리 오버헤드 큼 |
• ❌ 조건 재검사 오버헤드 • ✅ 컨텍스트 스위칭 적음 • ✅ 메모리 효율적 |
| 실제 사용 사례 | • 이론적 연구 • 정확성이 최우선인 시스템 • 실시간 시스템 일부 |
• Java synchronized • POSIX threads • 대부분의 현대 운영체제 |
| 주요 장점 | 정확성과 예측가능성 - 논리적 추론이 쉬움 | 실용성과 효율성 - 구현과 사용이 간단 |
| 주요 단점 | 복잡한 구현 - 성능 오버헤드 | 조건 재검사 필요 - 논리적 추론 복잡 |
핵심 차이점 요약
두 방식의 가장 근본적인 차이는 신호의 의미에 있습니다. Hoare 방식에서 signal()은 "조건이 만족되었다"는 확실한 보장을 의미하는 반면, Mesa 방식에서는 "조건을 다시 확인해보라"는 힌트에 불과합니다.
이런 철학적 차이가 구현의 모든 측면에 영향을 미칩니다. Hoare 방식은 정밀한 시계처럼 모든 것이 정확한 타이밍에 맞춰 동작하지만, 그만큼 복잡한 메커니즘이 필요합니다. Mesa 방식은 약간의 불확실성을 허용하는 대신 훨씬 단순하고 효율적인 구조를 제공합니다.
현대 시스템에서 Mesa 방식이 널리 채택된 이유는 실제 소프트웨어 개발에서 완벽한 정확성보다는 실용적인 성능과 구현의 용이성이 더 중요하기 때문입니다. while 루프를 통한 조건 재검사라는 작은 비용으로 훨씬 큰 이점을 얻을 수 있다는 것이 Mesa 방식의 핵심 철학입니다.
3차 PBL : Priority Inversion
우선순위 역전
우선순위 역전 현상의 정의
우선순위 역전(Priority Inversion)은 높은 우선순위의 태스크가 낮은 우선순위의 태스크로 인해 수행이 지연되는 현상으로, 실시간 스케줄링 시스템에서 발생하는 중대한 문제이다. 이 현상은 공유 자원에 대한 동기화 과정에서 발생하며, 시스템의 예측 가능성과 실시간 성능을 심각하게 훼손한다.


기본적인 우선순위 역전 시나리오는 다음과 같다.
세 개의 태스크 J1(높은 우선순위), J2(중간 우선순위), J3(낮은 우선순위)가 존재할 때, J3가 공유 자원을 점유하고 있는 상태에서 J1이 같은 자원을 요청하면 먼저 쓰고있는 J3가 자원을 놔주지 않아 J1은 대기 상태가 된다. 이때 J1, J3의 요구하는 공유자원을 사용 안하는 J2가 실행되면, J2는 J1보다 우선순위가 낮음에도 불구하고 J1보다 먼저 실행이 되고 종료되는 상황이 발생한다.
우선순위 역전의 발생 조건
우선순위 역전이 발생하기 위해서는 다음 조건들이 충족되어야 한다.
첫째, 서로 다른 우선순위를 가진 태스크들이 존재해야 한다.
둘째, 이들 태스크가 공유 자원을 사용해야 한다.
셋째, 뮤텍스나 세마포어와 같은 동기화 메커니즘이 사용되어야 한다.
넷째, 낮은 우선순위 태스크가 자원을 점유한 상태에서 높은 우선순위 태스크가 같은 자원을 요청한다.
이러한 조건이 만족될 때, 중간 우선순위의 태스크가 개입하면서 무제한 우선순위 역전(Unbounded Priority Inversion) 상황이 발생할 수 있다. 이는 높은 우선순위 태스크의 블로킹 시간이 예측 불가능하게 길어질 수 있음을 의미한다.
우선순위 역전의 해결책
Non-preemptive

어떤 Task이던 간에 Critical Section에서 실행하고 있으면, 절대 그 누구에게도 preemption을 허용하지 않겠다. ⇒ 불필요한 Blocking을 양산한다는 문제가 발생한다.
τ4가 실행되고 있는 상황에서 세마포어 S1에 대한 P연산을 실행한다. Ready라고 쓰여 있는 부분 각각에서 τ1,2,3이 실행 준비가 된다. 그럼에도 불구하고 일단 τ4가 세마포어에 대해 lock을 걸고 들어가면, 아무리 우선순위가 높은 Task라고 하더라고 CPU Preemption을 못 하게 막아버린다. 이렇게 되면 τ2나 τ3때문에 τ1의 실행이 지연되는 일은 발생하지 않으며, 우선순위 역전은 일어나지 않는다. 이 protocol은 wild한 옵션이며 가장 간단하게 구현될 수 있으나 굉장히 비효율적이다.
τ1이 준비가 되었음에도 불구하고, τ4가 Critical Section에 들어가 있기 때문에 준비되고 나서 시간이 흐른 뒤에야 실행이 되었고 Critical Section하고 전혀 연관이 없는 τ1 조차도 τ4로부터 선점을 해오지 못하기 때문이다.
우선순위가 낮은 task가 Critical Section에 들어가 있는 동안에 우선순위가 높은 그 어떤 Task의 실행도 다 Delay를 시켜 버리게 되면서 우선순위가 높은 Task의 Deadline Miss가 올라가게 된다.
Highest Lockers Priority Protocol

τ4가 실행되면 빗금친 부분에서 Critical Section에 들어간다. 이런 와중에 τ1이 Ready 상태가 되면서 τ1은 τ4로부터 CPU를 선점해 간다. 이 때의 문제는, τ3 또는 τ2가 τ4보다 먼저 실행되면서 τ1의 실행이 밀리는 데, 여기서 우선순위 역전이 생긴다.
그렇다면 이러한 우선순위 역전을 막기 위해, τ4가 Critical Section에서 τ1에게 뺏겼으면, τ4의 우선순위를 가장 우선순위가 높은 Task(τ1)의 우선순위로 임시로 승격시켜준다. τ2 또는 τ3는 τ1보다 우선순위가 높지 않기 때문에 임시로 승격된 τ4가 먼저 CPU를 가져간다 .즉, τ2, τ3은 Ready되었음에도 가져가지 못한다. τ4가 실행이 끝나고 세마포어를 v연산을 하고, 그 뒤 τ2가 실행되고, 마지막으로 τ3가 실행된다.
Priority Inheritance Protocol(PIP)
Priority Inheritance Protocol(PIP)
Basic Protocol의 정의
- 실행 준비가 된 Job들 중에서 가장 높은 우선순위를 갖는 job J가 CPU를 할당받는다.
- 만약 세마포어 S가 이미 잠겨있다면, J는 블록된다.
- J가 자신의 임계 구역을 벗어나면, 임계 구역의 잠금이 해제되고, J에 의해 블록되었던 작업 중 가장 우선순위가 높은 작업이 깨어난다.
- 만약 J가 자신보다 우선순위가 높은 Job들을 블록한다면, J는 자신이 블록한 작업들 중 가장 높은 우선순위를 상속받는다.
- J가 임계 구역을 벗어나면, 임계 구역에 진입하기 직전의 우선순위로 돌아간다.
- 우선순위 상속은 transitive
- Job J3가 J2를 블록하고, J2가 J1을 블록한다면, J3은 J2를 통해 J1의 우선순위를 상속받게 된다.
- J가 블록된 상태가 아니고, J의 우선순위가 현재 실행 중인 다른 작업 JL의 우선순위(상속되거나, 할당된)보다 높다면, J는 JL의 CPU를 선점할 수 있다.
PIP 예제

τ4가 먼저 실행되어서 세마포어 S1을 잠그고 임계 구역에 진입한다. 조금 시간이 흐른 뒤, τ1이 τ4의 CPU를 선점하여 실행한다. 그 사이에 τ2가 실행 준비가 되고, τ2는 τ1의 CPU 사용이 끝나면 실행된다. τ2는 실행 도중 임계 구역 S1에 진입하려고 하지만, τ4가 이미 세마포어를 잠근 상태이므로 들어가지 못한다. 이 때, τ2의 우선순위가 τ4에게 상속된다. 임계구역에 들어가지 못한 τ2는 CPU를 놓고, 준비중인 τ3와 τ4중에서 τ2의 우선순위를 상속받은 τ4가 CPU를 잡게 된다. 이렇게 CPU를 잡은 τ4가 실행 도중 임계 구역에서 빠져 나오면, S1을 풀어 주며 즉시 원래의 우선순위로 돌아 온다. 그렇게 되면 아까 임계 구역에 들어가지 못해서 CPU를 놓았던 τ2가 CPU를 잡고 S1을 잠그고 임계 구역에 진입한다. τ2의 실행이 끝나면, τ3, τ4의 순서대로 실행된다. 이를 통해 τ3가 τ2의 우선순위를 역전하는 상황을 막을 수 있다.
그러나 PIP에게 Deadlock과 Chain Blocking 문제가 발생할 수 있다.
Deadlock

이번에는 두 종류의 바이너리 세마포어 S1, S2가 있다고 가정해 보자.
τ2가 먼저 준비되어 실행되고, 세마포어 S2를 잠가버리며 S2의 임계 구역에 들어간다. 임계 구역에 들어가 있는 상태에서 τ1이 실행되어서 τ2의 CPU를 뺏어가 버리고, 세마포어 S1을 잠가 버리며 S1의 임계 구역으로 들어가 버린다.이 상태에서, τ1은 S2의 임계 구역에 들어가기를 원하나 이미 τ2가 들어가 있어서 block된다. 이 때, τ2는 τ1의 우선순위를 상속받아 재개하는데, 마찬가지로 S1의 임계구역에 들어가고자 요청하지만, τ1이 이미 들어가있는 상태이므로 Block되어 버린다.. 결국 이 시점부터 그 어떤 프로세스도 진행하지 못 하게 되는 데드락 상태가 되어버린다.
Chain Blocking

Chain Blocking은 하나의 높은 우선순위의 Task가 여러 개의 낮은 우선순위 Task들이 점유한 여러 자원을 기다려야 할 때 대기시간이 길어지는 현상이다.
이번에는 τ1은 세마포어 S1, S2를 사용하고, τ2는 세마포어 S1, τ3는 세마포어 S2를 사용한다고 가정해 보자.
먼저 τ3가 실행되어서 S2의 임계 구역으로 들어가 S2를 잠가 버린다. 그리고 τ2가 실행되어서 τ3의 CPU를 선점해 버리고, S1의 임계 구역으로 들어가 S1을 잠가 버린다. 이 상태에서 τ1이 실행되어서 τ2의 CPU를 선점해 버리고, 임계구역 S1에 들어가고자 한다. 그러나 이미 S1에는 τ2가 들어가 있기에 블락되고, τ2는 τ1의 우선순위를 상속받는다. τ2가 임계구역 S1에서 빠져 나오면, 그 즉시 원래의 우선순위로 돌아가게 되고, τ1이 다시 CPU를 선점해 가며 S1의 임계 구역으로 들어가게 된다.
τ1이 S1의 임계 구역에서 빠져 나오고 실행하던 도중, S2의 임계 구역으로 들어가려고 하나 이번에도 τ3가 먼저 들어가있어서 블락되어 버린다. 이 때 τ3는 τ1의 우선순위를 일시적으로 상속받으며, 임계 구역을 실행하고 빠져나온 뒤에 즉시 원래의 우선순위로 돌아온다. 다시 τ1의 CPU를 선점하고, 임계 구역 S2에 들어갔다 나온 뒤 실행을 마치게 된다.
Priority Ceiling Protocol(PCP)
개요
목표 : 데드락과 연쇄 블로킹 문제가 발생하는것을 막는다. PIP의 단점을 보완한 프로토콜로, 교착 상태와 연쇄 블로킹 문제를 해결한다.
- 어떤 Job J가 다른 job의 임계 구역을 선점하고 자신의 임계 구역 z를 실행할 때, J의 우선순위는 선점된 모든 임계 구역의 상속된 우선순위들보다 높다는 것을 보장받아야 한다.
- 만약 이 조건을 만족시키지 못한다면, 작업 J는 임계 구역 z로의 진입이 거부되고 중지되며, J를 블로킹하는 작업이 J의 우선순위를 상속받는다.
- J가 새로운 임계 구역을 시작하도록 허락하는 경우는, 오직 J의 우선순위가 J 자기 자신을 제외한 다른 작업들에 의해 잠긴 모든 세마포어들의 우선순위 실링(priority ceilings)보다 높을 때 뿐이다.
- 즉, 어떤 작업이 공유 자원을 사용하려고 할 때, 자신의 우선순위가 현재 사용중인 다른 모든 자원들의 ‘Priority Ceiling’( = 현재의 System Ceiling) 보다 높아야만 자원을 획득할 수 있도록 허용하는 방식이다. 이 규칙을 통해 데드락을 원천적으로 차단한다.
Priority Ceiling
세마포어 S의 우선순위 실링(Priority Ceiling)
- 세마포어 S를 잠글 수 있는 가장 높은 우선순위를 가진 Task의 우선 순위이다. 즉, 세마포어 S에 접근하는 모든 Task 중에서 가장 높은 우선순위를 해당 세마포어(공유 자원)에 대한 ‘실링’값으로 미리 정해 놓는다.
시스템 실링(System ceiling)
- 현재 다른 태스크들에 의해 잠겨 있는 모든 세마포어들의 실링값 중 최댓값.
- 실시간으로 시스템에서 잠겨 있는 여러 자원들의 ‘실링’ 값 중 가장 높은 값을 의미한다. 이 값은 시간이 흐르면서 계속 변화할 수 있다.
PCP의 기본 아이디어
- 실행 중이거나 중지된 임계 구역들의 전체적인 우선순위 순서를 만드는 것

그림을 보면, 세마포어 S를 잠글 수 있는 Task중에 높은 우선순위를 갖는 Task T(high)와 낮은 우선순위를 갖는 Task T(low)가 있다. 이 때, 세마포어 S의 우선순위 실링은 S를 잠글 수 있는 Task중 가장 높은 우선순위를 가진 T(high)의 우선순위가 된다.

Critical Section마다 가장 높은 우선순위를 갖는 τ의 우선순위가 Priority ceiling이 된다.
PCP Rules
세마포어(공유 자원)이 하나만 존재할 경우, PCP는 BIP/PIP와 똑같이 동작한다.
- preemption : 더 높은 실행 우선순위를 갖는 Task는 항상 더 낮은 실행 우선순위의 Task를 선점한다. 이는 일반적인 우선순위 기반 스케줄러의 기본 규칙이다.
- Ceiling : Task는 자신의 우선순위가 시스템 실링(system ceiling)보다 높지 않으면, 자신의 임계 구역에 진입할 수 없다.
- Inheritance : 더 높은 우선순위의 태스크 Jh를 블로킹하는 더 낮은 우선순위의 태스크는 Jh의 우선순위를 상속받는다.
J가 겪을 수 있는 4가지 상황
- 작업 J가 임계 구역(Critical Section)에 진입하고자 할 때
- J의 우선순위는 반드시 System Ceiling보다 높아야 한다.
- 임계 구역을 빠져 나올 때는, 블록된 Task 중에서 가장 높은 우선순위의 Task를 깨운다.
- 임계 구역 내에서 실행중일 때
- 항상 블록된 태스크들 중 가장 높은 우선순위를 상속받는다.
- J가 임계 구역에 진입하려는 것이 아닐 때
- 자신보다 낮은 우선순위의 태스크를 선점할 수 있다.(일반적인 스케줄링 규칙)
- J가 정상적으로 실행을 완료했을 때
- 만약 실행 준비가 된 동일한 우선순위의 Task들이 많다면, 다른 태스크를 블로킹하고 있는 태스크를 스케줄한다. 자원을 빨리 해제해서 다른 높은 우선순위 태스크의 대기 시간을 줄이기 위한 방법이다.
PCP 예제 1

이 예제에서는 바이너리 세마포어 3개를 사용하며, S0은 J0, S1은 J1과 J2, S2는 J1과 J2가 접근할 수 있다. 각 세마포어의 우선순위 실링은 P(J0), P(J1), P(J1)이다. J1 은 P(S1), P(S2), V(S2), V(S1)의 순서대로 연산하며, J2는 P(S2), P(S1), V(S1), V(S2)의 순서대로 연산한다.
t0 ~ t3
우선, t0에서 J2가 먼저 준비되어 CPU를 잡는다. J2는 실행 도중 t1 시기에 세마포어 S2를 잠그고 임계 구역에 들어간다. 이 때, System Ceiling은 S2의 우선순위 실링값인 P(J1)이 된다. t2 시기에 J1이 준비되어서 J2의 CPU를 선점한다. t3 시기에 J1은 임계구역 S1에 진입하고자 하지만, J1의 우선순위는 P(J1)으로, 현재 System Ceiling인 P(J1)보다 높지 않으므로, 블락된다. J1이 블락되었으므로, J1의 우선순위가 J2에게 일시적으로 상속되어서 다시 J2가 CPU를 잡게 된다.
t4 ~ t6
t4 시기에 J0가 준비되어서 J2의 CPU를 선점하고(이 때 J2의 우선순위는 J1이지만, J0는 J1보다도 우선순위가 높음) J0은 t5시기에 임계 구역 S0에 진입하며 t6 시기에 임계 구역 S0을 빠져 나온다. t5~t6동안의 System Ceiling은 일시적으로 S0의 우선순위 실링인 P(J0)가 되지만, t6 이후는 다시 S2의 우선순위 실링인 P(J1)이 된다.
t7~ t8
t7시기에 J0이 CPU를 놓고, 블록되었던 J1 대신 J2가 CPU를 잡는다. J2는 여전히 임계구역 S2 내에 존재하며, S1에도 진입하고자 한다. S1은 비어있으므로 무사히 들어갔다 나오고, t8시점에 S2의 임계 구역도 빠져 나온다. 임계 구역을 빠져 나올때는 원래의 우선순위로 돌아오며 블록되었던 Task중에 가장 높은 우선순위인 것을 깨우므로, J1을 깨워 준다.
t8 ~ t12
다시 J1이 CPU를 잡게 되며, 아까 블록되어 들어가지 못했던 S1에 진입한다. 그 뒤 t9 시기에 S2에도 진입했다가 t10에서 빠져나오며, t11에서 S1도 빠져나오고 t12까지 실행한 뒤에 CPU를 놓는다.
t12~ t13
CPU 우선순위가 제일 낮던 J2가 CPU를 잡고 t13까지 사용한 뒤 CPU를 놓는다.
PCP 예제 2

바이너리 세마포어 3개가 존재하며, S0은 J0가, S1은 J0와 J2가, S2는 J1, J2가 사용한다. S0의 우선순위 실링은 P(J0), S1은 P(J0), S2는 P(J1)이다. J0은 P(S0), V(S0), P(S1), V(S1)의 순서대로 연산하며, J2는 P(S2), P(S1), V(S1), V(S2)의 순서대로 연산한다.
t0 ~ t3
먼저 J2가 준비되어 CPU를 잡는다. t1 시기에 J2는 임계구역 S2에 들어가며, 이 때 시스템 실링은 S2의 우선순위 실링인 P(J1)이 된다. t2 시기에 J1이 준비되어서 CPU를 잡고, t3 시기에 임계 구역 S2에 들어가려 하지만, 이J1의 우선순위 P(J1)이 이 시점의 시스템 실링인 P(J1)보다 높지 않으므로 블락된다. 이 때, J1의 우선순위가 J2에게 일시적으로 상속된다.
t3 ~ t5
다시 J2가 CPU를 잡고,t4 시기에 S2에 들어가 있는 채로 임계 구역 S1에 들어가고자 한다. 이때 시스템 실링은 P(J1)이고, J2의 우선순위도 P(J1)이지만 J2는 이미 임계구역 S2에 진입중이므로 S1에 무사히 진입하고, 이 때 시스템 실링이 S1의 우선순위 실링인 P(J0)로 업데이트된다. t5시기에 J0가 준비되어서 J0가 CPU를 선점한다.
t5 ~ t7
J0가 CPU를 잡고, t6시기에 임계 구역 S0에 들어가려고 하지만, J0의 우선순위인 P(J0)이 시스템 실링인 P(J0)보다 높지 않으므로 블록된다. J0는 CPU를 놓고 다시 J2가 CPU를 잡게 되며, 이때 J2는 J0의 우선순위를 상속받는다. t7 시기에 J2는 임계 구역 S1에서 빠져 나오며, 이 때 시스템 실링은 S2의 우선순위 실링인 P(J1)으로 업데이트 되고, 시스템 실링도 P(J1)으로 바뀐다. 임계 구역에서 빠져 나왔으므로, 블록된 태스크중 가장 우선순위가 높은 J0를 깨우고, J0가 CPU를 잡는다.
t7~ t10
J0는 S0에 들어갔다가 나오고(t7~ t8), S1에도 들어갔다가 나온 뒤 t10에서 실행을 마친다. S0 또는 S1에 들어가 있을 때는 시스템 실링이 P(J0)로 업데이트된다. t10에서 실행을 마친 뒤, 현재 J1과 J2의 우선순위는 같지만, J1은 블락되어 있는 상태이므로 J2가 CPU를 잡게 된다.
t10 ~ t14
J2는 CPU를 잡고, t11 시기에 S2 임계 구역을 빠져나온다. 임계 구역에서 빠져 나왔으므로 J2는 원래의 우선순위 P(J2)로 되돌아가고, 블록된 태스크중 가장 높은 우선순위인 J1을 깨운다. t11에서 J1이 CPU를 잡고, 임계 구역 S2에 진입하고 t12 시기에 S2에서 빠져나온다. t13시기까지 CPU를 사용하다가 CPU를 놓고, J2가 CPU를 잡은 뒤 t14까지 실행된다.
Deadlock Avoidance

위 그림은 PCP가 어떻게 교착 상태를 회피하는지 보여주는 예시이다.
τ1, τ2 둘다 바이너리 세마포어 S1, S2를 사용하며, τ1 은 P(S1), P(S2), V(S2), V(S1)의 순서대로 연산하고, τ2는 P(S2), P(S1), V(S1), V(S2)의 순서대로 연산한다. S1, S2 둘 다 τ1이 접근하는 세마포어이므로, 우선순위 실링은 P(τ1)이 된다.
τ2가 먼저 CPU를 잡고, 임계 구역 S2에 진입한다. 이때 시스템 실링은 S2의 우선순위 실링인 P(τ1)으로 업데이트 된다. 시간이 조금 흐른 뒤 τ1이 준비되어서 CPU를 선점하고, 임계 구역 S1에 진입하고자 한다. 허나 PCP는 자신의 우선순위가 시스템 실링보다 높아야만 임계 구역에 진입할 수 있기 때문에, 이 시도는 블락된다. τ1의 우선순위 P(τ1)이 현재 시스템 실링인 P(τ1)보다 높지 않기 때문이다. 또한 τ2가 τ1의 우선순위를 상속받는다. 이 부분이 PIP와 다른 부분이며, 이렇게 함으로써 데드락 상황이 발생하는 것을 예방한다.
τ1은 블락되었으니 다시 τ2가 CPU를 선점하고, 임계 구역 S1에 진입한다. 이 때 τ1의 우선순위인 P(τ1)이 S1의 우선순위 실링인 P(τ1)보다 높지는 않지만, τ2는 τ1의 우선순위를 상속받아 τ1의 블로킹을 풀어야하는 긴급한 임무를 수행중이기 때문에 진입이 허용된다. S1을 빠져 나온 뒤, S2도 빠져 나오고, 블락되어있던 Task중 가장 높은 우선순위인 τ1이 깨어나 CPU를 잡게 된다. τ1은 S1에 들어간 뒤 S2에도 들어가고, S2에서 빠져나온 뒤 S1에서도 빠져나온 뒤 실행이 끝나면 CPU를 놓고, τ2가 CPU를 잡는다.
Blocked At Most Once

PCP에서는 최대 한 번만 블록된다. 아무리 많은 낮은 우선순위의 작업들이 여러 자원을 점유하고 있더라도, 가장 높은 우선순위의 작업은 최대 한 번만 블로킹됨을 보장한다.
위 그림에서 τ1은 바이너리 세마포어 S1, S2를 사용하며, τ2는 S1, τ3는 S2를 사용한다. S1, S2의 우선순위 실링은 P(τ1)이다.
τ1은 P(S1), P(S2), V(S2), V(S1)의 순서대로 연산
τ2는 P(S1), V(S1)의 순서대로 연산
τ3는 P(S2), V(S2)의 순서대로 연산
먼저 τ3가 CPU를 잡는다. τ3는 임계 구역 S2에 진입하며, 이 시점에서 시스템 실링은 S2의 우선순위 실링인 P(τ1)이 된다. 실행 도중, τ2가 준비되어서 τ3의 CPU를 선점(Preemption)하고, 임계구역 S1에 들어가려고 하지만 τ2의 우선순위 P(τ2)는 시스템 실링인 P(τ1)보다 높지 않으므로 블록된다. 이 때 τ2의 우선순위를 τ3가 상속 받게 된다.
τ2가 블록됨에 따라 다시 τ3가 CPU를 잡는다. CPU 사용 도중 τ1이 준비되어서 τ1이 τ3의 CPU를 선점하게 된다. τ1 또한 임계구역 S1에 들어가려고 하지만, 마찬가지로 본인의 우선순위인 P(τ1)이 시스템 실링인 P(τ1)보다 높지 않으므로 블로킹된다. 이 때 τ3는 τ1의 우선순위를 상속받는다. 결국 τ1, τ2는 블록되며 τ3가 다시 CPU를 잡게 된다.
τ3가 임계 구역 실행을 마치고 S2에서 빠져나온다. S2에서 빠져나오면 블로킹된 Task중에 가장 높은 우선순위의 Task를 깨우므로, τ1이 깨어나게 된다. 이제 시스템 실링이 없으므로, τ1은 즉시 자신이 원하는 임계 구역인 S1에 진입하며, 그 이후 S2에도 진입한다.그 다음 S2에서 빠져나오고, S1에서도 빠져나온다. 임계 구역을 빠져나오고 CPU도 다 사용하고 나면 τ1은 CPU를 놓고 τ2가 CPU를 잡는다.
τ2 역시 아까 임계 구역에 들어가지 못했으므로 CPU를 잡자마자 임계구역 S1에 진입했다가 나오며, 실행을 마치면 CPU를 놓는다.
마지막으로 τ3가 CPU를 잡고 실행을 마친다.
PIP에서 보았던 것과는 다르게, 두 개의 낮은 우선순위의 작업이 자원을 점유하는 상황 속에서도 가장 높은 우선순위를 갖는 τ1은 여러 번의 블록을 겪지 않고, 단 한번의 블록만을 겪었다.
PCP는 실링 규칙과 상속 규칙을 통해 불필요한 블로킹을 막고, 일단 블로킹이 발생하면 원인을 제공한 작업의 우선순위를 높여 신속히 해결해내는 모습을 보여주었다.