Sqix

CS - OS - 06. Thread 본문

CS/OS

CS - OS - 06. Thread

Sqix_ow 2021. 11. 23. 03:04

쓰레드(Thread)

프로세스는 실행 중인 프로그램으로, 운영체제로부터 자원을 할당받는 작업의 단위로서 디스크로부터 메모리에 올라가 CPU 자원을 할당받는 것을 말한다. 쓰레드는 프로세스의 실행 단위로, 하나의 프로세스 내에서 여러 개의 실행 흐름을 가진 쓰레드가 존재하며 프로세스의 자원이나 데이터(주소 공간)를 공유받는다.

 

각각의 쓰레드는 독립된 작업 수행을 위해 스택을 별도로 가지고 있는데, 이는 각각의 쓰레드가 독립적인 작업 수행을 하기 위해 독립적인 함수 호출이 필요하고, 이를 위해서는 독립적으로 사용할 수 있는 스택이 필요하기 때문이다.

 

또한 쓰레드는 마찬가지로 프로그램 카운터 레지스터를 독립적으로 가지고 있는데, 스케줄러에 의해서 CPU 할당을 받고 이후 특정 조건에 의해 선점당하게 되면 추후 실행 시 실행 흐름을 기록한 레지스터로부터 불러와야 하기 때문이다.

 

  • 쓰레드는 프로세스 실행의 가장 작은 단위로, 하나의 프로세스는 여러 개의 쓰레드로 구성이 가능하다.
  • 쓰레드는 자신만의 스택과 레지스터를 가지며, 프로세스와 동일한 실행 상태를 갖고 이에 따른 문맥 교환을 한다.
  • 하나의 프로세스를 구성하는 여러 쓰레드들은 프로세스에 할당된 메모리를 공유하고 프로세스 내의 데이터에 모두 접근이 가능하다.

 

쓰레드의 장점

  • 동시에 사용자에 대한 응답과 데이터에 대한 처리가 가능하여 사용자 응답성이 향상된다.
  • 프로세스의 데이터를 모두 접근이 가능하므로 자원 공유에 대한 작업(IPC)이 필요없어 자원 효율이 향상된다.
  • 프로세스에 비해 생성 / 종료되는 시간과 쓰레드 간 전환 시간이 짧다.

쓰레드의 단점

  • 하나의 쓰레드에만 문제가 있어도 전체 프로세스가 영향을 받는다.
  • 쓰레드를 많이 생성하면 스케줄링이 많이 일어나고 이로 인해 문맥 교환이 많이 일어나 성능이 저하된다.
  • 자원의 공유가 일어나므로 동기화 방식을 적용해야 한다.

동기화를 적용하는 이유에 대한 예를 데이터 레이스(Data Race)로 들어보자. 만약 우리가 2개의 쓰레드에서 특정 변수에 1을 더하는 연산을 한다고 가정해본다.

 

global_count = 0;

void *p_function(void *data)
{
...
    for(i = 0 ; i < 50 ; i++)
        global_count = global_count + 1;
...
}

int main(void)
{
...
    pthread_t pthread[2];
    int thr_id;
    int status;
... 
    thr_id = pthread_create(&pthread[0], NULL, p_function(), NULL);
    thr_id = pthread_create(&pthread[1], NULL, p_function(), NULL);
... 
    pthread_join(pthread[0], (void**) &status);
    pthread_join(pthread[1], (void**) &status);
}

 

물론 50회를 더하는 C 프로그램은 컨텍스트 스위칭이 일어나기 이전에 연산이 끝나겠지만, 만약 더하는 연산 과정에서 컨텍스트 스위칭이 일어난다고 가정해보자.

 

값을 더하는 연산은 위와 같이 레지스터에 특정 변수를 읽어오고, 가산 연산을 한 뒤, 다시 그 값을 저장한다. 이 때, 만약 두 쓰레드 모두 호출 - 가산까지 정상 실행된 뒤 문맥 교환이 일어나고 다시 저장되는 연산을 한다고 가정해보자.

 

그렇게 되면 global 변수에 저장이 되지 않은채로 Thread 1에서 가산 연산이 마무리되었기 때문에 Thread 1에는 현재 global_count보다 1 더해진 값이 레지스터에 들어가있다. 이후 Thread 2에서 변수를 호출하는 과정에서 기존 Thread 1에서 연산해 놓았던 변수가 덮어씌워지고, 문맥 교환 이후 Thread 2의 연산결과인 global_count + 1이 레지스터에 남아있다.

 

기존 실행흐름대로 Thread 1에서는 레지스터에 남아있는 값을 저장할 것이고, Thread 2로 문맥 교환이 일어난 뒤에도 마찬가지다. 그렇다면 여기서 각 쓰레드는 1을 더하고 저장을 하는 연산을 수행했지만, 한번의 덮어씌움으로 인해 사용자에게는 연산 한번이 수행되지 않은 것처럼 보여진다.

 

동기화 이슈와 이에 대한 해결 방안 - 뮤텍스와 세마포어

 

위와 같이 개별적인 쓰레드가 동일한 자원에 접근해서 동시에 수정할 시 각 쓰레드의 결과에 영향을 주는 동기화 이슈가 발생한다. 이를 해결하기 위한 방안으로 변수의 값을 변경하는 부분에 동기화 락을 걸어둘 수 있다.

※ 다만, 이러한 락을 거는 방식은 코드의 동작은 보장되나 해당 라인을 수행할 시 락 호출에 대한 오버헤드가 매우 커 성능 측면에서 손해를 볼 수 있다.

 

임계 영역에 대한 접근을 막기 위한 락을 거는 메커니즘에는 크게 2가지가 있다.

  • 뮤텍스(Binary Semaphore) : 임계구역에 하나의 쓰레드만 들어갈 수 있다.
  • 세마포어 : 임계구역에 여러 쓰레드가 들어갈 수 있으며, 카운터를 두어 동시에 들어가는 쓰레드 수를 제한한다.

세마포어의 주요 명령으로는 P 연산, V 연산이 있으며 다음과 같다.

 

  • S - 세마포어 값 (초기 S값만큼 임계 영역에 접근 가능함)
  • P 연산 : 세마포어의 값을 S라고 할 때 S = S - 1을 수행한다. 여기서 S - 1 > 0이면 쓰레드 작업을 계속 실행하고
    S < 0이면 프로세스를 대기 상태로 전환시킨다. (lock)
P(S): wait(S)
{
    while S <= 0 // 임계 영역에 들어갈 수 있는 수치가 없는 경우 루프를 돌며 대기(바쁜 대기)
    ;
    ...
    S--; // 임계 영역에 들어갈 수 있는 수치 - 1
}
  • V 연산 : S = S + 1을 수행한다. 만약 S > 0이면 쓰레드 작업을 계속 실행하고 S < 0이면 대기 상태 프로세스를 깨운 뒤 현재 프로세스를 계속 실행한다. (unlock)
V(S) : signal(S)
{
    S++; // 해당 쓰레드가 Unlock이 되었으므로 임계 영역에 하나 더 들어갈 수 있도록 값 +1
}

P 연산에서 Busy Waiting(바쁜 대기)을 하게 되면 대기 상태에서도 loop를 돌며 CPU의 점유율을 소모하기 때문에 이에 대한 보완책으로 대기 큐 기법을 사용한다. 그 방식은 다음과 같다.

P(S) : wait(S)
{
    S -> count --;
    if (S -> count < 0)
    {
        add this process to S->queue;
        block();
    }
}
V(S) : Signal(S)
{
    S -> count++;
    if (S->count < 0)
    {
        remove a process P from S->queue;
        wakeup(P);
    }
}

이전 P 연산 구현과 다르게 S->count를 먼저 1 감소시키고, 만약 이 때 S->count가 음수가 되면 현재 쓰레드를 Block시켜 대기하게 만들고, 이후 접근 가능 쓰레드 수가 증가하게 되면 두 가지 상황이 발생한다.

  • signal(s)로 인해 S->count가 0이 되는 경우
    • 이는 대기하던 쓰레드가 없다는 것을 의미하므로, wait(s)를 호출하더라도 signal(s)에서 따로 wakeup이 필요없이 진행 가능
  • signal(s)로 인해 S->count가 증가해도 음수인 경우
    • 대기 중인 쓰레드가 있었으므로 wait(s)를 호출하더라도 block이 되기 때문에 signal(s)에서 wakeup을 통해 기다리던 쓰레드를 깨워 작업을 진행

 

POSIX 세마포어를 사용하기 위해 호출하는 라이브러리는 다음과 같다.

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

sem_t semaphore;

semaphore.h 안의 sem_t 구조체 구현은 다음과 같다.

typedef struct {
     struct _pthread_fastlock    __sem_lock;
     int             __sem_value;
     _sem_lock_t         __sem_waiting;
 } sem_t;

POSIX 세마포어에는 2가지 종류의 세마포어가 있는데, Named 세마포어와 Unnamed 세마포어가 그것이다. 주요 세마포어 함수들은 다음과 같다. 

먼저 Named 세마포어 생성을 위한 함수이다.

sem_t* sem_open(const char *name, int oflag, /*optional*/ mode_t mode , /*optional*/ unsigned int value);
  • sem_open() 함수로, named 세마포어에 대한 생성을 담당한다.
    • *name : 세마포어의 이름
    • oflags : O_CREAT(세마포어가 존재하지 않는 경우 생성) / O_EXCL(세마포어가 존재하지 않는 경우 생성하며 이미 존재하는 경우 오류 메세지를 내뿜음) 중 1개 혹은 OR 연산을 통한 O_CREAT | O_EXCL이 들어간다.
    • mode : 새로운 세마포어에 대한 허가권을 제어한다.
    • value : 초기화될 때 세마포어가 가지는 값이다.
int sem_unlink(const char *name);
  • sem_unlink() 함수로, 해당하는 name의 세마포어를 삭제하는 역할을 담당한다.
  • 다만, 프로세스에서 이미 참조되고 있는 세마포어인 경우 sem_close() 혹은 exit()되기 전까지 삭제를 유예한다.

Unnamed 세마포어 생성을 위해서는 다음 2개의 동작이 필요하다.

sem_t sem_id;
int sem_init(sem_t *sem_id, int pshared, unsigned int value);
  • sem_init() 함수로, Unnamed 세마포어에 대한 생성을 담당한다.
    • pshared : 임의의 프로세스의 쓰레드들 사이 혹은 프로세스들 사이에서 세마포어가 공유되는지를 표시한다. pshared가 0이라면 쓰레드들 간 공유되는 세마포어이고, 0이 아니라면 프로세스들 사이에서 공유된다.
    • value : 초기화될 때 세마포어가 가지는 값이다.

다음은 세마포어의 P 연산에 해당하는 함수이다.

sem_wait(sem_t *sem);
sem_trywait(sem_t *sem);
sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
  • sem_wait() 함수는 세마포어 값이 0보다 작으면 세마포어를 대기시킨다.
  • sem_trywait() 함수는 세마포어의 값이 0보다 작으면 자원 획득 실패 오류를 뱉으며 빠져나온다.
  • sem_timewait()은 abs_timeout 이내로 자원을 획득하지 못하면 획득 실패 오류를 뱉으며 빠져나온다.

다음은 세마포어의 V 연산에 해당하는 함수이다.

sem_post(sem_t *sem);
  • 해당 함수는 세마포어에 대한 반환을 수행한다.

세마포어를 전부 사용한 경우, Named 세마포어는

sem_close(sem_t *sem);

함수를 통해서,

 

Unnamed 세마포어는

sem_destroy(sem_t *sem);

함수를 통해서 종료한다.

 

멀티 쓰레딩에서 발생하는 문제 : Deadlock(교착 상태)과 Starvation(기아 상태)

 

Deadlock 

 

무한 대기 상태로 두 개의 작업이 서로 상대방의 자원이 해제되길 기다리고 있어 다음 단계로 진행하지 못하는 상태.
쓰레드와 프로세스 모두 이러한 상태가 발생될 수 있다. 이러한 상태를 데드락이라고 한다.

교착 상태의 발생조건은 다음 조건을 모두 충족시켜야 한다.

  • 상호 배제 : 프로세스들이 필요로 하는 자원에 대한 배타적인 통제권 요구
  • 점유 대기 : 프로세스가 할당된 자원을 가진 상태에서 다른 자원에 대한 대기
  • 비선점 : 프로세스가 어떤 자원의 사용이 끝날 때 까지 그 자원을 뺏을 수 없다
  • 순환 대기 : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 것을 가질 수 있다.

데드락을 예방 혹은 회피하기 위해서는 위 4개의 상황 중 1개의 상황만 발생하지 않도록 하는 것으로 이를 해결할 수 있다. 다만, 위 조건을 방지하는 것으로 데드락을 예방하기 위해서는 시스템의 자원이 매우 많이 소모되므로 그 효율성이 떨어진다는 단점이 있다.

 

위처럼 예방 및 회피방법을 사용하지 않는다면 데드락에 대한 탐지와 회복을 하는 방식으로 데드락을 처리하는데, Allocation, Request, Available 등으로 데드락 여부를 탐색하고, 교착상태에 빠진 프로세스를 중단시키거나 자원을 선점하도록 하여 회복하는 방법을 이용한다.

 

Starvation

 

특정 프로세스의 우선순위가 너무 낮아 원하는 자원을 계속 할당받지 못하는 상태

  • 우선순위 변경 
    • 프로세스의 우선순위를 수시로 변경해서 우선순위를 높일 기회를 주기
    • 오래 기다린 프로세스의 우선순위 높여주기
    • 요청 순서대로 처리하는 fifo 기반 큐 사용

'CS > OS' 카테고리의 다른 글

CS - OS - 08. File System  (0) 2021.11.24
CS - OS - 07. 가상 메모리  (0) 2021.11.24
CS - OS - 05. Inter Process Communication(IPC)  (0) 2021.11.19
CS - OS - 04. 프로세스와 컨텍스트 스위칭  (0) 2021.11.19
CS - OS - 03. 인터럽트  (0) 2021.11.18
Comments