Race Condition
Critical Section
여러 개의 프로세스/스레드가 하나의 공유자원에 접근하여 값을 읽고 수정할 때 발생할 수 있는 문제상황
예를 들어, 프로세스 A, B가 money 값(100)을 읽은 다음, A는 50을 더하고 B는 100을 빼주었음. 그럼 A는 150값을 가지고, B는 50을 가지며 서로 다른 값을 가지게 됨.
여기서 값을 덮어씌어주면 저장하는 순서에 따라 다른 값이 저장되는 문제도 발생
이렇게 서로 다른 프로세스/스레드에서 값을 접근하는 순서나 타이밍에 따라 실행결과를 예측할 수 없는 문제 상황을 말함
값 변경 같이 단순작업은 atomic을 이용하여 원자적 단위로 연산이 이루어지게 함.
작업이 중간에 끊기지 않고 한 번에 이루어지므로 동기화문제가 발생하지 않음
#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1);
}
if-else같이 복잡한 구문은 불가능하여 다른 방법을 사용Lock을 걸어, 하나의 스레드만 자원에 접근 가능하고, 그 이후의 스레드들은 계속 while문을 돌며 Lock이 해제될 때까지 기다림#include <atomic> // std::atomic으로 구현
#include <thread>
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {} // 계속 확인
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
SpinLock spin;
int counter = 0;
void increment() {
spin.lock();
counter++;
spin.unlock();
}
C++표준에서 지원하지 않아 atomic을 통해 구현해야 함
lock을 얻은 스레드만 자신의 lock을 해제할 수 있으며, lock이 해제되자마자 다른 기다리는 스레드가 바로 lock을 얻어 자원에 접근할 수 있어 반응성이 빠름
대신, 자원을 오래 사용하여 다른 스레드들이 계속 기다려야하면 그 동안 cpu가 계속 낭비되는 단점이 있음. 그래서 mutex랑 같이 사용
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> guard(mtx); // RAII 자동 unlock
counter++;
// guard 소멸 시 자동 unlock
}
Spin Lock과 거의 동일하나, while문을 돌며 계속 기다리지 않고 이미 lock되어 있으면 바로 sleep으로 전환되어 다른 프로세스가 cpu를 차지함
내부적으로 futex 시스템 콜을 불러 sleep(Blocked)되고, 커널의 futex 대기 큐에서 대기함
이후에 lock이 해제되면, 커널모드로 전환되어 futex 대기 큐에서 프로세스 하나를 다시 깨워(Ready) 이후에 스케쥴링에 의해 실행되도록 함
cpu낭비는 없지만, 커널모드로의 전환 및 스레드 상태도 관리해줘야 하는 등 오버헤드가 존재
그래서 보통 처음엔 Spin Lock으로 하다가 Mutex로 넘어가는 방식으로 같이 사용
for(int i = 0; i < SPIN_LIMIT; i++) {
if (try_spinlock()) return; // 100ns 내 성공
cpu_relax();
}
mutex_lock(); // 실패 시 mutex로 전환
mutex는 자원에 하나만 접근하게 하였지만, semaphore는 여러 개 허용 가능
Binary Semaphore는 접근 하나만 허용하고, Counting Semaphore는 원하는 Counting 개수만큼 접근 허용 가능
mutex랑 또 다른점으로는 mutex는 lock 얻은 프로세스 본인이 unlock해야하지만, semaphore는 꼭 자신이 아니더라도 lock을 걸지 않은 다른 프로세스가 lock 풀어줄 수 있음
C++20 이상부터 semaphore 기능을 지원
#include <iostream>
#include <thread>
#include <semaphore>
using namespace std;
binary_semaphore semaphore(1); // 세마포어 생성, 초기 값은 1
void printNumbers(int start, int end) {
for (int i = start; i <= end; ++i) {
semaphore.acquire(); // 세마포어 획득
cout << i << " ";
semaphore.release(); // 세마포어 반환
}
cout << std::endl;
}
int main() {
thread t1(printNumbers, 100, 300);
thread t2(printNumbers, 400, 500);
// 각 스레드가 작업을 완료할 때까지 대기
t1.join();
t2.join();
}
위에서 본 것처럼 공유 자원에 접근하는 코드 영역을 의미
Lock을 걸어 데이터 무결성을 보장해주고, race condition을 방지하며, 동기화 문제를 해결해 줄 수 있다는 장점이 있다