[cpp] condition_variable과 spurious wakeup

@codemaru · December 04, 2023 · 7 min read

소위 모던 C++의 시초라고 할 수 있는 C++11부터 condition_variable이 생겼다. 조건 변수인데 단순하게 말하면 그냥 이벤트를 보내는 거라고 생각하면 된다. 조건 변수를 대기하고(wait) 있는 다른 스레드들을 하나만 깨우거나(notify_one), 모두 깨우거나(notify_all)할 수 있다. 아래는 조건 변수의 사용을 보여주는 간단한 예제다. waitThread가 먼저 실행돼서 wait를 호출해서 cv를 대기하고, wakeThread는 1초 후에 notify_one을 호출해서 해당 스레드를 깨운다.

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;

// 대기하는 스레드의 함수
void
waitThread()
{
	std::unique_lock<std::mutex> lock(mtx);
	cv.wait(lock);
	std::cout << "Wait thread is activated." << std::endl;
}

// 깨우는 스레드의 함수
void
wakeThread()
{
	std::this_thread::sleep_for(std::chrono::seconds(1)); // 1초 대기
	{
		std::lock_guard<std::mutex> lock(mtx);
		cv.notify_one();
		std::cout << "Wake thread has triggered the condition variable." << std::endl;
	}
}

int
main()
{
	std::thread w1(waitThread);
	std::thread n2(wakeThread);

	w1.join();
	n1.join();

	return 0;
}

큰 문제가 없어 보이는 이 예제는 한 가지 문제가 있다. 바로 spurious wakeup이다. spurious wakeup은 가짜 깨우기란 의미로 notify_one을 호출하지 않았음에도 wait 상태가 풀리는 것을 의미한다. 그래서 실제로 가짜 깨우기가 발생하면 위 예제는 개발자의 의도대로 동작하지 않는다. 그렇다면 문제를 해결하기 위해서는 어떻게 해야 할까? 가짜 깨우기를 제어할 수 있는 별도의 변수를 추가로 두는게 일반적인 방법이다. 다음 예제는 그런 방법을 보여준다.

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

// 대기하는 스레드의 함수
void
waitThread()
{
	std::unique_lock<std::mutex> lock(mtx);
	cv.wait(lock, [] { return ready; });
	std::cout << "Wait thread is activated." << std::endl;
}

// 깨우는 스레드의 함수
void
wakeThread()
{
	std::this_thread::sleep_for(std::chrono::seconds(1)); // 1초 대기
	{
		std::lock_guard<std::mutex> lock(mtx);
		ready = true;
		cv.notify_one();
		std::cout << "Wake thread has triggered the condition variable." << std::endl;
	}
}

int
main()
{
	std::thread w1(waitThread);
	std::thread n1(wakeThread);

	w1.join();
	n1.join();

	return 0;
}

가짜 깨우기를 제어할 수 있는 ready라는 변수가 추가되었다. ready가 false인 상태에서 깨어나면 가짜 깨우기인 것이고, ready가 true 상태에서 깨어나면 notify_one이 호출됐다고 판단하는 것이다. 실제로 wait함수는 내부적으로 깨어나면 lock을 다시 획득하고, lock을 획득한 상태에서 뒤에 있는 조건 함수를 평가한다. 조건 함수가 참을 반환하면 실제 깨우기라고 판단하고 wait에서 리턴하고, 참이 아니라면 가짜 깨우기라고 생각하고 다시 대기 상태에 들어간다.

여기까지는 아주 일반적인 상황이다. 조건 변수를 사용하는 스레드가 하나 밖에 없으니 말이다. main에서 다음과 같이 대기 스레드를 두개를 만들었다고 생각해보자.

int
main()
{
	std::thread w1(waitThread);
	std::thread w2(waitThread);
	std::thread n1(wakeThread);

	w1.join();
	w2.join();
	n1.join();

	return 0;
}

이 상태에서 앞선 ready와 동일한 조건 변수를 사용해서 상태를 관리한다고 하면 어떻게 될지 예측해보자. 대체로 물론 정상 동작한다. 하지만 이 상태에서는 단순 bool 변수만 가지고서는 가짜 깨우기를 구분하기가 어렵다. 왜냐하면 notify_one을 호출한 다음 시점에 임의 스레드가 가짜 깨우기로 일어난다면 그때는 ready 변수가 참이기 때문에 결국 하나만 깨워야 함에도 둘다 깨어나는 문제가 발생할 수 있다. 이 문제를 해결하기 위해서는 조건 체크 함수를 변경할 필요가 있다. 아래 함수는 조건 체크 함수 내에서 ready 변수를 변경해서 두 스레드가 동시에 깨어나는 문제를 해결한다.

void
waitThread()
{
	std::unique_lock<std::mutex> lock(mtx);
	cv.wait(lock,
			[]
			{
				if (ready)
				{
					ready = false;
					return true;
				}
				return false;
			});
	std::cout << "Wait thread is activated." << std::endl;
}

그나마 notify_one은 다루기 쉬운 편에 속한다. notify_all로 가면 문제가 더 복잡해진다. 스레드들이 랜덤하게 대기 상태에 들어가고, 정확하게 wait 중인 스레드만 깨우기 위해서는 어떻게 해야 할까? 아래 코드는 그러한 상태를 보여준다. waitThread는 100개가 생성돼서 랜덤하게 대기 상태에 들어간다. 주 스레드는 모든 스레드가 종료될 때까지 주기적으로 notify_all을 호출한다. 지금 이 코드는 조건 함수가 없기 때문에 무정부 상태나 다름 없다. 어떻게 하면 항상 wait 중이었던 스레드만 모두 깨울 수 있을까?


#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <vector>

std::mutex mtx;
std::condition_variable cv;

std::atomic<int> ecount = 0;

// 대기하는 스레드의 함수
void
waitThread(int no)
{
	for (int i = 0; i < 20; ++i)
	{
		if (rand() % 100 < 80)
			continue;

		std::cout << "try wait " << no << std::endl;
		std::unique_lock<std::mutex> lock(mtx);
		cv.wait(lock);
		std::cout << "Wait thread is activated. " << no << std::endl;
	}

	--ecount;
}

int
main()
{
	ecount = 100;

	const int num_threads = 100;
	std::vector<std::thread> threads;

	// 100개의 waitThread 스레드 생성
	for (int i = 0; i < num_threads; ++i)
	{
		threads.emplace_back(waitThread, i);
	}

	while (ecount)
	{
		std::lock_guard<std::mutex> lock(mtx);
		cv.notify_all();
		std::cout << "Wake thread has triggered the condition variable." << ecount << std::endl;

		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}

	// 모든 스레드가 완료될 때까지 대기
	for (std::thread& th : threads)
	{
		th.join();
	}

	return 0;
}

어쨌든 요는 condition_variable은 spurious wakeup 때문에 복잡한 스레딩 모델에서는 사용할 때 생각보다 많은 주의를 필요로 한다는 점이다. 별 생각 없이 썼다가는 라이브에서 어이 없는 황당한 문제들을 경험할 가능성이 높다.

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중