내일배움캠프 Node.js 트랙 64일차
스레드와 경쟁 상태
스레드 간의 경쟁 상태를 이해하기 위해선 스레드가 실제 어떻게 실행되는지를 이해해야 한다. 우리가 알아야 하는 건 컴퓨터는 생각보다 똑똑하지 않다는 점이다. 그야 물론 똑똑하기야 하다만, 인간에 비할 수는 없단 얘기다.
스레드에게 두 가지 일을 시킨다고 생각해보자. 보통 사람에게 두 가지 일을 해달라고 부탁한다면 하나를 먼저 하고 그 다음 나머지 일을 처리할 것이다. 그러나 컴퓨터 운영체제는 일을 번갈아 수행한다. 1번 스레드를 실행하다가 2번 스레드를 실행하고 또 1번을 실행하고 3번을 실행하는 등 왔다갔다 프로세스를 진행한다.
이런 과정을 컨텍스트 스위치라고 한다.
우스운 건 이 컨텍스트 스위치가 컴퓨터 입장에서도 부담스러운 일이라는 점이다. 컴퓨터는 말했다시피 멍청해서 하던 일을 멈추면 금방 잊어버린다. 따라서 1번 스레드가 하던 일들을 저장하고, 2번 스레드로 전환했다가 다시 저장한 걸 복원한 다음 1번 일을 실행하는 것이다. 당연히 이런 방식엔 연산이 많이 필요할 테고, 스위치를 하는 데 걸리는 시간도 있을 테니 비효율적이라고 할 수 있다. 게다가 컨텍스트 스위치는 무작위로 발생하기에 연산을 두 번 하거나 값을 건너뛰고 연산하는 등 여러 가지 문제를 발생시킨다.
이럴 때 원자성과 일관성을 유지하기 위해 필요한 조치를 동기화라고 부른다. 그리고 그 대표적인 방식이 뮤텍스(임계 영역) 잠금 기법이다.
뮤텍스(임계 영역)
뮤텍스(임계 영역)를 쉽게 설명하자면 화장실로 비유할 수 있다. 화장실을 먼저 선점한 스레드가 볼일을 다 마치기 전까지 다른 스레드들은 그 칸을 이용할 수 없다. 이걸 코드로는 lock()
, unlock()
함수를 이용해서 구현하게 된다.
그런데 잠금 기법에는 심각한 문제가 있다. 그게 바로 아래 설명할 데드락이다.
교착 상태(데드락)
멀티스레드 프로그래밍에서 교착 상태란 두 스레드가 서로를 기다리는 상황을 의미한다. 스레드 1이 스레드 2가 하던 일이 끝날 때까지 기다리는데, 사실 스레드2도 스레드1이 하던 일이 끝날 때까지 기다리는 상황이다. 아, 이건 DB에서도 일어나는 일이었다. 우리가 왜 트랜젝션을 사용하게 되었는지 돌이켜보면 스레드에서의 데드락 또한 쉽게 이해가 간다.
교착 상태를 예방하려면
데드락을 피하기 위해선 여러 뮤텍스의 잠금 순서를 먼저 그래프로 그려두어야 한다. 예를 들어 뮤텍스 A, 뮤텍스B, 뮤텍스C가 있을 때 A, B, C 순서대로 잠금 기회를 얻는 것이다. 여기서 오해하지 않아야 하는 건 중간에 건너 뛰거나 하나쯤 빼먹어도 순서만 지키면 상관 없다는 점이다. 거꾸로 잠그지만 않으면 된다!
기아 상태(Starvation)
교착 상태(데드락)만큼은 아니지만, 또 하나의 문제점이 존재하는데, 그것이 바로 기아 상태(Starvation)이다.
기아 상태란 특정 스레드가 계속해서 실행 기회를 얻지 못하고 무기한 대기하는 상황을 말한다.
기아 상태가 발생하는 이유
기아 상태는 주로 우선순위 기반의 스케줄링에서 발생한다. 예를 들어 우선순위가 높은 스레드들이 계속해서 실행된다면, 우선순위가 낮은 스레드는 실행될 기회를 얻지 못하고 계속 대기하게 된다. 이는 마치 음식점에서 VIP 손님들만 계속해서 먼저 입장하고, 일반 손님들은 계속 대기해야 하는 상황과 유사하다.
뮤텍스에서도 기아 상태가 발생할 수 있다. 만약 여러 스레드가 하나의 뮤텍스를 점유하려고 경쟁하는데, 특정 스레드가 계속해서 점유를 놓지 않는다면, 나머지 스레드는 계속해서 기다릴 수밖에 없다.
기아 상태를 해결하는 방법
기아 상태를 방지하기 위해선 공정한 스케줄링 정책을 적용해야 한다. 대표적인 방법은 다음과 같다.
- FIFO(First In, First Out) 정책
- 먼저 대기한 스레드가 먼저 실행되도록 보장하는 방식이다. 즉, 줄을 서서 기다린 순서대로 처리하는 것이다. 이를 위해 공정한 락(Fair Lock)을 사용하면 된다.
- 에이징(Aging) 기법
- 낮은 우선순위를 가진 스레드가 대기하는 시간이 길어질수록 점점 우선순위를 높여 실행 기회를 얻도록 하는 방식이다.
- 현실 세계에서도 적용되는 기법인데, 예를 들어 공항에서 대기 시간이 너무 길어진 승객이 있다면 우선적으로 탑승 기회를 주는 것과 유사하다.
- 락 프리(Lock-Free) 알고리즘 사용
- 뮤텍스 자체를 사용하지 않고, 비동기적 방법으로 공유 자원을 처리하는 방식이다.
- 대표적인 예로 CAS(Compare-And-Swap) 알고리즘이 있다. 이를 이용하면 별도의 잠금 없이 원자적 연산을 보장할 수 있다.
락 컨텐션(Lock Contention)
뮤텍스를 사용하면 또 하나의 성능 문제가 발생하는데, 그것이 바로 락 컨텐션(Lock Contention)이다.
락 컨텐션이란 여러 스레드가 동시에 같은 락을 얻으려고 경쟁할 때 발생하는 성능 저하 현상을 의미한다.
락 컨텐션이 발생하는 이유
락 컨텐션이 발생하는 이유는 간단하다. 너무 많은 스레드가 하나의 공유 자원(뮤텍스)을 차지하려 하기 때문이다.
이렇게 되면, 스레드들이 서로 뮤텍스를 얻기 위해 기다리는 시간이 증가하게 되고, 결국 프로그램의 성능이 떨어진다.
락 컨텐션을 줄이는 방법
- 락의 범위를 최소화하기
- 뮤텍스를 잠그는 코드 영역을 가능한 짧게 유지해야 한다.
- 예를 들어, 다음과 같이 코드 블록을 최소화하는 것이 좋다.
// 좋지 않은 예
std::mutex mtx;
void critical_function() {
mtx.lock();
do_heavy_task(); // 이 함수가 오래 걸릴 경우 락이 길어짐
mtx.unlock();
}
// 개선된 예
void better_critical_function() {
do_preparation();
{
std::lock_guard<std::mutex> lock(mtx);
update_shared_resource(); // 짧게 락을 걸고 처리
}
do_other_work();
}
- 읽기/쓰기 락(Read-Write Lock) 사용하기
- 읽기 연산과 쓰기 연산을 분리하여, 읽기 연산은 여러 스레드가 동시에 수행할 수 있도록 한다.
std::shared_mutex
같은 공유 락을 사용하면, 읽기 연산은 여러 개 동시 실행, 쓰기 연산은 단독 실행하도록 조절할 수 있다.
- 락 프리 데이터 구조 사용하기
- 앞서 언급한 CAS(Compare-And-Swap) 기법을 활용하면 락 없이도 스레드 간 동기화를 할 수 있다.
- 락 프리 큐(Lock-Free Queue) 같은 자료구조를 활용하면 성능을 크게 향상시킬 수 있다.
'JS > TIL(Today I Learned)' 카테고리의 다른 글
2025-02-04 <최종 프로젝트 D-38> (0) | 2025.02.04 |
---|---|
2025-02-03 (0) | 2025.02.03 |
2025-01-27 <운영체제 개요> (0) | 2025.01.27 |
2025-01-24 (1) | 2025.01.24 |
2025-01-23 <프로토 버프과 one of 문법> (1) | 2025.01.23 |
댓글