C++ 예제로 배우는 소프트웨어 설계 기법 (1) — 스레드 클래스(Thread Class)

pthread_create 같은 OS 스레드 API는 void* (*)(void*) 형태의 C 함수 포인터만 받는다. 스레드에서 객체의 멤버 함수를 돌리고 싶어도 그대로 넘길 수 없다. 그래서 C 콜백 API를 클래스로 감싸는 첫 단계가 스레드 클래스다. 이 글에서는 static 멤버 함수와 this 전달로 Thread 클래스를 만들고, 생성자·소멸자에서 스레드를 다룰 때 생기는 함정과 std::jthread로 옮겨가는 과정까지 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

멤버 함수는 스레드 함수가 될 수 없다

#include <pthread.h>
#include <cstdio>

class Downloader {
public:
    void* work(void*) { std::puts("download"); return nullptr; }
    void start()
    {
        pthread_t t;
        pthread_create(&t, nullptr, &Downloader::work, this);   // 멤버 함수를 그대로 넘기면?
        pthread_join(t, nullptr);
    }
};

int main() { Downloader d; d.start(); }
$ g++ -std=c++20 -c member_fp.cpp 2>&1 | grep -E "error" | head -2
member_fp.cpp:10:37: error: cannot convert ‘void* (Downloader::*)(void*)’ to ‘void* (*)(void*)’

비정적 멤버 함수는 숨은 this 인자를 받으므로 타입이 void* (Downloader::*)(void*)가 되어 일반 함수 포인터와 호환되지 않는다.

static 멤버 함수와 this 전달

#include <pthread.h>
#include <cstdio>
#include <unistd.h>

class Thread {
    pthread_t tid_{};
    bool started_ = false;

    static void* entry(void* p)                 // C API가 요구하는 시그니처
    {
        static_cast<Thread*>(p)->run();          // this를 되살려 가상 함수 호출
        return nullptr;
    }
public:
    virtual ~Thread() = default;
    void start()                                 // 고정된 흐름: 비가상
    {
        started_ = pthread_create(&tid_, nullptr, &Thread::entry, this) == 0;
    }
    void join() { if (started_) { pthread_join(tid_, nullptr); started_ = false; } }
protected:
    virtual void run() = 0;                      // 변하는 부분: 파생 클래스가 채운다
};

class Downloader : public Thread {
    const char* url_;
protected:
    void run() override
    {
        for (int i = 1; i <= 3; ++i) { std::printf("  [%s] %d/3\n", url_, i); usleep(10'000); }
    }
public:
    explicit Downloader(const char* u) : url_(u) {}
};

class Ticker : public Thread {
protected:
    void run() override { for (int i = 0; i < 2; ++i) { std::printf("  tick %d\n", i); usleep(15'000); } }
};

int main()
{
    Downloader d("a.zip");
    Ticker t;
    d.start();
    t.start();
    d.join();
    t.join();
    std::puts("main done");
}
$ g++ -std=c++20 -Wall -pthread -o t1 thread1.cpp
$ ./t1
  [a.zip] 1/3
  tick 0
  [a.zip] 2/3
  tick 1
  [a.zip] 3/3
main done

C API에는 static 멤버 함수 entry를, this는 void* 인자로 넘긴다. 스레드 생성 흐름은 기반 클래스가 고정하고 할 일만 파생 클래스가 run()으로 채우는 템플릿 메서드(Template Method) 패턴이다.

구성 요소역할
——
static entry(void*)C API와 C++ 객체를 잇는 다리
start()/join() (비가상)변하지 않는 스레드 생성·종료 흐름
virtual run() = 0스레드마다 달라지는 작업

생성자에서 스레드를 시작하면

#include <pthread.h>
#include <cstdio>
#include <unistd.h>

class Thread {
    pthread_t tid_{};
    static void* entry(void* p) { static_cast<Thread*>(p)->run(); return nullptr; }
public:
    Thread()
    {
        pthread_create(&tid_, nullptr, &Thread::entry, this);   // 생성자에서 바로 시작
        usleep(100'000);                         // 기반 클래스 생성자가 아직 끝나지 않은 상태를 재현
    }
    virtual ~Thread() { pthread_join(tid_, nullptr); }
protected:
    virtual void run() = 0;
};

class Worker : public Thread {
protected:
    void run() override { std::puts("worker run"); }
};

int main() { Worker w; }
$ g++ -std=c++20 -Wall -pthread -o cs ctor_start.cpp
$ ./cs; echo "exit=$?"
pure virtual method called
terminate called without an active exception
bash: line 1:  3350 Aborted                 (core dumped) ./cs
exit=134

기반 클래스 생성자가 실행되는 동안 vptr은 아직 Thread의 vtable을 가리킨다. 그 사이에 새 스레드가 run()을 부르면 순수 가상 함수가 호출되어 프로세스가 죽는다.

소멸자에서 join하면

#include <pthread.h>
#include <cstdio>
#include <unistd.h>

class Thread {
    pthread_t tid_{};
    static void* entry(void* p) { static_cast<Thread*>(p)->run(); return nullptr; }
public:
    void start() { pthread_create(&tid_, nullptr, &Thread::entry, this); }
    virtual ~Thread() { pthread_join(tid_, nullptr); }   // 소멸자에서 join하면 안전할까?
protected:
    virtual void run() = 0;
};

class Counter : public Thread {
    int n_ = 0;
protected:
    void run() override
    {
        usleep(50'000);                          // 객체가 소멸 중일 때 run()이 실행되도록
        std::fprintf(stderr, "count=%d\n", ++n_);
    }
public:
    ~Counter() { std::fprintf(stderr, "~Counter\n"); }
};

int main()
{
    Counter c;
    c.start();
}                                                // join 없이 범위를 벗어남
$ g++ -std=c++20 -Wall -pthread -o dj dtor_join.cpp
$ ./dj; echo "exit=$?"
~Counter
pure virtual method called
terminate called without an active exception
bash: line 1:  3487 Aborted                 (core dumped) ./dj
exit=134
$ for i in $(seq 20); do ./dj 2>&1 | grep -q "pure virtual" && echo "pure virtual" || echo ok; done | sort | uniq -c
      2 ok
     18 pure virtual

~Counter가 먼저 끝나고 ~Thread에서 join을 기다리는 동안 vptr은 다시 Thread로 돌아가 있다. 스레드가 늦게 출발하면 20번 중 18번처럼 순수 가상 함수 호출로 끝나고, 일찍 출발해도 이미 파괴된 n_을 건드린다.

상속 대신 실행할 일을 넘기기

#include <pthread.h>
#include <cstdio>
#include <functional>
#include <memory>

// 상속 대신 실행할 일을 객체로 받는 Thread
class Thread {
    pthread_t tid_{};
    std::function<void()> fn_;
    static void* entry(void* p) { static_cast<Thread*>(p)->fn_(); return nullptr; }
public:
    explicit Thread(std::function<void()> fn) : fn_(std::move(fn))
    {
        pthread_create(&tid_, nullptr, &Thread::entry, this);   // 실행할 일이 이미 준비됐으니 생성자에서 시작해도 안전
    }
    Thread(const Thread&) = delete;               // this를 넘겼으므로 복사·이동 금지
    Thread& operator=(const Thread&) = delete;
    ~Thread() { pthread_join(tid_, nullptr); }
};

struct Logger {
    void flush(int n) { std::printf("  flush %d lines\n", n); }
};

int main()
{
    Logger log;
    int total = 0;
    {
        Thread a([&] { total += 10; });                       // 람다
        Thread b(std::bind(&Logger::flush, &log, 42));        // 멤버 함수
    }                                                          // 소멸자에서 join
    std::printf("  total=%d\n", total);
}
$ g++ -std=c++20 -Wall -pthread -o cl callable.cpp
$ ./cl
  flush 42 lines
  total=10

실행할 일을 std::function으로 생성자에서 받으면 가상 함수가 없으므로 생성자에서 시작하고 소멸자에서 join해도 안전하다. this를 C API에 넘겼으므로 복사·이동은 막아야 한다.

std::jthread로 옮기기

#include <chrono>
#include <cstdio>
#include <thread>

class Poller {
    int polls_ = 0;
    std::jthread th_;                             // 마지막 멤버: 다른 멤버가 준비된 뒤 시작, 먼저 소멸
    void loop(std::stop_token st)
    {
        while (!st.stop_requested()) {
            ++polls_;
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
        std::printf("  stopped after %d polls\n", polls_);
    }
public:
    Poller() : th_([this](std::stop_token st) { loop(st); }) {}
};

int main()
{
    {
        Poller p;
        std::this_thread::sleep_for(std::chrono::milliseconds(55));
    }                                             // ~jthread: request_stop() 후 join()
    std::puts("  scope left");
}
$ g++ -std=c++20 -Wall -pthread -o jt jthread.cpp
$ ./jt
  stopped after 6 polls
  scope left

C++20 std::jthread는 지금까지 직접 만든 것(멤버 함수·람다 실행, 소멸 시 join)에 협조적 취소(stop_token)까지 제공한다. 멤버로 둘 때는 맨 마지막에 선언해야 다른 멤버가 초기화된 뒤 시작하고 가장 먼저 정리된다.

방식할 일 지정생성자에서 시작소멸 시 정리취소
—————
상속 + virtual run()파생 클래스불가(순수 가상 호출)파생 클래스에서 직접 join직접 구현
콜러블 전달std::function/람다가능소멸자 join직접 구현
std::jthread콜러블가능자동 request_stop + joinstop_token

주의사항

상황문제대응
———
기반 생성자에서 스레드 시작순수 가상 함수 호출start()를 분리하거나 콜러블 방식 사용
기반 소멸자에서만 join파생 부분 파괴 후 run() 실행파생 객체가 살아있을 때 join() 호출
Thread 객체 복사·이동스레드가 옛 this를 사용복사·이동 연산 삭제
std::jthread를 앞쪽 멤버로 선언뒤 멤버 초기화 전에 스레드가 접근맨 마지막 멤버로 선언
join 없이 std::thread 소멸std::terminate 호출std::jthread 사용

마무리

  • C 스레드 API에는 static 멤버 함수를 넘기고 this를 인자로 전달해 객체로 되돌아온다.
  • 시작·종료 흐름은 기반 클래스가 고정하고 작업만 가상 함수로 열어두는 것이 템플릿 메서드 패턴이다.
  • 가상 함수 기반 Thread는 생성자·소멸자에서 스레드를 다룰 수 없으므로, 새 코드에서는 콜러블을 받는 std::jthread를 쓴다.

참고

답글 남기기