pthread_create 같은 OS 스레드 API는 void* (*)(void*) 형태의 C 함수 포인터만 받는다. 스레드에서 객체의 멤버 함수를 돌리고 싶어도 그대로 넘길 수 없다. 그래서 C 콜백 API를 클래스로 감싸는 첫 단계가 스레드 클래스다. 이 글에서는 static 멤버 함수와 this 전달로 Thread 클래스를 만들고, 생성자·소멸자에서 스레드를 다룰 때 생기는 함정과 std::jthread로 옮겨가는 과정까지 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (2) — 시계와 타이머 콜백(Clock)
- C++ 예제로 배우는 소프트웨어 설계 기법 (3) — 윈도우 클래스와 메시지 처리(Window)
멤버 함수는 스레드 함수가 될 수 없다
#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 + join | stop_token |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
| 기반 생성자에서 스레드 시작 | 순수 가상 함수 호출 | start()를 분리하거나 콜러블 방식 사용 |
| 기반 소멸자에서만 join | 파생 부분 파괴 후 run() 실행 | 파생 객체가 살아있을 때 join() 호출 |
| Thread 객체 복사·이동 | 스레드가 옛 this를 사용 | 복사·이동 연산 삭제 |
std::jthread를 앞쪽 멤버로 선언 | 뒤 멤버 초기화 전에 스레드가 접근 | 맨 마지막 멤버로 선언 |
join 없이 std::thread 소멸 | std::terminate 호출 | std::jthread 사용 |
마무리
- C 스레드 API에는 static 멤버 함수를 넘기고
this를 인자로 전달해 객체로 되돌아온다. - 시작·종료 흐름은 기반 클래스가 고정하고 작업만 가상 함수로 열어두는 것이 템플릿 메서드 패턴이다.
- 가상 함수 기반 Thread는 생성자·소멸자에서 스레드를 다룰 수 없으므로, 새 코드에서는 콜러블을 받는
std::jthread를 쓴다.