PHpullh
심층 가이드/C++/비동기/동시성

C++ · 심층 가이드

C++ 비동기/동시성 완전 정리

thread와 future, 조건 변수를 쓰는 15개 예제로 병렬 실행, 타임아웃, 취소 신호, 생산자 소비자 큐를 안전하게 구성하는 법을 다룹니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

C++의 동시성에는 런타임 스케줄러가 없습니다. 스레드는 대체로 OS 스레드로 그대로 내려가고, 언제 합류시킬지와 데이터를 누가 지킬지는 전부 작성자 몫입니다. 다른 언어의 async/await처럼 문법 하나로 감춰지지 않기 때문에, 공유 상태에 뮤텍스를 걸었는지 여부가 곧 정의되지 않은 동작과 정상 동작을 가릅니다. 데이터 경쟁은 크래시가 아니라 가끔 틀린 값으로 나타나서 더 다루기 까다롭습니다.

비동기 함수 시작에서 std::async와 future의 관계를 잡고, 순차 작업 처리와 병렬 작업 처리를 나란히 비교하면 어디서 실제로 시간이 줄어드는지가 드러납니다. producer consumer는 뮤텍스와 조건 변수를 함께 쓰는 이 편의 핵심이고, 취소 신호와 timeout 처리는 그 위에 얹히는 제어 장치입니다. retry 로직과 배치 처리는 마지막에 실제 작업 흐름으로 조립하는 단계로 보면 됩니다.

std::async가 돌려준 future를 변수에 담지 않고 버리면, 그 임시 객체의 소멸자가 작업이 끝날 때까지 그 자리에서 블록합니다. 한 줄씩 비동기로 던진다고 쓴 코드가 실제로는 완전히 순차 실행되는 흔한 원인입니다. future는 반드시 받아 두고 필요한 시점에 get()을 호출해야 합니다. std::thread 쪽은 반대로, join도 detach도 하지 않은 채 소멸하면 프로그램이 즉시 종료됩니다. C++20의 std::jthread는 소멸 시 자동 join과 stop_token을 제공하므로 취소가 필요한 작업에는 이쪽이 기본 선택지입니다.

01비동기 함수 시작

std::launch::async를 명시해 람다를 별도 스레드에서 돌리고, get()으로 반환값 1을 받아 출력합니다. 계산을 백그라운드로 넘기고 결과는 나중에 한 번만 받으면 되는 자리에 맞습니다. 정책 인자를 생략하면 구현이 deferred를 선택해 get() 시점까지 실행이 미뤄질 수 있어, 병렬 실행을 원한다면 정책을 직접 적어야 합니다.

C++ code

// 비동기 함수 시작
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 1; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

std::async가 돌려주는 future는 공유 상태를 힙에 잡아 두므로, 값 하나를 얻자고 쓰기에는 원자적 참조 계수까지 딸려 오는 비교적 무거운 도구입니다.

자주 하는 실수

job.get()을 두 번 호출하면 이미 값을 옮긴 뒤라 std::future_error가 던져지므로, 결과가 두 번 필요하면 지역 변수에 받아 두어야 합니다.

02순차 작업 처리

여기서는 std::thread가 1밀리초 동안 잠든 뒤 join()이 그 종료를 기다리고, 그다음에야 done이 찍힙니다. 작업을 스레드로 분리했지만 결과 순서는 보장하고 싶을 때의 최소 형태입니다. join()도 detach()도 하지 않은 채 std::thread가 소멸하면 std::terminate가 호출되어 프로그램이 그대로 죽습니다.

C++ code

// 순차 작업 처리
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

C++20의 std::jthread는 소멸자에서 자동으로 join하므로, 이 코드처럼 join을 잊으면 프로그램이 죽는 구조 자체를 없앨 수 있습니다.

자주 하는 실수

worker.join() 앞줄에서 예외가 던져지면 join을 건너뛴 채 소멸자가 불려 std::terminate로 끝나므로, 스레드도 RAII 래퍼에 담는 편이 안전합니다.

03병렬 작업 처리

두 줄짜리 코드지만 실제로는 워커 스레드 생성, 값 3의 전달, 결과 수거까지가 모두 일어납니다. 독립적인 작업 여러 개를 각각 std::async로 띄워 두고 마지막에 몰아서 get()하면 병렬 처리가 됩니다. 다만 get()은 한 번만 쓸 수 있고 이후 valid()가 거짓이 되므로, 결과를 여러 곳에서 읽으려면 std::shared_future로 옮겨야 합니다.

C++ code

// 병렬 작업 처리
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 3; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

여러 작업을 병렬로 돌릴 때는 future를 std::vector에 먼저 모아 두어야 하며, 만들자마자 get()하면 한 번에 하나씩 순차 실행한 것과 다를 바 없습니다.

자주 하는 실수

std::async는 스레드 수에 상한을 두지 않아 수천 개를 한꺼번에 띄우면 std::system_error로 생성이 실패하거나 문맥 전환에 시간을 다 씁니다.

04timeout 처리

스레드가 sleep_for로 1밀리초를 쉬는 동안 주 스레드는 join()에 묶여 있습니다. 지연이 있는 작업의 뼈대로 보면 되는데, 진짜 타임아웃과는 다릅니다. sleep_for는 최소 대기 시간만 보장할 뿐 상한이 없어서, 제한 시간을 걸고 싶다면 future의 wait_for가 반환하는 상태값을 봐야 합니다.

C++ code

// timeout 처리
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

진짜 제한 시간을 두려면 wait_for가 돌려주는 std::future_status::timeout을 확인해야 하고, std::thread에는 시간 제한이 붙은 join이 아예 없습니다.

자주 하는 실수

시간이 지났다고 스레드를 버려 두면 그 작업은 계속 돌면서 이미 사라진 자원을 건드릴 수 있으므로, 타임아웃은 중단이 아니라 기다림을 포기하는 것일 뿐입니다.

05retry 로직

람다가 5를 돌려주고 get()이 그 값을 받아 출력하는, 한 번의 비동기 호출입니다. 실패하면 다시 시도하는 흐름을 짤 때 이 호출 하나가 시도 한 번에 해당합니다. future는 재사용되지 않아 재시도할 때마다 std::async를 새로 불러야 하고, 실행 중 던져진 예외는 get()이 그대로 다시 던져 주므로 반복문 안에서 잡을 수 있습니다.

C++ code

// retry 로직
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 5; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

재시도 사이에 간격을 두려면 std::this_thread::sleep_for에 시도 횟수만큼 커지는 지수 대기를 넣고, 상한을 정해 대기가 끝없이 늘어나지 않게 막습니다.

자주 하는 실수

이미 get()을 부른 future를 다시 대기하면 valid()가 거짓이라 정의되지 않은 동작이 되므로, 재시도할 때마다 새 future로 덮어써야 합니다.

06스트림 또는 채널 소비

스레드 하나를 만들어 짧게 재우고 join()으로 회수하는 구조라, 항목 하나를 처리하고 끝나는 흐름에 해당합니다. 들어오는 데이터를 계속 받아 처리하려면 이 모양을 반복하는 대신 스레드를 살려 두어야 합니다. 항목마다 std::thread를 새로 만들면 생성과 정리 비용이 처리 자체보다 커지므로, 보통은 큐와 고정된 워커 스레드를 함께 씁니다.

C++ code

// 스트림 또는 채널 소비
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

고정 워커 방식에서는 큐가 빌 때 std::condition_variable로 재우고, 종료 신호를 넣은 뒤 notify_all로 모두 깨워야 잠든 워커가 남지 않습니다.

자주 하는 실수

조건 변수는 아무도 깨우지 않아도 깨어나는 경우가 있어, wait에 술어를 넘기지 않고 한 번 깨어난 것만 믿으면 빈 큐에서 원소를 꺼내게 됩니다.

07producer consumer

std::async가 만든 통로로 값 7이 한 방향으로 한 번 흘러오고, get()이 그 값을 꺼냅니다. 만드는 쪽과 쓰는 쪽이 분리된 구조를 가장 얇게 표현한 형태입니다. 다만 future는 결과를 딱 하나만 나르므로, 항목이 계속 생기는 관계라면 뮤텍스로 보호한 큐와 std::condition_variable이 필요합니다.

C++ code

// producer consumer
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 7; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

결과가 여러 번 도착하는 관계라면 std::promise와 future 쌍을 그때마다 만들거나, 아예 잠금으로 보호한 큐 하나로 통일하는 편이 흐름이 단순해집니다.

자주 하는 실수

생산자가 큐에 넣기만 하고 소비자가 따라오지 못하면 큐가 무한정 커지므로, 가득 찼을 때 생산자를 재우는 상한이 없으면 메모리가 계속 늘어납니다.

08취소 신호

코드는 스레드를 띄운 뒤 join()으로 끝까지 기다릴 뿐, 중간에 멈추라고 말하는 경로가 없습니다. 취소 가능한 작업을 만들려면 이 자리에 신호를 하나 더 얹어야 합니다. C++에는 스레드를 강제로 죽이는 표준 기능이 없어서, std::atomic<bool> 플래그를 작업 쪽에서 주기적으로 확인하거나 C++20의 std::jthread와 stop_token을 씁니다.

C++ code

// 취소 신호
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

std::jthread는 콜러블의 첫 인자로 std::stop_token을 넘겨 주며, 소멸자가 자동으로 중단을 요청한 뒤 join까지 해 줍니다.

자주 하는 실수

취소 플래그를 평범한 bool로 두고 두 스레드가 동시에 읽고 쓰면 데이터 경합이라, 컴파일러가 검사를 루프 밖으로 들어내 영원히 멈추지 않을 수 있습니다.

09비동기 에러 처리

정상 경로만 보이는 코드지만, get()은 값을 받는 지점인 동시에 예외를 받는 지점이기도 합니다. 비동기 작업의 실패를 호출한 쪽에서 처리하려면 get()을 try 블록 안에 두면 됩니다. 람다 안에서 던져진 예외는 그 자리에서 프로그램을 끝내지 않고 future에 보관되었다가 get()에서 다시 던져지며, get()을 부르지 않으면 조용히 사라집니다.

C++ code

// 비동기 에러 처리
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 9; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

람다가 던진 예외는 std::exception_ptr로 공유 상태에 담겨 옮겨지므로, 받는 쪽에서는 원래 타입 그대로 catch할 수 있습니다.

자주 하는 실수

get()을 부르지 않고 future를 버리면 작업 안에서 발생한 예외도 함께 사라져, 실패가 아무 흔적 없이 성공처럼 지나갑니다.

10배치 처리

스레드 하나가 정해진 일을 마치고 join()으로 합류하는, 한 덩어리 작업의 시작과 끝입니다. 여러 건을 묶어 처리할 때 이 단위를 반복하거나 여러 개를 동시에 띄우게 됩니다. 주의할 점은 join()을 같은 스레드 객체에 두 번 부르면 안 된다는 것이며, 확신이 없다면 joinable()로 상태를 확인한 뒤 호출합니다.

C++ code

// 배치 처리
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

여러 스레드를 묶어 돌릴 때는 std::vector<std::thread>에 담아 두고 마지막에 차례로 join하면, 가장 오래 걸리는 작업 시간만큼만 기다리게 됩니다.

자주 하는 실수

이미 join한 스레드에 다시 join()을 부르면 std::system_error가 던져지며, 다른 곳으로 이동해 비워진 스레드 객체도 같은 상태라는 점을 놓치기 쉽습니다.

11debounce

std::async 호출과 get()이 붙어 있어 결과를 곧바로 소비합니다. 잦은 호출을 마지막 한 번으로 줄이는 흐름을 짤 때, 실제로 실행되는 그 한 번이 이 형태가 됩니다. 놓치기 쉬운 부분은 반환된 future를 변수에 담지 않고 버리면 임시 객체의 소멸자가 작업 종료까지 블록해, 비동기로 부른 의미가 사라진다는 점입니다.

C++ code

// debounce
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 11; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

소멸자가 작업 종료까지 기다리는 특별 규칙은 std::async가 만든 future에만 적용되고, std::promise로 만든 future는 그런 대기 없이 그대로 사라집니다.

자주 하는 실수

반환값을 변수에 담지 않고 std::async(...).get()처럼 한 줄에 이어 쓰면 곧바로 결과를 기다리게 되어 동기 호출과 같은 시간이 걸립니다.

12throttle

<chrono>의 milliseconds(1)만큼 스레드를 재운 뒤 합류시키는 코드로, 시간 간격을 두는 최소 구성입니다. 호출 빈도에 상한을 두려면 이런 대기를 실행 사이에 끼워 넣게 됩니다. 다만 실제 깨어나는 시각은 운영체제 스케줄러 해상도에 따라 요청보다 늦어질 수 있으므로, 간격을 누적 계산할 때는 steady_clock으로 실제 경과 시간을 재는 편이 안전합니다.

C++ code

// throttle
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

간격을 유지하려면 매번 같은 시간을 자는 대신 다음 실행 시각을 미리 정해 두고 sleep_until로 그 시점까지 기다리는 편이 오차가 쌓이지 않습니다.

자주 하는 실수

sleep_for로 대기하는 동안에는 그 스레드가 아무 일도 못 하므로, 요청을 처리하는 스레드에서 직접 재우면 빈도 제한이 아니라 전체 정지가 됩니다.

13파일 읽기 작업

람다가 값을 하나 만들어 돌려주고 get()이 이를 받는 구조로, 시간이 걸리는 읽기 작업을 이 람다 자리에 넣는 형태가 됩니다. 파일을 읽는 동안 주 스레드가 다른 일을 하게 만들 때 쓰는 골격입니다. 표준 라이브러리에는 비동기 파일 입출력이 따로 없어서, std::async는 블로킹 호출을 다른 스레드로 옮겨 줄 뿐 입출력 자체를 논블로킹으로 바꾸지는 않습니다.

C++ code

// 파일 읽기 작업
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 13; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

디스크 대기는 CPU를 쓰지 않으므로 이런 작업은 코어 수보다 많은 스레드를 두어도 되지만, 같은 장치를 여럿이 동시에 두드리면 오히려 탐색 비용이 늘 수 있습니다.

자주 하는 실수

람다 안에서 파일 경로를 참조로 캡처한 뒤 호출한 쪽에서 그 문자열을 먼저 없애면, 작업 스레드가 이미 해제된 메모리를 읽게 됩니다.

14요청 작업 감싸기

호출부를 스레드 안에 감싸고 join()으로 완료를 확인하는 모양입니다. 외부 요청처럼 응답이 늦는 작업을 별도 실행 흐름으로 떼어 낼 때 이 구조를 씁니다. std::thread는 복사가 막혀 있고 이동만 되므로, 여러 요청을 std::vector에 모아 관리하려면 emplace_back이나 std::move로 넣어야 합니다.

C++ code

// 요청 작업 감싸기
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}
알아두면 좋은 점

스레드 함수에 인자를 넘기면 기본적으로 복사본이 전달되므로, 원본을 수정하거나 큰 객체를 공유하려면 std::ref로 감싸 넘겨야 합니다.

자주 하는 실수

워커 안에서 던져진 예외는 스레드 경계를 넘지 못하고 그대로 std::terminate를 부르므로, 스레드 본문 전체를 try로 감싸 두어야 합니다.

15스케줄 작업

값 15를 만드는 람다를 비동기로 띄우고 결과를 받아 출력합니다. 정해진 시점에 돌려야 할 작업의 실행 부분만 떼어 놓은 형태로 보면 됩니다. std::async에는 시각이나 주기를 지정하는 기능이 없고, std::launch::deferred 역시 예약 실행이 아니라 get()을 부른 스레드에서 그때 동기적으로 실행하는 정책입니다.

C++ code

// 스케줄 작업
#include <future>
#include <iostream>

int main() {
    auto job = std::async(std::launch::async, [] { return 15; });
    std::cout << job.get() << "\n";
}
알아두면 좋은 점

정해진 시각에 실행하려면 대기 전용 스레드가 sleep_until로 기다리다 std::async를 부르는 구조가 되며, 표준 라이브러리는 그 이상을 제공하지 않습니다.

자주 하는 실수

std::launch::deferred로 만든 future에 wait_for를 걸면 시간과 무관하게 즉시 future_status::deferred를 돌려주어, 대기 루프가 끝나지 않기 쉽습니다.

정리하며

  • std::async의 future를 버리면 소멸자가 블록해 비동기 코드가 순차 실행됩니다
  • join도 detach도 하지 않은 std::thread가 소멸하면 프로그램이 즉시 종료됩니다
  • C++20의 jthread는 자동 join과 stop_token을 제공해 취소 처리가 단순해집니다
  • 생산자 소비자 큐는 뮤텍스와 조건 변수 조합이 기본이고 깨우기 조건을 반드시 재확인합니다

더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.