Multithreading에 대해
컴퓨터에는 CPU라는 연산 장치가 존재한다. CPU의 코어에서는 명령 집합들을 빠른 속도로 처리하는데 이 처리되는 단위를 Thread라 부른다. 현대의 CPU는 여러 개의 코어들로 구성되어 있고, 여러 스레드가 동시에 여러 명령 집합들을 수행할 수 있다. 여러 개의 스레드를 동시에 사용한다면, 하나의 스레드만 사용하는 것보다 훨씬 CPU를 효율적으로 활용할 수 있다. 이처럼 두 개 이상의 스레드를 통해 동시에 실행하는 것을 멀티스레딩이라 부른다.
스레드에 대해 자주 혼동하는 것 중 하나가 있다. 처리되는 단위를 스레드라 부르는데, 이것이 스레드를 코어 개수만큼만 생성해야 한다는 의미는 아니다. 스레드는 1000개 10000개까지도 생성시킬 수 있다. 다만, 코어의 개수가 한정되어있기 때문에 동시에 명령을 처리할 수 있는 스레드는 코어 개수만큼만 가능하다.
Multithreaded Data Synchronization
멀티스레드에서의 데이터 동기화란, 병렬 컴퓨팅 환경 내에서 여러 스레드를 이용하여 공유 데이터에 대한 접근을 처리하는 방법들을 말한다. 멀티스레드 응용 프로그램에서는 여러 스레드가 동시에 실행되기 때문에, 적절한 동기화 작업이 없으면, 공유 리소스에 접근하는 것을 서로 방해하여 Race Condition, Data Corruption 또는 그 외 여러 예상치 못한 문제가 발생하게 된다.
다음 예제를 보자
#include <iostream>
#include <thread>
int sharedValue = 0; // Shared data without proper synchronization
void incrementSharedValue()
{
for (int i = 0; i < 100000; ++i)
{
sharedValue++; // Increment the shared value
}
}
int main()
{
std::thread thread1(incrementSharedValue);
std::thread thread2(incrementSharedValue);
thread1.join();
thread2.join();
std::cout << "Shared value (without synchronization): " << sharedValue << std::endl;
return 0;
}
이 예제에서는 스레드를 두 개 생성하여 각각 100000씩 sharedValue를 증가시켰다.
스레드가 2개이므로 정상적인 경우라면 sharedValue는 200000이 맞겠지만, 출력해보면 다른 값이 나온다.
Shared value (without synchronization): 102195
왜 이런 일이 생길까?
위 코드에서는 다음과 같은 상황이 빈번하게 일어날 수 있다.
Thread1에서 sharedValue(0)를 임시변수에 저장 (0)
Thread2에서 sharedValue(0)를 임시변수에 저장 (0)
Thread1에서 sharedValue에 임시변수 값(0) + 1 을 저장 (1)
Thread2에서 sharedValue에 임시변수 값(0) + 1 을 저장 (1)
이런 경우 sharedValue의 현재 값은 2가 아닌 1이다.
위와 같은 예제에서 sharedValue를 Shared Resource이라 부르고, 공유 자원이 망가질 수 있는 영역을 Critical Section, 이 문제상황을 Race Condition 이라고 부른다.
이처럼 동시에 여러 스레드가 공유 자원을 건드리는 경우 다양한 문제들이 생길 수 있는데, 공유자원이 망가지지 않도록 하는 여러 방법론들을 Data Synchronization이라 한다.
System-Wide vs Per-Process Synchronization
동기화 방법에는 System-Wide 동기화와 Per-Process 동기화가 있다. 이는 동기화되는 범위에 대한 개념이다.
System-Wide 동기화는 여러 프로세스 간 리소스 공유까지 포함하는 동기화 방법들을 의미하고,
Per-Process는 하나의 프로세스 내부에서 여러 스레드 간 리소스 공유에 대한 동기화 방법들을 의미한다.
System-Wide 동기화는 플랫폼 간 종속성이 크고, 일부 운영체제에서는 사용하지 못할수도 있다. C++은 다양한 플랫폼에서도 일반적으로 동작할 수 있는 형태의 라이브러리를 지원하는것을 목표로 하므로, System-Wide 동기화는 지원하지 않는다. 그러므로 C++ Standard Lock은 모두 Per-Process 동기화에 대한 개념으로 이해하면 될 것이다.
ps.
동기화 관련 예제들을 정리하는 과정이 복잡해지는 이유는 용어에 대한 교통정리가 되어있지 않기 때문이라고 본다. 예를들어 Window API에서 말하는 Mutex는 System-Wide 동기화 기법으로 프로세스 사이에서 동기화를 수행하는 Lock의 한 종류인 반면, C++의 std::mutex는 Per-Process 내의 크리티컬 섹션을 정의하기 위한 수단이다. 또한 윈도우에서는 CriticalSection에 대한 api도 존재하는데, 이는 Per-Process 내의 크리티컬 섹션 정의와 Lock을 수행한다. 개인적으로 느끼는 점은, 오래된 운영체제 api보다 현대적인 C++ 라이브러리에서 Lock과 크리티컬 섹션의 대한 역할을 더 명확하게 구분하였고, 용어에 대한 부분도 더 잘 정리했다고 생각한다.
C++의 Lock 라이브러리를 이용할 때는 외부 운영체제 API로 인한 혼동은 겪지 않도록 주의해야 할 것이다.
C++의 Critical Section 정의 방법
위에서 언급했듯이 공유 자원이 망가질 수 있는 영역을 Critical Section이라 한다.
이 개념을 어떻게 구현할 수 있을까?
C++에서는 std::mutex라는 자물쇠와 같은 객체를 통해 Critical Section을 정의할 수 있도록 하였다.
#include <iostream>
#include <thread>
#include <mutex>
int sharedValue = 0;
std::mutex mtx;
void incrementSharedValue()
{
for (int i = 0; i < 100000; ++i)
{
mtx.lock();
sharedValue++;
mtx.unlock();
}
}
int main() {
std::thread thread1(incrementSharedValue);
std::thread thread2(incrementSharedValue);
thread1.join();
thread2.join();
std::cout << "Shared value : " << sharedValue << std::endl;
return 0;
}
출력 결과는 데이터가 잘 동기화되었음을 보여준다.
Shared value : 200000
C++의 Lock
앞서 mutex 객체를 생성하여 mtx.lock()을 통해 Lock을 수행하였다. 하지만 C++의 Lock 라이브러리를 잘 이해하려면 mutex와 lock을 분리된 개념으로 생각하는 것이 좋다. mutex가 자물쇠라면 lock은 자물쇠를 잠구는 행위에 대한 개념이다. 다양한 형태의 mutex들은 자물쇠의 특성에 대해 정의하고, 다양한 방법의 lock들은 자물쇠를 어떻게 잠굴 것인지에 대해 정의한다. 각 개념이 분리되어 있기 때문에 lock들과 mutex들은 서로 조합될 수 있고, 굉장히 다양한 동기화 방법들을 표현할 수 있다.
문제점
위에서 이용한 가장 기본적인 lock인 mutex 멤버함수 lock이 어떤 문제점을 갖을 수 있을까?
먼저 개발자가 실수로 lock을 잡아놓고 릴리즈하지 않는 문제가 발생할 수 있을 것이다. (이 경우 데드락이 발생하게 될 것이다.) 물론 개발자의 실수로 인한 문제겠지만, 이런 실수를 자동화하여 해결할 수 있다면 상당히 좋을 것이다.
다음으로 lock을 잡아둔 상태에서 예상치 못한 예외로 릴리즈 루틴을 타지 않게 되는 상황도 있을 것이다. 이 경우도 어떻게 보면 예외상황까지 고려하여 릴리즈 처리를 하지 않은 개발자 잘못이겠지만, 예외상황이 복잡해질수록 릴리즈 코드가 많이 필요하고, 전체 코드의 복잡도는 증가할 수 밖에 없다.
이러한 상황들을 자동화하여 해결할 방법이 있을까?
RAII (Resource Acquisition is Initialization)
RAII(Resource Acquisition Is Initialization)는 C++ 프로그래밍에서 자원 관리를 위한 중요한 개념이다. 이 원칙은 객체가 생성될 때 자원을 획득하고 객체의 생명주기가 끝날 때 자원을 해제하는 것을 의미한다. RAII는 자원 누수를 방지하고 예외 안전성을 향상시키는 데 도움이 된다.
RAII를 사용하면, 자원을 필요로 하는 객체가 해당 자원을 생성자에서 획득하고 소멸자에서 해제한다. 이렇게 하면 명시적인 할당 및 해제 호출 없이도 자원 관리가 가능하며, 예외가 발생하더라도 소멸자가 자동으로 호출되어 자원이 안전하게 해제된다.
예를 들어, 파일 핸들이나 메모리 할당, 락 등의 자원을 관리하는 객체를 만들 때 RAII를 적용할 수 있다. 객체가 생성될 때 자원을 획득하고, 객체가 스코프를 벗어나거나 삭제될 때 자동으로 자원을 해제한다.
RAII를 활용하면 위에서 언급한 문제점을 해결할 수 있다.
C++에는 RAII 패턴이 적용된 C++의 lock으로 std::unique_lock이 있다.
예제를 통해 확인해보자.
void incrementSharedValue()
{
for (int i = 0; i < 100000; ++i)
{
// 객체 초기화와 동시에 lock
std::unique_lock<std::mutex> lock(mtx);
sharedValue++;
// block을 벗어나면서 자동으로 객체 파괴 -> unlock
}
}
RAII 패턴을 적용하면 자동으로 릴리즈가 수행되고, 확정적인 파괴에 대한 확신을 얻을 수 있으며, 코드까지 간결해진다.
C++에는 RAII 패턴을 활용한 lock으로 std::unique_lock과 lock_guard가 있다. unique_ptr의 경우 여러가지 전략을 통해 사용될 수 있고, 초기화와 동시에 lock이 걸리지 않도록 사용할수도 있는 반면, 순수하게 block의 시작에서 초기화되고 block의 끝에서 파괴될 목적으로 설계된 lock이 std::lock_guard이다. 그러므로 lock_guard를 써도 위와 동일하게 문제를 해결할 수 있다. (다만, 최신 C++에서는 lock_gaurd는 deprecated 되었다.)
Deadlock
데드락(Deadlock)은 병렬 프로그래밍에서 자주 발생하는 문제 중 하나로, 두 개 이상의 스레드 또는 프로세스가 서로의 작업을 기다리면서 아무것도 진행하지 못하는 상황을 말한다. 데드락은 다음 네 가지 조건이 동시에 충족될 때 발생한다. 이 네 가지 조건은 데드락 발생의 필수 조건이다.
1. 상호 배제 (Mutual Exclusion)
최소한 하나의 리소스(예: 뮤텍스, 세마포어)는 동시에 하나의 스레드 또는 프로세스만 사용할 수 있어야 한다. 즉, 리소스를 독점적으로 사용할 수 있어야 한다.
2. 보유 및 대기 (Hold and Wait)
스레드 또는 프로세스가 최소한 하나의 리소스를 보유한 상태에서 다른 리소스를 얻기 위해 대기해야 한다. 이때 이미 보유한 리소스를 반납하지 않고 대기하는 상황이 발생한다.
3. 비 선점 (No Preemption)
다른 스레드나 프로세스가 이미 보유한 리소스를 강제로 빼앗아올릴 수 없어야 한다. 리소스를 얻기 위해서는 보유한 리소스를 자발적으로 반납해야 한다.
4. 순환 대기 (Circular Wait)
스레드 또는 프로세스 간에 리소스를 기다리는 순환적인 체인이 형성되어야 한다. 즉, 스레드 A는 스레드 B가 보유한 리소스를, 스레드 B는 스레드 C가 보유한 리소스를, 스레드 C는 스레드 A가 보유한 리소스를 기다리는 상황이어야 한다.
Deadlock 예제 및 회피방법
다음은 여러 스레드에서 공유자원에 접근하면서 데드락이 발생하는 예제이다.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std;
mutex mutex1;
mutex mutex2;
void func1()
{
cout << "begin 1\n";
std::lock_guard<std::mutex> lockGuard1(mutex1);
std::this_thread::sleep_for(std::chrono::seconds(1));
std::lock_guard<std::mutex> lockGuard2(mutex2);
cout << "end 1\n";
}
void func2()
{
cout << "begin 2\n";
std::lock_guard<std::mutex> lockGuard1(mutex2);
std::this_thread::sleep_for(std::chrono::seconds(1));
std::lock_guard<std::mutex> lockGuard2(mutex1);
cout << "end 2\n";
}
int main()
{
std::thread thread1(func1);
std::thread thread2(func2);
thread1.join();
thread2.join();
return 0;
}
thread1 : lock mutex1
thread2 : lock mutex2
thread1 : wait mutex2
thread2 : wait mutex1
이처럼 각자가 lock을 잡은 상태에서 서로의 lock이 풀리기를 기다리면 데드락에 걸리게 된다. 위와 같은 상황은 생각보다 자주 발생한다. 보통 객체로 공유자원을 갖고, 공유자원에 대한 mutex또한 갖도록 설계하는데, 이런 형태의 객체를 인자로 받아 처리하는 상황에서 위와 같은 데드락이 자주 발생한다.
다음 예제는 은행 계좌에 있는 돈을 다른 계좌로 이동시키는 예제이다. 공유자원인 balance(잔고)를 객체가 소유하고, 공유자원을 지키기 위한 mutex도 객체가 소유하도록 설계되어있다. 하지만 이 예제의 경우 위에서 언급한 상황과 똑같은 형태로 데드락이 발생한다.
(이 예제에서 핵심은 lock1, lock2 사이의 빈틈이다. 이 빈틈을 극대화하기 위해 sleep을 이용하였다.)
#include <mutex>
#include <thread>
#include <iostream>
#include <chrono>
struct bank_account
{
explicit bank_account(int balance) : balance{ balance } {}
int balance;
std::mutex m;
};
void transfer(bank_account& from, bank_account& to, int amount)
{
if (&from == &to) return; // avoid deadlock in case of self transfer
std::lock_guard<std::mutex> lock1{ from.m};
std::this_thread::sleep_for(std::chrono::seconds(1));
std::lock_guard<std::mutex> lock2{ to.m};
from.balance -= amount;
to.balance += amount;
}
int main()
{
bank_account my_account{ 100 };
bank_account your_account{ 50 };
std::thread t1{ transfer, std::ref(my_account), std::ref(your_account), 10 };
std::thread t2{ transfer, std::ref(your_account), std::ref(my_account), 5 };
t1.join();
t2.join();
std::cout << "my_account.balance = " << my_account.balance << "\n"
"your_account.balance = " << your_account.balance << '\n';
}
thread1 : lock my_account.mutex
thread2 : lock your_account.mutex
thread1 : wait your_account.mutex
thread2 : wait my_account.mutex
이 문제를 어떻게 해결할 수 있을까?
std::lock은 이러한 데드락 상황을 피하기 위해 개발되었다. std::lock은 여러개의 뮤텍스를 동시에 잠굴 수 있기 때문에 lock1과 lock2 사이의 빈틈이 없다. 빈틈이 없기 때문에 애초에 위와 같은 상황이 발생할 수도 없다.
다음 예제를 보자.
#include <mutex>
#include <thread>
#include <iostream>
struct bank_account {
explicit bank_account(int balance) : balance{balance} {}
int balance;
std::mutex m;
};
void transfer(bank_account &from, bank_account &to, int amount)
{
if(&from == &to) return; // avoid deadlock in case of self transfer
// lock both mutexes without deadlock
std::lock(from.m, to.m);
// make sure both already-locked mutexes are unlocked at the end of scope
std::lock_guard<std::mutex> lock1{from.m, std::adopt_lock};
std::lock_guard<std::mutex> lock2{to.m, std::adopt_lock};
// equivalent approach:
// std::unique_lock<std::mutex> lock1{from.m, std::defer_lock};
// std::unique_lock<std::mutex> lock2{to.m, std::defer_lock};
// std::lock(lock1, lock2);
from.balance -= amount;
to.balance += amount;
}
int main()
{
bank_account my_account{100};
bank_account your_account{50};
std::thread t1{transfer, std::ref(my_account), std::ref(your_account), 10};
std::thread t2{transfer, std::ref(your_account), std::ref(my_account), 5};
t1.join();
t2.join();
std::cout << "my_account.balance = " << my_account.balance << "\n"
"your_account.balance = " << your_account.balance << '\n';
}
thread1 : lock my_account.mutex && lock your_account.mutex
thread1 : release my_account.mutex
thread1 : release your_account.mutex
thread2 : lock your_account.mutex && thread2 : lock my_account.mutex
thread2 : release your_account.mutex
thread2 : release my_account.mutex
이렇게 데드락을 피할 수 있다.
adopt_lock은 이미 뮤텍스가 잠겨있다고 가정하고 해당 뮤텍스를 이용하려할 때 사용되는 태그이다.
이 태그를 이용해 std::lock을 통해 잠겨진 mutex들을 lock_guard로 다시 래핑하고 lock_guard가 파괴되면서 차례대로 release된다. (즉, std::lock으로 잠겨진 mutex들을 안전하게 릴리즈하기 위한 코드이다.)
최근에는 lock_guard가 사용되지 않는 코드로 넘어갔고, 위 예제는 아래와 같이 더 간단한 형태로 작성된다.
void transfer(bank_account &from, bank_account &to, int amount)
{
if(&from == &to) return; // avoid deadlock in case of self transfer
// lock both mutexes without deadlock
std::scoped_lock lock{from.m, to.m};
from.balance -= amount;
to.balance += amount;
}
Mutex를 이용한 생산자 소비자 패턴의 한계와 해결방법 (Condition Variable)
다음 예제는 간단한 consumer producer 예제이다. 이 예제에서는 생산자 thread에서 생성한 결과물을 공유데이터인 queue에 넣고, 소비자 thread에서는 queue의 데이터를 읽어서 처리한다. 여러 개의 생산자 thread와 여러 개의 소비자 thread들이 queue에 접근하기 때문에 race condition 문제가 발생할 수 있다. 그러므로 각 thread에서 queue에 접근하는 영역은 mutex로 감싸주어야 한다.
(이 예제는 lock을 하나만 이용하기 때문에 위의 deadlock 예제와 같은 deadlock 문제는 생기지 않는다.)
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>
#include <queue>
#include <vector>
#include <string>
std::queue<std::string> outputs;
std::mutex m;
int consumed_count = 0;
void producer(int index)
{
for (int i = 0; i < 10; i++)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100 * index));
std::string content = "output : " + std::to_string(i) + " producer : " + std::to_string(index);
m.lock();
outputs.push(content);
m.unlock();
}
}
void consumer(int index)
{
while (consumed_count < 30)
{
m.lock();
if (outputs.empty())
{
m.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
// read output generated from producer
std::string output = outputs.front() + " consumer : " + std::to_string(index) + "\n";
outputs.pop();
consumed_count++;
m.unlock();
std::cout << output;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
int main(void)
{
const int producer_num = 3;
const int consumer_num = 2;
std::vector<std::thread> producers;
for (int i = 0; i < producer_num; i++)
{
producers.push_back(std::thread(producer, i));
}
std::vector<std::thread> consumers;
for (int i = 0; i < consumer_num; i++)
{
consumers.push_back(std::thread(consumer, i));
}
for (int i = 0; i < producer_num; i++)
{
producers[i].join();
}
for (int i = 0; i < consumer_num; i++)
{
consumers[i].join();
}
return 0;
}
위 예제에서의 문제점이 무엇일까? 여러 가지 문제점들이 있지만 현재 집중하고자 하는 문제는 소비자 쪽에서 지속적으로 결과물이 만들었는지에 대해 체크해야한다는 점이다. 소비자들이 생산자에서 생성한 결과물이 올때까지 루프를 돌고, 루프 내부에서는 큐를 확인해야 하는데 이를 위해 불필요하게 lock과 unlock을 반복해야한다.
만약 소비자가 매번 체크하는 구조가 아니라, 생산자 쪽에서 어떤 결과물이 생성될 때마다 소비자에게 알려줄 수 있다면 어떨까? 소비자는 그 동안 매번 루프를 돌며 체크하느라 성능을 낭비할 필요 없이 wait 상태에 빠져 있다가 생성자에서 알림이 올 때만 깨어나 특정 작업을 수행할 수 있을 것이다. 이러한 문제를 해결하기 위해 나온 개념이 조건 변수 (Condition Variable)이다. 조건 변수는 wait과 notify 라는 함수를 지원하는데, wait을 통해 소비자들은 생산자에서 결과물을 만들때까지 온전히(CPU를 점유하지 않은 상태로) 잠들도록 할 수 있다. 그리고 생산자에서는 notify 함수를 통해 결과물이 준비되었을 때 소비자를 깨우도록 할 수 있다.
다음 예제는 std::condition_variable 을 이용하여 루프를 돌며 성능이 낭비되는 부분을 보강한 예제이다.
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>
#include <queue>
#include <vector>
#include <string>
std::queue<std::string> outputs;
std::mutex m;
std::condition_variable cv;
int consumed_count = 0;
void producer(int index)
{
for (int i = 0; i < 10; i++)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100 * index));
std::string content = "output : " + std::to_string(i) + " producer : " + std::to_string(index);
{
std::unique_lock<std::mutex> ul(m);
outputs.push(content);
}
// wake up on of consumer which is waiting
cv.notify_one();
}
}
void consumer(int index)
{
while (consumed_count < 30)
{
std::string output;
{
std::unique_lock<std::mutex> ul(m);
// wait until condition is established
// this function actually unlock mutex when waiting.
// and lock again if wake up after condition established.
cv.wait(ul, [&] { return !outputs.empty() || consumed_count == 30; });
if (consumed_count < 30)
{
// read output generated from producer
output = outputs.front() + " consumer : " + std::to_string(index) + "\n";
outputs.pop();
consumed_count++;
}
}
std::cout << output;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
int main(void)
{
const int producer_num = 3;
const int consumer_num = 2;
std::vector<std::thread> producers;
for (int i = 0; i < producer_num; i++)
{
producers.push_back(std::thread(producer, i));
}
std::vector<std::thread> consumers;
for (int i = 0; i < consumer_num; i++)
{
consumers.push_back(std::thread(consumer, i));
}
for (int i = 0; i < producer_num; i++)
{
producers[i].join();
}
// wake up some consumers steel waiting
cv.notify_all();
for (int i = 0; i < consumer_num; i++)
{
consumers[i].join();
}
return 0;
}
이 예제에서 눈여겨봐야하는 부분은 조건변수의 wait을 호출할 때 lock을 인자로 받는다는 것이다. 이는 조건변수가 wait에 빠질 때 lock을 릴리즈하고, notify를 통해 깨어날 때 다시 lock을 걸기 때문이다. (내부적으로 그렇게 동작하도록 되어있다.) 왜 이런 복잡한 내부로직이 필요한 것일까? 이는 데드락을 피하고 다른 스레드에서 공유 자원을 획득할 수 있도록 하기 위함이다.
스레드가 조건 변수에 대해 wait()를 호출하는 경우 이는 일반적으로 진행하기 전에 일부 조건이 true가 될 때까지 기다리기를 원하기 때문이다. 그러나 뮤텍스가 잠금 해제되지 않으면 다른 스레드는 조건에 영향을 줄 수 있는 공유 데이터를 수정하기 위해 뮤텍스를 획득할 수 없다. 이는 다른 스레드가 대기 스레드에 신호를 보내는 조건을 변경할 수 없기 때문에 대기 스레드가 결코 깨어나지 않는 교착 상태 상황으로 이어질 수 있다. 그러므로 wait() 시에 락을 해제하는것은 필수적이고, wait()에서 깨어날 때는 다시 원상복구를 위해 락을 다시 걸어주는 것이다.
만약 wait이 락을 해제하지 않는다고 가정하고 위 예제를 실행시킨다면 어떻게 될까?
1. producer에서 생성
2. consumer 처리, lock 획득 후 wait에 빠짐
3. producer에서 생성하는 과정에서 lock을 대기함.
4. 조건변수의 notify가 영원히 호출되지 않게 되면서 데드락 발생
동기 vs 비동기
컴퓨터에는 CPU와 같은 연산장치 외에도 여러 장치들이 붙어있다. 모니터, 마우스, 하드디스크 등등 다양한 장치들이 컴퓨터의 기능들을 담당한다. 이러한 장치들은 특수한 형태로 커널의 일부분으로 개발된 소프트웨어에 의해 제어되는데 이 제어 프로그램들을 Device Driver라 부른다. CPU는 프로그램을 수행하면서 디바이스 드라이버에 명령을 내리게 되는데 이 때 디바이스 드라이버가 CPU로부터 받은 명령을 처리 완료할때까지 대기한다. 다만, CPU의 처리속도는 엄청나게 빠른 반면, 디바이스 드라이버의 처리 속도는 대부분 느린 편이고, 그 여분의 시간동안 CPU가 blocking 되어 놀게 되는 문제가 생긴다. 이처럼 CPU가 디바이스 드라이버의 명령 처리를 기다리는 경우 동기적 실행이라 부른다. 그렇다면 반드시 이렇게 기다려야할까? CPU가 디바이스 드라이버에게 명령을 맡겨 놓고 다른 작업을 처리할 수도 있는데, 이런 경우를 비동기적 실행이라 부른다. 상황에 따라 동기와 비동기를 구분해서 사용해야겠지만 대부분의 경우 비동기적으로 작업을 실행시키는 것이 CPU의 한정된 자원을 더 효율적으로 활용할 수 있기 때문에 성능적으로 더 유리하다. 다만, 비동기적으로 작업을 처리하는것은 여러 개의 스레드를 활용하여 작업을 분배하고, 그 결과물을 가져오는 형태로 구성되는데 이는 위에서 언급한 Race Condition 문제를 피할 수 없다. 그러므로 데이터가 망가지지 않도록 동기화를 위한 추가 작업이 필요하다.
먼저 비동기로 처리해야할 blocking 작업들을 찾고, 메인 스레드가 아닌 다른 스레드에서 이 blocking 작업을 처리한 후 그 결과물을 메인 스레드로 가져오면 될 것이다. 이 과정 중 어려운 것은 현재 스레드가 아닌 다른 스레드를 관리하는것과, 다른 스레드에서 문제 없이 작업 결과물을 얻어오는 것이다. 전자는 Thread Pool을 만들어서 해결가능하고, 후자는 Future & Promise를 이용하여 해결가능하다.
비동기 작업의 결과물을 위한 약속 (Future & Promise)
비동기 작업에서 어려운 문제 중 하나는 분리된 다른 스레드로부터 원하는 작업 결과물을 문제 없이 가져오는 것이다. 이처럼 작업 결과물과 그 결과물이 문제 없이 현재 스레드에서 참조해 올 수 있을 것이라는 약속을 개념적으로 정의한 것이 Future와 Promise이다.
여기서 생길 수 있는 의문 중 하나는, 조건 변수 이용하더라도 하나의 스레드에서 다른 스레드로 값을 전달하는 예제를 만들 수 있는데 (생산자 소비자 예제) 왜 굳이 future와 promise를 이용해야하는가이다. 조건 변수는 스레드 동기화에 유용하지만, 특정 비동기 작업에 대해 명시적인 잠금과 조건 검사가 필요하고, lock에 대한 처리도 따로 다루어주어야 하는데, 이는 번거롭고 오류가 발생하기 쉽다. 반면, Future와 Promise의 사용은 하나의 스레드에서 다른 스레드로 값을 전달하거나 예외 상태를 전달할 수 있는 비동기 프로그래밍을 위한 추상화를 제공한다.
Future와 Promise가 선호되는 몇 가지 이유는 다음과 같다.
1. 코드 단순화
Future와 Promise는 동기화 및 통신 세부 사항을 캡슐화하여 비동기 작업을 처리하는 더 깨끗하고 직관적인 방법을 제공한다. 이로 인해 코드가 더 이해하기 쉽고 유지 관리가 용이해진다.
2. 비동기 반환 값
Promise 객체는 값을 설정하거나 예외를 발생시킬 수 있고, 해당 Future 객체는 이 값을 검색하거나 예외를 처리할 수 있어, 비동기 작업의 반환 값과 예외를 효과적으로 처리할 수 있다.
스레드 콜백을 통해 비동기 결과물을 가져오는 코드의 경우, 연쇄적인 비동기 루틴을 태우는 경우 비동기 결과물을 가져오기 번거로워지는 경우를 많이 경험하는데, Future & Promise는 이러한 문제를 피할 수 있다.
3. 명시적 잠금 없음
Future와 Promise를 사용하면 명시적 잠금 없이 스레드 간에 데이터를 전달할 수 있어, 뮤텍스와 조건 변수와 관련된 복잡성과 잠재적인 데드락을 줄일 수 있다.
4. 예외 처리
Future는 비동기 작업에서 발생할 수 있는 예외를 처리하는 구조화된 방법을 제공한다. 비동기 작업에서 발생한 예외는 Future를 기다리는 스레드에서 캡처되어 다시 발생할 수 있어 견고한 오류 처리가 가능하다.
조건 변수는 강력하고 스레드 동기화에 대한 세밀한 제어를 제공하지만, Future와 Promise는 비동기 코드의 안전하고 효율적인 개발을 단순화하는 더 높은 수준의 추상화를 제공한다.
ps.
사실 Future와 Promise는 내부적으로 조건변수를 이용하여 구현되어있다. 조건변수라는 개념을 좀 더 개발자 친화적으로 추상화시킨 개념이라고 생각할 수 있다.
다음은 std::future와 std::promise를 이용한 간단한 예제이다.
#include <iostream>
#include <chrono>
#include <mutex>
#include <thread>
#include <queue>
#include <vector>
#include <string>
#include <future>
std::promise<std::string> promise;
void producer()
{
std::this_thread::sleep_for(std::chrono::seconds(1));
// 2. promise를 통해 작업 결과물을 세팅한다.
// 이 결과물은 future를 통해 가져올 수 있다.
promise.set_value("data is ready.");
}
int main(void)
{
std::thread p(producer);
std::thread c(consumer);
// 1. promise와 1:1 대응되는 future를 가져온다.
std::future<std::string> future = promise.get_future();
// 3. promise를 통해 세팅될 때, future는 wait에서 빠져나오게 된다.
future.wait();
// 4. 결과물을 안전하게 현재 스레드에서 사용할 수 있다.
std::string data = future.get();
std::cout << data;
p.join();
c.join();
return 0;
}
std::shared_mutex
read/write lock
std::shared_mutex with unique_lock
std::recursive_mutex
std::atomic
'프로그래밍 > C++' 카테고리의 다른 글
[C++] C++에서 Partial Class 구현 방법 (0) | 2023.09.23 |
---|---|
[C++] C++에서 Interface 구현 방법 (0) | 2023.09.23 |
[C++] Casting 총정리 (0) | 2023.09.23 |
[C++] 객체 복사 (0) | 2021.04.27 |
[C++] C++의 데이터 타입 종류 (0) | 2021.04.17 |