[Java] Thread(스레드)에 관한 고찰 - 스레드 관련 코드를 어떻게 짜야할까
이번 포스팅은 스레드를 다루는 방법과 이슈들을 소개합니다.
스레드 이슈에 있어서 간단한 예는 두 스레드가 같은 객체를 공유하면서 서로 간섭하며 예상치 못한 결과를 내놓는 것입니다. 예제는 스레드를 공부하면서 한 번 쯤 봤을 만한 아주아주 간단한 예제입니다. 하지만 왜 그렇게 예상치 못한 결과가 되는지 명확히 설명하는 곳이 없어 책을 참고하여 정리를 해보았습니다.
먼저, 아주 간단하고 대중적인 예로 코딩을 해보았습니다.
계산기로 초기화 된 index 값에서
getNextIndex 를 호출하면 다음 번 인덱스를 반환하는 클래스 입니다.
다음과 같이 두 개의 스레드를 만들고 계산기의 인덱스를 각각 1만번 씩 호출을 해보았습니다. 최적의 시나리오는 각각 Set 에 고유한 숫자 1만개씩 보유하는 경우인데요. 사실 실행을 해보면 각각 1만 개의 고유한 숫자로 예상과 같은 결과를 낼 수도 있고 아닐 수도 있습니다.
1번 스레드가 n 을 얻고 2번 스레드가 n+1 을 얻고 index 는 n+1이 되는 것이 최적의 진행상황 이었을 겁니다.
다음과 같이 두 번째 스레드는 2개의 데이터가 예상과 벗어났습니다.
원인은 1번 스레드가 n 을 얻고 2번 스레드도 n을 얻으며 마지막 인덱스도 n이 되어 다음번 nextIndex 를 계산할 때 중복된 데이터를 가져온 경우입니다.
다시 말하면 1번 스레드와 2번 스레드가 같은 값을 가지고 있고 증가된 인덱스 역시 같은 값을 가진 상태 3군데 모두 같은 값을 가진 상태입니다.
이 경우가 어떻게 가능할까요...?
그러려면 먼저 원자적 연산 (atomic operation) 을 알아야 합니다.
원자적 연산이란 중단이 불가능한 연산을 의미합니다. 즉 연산의 최소 단위를 의미하며 스레드가 절대 간섭할 수 없습니다. 자바 메모리 모델에서는 32 비트 메모리에 값을 할당하는 (int 값을 저장하는) 연산은 원자적 연산, 즉 중단이 불가능한 연산입니다.
즉 index = 0 이라는 연산은 중단이 불가합니다. 조금 더 자세히 들어가 봅시다.
JVM 에서 스레드가 생성될 때 해당 스레드를 위한 stack 이 만들어지죠. 그 스택에는 frame 이라는 것이 들어갑니다. 프레임은 모든 메서드 호출에서 만들어지는 것으로 매개변수, 지역변수, 반환주소를 포함합니다. 그렇다면 index 값을 초기화하는 연산을 바이트 코드로 나타내면,
맨 처음 메서드가 호출 될 때 다음과 같이 this (현재객체) 가 스택에 쌓입니다. 그리고 상수값이 스택에 쌓입니다. 그리고 이 상수 값을 index 변수에 넣어서 초기화를 하고 끝나고 프레임이 비워지게 됩니다. 이 연산의 결과는 상수 값을 초기화를 하는 일이므로 여러 스레드가 수행하더라도 결과는 같게됩니다. 문제는 ++ 연산입니다. (밑의 그림은 바이트 코드를 약식으로 설명한 것이므로 정확한 바이트 코드를 알고싶으면 직접 찾아보길 바랍니다. 전부 모르더라도 스레드를 이해하는데 상관은 없습니다)
스레드 1 번에서 프레임을 만들고 this 를 로딩하고 변수의 ++를 호출하기 위해 값을 가져옵니다 (get field) 예를들어 10을 가져왔다고 하죠. 그런데 갑자기 스레드2 가 끼어 듭니다. 스레드 1은 인덱스를 1증가 시키려고 프레임에 인덱스 값(10)을 로드한 상태로 멈춰버렸습니다. 스레드2가 인덱스를 가져오고 1증가시키고 리턴까지 해버렸습니다. 리턴 값은 11입니다. calculator index 의 값은 11로 증가된 상태로 변했겠죠. 이제 스레드 1이 재개됩니다. 프레임에 올라와 있는 값은 10 이죠. 진행합니다. 1증가 시켜서 11로 만들고 리턴합니다. calculator 의 index 를 다시 11로 덮어 씁니다.
결과적으로 스레드 2가 업데이트 한 index 의 값을 스레드 1이 덮어 써버리기 때문에 값이 똑같은 것입니다. ++ 연산은 원자적이지 않기 때문에 이런 결과가 나타나는 것입니다.
위에서 궁금해 했던 스레드1 , 스레드2, index 가 모두 같은 값인 상태가 되는 문제가 이해가 되겠죠.
참고) JVM 명세에서는 64비트 값을 할당하는 (long 자료형 같은) 연산은 32비트 값을 할당하는 연산 두 개로 나눠집니다. 결국 64비트 초기화는 원자적이지 않죠. long 값을 초기화 할 때 다른 스레드가 끼어들어 값을 바꿀 수 있다는 의미입니다.
저 코드는 어떻게 thread-safe 하게 만들 수 있을까요.
public synchronized int getNextIndex() {
return ++index;
}
임계점에 있는 메서드 (++ 연산)을 synchronized 로 감싸면 됩니다. 이런방식을 보고 동기화를 시킨다고 하죠. 이 방식으로 동기화를 시키면 저 메서드를 수행할 때 다른 스레드가 간섭하지 못하게 됩니다.
이런 방식의 단점이라면 스레드를 차단하는 부분인데요. 스레드를 차단하지 않고 하는 방법도 존재합니다.
바로 AtomicInteger 라는 것을 사용하는 방법입니다. AtomicBoolean, AtomicReference 등 여러 원자 연산을 지원하는 클래스가 있습니다. 자세한 내용은 AtomicInteger 문서를 참고하세요.
앞서 말씀드린 synchronized 의 단점은 스레드가 침범하지 않더라도 항상 락(lock) 을 거는 것입니다. 자바 버전이 올라가면서 내장 락의 성능이 올라가긴 하지만 여전히 비용이 비쌉니다. 이런걸 보고 비관적 잠금 (pessimistic locking)이라고 하죠. 비관적 잠금은 자원 경합이 일어날 것으로 보고 독점적으로 자원을 잠그는 행위입니다.
현대 프로세서는 Compare And Swap (CAS) 이라는 낙관적 잠금 연산을 지원합니다. 이름 그대로 비교하고 잠급니다. 이것은 스레드가 같은 자원으로 경쟁하는 일이 잦지 않다는 가정에서 출발합니다. 락을 거는 쪽보다 문제를 감지하는 쪽이 비용이 적게듭니다. 그렇기 때문에 더 효율적이라고 볼 수 있죠.
즉 CAS 원자적 연산이며 공유변수를 갱신하려 든다면 현재 변수값이 최종으로 알려진 값인지 확인을 하고 맞다면 락을 걸지 않고 데이터를 변경합니다. 그렇지 않다면 다른 스레드가 끼어든 것이므로 갱신하지 않습니다.
이런 CAS 알고리즘을 도입한 컬렉션도 물론 존재합니다. 예를들어 HashMap은 스레드에 안전하지 않습니다.
if(!hashTable.containsKey(key)) {
hashTable.put(key, value);
}
이런식으로 메서드를 만들어서 스레드 세이프하게 방어를 해보려고 해도, if 문을 탄 순간 put을 호출하기 전에 다른 스레드가 hashTable에 값을 추가할 수 있습니다. 결과적으로는 스레드에 세이프하지 않게되죠.
synchronized(map) {
if(!map.containsKey(key)) {
map.put(key, value);
}
}
다음과 같이 락으로 방어를 해야 합니다. 혹은 스레드에 안전한 ConcurrentHashMap 이라는 것을 사용해야 합니다. ConcurrentHashMap은 위에서 설명한 CAS 알고리즘을 도입한 클래스로 스레드에 안전합니다.
메서드 사이의 의존성
다음 예는 아주 골치아픈 경우입니다. 다중 스레드를 잘 방어했다고 생각했지만 결국에 엣지 케이스에서 터져버리는 예입니다.
public class Iterator {
private Integer nextVal = 0;
public synchronized boolean hasNext() {
return nextVal < 100;
}
public synchronized Integer next() {
if (nextVal == 100) {
throw new RuntimeException();
}
return nextVal++;
}
public synchronized Integer getNextVal() {
return nextVal;
}
}
다음의 코드는 스레드 세이프 해보입니다. 하지만 자세히 들여다보면 메서드 사이의 의존성이 존재합니다. 버그가 발생할 부분은 바로 value 가 100 부근인 엣지 케이스에서입니다.
스레드 1에서 next를 호출해 true 를 얻고, next() 를 호출할 찰나에 스레드 2가 끼어들어 선점당했습니다. 스레드 2도 역시 next를 호출해 true를 얻고 next를 호출해버렸습니다. 결국 nextVal은 100이 되어 버렸습니다. 다시 스레드 1이 재개됩니다. 스레드1은 next가 true 였었으므로 값을 가져와도 된다고 생각해 next를 호출합니다. 그럼 터지겠죠.
이런 식으로 알게모르게 락이 걸린 메서드들 사이에 의존성이 생기면 버그를 잡기 굉장히 까다롭습니다.
이런 버그를 잡기위해 클라이언트 쪽에서도 저 클래스를 사용하는 부분에 lock 을 걸 수 있습니다. 이것을 클라이언트-기반 락이라고 하는데 결국에 서버쪽과 코드가 중복이 생기는 Don't Repeat Yourself 라는 원칙을 위배하게 됩니다. 결국 서버-기반 잠금이 더 낫습니다. 리팩토링을 해본다면 다음과 같이 가능할 수 있겠네요.
public synchronized Integer nextOrNull() {
if (nextVal < 100000) {
return nextVal++;
}
return null;
}
(물론 null 반환이 아닌 다른 빈 객체를 반환하든지 해서 안전하게 작성해야 겠죠)
이렇게 서버 쪽에서 잠그는 서버-잠금 기반 모델은 다음과 같은 장점이 있습니다.
코드의 중복이 줄어듭니다. 클라이언트-기반으로 잠그게 되면 코드의 중복이 일어납니다.
오류가 발생할 가능성이 줄어듭니다. 잠금을 잊고 오류가 발생할 위험은 서버 개발자 1명으로 제한됩니다.
스레드 정책이 하나입니다. 클라이언트-기반 잠금은 정책을 여러 곳에서 구현해야 하지만 서버-기반 잠금은 서버 한 곳에서 정책을 구현합니다.
서버 코드 쪽에 손을 대지 못하는 상황이라면 클라이언트쪽에서 사용하는 객체를 어댑터 패턴으로 변경해 잠금을 걸고 사용합니다.
이런식으로 방어를 하지만 결국 중요한 것은 원칙을 지키는 일입니다.
동시성 방어 원칙
단일 책임 원칙
단일 책임원칙은 주어진 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 것입니다. 동시성에 관한 내용은 '동시성'이라는 복잡성 하나만으로 분리할 이유가 충분합니다. 그렇기 때문에 동시성 코드는 다른 코드와 분리해야 합니다.
임계영역 보호
공유 객체를 사용하는 코드 내 임계영역(critical section)을 synchronized 키워드로 보호하는 것이 권장됩니다. 여기서 굉장히 중요한 부분은 정말 필요한 임계영역을 구분하는 일입니다. 임계영역 개수를 줄인다고 거대한 영역 하나에 몰아 넣는 것은 프로그램 성능이 떨어지는 원인입니다. 필요한 부분에 최대한 작게 설정해야 합니다.
사본 이용
공유 객체를 피하는 방법이 있다면 코드 상에서 문제를 일으킬 가능성이 낮아집니다. 사본을 이용하여 동기화를 피할 수 있다면 synchronized 를 수행하는 비용을 없앨 수 있어 부하를 낮추는 것이 가능합니다. 단, 객체를 복사하는 비용을 잘 고려해야 합니다.
라이브러리 활용
자바로 스레드 관련 코드를 작성할 때는 스레드 환경에 안전한 컬렉션을 사용하거나 executor 라는 프레임워크를 이용합니다. 혹은 관련 클래스를 이용하면 됩니다.
ReentrantLock: 한 메서드에서 잠그고 다른 메서드에서 푸는 락
Semaphore: 개수를 가지고 있는 락
CountDownLatch: 지정한 수만큼 이벤크가 발생하거 대기 중인 스레드를 모두 해제시킨다. 모든 스레드에게 공평하게 시작할 기회를 줌
스레드 실행 모델
간단하게 스레드 실행 모델 용어를 정리하고 넘어갈 필요가 있습니다.
Round Resource (한정된 자원): 멀티 스레드 환경에서 사용하는 자원으로 갯수에 제한이 있습니다. 예를들어, 데이터베이스 연결이나 길이가 일정한 읽기/쓰기 버퍼 등입니다.
Mutual Exclusion (상호 배제): 한 번에 한 스레드만 공유 자원을 사용할 수 있는 경우를 말합니다.
Starvation (기아): 한 스레드나 여러 스레드가 오랜시간 혹은 영원히 자원을 기다리는 경우입니다. 예를들어, 짧은 스레드에게 우선순위를 부여하고 그것이 계속 지속될 경우 긴 스레드가 기아 상태에 빠질 수 있습니다.
DeadLock (데드락): 여러 스레드가 서로 끝나기를 기다리는 경우입니다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 아무도 진행을 할 수 없는 상태를 말합니다.
LiveLock (라이브락): 락을 거는 단계에서 각 스레드가 서로를 방해하는 경우입니다.
여기서 데드락이 발생할 조건은 상호 배제, 잠금&대기, 선점 불가, 순환 대기 라는 네 가지 조건을 만족시킬 때 발생합니다.
상호 배제: 여러 스레드가 한 자원을 공유하는데 그 자원이 동시 사용을 하지 못하고 개수가 제한적인 것입니다. 예를들어 데이터베이스 연결이 있습니다.
잠금&대기: 자원을 점유한 스레드가 일을 마칠 때 까지 자원을 들고 있는 경우입니다.
선점 불가: 한 스레드가 다른 스레드의 자원을 뺐지 못하는 경우입니다.
순환 대기: deadly embrace (죽음의 포옹) 라고 불리는 순환대기는 두 스레드가 서로 필요한 자원을 각기 들고 있는 상황입니다.
데드락 방지하기
상호 배제 깨기: 동시에 사용해도 괜찮은 자원을 이용합니다. (AtomicInteger 나 thread-safe 한 collection 등) 그리고 자원을 쓰기 전에 쓸 수 있는지 확인하며 스레드 수보다 자원수를 늘립니다. 하지만 데이터베이스 커넥션 처럼 수를 많이 늘릴 수 없는 경우는 할 수 없습니다.
잠금 & 대기 깨기: 각 자원을 쓰려고 확인하는데 아무것도 쓸 수 없는 상태라면 지금까지 점유한 모든 자원을 내놓고 다시 시작합니다. 하지만 이것은 위에 언급한 기아 문제가 발생하거나 라이브 락(live lock) 처럼 계속 자원을 점유했다 내놨다를 반복하는 문제가 있을 수 있습니다.
선점 불가 조건 깨기: 스레드의 자원을 뺐어 오게 하는 방법이지만 요청을 관리하기가 쉽지 않다는 점이 있습니다.
순환 대기 조건 깨기: 가장 흔한 전략으로 자원을 쓰는 순서를 지정하는 것입니다. 예를들어 t1 t2 순으로 자원을 쓰게 하는 것입니다. 그러면 t2를 쓰는 스레드는 t1을 기다리지 않습니다. (이미 썼으므로) 단점으로는 자원을 할당한 순서와 사용하는 순서가 다를지도 모른다는 점과 순서에 따라 자원을 할당하기 어려운 문제가 있을 수 있습니다.
결국 애플리케이션의 최적의 전략을 찾는 것이 중요합니다.
스레드 프레임워크
자바에서는 Executor 라는 프레임워크를 지원합니다. 이 프레임워크는 스레드 풀링으로 정교한 실행을 지원합니다. 그리고 스레드 풀을 관리하고, 풀 크기를 조정하는 등 다양한 지원을 해줍니다. 게다가 Future 라는 것을 사용해 결과값이 나올 때 까지 기다릴수 있습니다. Future 는 독립된 연산이 모두 끝나기를 기다릴 때 유용합니다.
맨 처음 계산기 예제에는 10만번의 연산을 기다리기 위해 강제로 sleep을 주었는데 future 를 사용한다면 연산이 끝날 때 까지 기다릴 수 있게 설정할 수 있습니다.
위의 계산기 예제를 executor 를 사용한 코드로 변경해보겠습니다.
Runnable 과 달리 Callable 은 반환형이 있습니다. 일단 동작 확인을 위해 아주 대충 만들어 보았습니다. ExecutorService 는 스레드 풀의 개수를 지정할 수 있습니다. 각 스레드에서 독립적으로 연산을 실행한 후 isDone 메서드를 호출하여 검사합니다.
스레드 코드 테스트
스레드 코드는 테스트 하기 어렵습니다. 방법은 문제를 노출하는 테스트 케이스를 작성하는 것입니다. 바로 위의 예제는 메인 메서드에서 스레드 상태를 확인만 한 것입니다. 실제로는 테스트 코드를 작성해서 검증해야 합니다. 하지만 스레드 테스트는 간헐적으로 실패합니다. 많이 돌려봐도 문제 없이 돌아갈 때도 있습니다.
몬테 카를로 테스트: 실패를 증명하기 위해 조율이 가능하게 테스트를 만듭니다. 시스템을 배치할 플랫폼에서 전부 반복해서 테스트를 돌립니다. 계속해서 돌린다면 코드가 올바르거나 아직 테스트가 부족한 경우가 있겠죠. 부하가 변하는 장비에서 테스트를 합니다. 할 수 있는 한 최대한으로 테스트를 하는 방법입니다.
혹은 스레드 코드 테스트를 도와주는 도구를 사용합니다. 이 부분은 추후 공부하여 정리해 보려 합니다.
이번 내용에서는 스레드에서 대해서 간단히 알아보았는데 다루기 까다로운 친구임에 틀림 없습니다. 다음에는 스레드에 대해서 추가적으로 더 다뤄볼 예정입니다.
참조 - Clean Code (로버트C.마틴)