쓰레드

mydailylogs
|2022. 12. 7. 02:29

동시성 논리 흐름을 생성하는 대표적인 방법으로 크게 3 가지가 존재한다.

먼저, 각각의 흐름에 대해 별도의 프로세스를 생성하는 프로세스 기반 동시성 프로그래밍이 있다. 커널은 각각의 프로세스를 자동으로 스케쥴링하여 각 프로세스는 자신만의 고유한 사적 주소공간을 가지며 이로 인해 흐름들이 데이터를 공유하기가 어려워진다.

두 번째 방법으로 우리 자신의 논리흐름을 생성하고 명시적으로 이 흐름들을 스케쥴링하기 위해 I/O 다중화를 이용한다. 이 경우 단 하나의 프로세스만이 존재하기 때문에 흐름들은 전체 주소공간을 공유한다.

해당 글에서는 동시성 프로그래밍을 수행하기 위한 세 번째 방법인 쓰레드라는 개념을 소개하고자 한다.


 쓰레드는 프로세스의 컨텍스트 내에서 돌아가는 논리 흐름이다. 앞서 프로세스를 설명하는 글에서는 자연스럽게 프로세스마다 하나의 쓰레드를 가정하였다. 그러나 현대 시스템은 다수의 쓰레드가 하나의 프로세스에서 동시에 돌아가는 프로그램을 작성할 수 있도록 해준다. 쓰레드는 프로세스와 마찬가지로 커널에 의해서 자동으로 스케쥴되며, 각 쓰레드는 고유의 정수 쓰레드 ID(TID)를 갖는다.

또한 별도의 스택, 스택 포인터, 프로그램 카운터, 범용 레지스터, 조건 코드를 포함하는 자신만의 고유한 쓰레드 컨텍스트를 갖는다. 다만 기억해야 하는 점은 한 개의 프로세스 내에 돌고 있는 모든 쓰레드는 해당 프로세스의 전체 가상 주소를 공유한다는 것이다.

쓰레드의 기초한 논리흐름은 프로세스와 I/O 다중화에 기초한 흐름의 품질을 결합해준다. 프로세스와 같이, 쓰레드는 커널에 의해 자동으로 스케쥴되고 커널에 정수 ID로 알려진다. I/O 다중화에 기초한 흐름에서와 같이 다수의 쓰레드는 한 개의 프로세스의 컨텍스트에서 돌아가며 그러므로 이 프로세스 가상 주소 공간의 전체 내용을 공유한다.

이때 프로세스의 가상 주소 공간에는, 코드, 데이터, , 공유 라이브러리, 파일 디스크립터 테이블 등이 포함된다.


쓰레드 실행 모델

다중 쓰레드에 대한 실행 모델은 다중 프로세스를 위한 실행 모델과 어떤 면에서는 비슷하다. 

위의 그림에서 각 프로세스는 메인 쓰레드라고 부르는 한 개의 쓰레드로 실행을 시작한다. 어떤 시점에서 메인 쓰레드는 피어(Peer) 쓰레드를 생성하고 이때부터 두 쓰레드가 동시에 돌아간다. 결국 제어는 컨텍스트 스위치를 거쳐 피어 쓰레드로 전달되며, 그 이유는 메인 쓰레드가 read나 sleep과 같은 느린 시스템 콜을 실행했거나, 시스템의 인터벌 타이머에 의해 중단되었기 때문이다. 피어 쓰레드는 제어를 다시 메인 쓰레드로 돌려주기 전에 잠시 실행되는 식으로 실행이 진행된다.

쓰레드의 실행은 일부 중요한 부분에서 프로세스와 다른다. 쓰레드 컨텍스트가 프로세스 컨텍스트보다 훨씬 더 작기 때문에, 쓰레드의 컨텍스트 스위치는 프로세스의 컨텍스트 스위치보다 훨씬 빠르다.

또 다른 차이점은 쓰레드가 프로세스와 달리 엄격한 상위-하위 계층 구조로 구성되지 않는다는 것이다. 프로세스와 연결된 쓰레드는 피어 풀을 형성하며, 누가 해당 쓰레드를 생성하였다는 것을 구분하지 않는다. 즉, 프로세스에서 엄격하게 부모와 자식 프로세스를 나눴던 것과 달리 쓰레드에서는 이러한 부모-자식 계층 구조가 없다. 단지, 주 쓰레드는 프로세스에서 항상 처음 실행되는 쓰레드라는 점에서만 다른 스레드와 구별될 뿐이다. 이러한 피어 풀 개념을 굳이 사용하는 이유는 쓰레드가 피어를 죽이거나 피어가 종료될 때까지 기다릴 수 있다는 점에 있다. 또한 하나의 프로세스에 연결된 피어들은 프로세스의 가상 주소 공간 전체를 공유하게 된다.


Posix 쓰레드

posix 쓰레드(pthread)는 C 프로그램에서 쓰레드를 조작하는  표준 인터페이스이다. 이것은 1995년 채택되어 대부분의 Unix 시스템이 이를 공유하는 구조를 갖고 있다. 

아래 프로그램은 간단한 Pthread 프로그램을 보여준다. Pthread는 데이터를 피어쓰레드와 안전하게 공유하기 위해서, 시스템의 상태 변화를 피어들에게 알리기 위해서 프로그램이 쓰레드를 생성하고, 죽이고, 청소하도록 하는 약 60개의 함수를 정의한다.

#include <pthread.h>
#incldue <stdio.h>
#include <stdlib.h>

void *thread(void *vargp);

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread, NULL);
    pthread_join(tid, NULL);
    exit(0);
}

void thread(void *vargp)
{
    printf("Hello, world!\n");
}

 

프로그램 실행 자체는 간단하다. 해당 프로그램은 먼저, thread라는 쓰레드 루틴을 가지고 쓰레드를 생성한다(pthread_create). 각 쓰레드 루틴의 실행이 완료가 되면 pthread_join을 가지고 자원을 회수하게 된다.

다만 함수에 익숙하지 않다면 이러한 쓰레드의 동작이 낯설 수 있다.

함수를 소개하자면 다음과 같다.

pthread_create: 쓰레드를 생성하는 함수

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

pthread_create 함수는 새 쓰레드를 만들고 쓰레드 루틴 start_routine을 새 쓰레드의 컨텍스트 내에서 수행한다. 이때의 입력인자는 arg로 전달된다.

attr 인자는 새롭게 만들어진 쓰레드의 기본 성질을 바뀌기 위해 사용될 수 있다. 

 

pthread_exit: 쓰레드 명시적으로 종료

#include <pthread.h>

void pthread_exit(void *thread_return);

만일 메인 쓰레드가 pthread_exit을 호출하면 다른 모든 쓰레드가 종료하기를 기다리고 그 후에 메인 쓰레드가 전체 프로세스를 thread_return 값으로 종료한다.

일부 피어 쓰레드는 Unix exit 함수를 호출하여 프로세스와 해당 프로세스와 관련된 쓰레드를 모두 종료한다.

다른 피어 쓰레드는 pthread_cancel 함수를 현재 쓰레드의 ID로 호출하여 현재 쓰레드를 종료한다.

pthread_cancel: 자기 자신 종료

#include <pthread.h>

int pthread_cancel(pthread_t tid);

 

pthread_join: 종료한 쓰레드 청소

#include <pthread.h>

int pthread_join(pthread_t tid, void **status);

쓰레드는 pthread_join 함수를 통해 다른 쓰레드가 종료하기를 기다릴 수 있다. 

 

pthread_detach: 쓰레드 분리하기

#include <pthread.h>

int pthread_detach(pthread_t tid);

언제나 쓰레드는 연결가능하거나 분리되어 있다. 연결가능한 쓰레드는 다른 쓰레드에 의해 청소되고 종료될 수 있으며 자신의 메모리 자원(스택 등)은 다른 쓰레드에 의해 청소될 때까지는 반환되지 않는다. 반대로 분리된 쓰레드는 다른 쓰레드에 의해 청소되거나 종료될 수 없다. 자신의 메모리 자원들은 이 쓰레드가 종료할 때 시스템에 의해 자동으로 반환된다.

예를 들어 고성능 웹 서버는 웹 브라우저로부터 연결 요청을 수락할 때마다 새로운 피어 쓰레드를 생성할 수 있다. 각각의 연결이 별도의 쓰레드에 의해서 독립적으로 처리되기에 서버가 명시적으로 각각의 피어 쓰레드가 종료하기를 기다리는 것은 불필요하다. 

이 경우에는 대신 각 피어 쓰레드는 요청을 처리하기 전에 자신을 메인 쓰레드가 속한 피어풀에서 분리하여 자신의 메모리 자원들이 종료 이후에 자동으로 반환될 수 있도록 한다. 


쓰레드의 메모리 모델

동시성 쓰레드 풀은 한 개의 프로세스 컨텍스트에서 돌아간다. 각각의 쓰레드는 자신만의 별도의 쓰레드 컨텍스트를 가지며, 여기에는 쓰레드 ID, 스택, 스택 포인터, 프로그램 카운터, 조건 코드, 범용 레지스터 값들이 포함된다. 각 쓰레드는 나머지 프로세스 컨텍스트를 다른 쓰레드와 공유한다. 여기에는 해당 프로세스의 가상 주소공간이 포함되며, 앞서 설명하였듯 .code 와 .data, .bss, 힙, 공유 라이브러리 코드 등이 여기에 해당한다. 마지막으로 단일 프로세스 내의 쓰레드는 동일한 파일 디스크립터 테이블을 공유한다.

동작적인 측면에서 하나의 쓰레드가 다른 쓰레드의 레지스터를 읽거나 쓰는 것은 불가능하지만 모든 쓰레드는 공유 가상메모리 내의 모든 위치에 접근할 수 있다. 만일 어떤 쓰레드가 메모리의 위치를 수정하면 그 위치를 읽는 다른 모든 쓰레드는 이 변경 사항을 알 수 있다. 그래서 레지스터들은 공유되지 않지만, 가상 메모리는 항상 공유된다.

이는 장점이자 단점으로, 앞서 프로세스가 데이터를 공유하기 위해서 IPC를 거쳐 오버헤드가 발생했던 점을 생각하면 장점으로 작용하지만,  동시에 여러 프로세스가 별다른 규칙 없이 공유 자원에 접근할 수 있다는 점에서 여러 문제를 가져오게 되며 Synchronization 에 대한 필요성을 낳게 된다.

결론

쓰레드는 프로세스의 단점을 보완하는 한편, 고유한 단점도 존재한다. 쓰레드를 너무 크게 쪼개면 수행할 동시성 작업이나 병렬 처리의 장점이 잘 드러나지 않고, 쓰레드를 너무 잘게 쪼개면 쓰레드 오버헤드가 늘어나게 되어 실행 속도가 느려질 뿐만 아니라 쓰레드가 차지할 메모리 공간의 총합이 너무 방대해진다는 문제가 있다. 때문에 쓰레드를 사용하기에 앞서 쓰레드의 단위를 어떻게 설정할지 또한 프로세스 대신 쓰레드를 사용해야 하는 이유에 대해서 심도있는 고민이 필요할 듯 보인다.

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

동기화 (2) 세마포어  (0) 2022.12.07
동기화 (1)  (0) 2022.12.07
프로세스를 이용한 동시성 프로그래밍  (0) 2022.12.07
프로세스 제어  (0) 2022.12.07
시스템 콜의 에러 처리  (0) 2022.12.07