본문 바로가기
C,C++/C++

[C++] std::thread 생성에 대해 알아보자

by woohyeon 2020. 6. 26.
반응형

개인적으로 공부하는 내용이므로 틀린 부분이 있을 수 있습니다. 있다면 알려주세요 :)

1. 스레드 생성하기 (Create)


C++에서의 스레드는 C++ 11에서 등장한 thread 클래스를 사용한다. 이전엔 각 OS에서 제공하는 스레드를 사용하였으며, 해당 스레드는 윈도우와 리눅스 모두 호환이 된다. 우선 스레드를 생성하기 위해선 헤더 파일을 인클루드 해주어야 한다. 그리고 다음과 같이 스레드를 생성한다.

Foo 함수를 실행하는 스레드 생성

#include <thread>

void Foo() {;}

void main()
{
    std::thread my_thread1(Foo);
}

PrintMessage 함수를 실행하는 스레드 생성

위 코드는 Foo() 함수를 실행하는 스레드를 1개 생성한 것이다. 만약 함수에 인자가 존재한다면 다음과 같이 생성한다.
(헤더는 생략)

void PrintMessage(const std::string& msg) { std::cout << msg << std::endl; }

void main()
{
    std::thread my_thread1(PrintMessage, "Hello, I'm a thread");
}

스레드의 인자로 람다 함수를 전달

함수가 해당 스레드를 생성하는 데 한 번만 사용된다면 다음과 같이 간단하게 람다를 사용할 수도 있다.

void main()
{
    std::thread my_thread1([](const std::string& msg) { std::cout << msg << std::endl; }, "Hello, I'm a thread");
}

스레드 생성자에 함수의 인자를 전달할 때 printf와 같이 가변 인자로 받으므로 걱정하지말고 함수의 인자만큼 넣어주면 된다. 이처럼 우리가 생성한 스레드들은 모두 메인 스레드의 자식 스레드들이다. 메인 스레드는 프로그램 실행 시 기본적으로 하나 생성되는 스레드를 말한다.

메인 스레드와 자식 스레드의 동시 수행

다음은 메인 스레드와 자식 스레드가 동시에 함수를 호출하는 예이다.

void PrintMessage(const std::string& msg) { std::cout << msg << std::endl; }

void main()
{
    std::thread my_thread1(PrintMessage, "Hello, I'm a child thread");
    PrintMessage("Hello, I'm a main thread");
}

위 코드의 동작 방식

image

메인 스레드는 프로그램을 진행하다가 자식 스레드를 새로 만들라는 명령을 받고 PrintMessage() 함수를 실행하는 자식 스레드를 생성한다. 생성하자마자 메인 스레드는 다음 작업을 계속 이어서 한다. 여기서 다음 작업은 PrintMessage() 함수를 실행하는 것이다. 자식 스레드는 만들어지는 순간 PrintMessage() 함수를 실행한다. 결과는 실행될 때마다 다를 수도 있다. 이는 스케줄러가 선택하는 스레드가 매번 다를 수 있기 때문이다.

2. 스레드 Join


위에서 스레드를 생성하고 간단하게 사용하는 법을 살펴보았다. 하지만 위처럼 단순히 스레드를 생성만하고 손을 놓으면 문제가 발생할 수 있다. 예를 들어 자식 스레드가 아직 실행 중인데 부모 스레드가 return 0;에 도달하여 프로그램이 종료되는 경우이다. 기본적으로 메인 스레드가 종료되고도 자식 스레드가 계속 실행 중인 것은 비정상적인 상황으로 본다. 때문에 이 경우, 강제로 오류를 발생시킨다. 만약 이것이 정말 프로그래머가 의도한 경우라면 잠시 후 볼 detach() 함수를 사용해야 한다.

어쨌든 이러한 경우를 위해 C++에선 join() 이라는 멤버 함수를 제공한다. 만약 my_thread1.join() 시 호출 스레드는 my_thread1 스레드가 실행이 끝날 때까지 멈추게 된다. 즉 my_thread1 스레드의 실행이 끝나면 실행을 재개한다. join이라는 의미는 부모 스레드에 의해 갈라진 자식 스레드가 실행이 종료되어 부모 스레드에 합류한다는 것으로 생각하면 이해하기 편하다. join()은 다음과 같이 사용한다.

join() 사용 예제

int main()
{
    thread th1([]() 
        { 
            this_thread::sleep_for(chrono::milliseconds(1000));
        });

    thread th2([]()
        {
            cout << "Hello from th2" << endl;
        });


    th1.join();
    cout << "Hello from main" << endl;
    th2.join();

    return 0;
}

위 코드의 실행 흐름은 다음과 같다. 우선 메인 스레드는 th1, th2 스레드를 순서대로 실행한다. 그리고 th1과 th2는 스케줄러에 의해 선택되는대로 각각 실행될 것이고 메인 스레드는 계속해서 자기 갈 길을 갈 것이다. 그럼 th1.join() 함수를 만나게 되고 메인 스레드는 blocking 상태가 된다. 블로킹 상태란 실행이 중단되고, 현재 스레드 대신 다른 스레드가 스케줄링될 수 있는 상태라고 생각해두자. th1 스레드의 실행이 종료되면 메인 스레드는 계속해서 다음 코드를 실행하게 된다. 참고로 this_thread는 현재 실행 중인 스레드를 의미하고 sleep_for는 지정된 시간동안 sleep 상태에 들어가는 것을 말한다.

joinable()

image

join() 함수의 내부를 살펴보면 3개의 조건문과 1개의 대입문이 존재한다. joinable() 이라는 함수는 join이 가능한지의 여부를 반환한다. 이 판단은 스레드의 ID를 통해서 한다. 생성된 스레드는 고유한 id를 가지게 되는데, 이는 실행이 완료될 때까지 유효하다. 실행이 종료되면 id는 0이 된다. 하지만 자동으로 0이 되는 것은 아니다. join() 또는 detach()에 의해 0이 된다. join이 완료될 시점에 _Thr = {}; 이라는 코드를 실행하는 걸 볼 수 있다. 이는 _Thr을 0과 같은 값으로 만들겠다는 의미이다. _Thr은 스레드 핸들과 스레드 ID 값으로 구성되어 있다. 결국 두 값은 0이 되어 활성화된 스레드가 아니라는 것을 나타낸다. detach()라는 함수에도 이와 같은 코드가 존재한다.

그리고 스레드의 소멸자엔 joinable이 true일 경우 에러를 발생시키는 코드가 있다. 즉 id가 0이 아닐 경우 소멸자가 불리면 에러가 발생한다. 이는 결국 join() 또는 detach()를 통해 스레드 id를 0으로 만든 뒤에 종료시키라는 뜻이다.

아래 사진과 같이 joinable()은 id가 0이 아닐 경우 join이 가능한 상태로 보고 true를 반환한다. 위에서 봤듯이 join이 불가능할 때 join을 쓰면 에러를 던지기 때문에 joinable()을 활용하여 join() 함수를 사용할 수 있다. 참고로 스레드의 id는 get_id()라는 함수를 사용할 수 있다.

image

 

3. 스레드 Detach


자식 스레드가 끝나기 전에 부모 스레드가 끝나는 것이 의도라면 detach() 함수를 사용해야 한다.

th1.detach();

위처럼 작성하면 부모 스레드는 자식 스레드의 실행 또는 종료를 신경쓰지 않고 계속해서 진행해 나간다. 즉 자식 스레드가 끝나기 전에 부모 스레드가 먼저 끝날 수 있다. 또한 자식 스레드 또한 부모 스레드가 끝나도 계속해서 자기 일을 진행한다. 하지만 종료된 부모 스레드가 메인 스레드라면 프로그램 자체가 종료되는 것이기 때문에 자식 스레드도 종료된다.

detach() 함수도 내부적으로 스레드의 id를 0으로 만든다. 따라서 스레드의 소멸자가 불려도 오류가 발생하지 않는다.

image

 

다음 포스팅은 mutex와 condition_variable에 대해 알아본다.




댓글