이전 글에서는 lock-free 관점에서 쓰다 보니 spin lock은 쓰면 안 되는 것처럼 생각할 수 있는데 본 글에서 spin lock의 필요성에 대해 이야기하고자 한다.
spin lock의 필요성 이해
context switching에 따른 오버헤드 발생
Context switching은 CPU가 현재 실행 중인 프로세스나 스레드를 중단하고 다른 프로세스나 스레드를 실행하는 작업을 말한다.
우선 아래 예제를 보자.
예제 1
두 개의 스레드가 공유 데이터를 호출하는데 공유 데이터 접근은 mutex로 처리한 프로그램 코드이다.
#include <iostream>
#include <atomic>
#include <thread>
#include <mutex>
std::mutex m;
int shared_counter = 0;
void work()
{
m.lock();
shared_counter++;
m.unlock();
}
int main()
{
std::thread t1(work), t2(work);
t1.join();
t2.join();
}
우리가 기대하는 흐름은 아래와 같이 mutex 반환과 획득이 같이 일어나는 것이지만
thread1 -> mutex 획득 -> 임계영역 실행 -> mutex 반환 thread2 -> mutex 획득실패(대기) -> mutex 획득 -> 임계영역 실행 |
흐름에서 노란색으로 표기된 부분을 좀 더 상세히 표현하면 아래와 같은 내부 동작을 더 한다.
스레드2의 mutex 획득 실패 -> OS가 스레드를 대기 큐에 옮긴다(상태저장) -> thread1의 mutex 반환 -> OS가 스레드를 대기 큐에 꺼내서 mutex 획득 |
만약 스레드 2의 대기 절차가 시작되자마다 스레드 1의 mutex 반환이 되었다면 스레드 2가 대기 큐에서 꺼내져 mutex를 획득하기까지의 시간만큼 오버헤드가 발생한다.
이 시간이 찰나 혹은 잠깐이라고 생각한다면 CPU와 메모리 처리속도에 대해 생각해 보자.
CPU와 메모리 처리속도 차이 이해
CPU와 메모리의 처리 속도 차이가 있기에 스레드가 대기큐에 왔다 갔다 하는 메모리에서의 오버헤드가 CPU입장에서는 훨씬 큰 오버헤드이다.
CPU와 메모리 속도차이를 알고 싶다면 (아래 '더 보기' 확인)
CPU와 메모리 속도 차이
CPU와 메모리의 속도 차이는 일반적으로 100~1000배 정도입니다. CPU의 속도는 GHz(기가헤르츠) 단위로 측정되며, 메모리의 속도는 MT/s(메가트랜잭션/초) 단위로 측정된다. 예를 들어, CPU의 클럭 속도가 3.4GHz인 경우, 1초에 3.4억 번의 연산을 수행할 수 있다. 반면, 메모리의 속도는 3200MT/s인 경우, 1초에 3200만 번의 데이터 전송이 가능하다.
CPU와 메모리의 속도 차이가 큰 이유는
CPU는 연산을 수행하는 장치이기 때문에 빠른 속도가 요구된다. 반면, 메모리는 데이터를 저장하는 장치이기 때문에 속도가 상대적으로 느릴 수 있다.
CPU는 트랜지스터를 사용하여 연산을 수행하며 트랜지스터는 크기가 작아질수록 속도가 빨라지지만, 전력 소모도 증가한다. 반면, 메모리는 트랜지스터를 사용하여 데이터를 저장하지만, 연산을 수행하는 기능은 없다. 따라서, 메모리는 CPU에 비해 전력 소모가 적은 대신 속도가 느리다.
spin lock
지금 작업을 다른 CPU에게 양보하는 콘텍스트 스위칭을 막기 위해 현재 CPU에서 루프를 돌며 반환되었는지 확인 방법이 spin lock이다.
spin lock의 콘셉트 코드를 만들면 아래와 같다.
spin lock의 콘셉트 코드
예제 2-1
스핀락의 콘셉트 프로그램 코드이다.
#include <iostream>
#include <thread>
bool use_flag = false;
int shared_counter = 0;
void work()
{
while (use_flag);
use_flag = true;
shared_counter++;
use_flag = false;
}
int main()
{
std::thread t1(work), t2(work);
t1.join();
t2.join();
}
첫 번째 스레드가 use_flag를 true로 변경하여, 두 번째로 도착한 스레드는 while 문에 걸려 무한무프를 수행하면서 대기하게 되고, 첫 번째 스레드의 shared_counter 사용이 끝나면 use_flag가 false로 되는 시점에 두 번째 스레드가 shared_counter에 접근하게 된다.
이처럼 하나의 스레드가 무한루프를 수행하고 있기에 오랜 시간을 잡고 있다면 오버헤드가 생기겠지만 빨리 대기가 풀리는 경우에는 오히려 콘텍스트 스위칭을 하면서 생기는 오버헤드를 막을 수 있다.
정리하면 아래 표와 같다.
spin lock | 자원이 사용 중인 동안 계속해서 루프를 돌기 때문에 CPU를 낭비한다. 하지만 자원이 빨리 반환 된다면 context switching 하는 오버헤드를 절약 할 수 있다. |
mutex | 자원이 사용 중인 경우 blocking 상태가 되기 때문에 CPU를 낭비하지 않는다. 하지만 자원이 빨리 반환 된다면 context switching 하는 만큼 오버헤드가 생긴다. |
atomic lock-free로 해결할 수 있으면 베스트이고, mutex보단 덜 오버헤드가 발생하는 경우라면 spin lock을 사용하여 동기화 처리를 하는 것이 이득일 수 있다.
std::atomic_flag
위 콘셉트 코드는 멀티 스레드에 안전하지 않기에 atomic_flag로 thread-safe 하게 만들면 다음과 같다.
예제 2-2
atomic_flag를 사용한 spin lock 프로그램 코드이다.
#include <iostream>
#include <atomic>
#include <thread>
//1.
std::atomic_flag use_flag = ATOMIC_FLAG_INIT;
int shared_counter = 0;
void work()
{
//2.
while (use_flag.test_and_set() );
shared_counter++;
//3.
use_flag.clear();
}
int main()
{
std::thread t1(work), t2(work);
t1.join();
t2.join();
}
코드리뷰
std::atomic_flag use_flag = ATOMIC_FLAG_INIT;
atomic_flag는 항상 lock-free 임을 보장한다.
= ATOMIC_FLAG_INIT
C++20부터는 초기화가 필요 없어졌기에 생략해도 된다.
처음에 lock-free가 아닌 경우, spin lock을 사용하다 보니, '왜? atomic_flag가 lock-free인데 spin lock에 사용할까?'로 의문이 든 적이 있었다.🤔
여기서 atomic_flag는 임계영역에서 사용하는 flag가 아니라 임계영역에 사용하는 코드를 안전하게 만들기 위한 flag 값이며, 멀티스레드에서 안전하게 spin lock 기법을 사용하기 위해 lock-free가 보장되는 atomic_flag가 사용된 것이다.🤗
while (use_flag.test_and_set() );
위 함수는 아래 2줄을 한 번에 수행하는 함수이다.
while(use_flag);
use_flag = true;
즉 플래그가 set 되어 있지 않으면 플래그를 set 한다.
use_flag 가 set 되어 있으면 clear 될 때까지 무한루프, clear 라면 탈출
use_flag.clear();
플래그를 unset(false) 한다.
표준에서 제공하지 않는 spinlock 만들기
표준 C++에서는 mutex, semaphore, lock_guard, std::atomic 등을 동기화 기법으로 제공하고 있으며 spin lock처럼 busy-waiting 방식으로 사용하는 동기화 기법은 제공하지 않는다. 그래서 필요시 아래와 같이 만들어 써야 한다.
예제 3
spinlock 클래스를 만들어 spinlock을 사용하는 프로그램 코드이다.
#include <iostream>
#include <atomic>
#include <thread>
class SpinLock {
public:
SpinLock() : locked_(ATOMIC_FLAG_INIT) {}
void lock() {
while (locked_.test_and_set()) {};
}
void unlock() {
locked_.clear();
}
private:
std::atomic_flag locked_;
};
SpinLock spin;
int shared_counter = 0;//thread-safe하게 처리되어야 하는 공유 데이터
void work()
{
spin.lock();
shared_counter++;
spin.unlock();
}
int main()
{
std::thread t1(work),t2(work);
t1.join();
t2.join();
std::cout << "shared_counter = " << shared_counter << std::endl;
}
결과
shared_counter = 2
알아두면 요긴하게 사용할 일이 있을지도 모르기에 이 글을 읽는 사람에게 도움이 되었으면 한다.
'L C++ > Concurrency' 카테고리의 다른 글
modern C++ Concurrency 기술 공부를 위하여✔ (0) | 2024.02.06 |
---|---|
memory order 조정 이야기 (30) | 2024.01.30 |
[Concurrency] 동기화 기본 요소(synchronization primitive) 정리 (0) | 2024.01.19 |
[Concurrency] ❇ std::atomic ❇의 이해 (0) | 2024.01.16 |
[Concurrency] C++20 스레드 조정 메커니즘 std::latch와 std::barrier (2) | 2024.01.09 |