멀티쓰레딩(Multithreading)
하나의 프로세스 내에서 여러 개의 스레드를 동시에 실행하는 프로그래밍 방식.
비동기(Asynchronous) 는 어떤 작업을 요청한 후 그 작업이 완료되기 전에도 다른 작업을 수행할 수 있는 프로그래밍 방식을 의미.
스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다.
따라서 이 두 요소를 분리하는 것이 동시성을 가지도록 프로그래밍을 한다면, 구조와 효울이 극적으로 나아진다.
기본적으로 웹 애플리케이션에서 클라이언트의 요청을 독립적인 서블릿 스레드로 처리
➡️ 결합분리 전략은 완벽과는 거리가 멀다. 동시성을 정확히 구현하는 것은 정말 어려움
동시성은 때로 성능을 높여준다.
- 여러 프로세스가 동시에 처리할 독립적인 계산이 충분히 많은 경우
- 대기시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있는경우
두 경우 모두 흔한 경우는 아니다.
단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다.
컨테이너가 어떻게 동작하는지 알아야, 데드락 같은 문제를 피할 수 있다.
- 동시성은 다소 부하를 유발한다.
- 동시성은 복잡하다.
- 동시성 버그는 재현하기 어렵다 . ex) 데드락
- 동시성을 구현하려면 근본적인 설계 전략을 재고해야 함.
SRP는 클래스나 모듈을 변경할 이유가 하나뿐이어야 한다는 원칙이다.
동시성은 복잡성 하나만으로도 분리할 이유 충분.
동시성을 구현할 때는 다음 몇 가지를 고려한다.
- 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
- 동시성 코드에는 다른 코드보다 훨씬 어려운 독자적인 난관이 있다.
- 잘못 구현한 동시성 코드는 갖가지 방식으로 실패한다.
- 권장사항: 동시성 코드는 다른 코드와 분리하라!
코드 내 임계영역(critical section) 을 최소한으로 하라.
공유 자료가 많을수록 다음 부작용들이 발생한다.
- lock을 걸어주어야할 임계영역을 놓치는 경우 ➡️ 공유 자료를 수정하는 모든 코드가 망가진다.
- 모든 임계영역을 올바르게 보호했는지 확인하느라 똑같은 노력과 수고 반복
- 버그들을 더더욱 찾기 어려워짐
- 권장사항 : 자료를 캡슐화하고, 공유 자료를 최대한 줄여라!
처음부터 공유자료를 사용하지 않고, 참조 대신 복사해서 사용하기.
복사 비용과 동기화 에러 해결 비용과 저울질 해보자.
스레드간의 자료 공유는 최대한 피하고, 한 스레드는 클라이언트 요청 하나를 맡아 처리한다.
자바 5로 스레드 코드를 구현한다면 고려해야할 사항들
- 자바 5부터 제공하는 스레드 환경에 안전한 컬렉션 사용.
- 서로 무관한 작업을 수행할 때는 executor 프레임워크 사용
- 가능하다면 스레드가 차단되지 않는 방법 사용
- 일부 클래스 라이브러리는 스레드에 안전하지 x
java.util.concurrent 패키지가 제공하는 클래스는 대개 다중 스레드 환경에서 사용해도 안전하고, 성능이 좋다.
실제로 ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 빠르다.
유용한 패키지들 : java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks
멀티쓰레딩의 기본 용어 정리
- 한정된 자원(Bound Resource): 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다. 데이터베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등.
- 상호 배제(Mutual Exclusion): 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다.
- 기아(Starvation): 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다.
- 데드락(Deadlock): 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못한다.
- 라이브락(Livelock): 라이브락은 두 스레드가 락의 해제와 획득을 무한 반복하는 상태이다. 라이브락은 데드락을 피하려는 의도에서 수정한 코드가 불완전할 때 발생하곤 한다.
라이브락 예시
// Thread 0
int done = 0;
while(!done) {
획득(A)
if (획득시도(B) == true) {
어떤작업()
해제(B)
해제(A)
done = 1;
}
else {
해제(A)
}
}
// Thread 1
while(!done) {
획득(B)
if (획득시도(A) == true) {
어떤작업()
해제(A)
해제(B)
done = 1;
}
else {
해제(B)
}
}
공유 클래스 하나에 동기화된 여러 메서드 사이의 의존성을 파악하라.
이왕이면, 공유 객체 하나당 메서드 하나만 사용하라.
어쩔수 없이 메서드 여러개를 사용해야 하는 상황이면 다음 규칙을 지킬 것.
- 클라이언트에서 잠금 - 클라이언트에서 동기화된 메서드를 사용하는 부분 전체에 lock을 건다.
- 서버에서 잠금 - 동기화된 메서드를 사용하는 부분전체에 lock을 걸었다가 푸는 메서드를 따로 구현하고, 클라이언트는 해당 메서드를 호출한다.
- 연결 서버 - 잠금을 수행하는 중간 단계를 생성한다.
임계영역은 반드시 보호해야 하지만, 그 수를 최대한 줄여야 한다.
만약 생산자 쓰레드가 먼저 종료되고 , 소비자 쓰레드는 생산자 쓰레드의 시그널을 기다린다면,
시스템은 영영 종료되지 못한다.
권장사항 : 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현할 것
권장사항 : 데드락 등 문제를 노출하는 TC를 작성하고, 테스트가 실패하는 원인을 추적할 것
- 말이 안되는 실패는 잠정적인 스레드 문제로 취급할 것.
- 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 할 것.
- 다중 스레드를 쓰는 코드 부분을 다양한 설정으로 실행하기 쉽게 구현할 것.
- 다중 쓰레드를 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성할 것.
- 다른 플랫폼에서 돌려볼 것.
- 코드에 보조 코드를 넣어 돌려볼 것. 강제로 실패를 일으키게 하라.
- object.wait()등 메서드를 추가해 다양한 순서로 코드를 실행 시킨다.
- ConTest등의 도구를 활용한다.
스레드 코드는 올바르게 구현하기 어렵다.
SRP를 준수해야 하고, 쓰레드를 테스트할 때는 전적으로 쓰레드만 테스트해야한다.
또, 동시성 오류를 일으키는 잠정적인 원인들을 철저히 이해한다.
사용하는 라이브러리와 기본 알고리즘을 이해하고 , 특정 기능이 어떻게 작동하는지 파악한다.
LOCK은 최대한 작은 구간에 최대한 적게 건다.