지난 글에서 프로세스와 쓰레드에 대해 알아봤는데 이번엔 멀티 쓰레드 환경에서 발생 가능한 여러 상황에 대해 이야기 해볼까 한다. 멀티 스레드 환경에서 발생할 수 있는 문제를 이해하기 위해서는 우선 동시성에 대해 알아야 한다.
동시성
동시성은 동시에 진행되는 것처럼 보이지만, 실제로는 여러 작업을 번갈아가며 실행되는 것을 동시성이라고 한다. 동시성을 이야기할 때 많이 언급되는 것 중 하나인 병렬성은 실제로 여러 작업을 동시에 실행한다. 그렇다면 동시성은 어떻게 동시에 실행되는 것처럼 보일 수 있게 한걸까?
그림과 같이 동시성은 Context Switch(문맥 교환)으로 인해 동시에 실행되는 것처럼 보이지만 실제로 쓰레드 1을 조금 실행하고 멈췄다가 다시 쓰레드 2를 조금 실행하고 멈추는 방식이다. Context Switch는 현재의 작업의 상태(메모리 상태, 주소 값 등)를 저장하고 다른 작업으로 전환할 때 작업 상태를 복원하는 과정을 말한다. 그렇기 때문에 Context Switch는 스레드 뿐만 아니라 프로세스 등 모든 실행 단위에 적용되는 개념이다. 마지막으로 병렬성의 경우 멀티코어 환경에서만 구현이 가능하며, 물리적으로 동시에 실행되는 것을 의미하기 때문에 단일 코어에서는 구현할 수 없다.
이제 본론으로 들어와서 멀티 쓰레드 환경에서 만날 수 있는 문제는 대부분 동시성과 관련이 있다. 여러 쓰레드가 동시에 공유 자원에 접근하거나 자원 상태를 변경하는 과정에서 충돌이 생기기 때문이다. 여기서 잠깐, 의문이 생긴다. 🧐…
왜 동시성(Concurrency)이 문제가 될까?
멀티 스레드는 기본적으로 동시성(Concurrency)를 가진다. 참고로 멀티스레드를 멀티코어 환경에서 병렬성으로 구현하더라도 동시성을 포함하기 때문에 스레드 간의 동시성 문제는 여전히 발생한다.
동시성의 본질은 “작업을 나누고, 교대로 실행” 하는 것인데, 이 과정에서 문제가 발생한다. “작업을 나누고” 라는 것은 “작업을 완전히 끝내고”가 아니다. 위에서 봤던 동시성을 자세히 보면 Thread 1의 작업을 진행하다가 Context Switch가 일어나면서 교대로 Thread 2 작업이 실행되었다가 다시 Thread 1 작업을 실행한다. 이때 작업 간의 중간 상태로 인해 공유 자원이 엉키는 문제가 발생할 수 있다.
예를 들어 여러 스레드가 같은 자원(변수, 데이터베이스 등)에 접근할 때, 하나의 작업이 끝나기 전에 다른 스레드가 그 자원에 접근하려고 하면 데이터가 꼬이거나, 작업 도중 자원을 확보한 스레드가 다른 스레드에게 자원을 넘겨주지 않는 문제 등이 생길 수 있다. 동시성의 문제는 결국 스레드가 작업을 끝내지 않았기 때문에 다른 스레드에게 자원을 넘겨주지 못하거나 다른 스레드가 접근하려고 하거나 등의 문제로 인해 발생된다.
특히나 멀티 쓰레드 환경에서는 쓰레드 간의 자원이 독립적이지 않고 특정 데이터나 객체를 공유하는 경우가 많은데 한 쓰레드가 데이터를 읽는 동시에 다른 쓰레드가 데이터를 변경하려고 하면, 데이터 충돌이나 원치 않은 결과를 발생 시킬 수 있다.
int counter = 0 ;
// Thread A : counter++;
// Thread B : counter--;
예를 들어 두개의 쓰레드가 같은 데이터를 사용한다고 가정 했을 때 counter++
는 단일 연산처럼 보이지만 실제 내부적으로 counter의 현재 값을 읽고, 현재 값에 +1을 하고, 결과를 다시 counter에 저장하는 3단계 작업으로 진행한다. 동시에 counter--
도 실행되면, 두 쓰레드가 서로의 작업에 간섭하면서 최종 결과가 예상과 다르게 나올 수 있다. 이러한 현상을 레이스 컨디션(Race Condition) 이라고 한다.
이 문제를 해결하려면 어떻게 해야될까 ? 🤔
- 락(lock)
- synchronized, ReentrantLock으로 코드 블록을 보호해, 한 번에 한 쓰레드만 접근할 수 있도록 락을 거는 방법이다.
- 원자적 연산
- AtomicInteger를 사용하게 되면 락 없이도 원자적 연산을 할 수 있다.
모든 멀티 쓰레드가 위에서 언급한 해결 방법으로 모든 걸 해결할 수 있다면 좋겠지만 그렇지 않다. 특히나 락(lock)은 동시성 문제를 해결하는 방법이자 문제를 발생시키는 원인 중 하나이다. lock로 인해 발생할 수 있는 문제들을 보통 lock contention (락 경쟁) 이라고 부르며, 락 경쟁은 동시성 환경에서 발생할 수 있는 문제 중 하나다. 동시성 문제 초점은 작업 간의 간섭이나 데이터 일관성 등의 문제지만 동시성이 락 경쟁의 배경을 제공하기 때문에 동시성 문제로 보는 것이다.
철학자의 점심식사
철학자의 점심식사 문제는 동시성 문제와 락을 이해하고 해결하는데 가장 도움되는 대표적인 예시다.
철학자의 점심식사 룰은 아래와 같다.
- 철학자가 원형 테이블에 앉아 있다.
- 철학자는 생각하거나 밥을 먹는 두 가지 행동할 수 있다
- 테이블 위에는 접시와 젓가락이 있다.
- 밥을 먹으려면 철학자는 양손에 젓가락 두 개를 들어야 한다. (자신의 왼쪽과 오른쪽 젓가락)
- 철학자는 다음 규칙을 따른다:
- 동시에 두 개의 젓가락을 들 수 없다
- 젓가락 두 개를 모두 집을 수 있을 때만 밥을 먹을 수 있다.
- 그렇지 않으면 계속 기다려야 한다.
철학자 문제는 락 경쟁(lock contention), 데드락(deadlock), 기아(starvation) 등 모든 공유 자원(=젓가락)과 쓰레드(=철학자)가 상호작용하는 과정에서 나타난다.
락 경쟁(lock contention)
락 경쟁은 철학자들이 동시에 젓가락(공유 자원)을 잡으려고 할때 경쟁 상태에 빠져 대기 시간이 늘어나거나 성능이 저하되는 것을 말한다. 철학자A와 철학자 B가 동시에 자신들의 왼쪽 젓가락을 잡으려고 시도하지만 젓가락은 하나 뿐이기 때문에 서로 것가락을 얻기 위해 경쟁하게 된다. 멀티 쓰레드 환경으로 본다면 여러 쓰레드가 동일한 자원에 락(lock)을 얻으려 하면서 서로 경쟁하는 상황과 비슷하다.
synchronized (sharedResource) {
// 하나의 쓰레드만 접근 가능
}
예를 들어 synchronized 블록 안에 자원을 여러 쓰레드가 동시에 요청하게 된다면 서로 락을 얻기 위해 경쟁을 하게 된다.
락 경쟁의 해결방법
기본적인 락 구현 (synchronized, ReentrantLock)에는 자동으로 다른 쓰레드가 접근 시, 대기하도록 설계 되어 있다.
-
읽기- 쓰기 락 (Read-Write Lock) : 기본적인 락 구현은 읽기 작업도 쓰기 작업도 전부 막기 때문에 단순히 읽는 작업에도 락이 걸린다. 락 경쟁을 최소화 하는 것이 락 경쟁의 근본적인 해결 방법이기 때문에 읽기 작업에는 동시 접근을 허용하면서 쓰기 작업만 독접하도록 설정 하는 것 좋다. 읽기 작업은 공유 데이터의 상태를 변경하지 않기 때문에 동시에 실행해도 안전하다.
-
타임 아웃(TimeOut) : 락 경쟁 상황은 언제나 찾아 올 수 있기 때문에 무한 대기를 우선적으로 방지하기 위해 락 요청에 타임아웃을 설정할 수 있다. 일정 시간동안 락을 획득하지 못한 쓰레드는 다른 작업을 수행하거나 재시도 할 수 있도록 한다.
데드락(deadlock)
데드락은 모든 철학자가 왼쪽 젓가락을 잡은 상태에서 오른쪽 젓가락을 잡으려고 하지만, 이미 다른 철학자들이 오른쪽 젓가락을 모두 잡고 있어 아무도 젓가락을 내려놓지 않고 서로를 무한히 기다리는 상황을 말한다. 멀티 쓰레드 환경으로 본다면 쓰레드 A가 자원 1을 락(lock)을 건 상태로 자원 2를 기다리고, 쓰레드 B가 자원 2를 락(lock)을 건 상태로 자원 1을 기다릴 때 발생한다. 이러한 상황은 철학자와 마찬가지로 쓰레드 A와 B가 서로 상대방의 락 해제를 기다리면서 무한 대기에 빠지게 되는 것을 데드락이라고 한다.
// Thread A
synchronized (resource1) {
synchronized (resource2) {
// 작업 수행
}
}
// Thread B
synchronized (resource2) {
synchronized (resource1) {
// 작업 수행
}
}
데드락 해결방법
데드락도 락 경쟁과 마찬가지로 제일 쉬운 해결 방법은 타임아웃이지만, 세마포어(Semaphore)로 데드락을 방지하는 게 제일 좋은 방법이다.
세마포어를 철학자의 점심식사에 적용한다면 최대 4명까지만 동시에 젓가락을 잡을 수 있도록 설정하여 데드락을 방지할 수 있다. 데드락은 사실 쓰레드가 자원에 대한 락을 교착 상태로 걸면서 발생 한 것이 때문에 중간에 끊어만 주어도 해결 할 수 있으니 락 순서를 정하거나 자원 개수 제한을 하거나 타임아웃을 거는 것이 좋다.
데드락은 예방(prevention)이 핵심이기 때문에 만약 이미 데드락이 발생한 상황이라면 데드락 상태에 있는 쓰레드를 강제로 종료해서 자원을 해제 해줘야 한다…
기아 (Starvation)
기아는 특정 철학자가 젓가락을 계속 잡지 못해서 밥을 먹지 못하는 상황을 말한다. 멀티 쓰레드 환경으로 보자면 우선 순위가 낮은 쓰레드가 높은 우선순위의 쓰레드에 의해 자원을 계속해서 얻지 못해서 작업을 수행하지 못하는 상황을 말한다.
Thread highPriorityThread = new Thread(() -> {
while (true) {
// 높은 우선순위의 작업
}
});
Thread lowPriorityThread = new Thread(() -> {
while (true) {
// 낮은 우선순위 작업
}
});
기아의 해결방법
우선순위에 의해 밀려나서 발생된 현상이기 때문에 락을 요청한 순서대로 쓰레드가 락을 얻도록 공정 락을 사용하거나 우선순위를 재조정하는 것이 해결방법이다. 우선순위 재조정의 경우 낮은 우선순위 쓰레드가 일정 시간이 지나면 우선순위를 높이는 방식이 있지만 제일 간편한 것은 공정하게 요청한 순서대로 처리하는 것이 좋다.
위에서 원자적 연산과 락에 대해서 간단하게 정리했는데 이제 멀티 쓰레드에서 안전해질 수 있을까? 🤔 당연하게도 그렇지 않다. 쓰레드 간의 데이터 불일치나, 쓰레드 풀 과부하, 스택 오버플로우 등 정말 다양한 문제가 발생할 수 있다.
- 스레드 간 데이터 불일치 문제
한 쓰레드에서 데이터를 수정하고 다른 쓰레드에서 이 데이터를 읽을 때, 읽기/쓰기 작업이 중복되거나 누락되는 경우 데이터 불일치 문제가 발생할 수 있다.
boolean flag = false;
// 쓰레드 1
flag = true;
// 쓰레드 2
if (flag) {
// 작업 수행
}
멀티 쓰레드 환경은 강력하지만 동시성 관련 문제가 제대로 처리되지 않으면 실제 서비스 운영시 많은 문제가 된다. 위 상황을 염두해두고 동기화 메커니즘과 테스트 도구, 분석 툴 등 활용하는 것이 좋다.