1. 소개
멀티스레드 코드를 작성하기 시작하면 곧 알게 됩니다: 스레드들은 단지 "병렬로 동작"할 뿐만 아니라 서로의 실행을 방해하기도 합니다. 흔히 한 스레드는 다른 스레드가 자신의 부분을 완료할 때까지 진행할 수 없습니다: 데이터를 준비해야 하거나, 작업을 큐에 넣어야 하거나, "시작 가능" 플래그를 켜야 합니다. 이럴 때 질문이 생깁니다: 스레드는 어떻게 올바르게 기다려야 하는가?
실제 프로그램에서 대기는 늘 발생합니다. 워커 스레드는 새로운 작업을 기다립니다. 로거 스레드는 기록할 메시지를 기다립니다. 네트워크 서비스 스레드는 들어오는 요청을 기다립니다. 서버를 작성하지 않고 단순한 학습용 프로그램을 만들더라도 모델은 같습니다: "할 일이 없을 때는 기다려야 하지만, 컴퓨터(과 배터리)를 망가뜨리면 안 된다".
이 강의에서는 두 가지 접근을 구분하는 것이 중요합니다:
- Busy-wait / polling: "조건이 만족됐는지 루프에서 계속 확인한다".
- Blocking wait: "조건이 될 때까지 스레드를 잠재우고, 조건이 될 가능성이 있을 때만 깨어난다".
오늘은 동기 부여에 초점을 맞춥니다: 왜 스핀(wait-loop)은 단순해 보여도 거의 항상 나쁜 아이디어인지.
Busy-wait: 해를 끼치는 단순한 '스핀'
Busy-wait는 스레드가 문자 그대로 "조건 확인 → 준비되지 않음 → 다시 확인 → …"을 수행하는 경우입니다. 보기에는 완벽한 해결책처럼 보일 수 있습니다: 라이브러리도 필요 없고 복잡한 객체도 없으며 모든 것이 투명합니다. 그러나 그 투명성에는 부작용이 있습니다: 당신의 CPU는 선풍기처럼 동작하기 시작합니다.
최소 예제를 보겠습니다:
#include <iostream>
bool ready = false;
void spin_wait_bad() {
while (!ready) {
// busy-wait: 아무것도 하지 않고 그냥 도는 중
}
std::cout << "Ready!\n"; // Ready!
}
단일 스레드에서는 "그냥 이상할 뿐"입니다. 멀티스레드에서는 더 심각합니다: 변수 ready는 공유 상태가 되고, 한 스레드가 쓰고 다른 스레드가 동기화 없이 읽으면 이것은 허용되지 않는 영역에 들어갑니다 (data race). "내 컴퓨터에서는 동작한다"고 해도 그 코드가 올바르다는 뜻은 아닙니다.
하지만 데이터 접근의 올바름을 잠시 잊더라도, busy-wait에는 실용적인 문제가 있습니다: 스레드는 잠들지 않습니다. CPU를 소비하며 초당 수백만 번 조건을 확인하고, "쓸데없는 노력상"을 수상합니다.
이를 체감하려면 "빈 회전" 카운터로 작은 데모를 할 수 있습니다:
#include <iostream>
bool ready = false;
void spin_wait_with_counter() {
long long spins = 0;
while (!ready) {
++spins;
}
std::cout << "Spins = " << spins << '\n'; // 예: Spins = 154238910
}
만약 ready가 1–2초 후에 true가 된다면, spins는 엄청난 값으로 증가할 수 있습니다. 이것이 바로 "헛되이 소모된 CPU 사이클"입니다.
2. Busy-wait가 나쁜 이유 (설령 "동작하더라도")
"조금 돌았을 뿐"이라고 생각하고 싶습니다. 문제는 멀티스레드 환경에서 "조금"이 곧 "항상"으로 바뀔 수 있다는 점입니다. 그다음에는 불쾌한 효과들이 연쇄적으로 발생합니다: 불필요한 부하부터 약한 머신에서의 예측 불가능한 동작까지.
첫째, busy-wait는 CPU를 먹습니다. 하나의 스레드가 빈 루프에서 도는 경우 전체 코어를 차지할 수 있습니다. 쿼드코어 머신에서는 참을만하지만 듀얼코어에서는 슬퍼집니다: 한 스레드는 "스핀", 다른 스레드는 "일하려고 시도"하고, 운영체제는 스케줄링을 하며 기대했던 것보다 적은 효율을 얻습니다.
둘째, 전력 소비에 안 좋습니다. 노트북이나 특히 모바일 기기에서는 "스핀"이 CPU가 절전 상태로 가지 못하게 하며, 결과적으로 배터리가 빠르게 닳습니다. 사용자는 결과를 보지 못하고 단지 선풍기 소리만 듣게 됩니다.
셋째, busy-wait는 대개 나쁜 동기화 설계로 이어집니다. 초보자는 보통 mutex 없이 공유 플래그로 시작하고, 두 번째 플래그, 세 번째 플래그를 추가하면서 "만약 이 플래그가 true인데 저 플래그는 아직 업데이트되지 않았다면…" 같은 문제가 생깁니다. 코드가 "마법 상태"의 취약한 집합으로 변합니다.
마지막으로, 가장 중요한 점: 멀티스레드에서는 단순히 "공유 플래그를 일반 변수처럼 읽어서는" 안 됩니다. 공유 데이터는 접근 규율을 필요로 합니다. 이미 std::mutex를 사용하고 있다면, 당신은 분명히 동의하는 것입니다: "이것은 공유 상태다; 잠금 아래에서만 만진다". while(!ready){} 스타일의 busy-wait는 대개 이 규약을 무시합니다.
4. sleep_for를 사용하는 Polling: 스핀을 '수선'하지만 타협이 생긴다
busy-wait와 처음 만나면 보통 이렇게 생각합니다: "좋아, sleep_for을 추가하면 스레드가 쉬겠지". 이것은 순수한 스핀보다는 낫지만, 고전적인 타협에 빠집니다: 자주 깨어나면(시스템 부하), 드물게 깨어나면(응답성 저하).
우선 공유 플래그에 대한 접근을 적어도 올바르게 만들자: ready를 mutex로 보호하여 읽습니다.
#include <chrono>
#include <mutex>
#include <thread>
std::mutex m;
bool ready = false;
void polling_with_sleep_still_not_great() {
while (true) {
{
std::lock_guard<std::mutex> lock(m);
if (ready) break;
}
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
}
이제 ready 읽기는 보호됩니다. 그러나 설계상의 문제는 남아 있습니다:
- sleep_for(1ms)를 설정하면 자주 깨어나서 불필요한 부하를 만들게 됩니다. 특히 이런 "대기" 스레드가 여러 개라면 더욱 그렇습니다.
- sleep_for(100ms)를 설정하면 부하는 줄어들지만 이벤트에 대한 반응이 최대 100ms까지 지연될 수 있습니다. UI에서는 사람이 느낄 수 있고, 작업 처리 시스템에서는 "불필요한 정지"로 보일 수 있습니다.
게다가 sleep_for은 "정확히 10ms"를 보장하는 약속이 아니라 운영체제에 대한 요청입니다. 부하가 있는 시스템에서는 실제로 더 길어질 수 있습니다.
따라서 sleep_for-polling은 '임시 방편'입니다: 학습 예제에서는 괜찮지만, 주된 대기 메커니즘으로 쓰기에는 의심스럽습니다.
5. 올바른 모델: 상태를 기다리고, notify는 재확인하라는 신호
여기서 머릿속 그림을 바꿔야 합니다. 멀티스레드에서 우리는 거의 절대 "신호 그 자체"를 기다려서는 안 됩니다. 우리는 지금 바로 확인할 수 있는 상태를 기다립니다: "큐가 비어있지 않다", "종료 플래그가 세워졌다", "토큰이 하나 이상 있다", "데이터가 준비되었다" 등.
좋은 동기화 원시들에서의 알림(signal)은 "기억된 이벤트"가 아니라 단지 "다시 확인해봐"라는 의미의 메시지입니다. 올바른 프로토콜은 보통 이렇게 구성됩니다: 상태가 맞지 않으면 스레드는 잠들고, 알림을 받아 깨어나면 상태를 다시 확인합니다.
C++ 표준 라이브러리에는 바로 이런 모델을 위한 메커니즘이 있습니다: std::condition_variable이고, "프레디케이트가 true가 될 때까지 기다리는" 형태인 wait(lock, predicate)가 있습니다. 다음 강의에서 자세히 다루겠지만, 오늘은 원칙만 이해하면 충분합니다: 대기는 "검사 가능한 상태"에 묶여야 하며, "마법의 신호"에 의존하면 안 됩니다.
이를 도식화하면 다음과 같습니다:
flowchart TD
A[스레드가 작업을 계속하고 싶어함] --> B{상태 '준비됐나'?}
B -- 예 --> C[작업 계속]
B -- 아니요 --> D[대기 상태로 전환]
D --> E[깨어남: '다시 확인']
E --> B
이 도식에서 보듯이 "신호를 저장한다"는 아이디어는 없습니다. 오직 "상태"와 재확인의 순환만 있습니다.
6. 실전 예: polling으로 구현한 작업 처리기와 그 불편함
예제가 흩어진 조각들이 되지 않도록 간단한 학습용 시나리오를 만듭니다. 메인 스레드는 정수 작업을 추가하고 워커 스레드는 그 숫자를 "처리"(간단히 출력하고 잠시 쉬기)합니다. 작업 큐는 공유 자원입니다.
아직 condition_variable을 쓰지 않는(다음 강의 내용) 상태에서 polling 버전을 만들어 단점을 느껴봅시다.
공유 상태의 골격:
#include <mutex>
#include <queue>
std::mutex m;
std::queue<int> tasks;
bool stop = false;
작업 추가 함수:
#include <mutex>
#include <queue>
extern std::mutex m;
extern std::queue<int> tasks;
void push_task(int x) {
std::lock_guard<std::mutex> lock(m);
tasks.push(x);
}
이제 주기적으로 큐를 확인하는 worker입니다:
#include <chrono>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
extern std::mutex m;
extern std::queue<int> tasks;
extern bool stop;
void worker_polling() {
while (true) {
int item = 0;
bool has_item = false;
{
std::lock_guard<std::mutex> lock(m);
if (stop) return;
if (!tasks.empty()) {
item = tasks.front();
tasks.pop();
has_item = true;
}
}
if (has_item) {
std::cout << "Processed " << item << '\n'; // Processed 10
} else {
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
}
}
이 코드는 pure busy-wait보다는 낫습니다: CPU를 100% 태우지는 않습니다. 그러나 주요 단점이 보입니다: 작업이 없을 때 워커는 "주기적으로 깨어나는 경비원"이 됩니다: "확인—비어있음—잠듦—확인—비어있음…". 결국 당신은 작은 sleep을 택해 반응성을 높이거나 큰 sleep을 택해 부하를 줄이는 선택을 해야 합니다.
작업이 많고 워커가 즉시 반응하길 원한다면 sleep을 줄여야 합니다. 그러나 그러면 다시 부하 문제가 발생합니다. 이것이 polling이 타협인 이유이고, 블로킹 대기가 엔지니어링적으로 더 나은 해답인 이유입니다.
7. 접근법 비교와 busy-wait가 사용되는 경우
접근법 비교 표
초보자가 busy-wait, polling, blocking을 읽으면 쉽게 섞어버립니다. 따라서 동작면에서 한 번에 비교해보는 것이 유용합니다: API의 "예쁨"이 아니라 프로그램의 행동으로.
| 대기 접근법 | 스레드는 무엇을 하는가 | CPU 부하 | 이벤트에 대한 반응 | 프로젝트에서의 전형적 운명 |
|---|---|---|---|---|
| Busy-wait (while(!ready){} ) | 끊임없이 도는 중 | 매우 높음 | 매우 빠름 | 빠르게 제거되거나 다시 작성됨 |
| Polling + sleep_for | 깨고 확인함 | 중간/낮음 | 타협적 | 종종 임시 패치로 남음 |
| Blocking wait (예: condition_variable 이용) | 알림까지 잠자고 상태를 재확인함 | 낮음 | 빠름 | 대개 "올바른" 최종 선택 |
이 표에서 중요한 것은 busy-wait가 "그저 나쁘다"가 아니라, 기본적으로 많은 응용 작업에서 시스템에 해를 주고 실질적 이점을 제공하지 않는다는 점입니다.
그럼에도 불구하고 busy-wait를 사용하는 경우
누군가는 "하지만 고성능 시스템에서는 spin-wait를 사용한다더라!"고 말할 수 있습니다. 맞습니다. 그런 경우에는 다른 전제 조건이 있습니다: 대기가 매우 짧을 것이 보장되고, 스레드가 코어에 고정되어 있으며, 아키텍처가 엄밀히 측정되어 있고, 작성자가 컨텐션(contention)이 무엇인지 이해하고 있습니다.
학습용이나 대부분의 응용 프로그램에서는 이런 경우가 거의 없습니다. 일반적인 상황은 작업 큐, I/O 대기, 다른 스레드로부터의 데이터 대기 등입니다. 이것은 밀리초, 초, 심지어 분 단위로 지속될 수 있습니다. CPU를 몇 분 동안 돌리는 것은 "조금의 추가 명령"이 아니라 컴퓨터를 괴롭히는 철학입니다.
따라서 스레드가 상태가 바뀔 때까지 잠들 수 있게 하는 메커니즘이 필요합니다. 다음 강의에서 다룰 std::condition_variable가 바로 그 방향입니다.
블로킹 대기의 아이디어(세부 사항은 다음에 설명)는 대략 다음과 같습니다:
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void blocking_wait_idea() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // ready==true 상태를 기다림
}
여기서는 스레드가 도는 것도 아니고 매 10ms마다 "틱"하지도 않습니다. 알림이 올 때까지 잠들어 있다가 그때 깨어나서 계속합니다.
8. 스레드 대기에서 흔한 실수
오류 #1: sleep_for를 "동기화"로 간주하는 것.
매우 흔한 로직: "내가 잠깐 자면 다른 스레드가 끝낼 것이다". 멀티스레드에서는 이것이 계약이 아니라 점괘입니다. sleep_for는 단지 현재 스레드에게 일시 정지를 주지만, 다른 스레드가 필요한 작업을 했을 것을 보장하지 않으며 공유 데이터의 올바른 접근을 보장하지도 않습니다.
오류 #2: 접근 규율 없는 공유 플래그를 사용하는 것.
초보자는 종종 한 스레드가 쓰고 다른 스레드가 읽는 bool ready를 만듭니다. mutex나 다른 동기화 수단 없이 이것은 데이터 레이스로 이어집니다. "단순히 bool을 읽는다" 하더라도 그것은 여전히 공유 상태이며 큐나 벡터에 대한 규칙과 동일한 엄격성이 필요합니다.
오류 #3: 기다리는 동안 mutex를 잡고 있는 것.
종종 lock_guard로 조건을 확인하고 동일한 스코프 안에서 sleep_for을 호출하는 코드가 있습니다. 결과적으로 스레드는 "잠들었지만 잠금은 풀지 않은" 상태가 되고, 다른 스레드는 상태를 변경할 수 없게 되어 대기는 자가 차단이 되거나(약한 형태) 교착 상태(강한 형태)를 초래합니다.
오류 #4: "신호"와 "상태"를 혼동하는 것.
condition_variable을 쓰게 되더라도 초보자는 종종 "notify를 잡아야 한다"고 생각합니다. 그러나 올바른 로직은 "상태를 확인한다; 알림은 단지 다시 확인하라는 신호"입니다. 이벤트 중심의 프로토콜을 만들면 알림이 지나가 버려서 특정 상황에서 대기 상태가 풀리지 않는 버그가 생깁니다.
오류 #5: 너무 작은 sleep_for을 선택해 polling을 다시 busy-wait로 만드는 것.
sleep_for(1ms)가 "거의 공짜"라고 생각하기 쉽습니다. 실제로는 자주 깨어나는 것을 의미하며 높은 부하와 약한 시스템에서 성능 저하를 초래합니다. 아주 짧은 수면을 사용하는 polling은 외형상으로는 "교양 있는" 것 같지만 여전히 자원을 소비하며 때로는 정직한 busy-wait보다 더 나쁠 수 있습니다.
GO TO FULL VERSION