이번 글에서는 C++의 Thread에 대해서 알아보겠습니다.
C++의 Multi Threading 지원
C++은 User level thread가 아닌 OS의 Kernel level thread입니다.
따라서 C++ 상에서 thread를 만들면 OS가 제공하는 thread를 생성하게 됩니다.
헤더파일
#incldue <thread>
C++ Thread 생성
class thread 객체를 생성하는 방식으로 새로운 코드 흐름인 Thread를 생성합니다.
#include <iostream>
#include <thread>
using namespace std;
void f1() {
cout << "f1" << endl;
}
void f2(int arg) {
cout << "f2: " << arg << endl;
}
int main() {
thread t1;
thread t2(f1);
thread t3(f2, 10);
t2.join();
t3.join();
return 0;
}
thread t2(f1)와 thread t3는 함수의 메모리를 준 것입니다.
thread코드를 실행하면 이것 자체가 스레드를 만드는 것이 아닌 대응되는 스레드를 만드는 것입니다.
thread t1의 경우 실행할게 지정되지 않았으니 아무것도 하지 않습니다.
main() 같은 경우 메인 스레드가 돌고 있고 t2와 t3를 실행하면서 각각 스레드를 한 개씩 만들게 됩니다.
join()은 이 스레드들이 끝날 때까지 기다리는 코드입니다.
그래서 t2, t3가 끝나면 메인 스레드를 종료하게 됩니다.
스레드로 동시성과 병렬성을 활용할 수 있습니다. 그래서 순서대로 t2, t3를 실행했다고 해서 t2가 t3보다 먼저 실행되는 것이 아닙니다. 각각 독립적으로 실행되기 때문에 누가 먼저 실행될지는 아무도 모릅니다.
CPU가 여러 개가 아니더라도 결과는 같습니다. 스레드별로 CPU를 작은 시간 단위로 잘게 쪼개서 할당받기 때문입니다. 그래서 출력 결과가 순차적이라면 그것은 우연입니다.
참고: 입출력 장치의 성능
결론적으로 입출력은 성능적으로 매우 느립니다.
예를 들어 서버 프로그램을 만들고 그 안에 굉장히 빈번하게 실행되는 코드 안에 로그를 찍게 되면
서버 성능은 10분의 1 이하로 떨어지게 됩니다.
I/O를 파일에 쓸 때는 괜찮지만 화면에 찍는다는 것은 프로그램 입장에서 엄청난 노력이 들어가는 것입니다.
C++ Thread 식별자
thread::get_id() // C++ 상에서의 thread 식별자 타입
thread::native_handle() // OS 상에서의 thread 타입
get_id는 프로그래밍 언어에서 구분하기 위한 식별자입니다.
native_handle()은 OS상에서 구분하기 위한 것입니다.
C++ this_thread
namespace this_thread {
// 현재 스레드의 식별자를 반환
thread::id get_id();
// CPU를 포기하고 현재 thread를 CPU ready queue에 넣음
void yield();
// 현재 스레드를 일정 시간 동안 잠재움
void sleep_until(const chrono::time_poing& abs_time);
void sleep_for(const chrono::duration& rel_time);
}
Thread의 Join
join()은 대상이 되는 thread가 terminated 상태가 될 때까지 기다립니다.
(중요) 또한 join() 후 terminated 된 thread의 메모리를 정리합니다.
주의
- 자기 스레드에 대해서 join() 할 수 없습니다.
- 이미 join() 한 thread에 다시 join() 할 수 없습니다.
- Detach 되지 않은 thread는 반드시 join()으로 정리되어야 합니다.
생성한 Thread 중간에 종료하기
생성한 작업 thread를 중간에 종료하려면 어떻게 해야 될까요?
앞의 C++ class thread는 OS의 thread에 대응되는 객체일 뿐입니다.
따라서 그 객체를 소멸시킨다고 OS thread도 자동으로 종료되는 것이 아닙니다.
그렇다면 만들어진 작업 thread를 어떻게 중간에 종료할 수 있을까요?
Main thread와 작업 thread가
Boolean flag를 공유하면서 종류 상황을 전달하게 프로그래밍 해야 합니다.
- 이때 flag는 값을 쓰는 thread, 그걸 읽는 thread 두 개가 존재하는 것이므로
실행 순서에 따라 동작이 달라집니다.(= race condition) - 따라서 race condition이 없도록 atomic한 방식을 써야 하는데,
C++에서는 std::atomic<bool> 같은 것을 쓸 수 있습니다.
Multi-Threaded에서 Race Condition
실행 순서에 따라 동작이 달라지는 것을 Race Condition이라고 합니다. 이 레이스 컨디션이 없도록 atomic 한 한마디로 나눌 수 없는 단위로 이 값을 세팅해야 합니다.
mutex를 사용해도 되지만 C++에서는 이를 위해 atomic하게 쓸 수 있는 변수를 제공합니다.
- 스레드 간 경합(Contention):
- 복수개의 스레드가 공유될 수 없는 같은 자원에 접근하려고 하는 것
- 레이스 컨디션(Race Condition)
- 연산의 결과가 스레드의 실행 순서에 의해 달라지는 것
- 복수개의 스레드가 공유될 수 없는 같은 자원을 동시에 접근함에 따라 발생하는 예상치 못한, 대개의 경우 에러를 유발하는 상황
- 동기화(synchronization)를 통해 해결합니다.
동기화(Synchronization)를 구현하기 위한 요소
- Mutex (Mutual Exclusion)
- 어떤 자원에 접근하기 위한 배타적 권한을 표현하기 위해서 사용됩니다.
- 즉, 자원에 접근하기 전에 뮤텍스를 잡고, 자원 접근 후 뮤텍스를 풀어줍니다.
- Condition variable
- 다른 스레드에 의해 생성되는 조건(condition)을 기다리기 위해서 사용됩니다.
- 여러 스레드가 "조건" 발생에 대해서 통신하는 수단이므로
(동시에 여러 스레드가 읽고 쓰는 것을 막기 위해) 반드시 mutex를 필요로 합니다.
※참고: 같은 mutex가 여러 condition variable을 담당해도 괜찮습니다.
C++에서 Mutex 지원
class mutex // 기본 뮤텍스 구현
class recursive_mutex // 같은 스레드가 반복해서 잡아도 괜찮은 뮤텍스
class timed_mutex // 정해진 시간 동안만 보장해주는 뮤텍스
class recursive_timed_mutex // recursive mutex + timed_mutex
Mutex 사용 코드 패턴
보호되어야 되는 부분 ①시작 부분에서 "lock", ②끝 부분에서 "unlock" 합니다.
이때 경합하는 모든 스레드들이 동일은 mutex를 써야 합니다.
#include <mutex>
mutex m; // 공유 될 자원에 대한 mutex, 경합하는 스레드들은 모두 같은 mutex를 써야 합니다.
m.lock(); // mutex를 잠급니다.
// 공유 자원에 대한 접근 코드
// 오직 한번에 한 스레드만 여기를 실행합니다.
m.unlock() // mutex를 풉니다.
C++에서 mutex의 문제점
unlock() 호출이 누락될 경우 영원히 어떤 스레드도 접근하지 못하게 됩니다.
- 실수로 누락된 경우
- C++ exception 발생으로 코드가 unwinding 된 경우
C++의 class unique_lock 활용
생성자에서 mutex을 잠그고, 소멸자에서 해제하는 역할을 합니다.
따라서 명시적으로 lock을 잠그고 해제하는 것이 아니라
아래처럼 scope을 만들어서 {· · ·}로 묶어주기만 합니다.
mutex m; // 공유될 자원에 대한 뮤텍스.
// 여러 스레드가 같은 뮤텍스를 써야합니다.
{
unique_lock<mutex> ul(m); // 자동으로 뮤텍스를 잠급니다.
// 공유 자원에 대한 접근 코드
// 오직 한번에 한 스레드만 여기를 실행합니다.
} // 자동으로 뮤텍스를 풉니다.
교착(Deadlock)
데드락이란 두 개 이상의 작업이 서로 상대방이 배타적 독점한 자원을 기다리느라 결국 어떤 작업도 끝내지 못하는 상태입니다.
예: Dining Philosopers Problem (식사하는 철학자 문제)
- 철학자들이 둘러 앉아 밥을 먹습니다.
- 양손에 포크를 다 들어야 밥을 먹을 수 있습니다.
- 포크가 하나만 있는 경우 다른 포크를 기다립니다.
- 옆 사람 포크는 뺏을 수 없습니다.
- 자, 이제 어느 순간 모두 같은 손에 포크를 들고 멀뚱멀뚱 옆 사람을 쳐다보는 상황이 발생합니다.
교착이 발생하기 위한 4가지 필요조건(모두 갖춰야 함)
- 상호배제(Mutual Exclusion): 자원에 대한 배타적 독점
- 점유대기(Hold & Wait): 자기가 가진 자원을 놓지 않고 다른 자원을 기다림
- 비선점(No Preemption): 작업이 가진 자원을 뺏을 수 없음
- 순환대기(Circular Wait): 자원에 대한 의존성이 순환적임
현실적으로 이 4가지 중 프로그래머가 방지 가능한 것은 "순환대기"입니다. 따라서 복수개의 lock을 다룰 때 lock순서를 반드시 일치시켜서 순환대기를 방지합니다.
Condition Variable
어떤 조건(condition)이 발생할 때까지 thread를 중단할 때 쓰입니다.
조건을 wait()하는 스레드와 조건이 발생했을을 notify()하는 스레드가 있습니다.
(매우 중요) 반드시 대응되는 mutex를 잡은 후 wait()이나 notify()를 합니다.
이 말은 CV는 단독으로 쓰이지 않고, 반드시 mutex와 같이 쓰인다는 의미입니다.
wait()은 호출 하면 자동으로 mutex를 풀고 스레드를 block 시킵니다.(= 재운다)
(매우 중요) 그리고 반환할 때 다시 자동으로 mutex를 잡은 상태로 깨어납니다.
(매우 중요) notify()를 호출한 뒤에는 반드시 mutex를 해제하게 코딩합니다.
notify()를 한다고 하더라고 mutex를 해제해야만 상대가 wait()에서 깨어납니다.
void consumer() {
m.lock()
// lock을 잡은 후 호출합니다.
// wait은 스레드를 sleep 시킵니다.
// 깨어날 때 다시 락을 잡은 상태로 만듭니다.
cv.wait(m)
// lock이 잡힌 상태로 깨어나 작업을 합니다.
m.unlock() // lock이 여기서 해제됩니다.
}
void producer() {
m.lock()
// 작업을 합니다.
// lock을 잡은 후 호출합니다.
// 호출한다고 lock을 놓지는 않습니다.
cv.notify()
m.unlock() // 이제 lock 을 풉니다.
// 상대는 wait()에서 깨어납니다.
}
wait()을 할 때는 연동된 mutex가 있어야 하므로 mutex를 잡고 wait을 합니다.
그러면 이런 질문을 할 수 있습니다.
mutex를 잡고 잠드는 거 아닌가요?
그럼 그 mutex는 아무도 못 잡는 거 아닌가요?
그에 대한 답변으로는
wait안에서 그 mutex를 풉니다. 그리고 notify()에 의해서 깨어날 때 다시 mutex를 잡고 나옵니다.
한마디로 lock을 잡고 wait하면 계속 lock이 잡혀 있는 것으로 계산하면 됩니다.
그 이후 notify가 됐으면 lock을 풀어줘야 합니다.
코드 개선 버전
void consumer() {
unique_lock<mutex> lg(m);
cv.wait(lg);
} // lock이 여기서 해제됩니다.
void producer() {
lock_guard<mutex> lg(m);
cv.notify_one();
} // 이제 lock이 풀리고 wait()에서 깨어납니다.
C++ CV의 notify_one() vs. notify_all()
condition_variable에 대해 wait()을 하는 순간 CPU queue에서 condition_variable의 waiting queue로 스레드고 옮겨집니다.
notify_one()은 이 waiting queue에 있는 것 중 맨 앞 스레드 하나를 깨웁니다.
notify_all()은 이 waiting queue에 있는 모든 스레드를 깨웁니다.
대개 notify는 뭔가를 생산 하는 쪽에서 호출하는데,
만일 여러 스레드가 소비할 정도로 충분히 생산한 경우 notify_all()을 호출하고
한 스레드만을 위한 경우라면 notify_one()을 호출하는 것이 좋습니다.
만일 notify_all()을 호출했는데 한 스레드만 가능하다면
나머지 스레드는 다시 잠들어야 합니다.
notify_one() 예시
현재 CPU 큐와 Condition Variable 큐 외 자원별로 큐가 존재하는 상황입니다.
notify_one()이 호출되면 CV 큐에서 스레드 한 개를 빼서 CPU 큐에 넣습니다.
이때 락을 잡고 깨어나야 하므로 다시 mutex 큐로 들어갑니다.
T4가 lock을 잡아서 다시 CPU 큐에 올라갔습니다.
notify_all() 예시
CV 큐에 4개의 스레드가 잠자고 있는 상황입니다.
notify_all()이 호출되면 CV 큐에 있는 모든 스레드들을 깨웁니다.
이 스레드들도 락을 잡은 상태로 깨어나야 하기 때문에 lock을 얻기 위해 다시 Mutex큐로 들어갑니다.
Mutex 큐에 들어가 있는 스레드들을 하나씩 CPU 큐에 넣어서 처리합니다.
CPU 큐에 올라왔을 때 처리할 일감이 없으면 일감이 생길 때까지 다시 CV큐에 들어갑니다.
여기서 자원 낭비가 발생하게 되어서 notify_all() 같은 경우는 일감을 많이 만들어놓고 해야 합니다.
wait과 notify를 이용한 예시 코드
#include <condition_variable>
#include <mutex>
#include <thread>
#include <iostream>
using namespace std;
int sum = 0;
mutex m;
condition_variable cv;
void f() {
for(int i = 0; i < 10 * 1000 * 1000; ++i) {
++sum;
}
{
unique_lock<mutex> lg(m);
cv.notify_one();
}
}
int main() {
thread t(f);
{
unique_lock<mutex> lg(m);
cv.wait(lg);
cout << "Sum: " << sum << endl;
}
t.join();
}
f 함수가 1천만을 카운트하고 다 계산했을 때 notify_one을 호출해 wait 하고 있는 main thread를 깨워 Sum 결과를 반환합니다.
'CS' 카테고리의 다른 글
[Network] HTTP 1.1, 2.0 (0) | 2023.12.12 |
---|---|
[OS] Multi-processing, Multi-threading (0) | 2023.12.10 |
[OS] Multi-Programming, Multi-Tasking (0) | 2023.12.10 |
[OS] CPU utilization, CPU load (1) | 2023.12.10 |
[Network] Socket Options, I/O Multiplexing과 Non-blocking I/O (1) | 2023.10.23 |