실행 흐름을 만들어내는 프로세스 vs 쓰레드

CPU는 하나이지만 여러 프로세스가 동시에 실행된다는 착각이 들게 해주는 것이 바로 병행성이다.

프로세스가 문맥 교환을 할 때 프로세스의 상태를 PCB 에 저장하여 자신의 상태를 보존함으로써 CPU가 여러 프로세스를 왔다갔다 하면서 재빠르게 모두 처리해줄 수 있다.

이러한 프로세스들은 메인 함수가 호출되면서 시작되는데, 이는 메인함수를 실행하는 쓰레드가 형성되어서 메인 쓰레드가 시작된다고 할 수 있다.

쓰레드는 프로세스와 개념적으로 매우 유사하지만, 쓰레드는 프로세스 안에서의 개별적인 실행 흐름을 의미한다는 점에서 다르다.

프로세스가 실행 흐름을 만들어내기 위해 실행코드들을 디스크에서 읽어오는 반면, 쓰레드로 실행흐름을 만들어낸다는 것은 프로세스 안에서 특정 함수부터 실행을 시작할 흐름(쓰레드)을 만들어낸다는 뜻이다.

기본적으로 어떤 함수를 호출해서 작업을 하기 위해서는, 해당 함수에서 사용하는 지역변수, 인자로 받는 매개변수들, 반환받을 주소 등을 위한 공간이 필요하다.

따라서 이런 값들을 스택을 통해서 공급해주고, 함수 호출이 종료되는 시점에 해당 영역을 반환하기 때문에, 함수는 스택과 밀접한 관련이 있을 수 밖에 없다.

그렇기 때문에 실행 흐름(쓰레드)이라고 하면 자연스럽게 떠올려야 하는 내용은 스택이 되는 것이며, 하나의 쓰레드를 위해 반드시 필요한 것이 별도의 스택 영역이다.

프로세스는 각자 독립적인 가상 주소 공간, 파일 디스크립터, PCB 등을 갖지만, 쓰레드는 프로세스 안에서의 실행 흐름이기 때문에 쓰레드마다 이러한 것들을 유지하지 않는다.

대신에 각 쓰레드마다 독립된 스택 영역을 가지며, 프로세스의 데이터나 힙, 코드 영역, 파일 디스크립터들은 모든 쓰레드들이 다같이 공유한다.

결국 멀티쓰레드, 쓰레드가 여러개 있는 환경이라는 것은 같은 프로세스의 주소 공간 위에서 스택 영역이 쓰레드마다 따로 존재한다는 것이 특징이다.

즉 하나의 프로세스 안에서 여러 쓰레드를 만들어내면, 이 쓰레드들은 모두 동일한 프로세스의 가상 주소 공간과 주소 변환 정보 등을 참조하는 것이다.

그렇다면 멀티쓰레드 환경에서 병행성의 문제는 어떤 식으로 해결할까?

CPU는 하나인데 여러 프로세스를 실행하기 위해 PCB 를 유지해서 상태를 보존하는 방식과 마찬가지로, 여러 쓰레드를 실행시키기 위해 쓰레드마다의 TCB 를 유지한다.

그렇기 때문에 애초에 여러 프로세스를 띄우는 멀티 프로세스 방식이 아닌, 하나의 프로세스 안에서 여러가지 실행 흐름을 만드는 멀티 쓰레드 방식으로 구현할 수도 있다.

멀티 쓰레드의 경우에는 프로세스와는 다르게 주소 공간을 공유하기 때문에 공유되는 자원에 접근할 수 있다는 점에서 다르다.


아래 코드는 쓰레드를 생성하는 간단한 예시 프로그램이다.

#include <stdio.h>
#include <assert.h>
#include <pthread.h>

void* thread(void* arg)
{
    printf("%s\n", (char*) arg);
    return NULL;
}

int main(int argc, char* argv[])
{
    pthread_t t1, t2;
    int rc;
    printf("Thread main BEGIN\n");
    pthread_create(&t1, NULL, thread, "Thread 1");
    pthread_create(&t2, NULL, thread, "Thread 2");
    pthread_join(t2, NULL);
    pthread_join(t1, NULL);
    printf("Thread main END\n");
    return 0;
}

우선 프로세스를 실행하면 메인함수가 호출되며 프로그램이 시작되는데, 메인 함수 역시 argc, argv 등을 파라미터로 받고 지역변수 p1, p2, rc 등을 사용하는 일종의 쓰레드이다.

pthread_create 으로 쓰레드를 하나 생성할 수 있는데, 프로세스에서의 pid 처럼 쓰레드에도 식별자가 존재하기 때문에 이 값을 가져올 수 있다.

pthread_create 의 첫번째 매개변수로는 tid를 세팅받을 변수의 주소를 넘겨주고, 함수 이름(코드의 주소)을 넘겨줘서 쓰레드의 시작 지점을 지정해주며, 해당 함수에 넣어줄 파라미터도 공급해줄 수 있다.

이렇게 쓰레드를 만들면 해당 프로세스 내에서 새로운 실행 흐름이 생성되고 실행되는데, 이는 실행시킬 함수를 위한 환경(스택 등)이 구성된다는 뜻이다.

같은 방식으로 쓰레드를 2개 만들면, cpu는 메인쓰레드를 계속 실행하다가 pthread_join 을 호출함으로써 특정 쓰레드의 종료까지 대기한다.

2번째 쓰레드에 조인을 호출할 경우 해당 쓰레드가 끝날때까지 대기하기 때문에, 메인쓰레드는 블록 상태가 되며 T2 쓰레드가 cpu를 점유하여 명령어들이 수행된다.

T2 종료시에는 다시 메인이 실행되어 T1에 대한 조인을 수행한 뒤 다시 블록되어 T1 쓰레드 실행되는데, 사실 이런 쓰레드 실행 순서가 결정적인 것은 아니다.

쓰레드는 만들어지면 바로 실행될 수 있기 때문에, 애초에 메인에서 join 날리기 이전에 해당 쓰레드가 스케줄링 되면 cpu를 점유할 수 있다.

코드만 봤을 때는 T2 - T1 순서대로 실행될 것 같지만, 실제로는 T1 쓰레드가 먼저 만들어짐과 동시에 스케줄링 되어 실행된 뒤, T2 쓰레드가 만들어지고 조인될 때 수행되어 T1 - T2 순서대로 나올 수 있다.

bconfiden2@h01:~$ ./thread.out
Thread main BEGIN
Thread 2
Thread 1
Thread main END
bconfiden2@h01:~$ ./thread.out
Thread main BEGIN
Thread 1
Thread 2
Thread main END