Clean Code 내용 정리; 13장 동시성
여러 스레드를 동시에 돌리는 이유와 어려움, 이런 어려움에 대처하고 깨끗한 코드를 작성하는 방법을 제안하며 동시성(Concurrency)을 테스트하는 방법과 그 문제점에 대해 이야기한다.
동시성(Concurrency)이란?
동시성: '무엇'을 '언제'까지 돌릴지 하는 것에 대한 결합(Coupling)* 을 분리하는 전략.
*결합도: 어떤 모듈이 다른 모듈에 의존하는 정도
- 동시성은
때로 성능을 높여준다.
프로그램이 거대한 루프가 아닌 작은 협력 프로그램으로 비춰지며, 시스템 자체를 이해하기 쉽고 문제를 분리하기도 쉬워지고, 응답 시간과 작업 처리량을 개선시킬 수도 있다. 그러나, 항상 성능을 높여주는 것이 절대 아니다. 오히려 동시성은 성능 측면에서 다소 부하를 유발하며, 코드도 더 짜야 한다.
- 동시성을 구현하면 '무엇'과 '언제'가 분리되기 때문에
애플리케이션 구조가 변하고 단일 스레드 시스템과 설계 자체가 달라진다.
그렇기에 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.
- 동시성을 구현하는 것, 다중 스레드 코드를 깔끔하게 짜는 것은 매우 어렵다.
아무리 간단한 문제라도 동시성은 복잡하다.
또한 일반적으로 동시성 버그는 재현하기 어렵다.
동시성을 구현하기 어려운 이유는?
두 스레드가 같은 변수를 동시에 참조하면, 두 스레드가 자바 코드 한 줄을 거쳐가는 수많은 경로 중에 잘못된 결과를 내놓는 일부 경로가 존재하기 마련이다.
public class X {
private int lastIdused;
public int getNextId(){
return ++lastIdUsed;
}
}
예를 들어 인스턴스 X를 생성하고 lastIdUsed 필드를 42로 설정한 다음, 두 스레드가 해당 인스턴스를 공유하며 각각의 스레드가 getNextId();를
호출한다고 가정하자.
한 스레드는 43을, 다른 스레드는 44를 받을 수도 있지만 둘다 43을 받는 놀라운 결과를 내놓는 경로가 있다. 대다수의 경로는 올바른 결과를 내놓지만, 잘못된 결과를 내놓는 이 일부의 경로가 문제가 된다.
동시성 방어 원칙
동시성 코드가 일으키는 위와 같은 문제로부터 시스템을 방어하는 원칙과 기술을 알아보자.
1. 단일 책임 원칙(SRP)
동시성은 그 복잡성 하나만으로도 따로 분리할 이유가 충분하다. 동시성 관련 코드는
다른 코드와 분리
하자.
자료를 캡슐화하고 공유 자료를 최대한 줄여라.
객체를 사용하는 코드 내 임계 영역(Critical Section)* 을 synchronized 키워드로 보호한다.
*임계 영역(Critical Section): 둘 이상의 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하는 코드의 일부
공유 자료를 줄이기 위해 처음부터 자료를 직접 공유하지 말고,
자료 사본을 사용
하라.
독자적인 스레드로, 가능하면 다른 프로세서에서 돌려도 괜찮도록 자료를 독립적인 단위로 분할하라. 다른 스레드와 자료를 공유하지 않고, 각 스레드는 클라이언트 요청 하나를 처리하며 모든 정보는 비공유 출처에서 가져오고 로컬 변수에 저장하도록 만들어서
자신만의 세상에 존재하는 스레드를 구현
하라.
2. 라이브러리를 이해하라
언어가 제공하는 클래스를 검토하여 스레드 환경에 안전한 컬렉션을 사용하라. 일부 클래스 라이브러리는 스레드에 안전하지 못하다.
ex) 자바의 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks
서로 무관한 작업을 수행할 때는 executor 프레임워크를 사용하고, 가능하다면 스레드가 차단되지 않는 방법을 사용한다.
3. 실행 모델을 이해하라
다중 스레드 프로그래밍에서 사용하는 기본 실행 모델들과 각 해법을 이해하라.
우선, 기본 용어부터 이해하자.
<기본 용어>
용어 | 설명 |
---|---|
한정된 자원(Bound Resource) | 다중 스레드 환경에서 사용하는 자원. 크기나 숫자가 제한적. ex) 데이터 베이스 연결, 길이가 일정한 읽기/쓰기 버퍼. |
상호 배제(Mutual Exclusion) | 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우. |
기아(Starvation) | 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다리는 것. 짧은 스레드에게 우선순위를 주고 짧은 스레드가 지속적으로 이어지면 긴 스레드가 기아 상태에 빠짐. |
데드락(Deadlock) | 여러 스레드가 서로 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못한다. |
라이브락(Livelock) | 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 진행하려 하지만, 공명(resonance)로 인해 굉장히 오랫동안 혹은 영원히 진행하지 못한다. |
기본 개념을 이해했으니 이제 다중 스레드 프로그래밍에서 사용하는 기본 알고리즘 몇 개를 살펴보자.
- 생산자-소비자(Producer-Consumer)
- 하나 이상의 생산자 스레드가 정보를 생성해 버퍼나 대기열(한정된 자원)에 넣음.
- 하나 이상의 소비자 스레드가 대기열에서 정보를 가져와 사용.
- 생산자 스레드는 대기열에 빈 공간이 있어야 정보를 넣고, 즉 빈 공간을 기다리고, 소비자 스레드는 대기열에 정보가 있어야 가져온다. 즉, 정보를 기다린다.
- 생산자 스레드와 소비자 스레드는 서로에게 대기열의 상태에 대한 시그널을 보낸다. (생산자는 정보를 채운 후 소비자에게 정보가 있다는 시그널을, 소비자는 정보를 읽은 후 대기열에 빈 공간이 있다는 시그널을)
- 둘 다 진행 가능함에도 불구하고 동시에 서로에게서 시그널을 기다릴 가능성이 있다.
- 읽기-쓰기(Readers-Writers)
- 읽기 스레드를 위한 주된 정보원으로 공유 자원을 사용하고, 쓰기 스레드가 공유 자원을 간헐적으로 갱신한다.
- 읽기 스레드의 '공유 자원 읽기'와 쓰기 스레드의 '공유 자원 갱신'이 중첩되지 않도록 복잡한 균형잡기가 필요하다.
- 쓰기 스레드가 버퍼를 오랫동안 점유하면 여러 읽기 스레드가 버퍼를 기다리느라 처리율이 떨어진다.
- 읽기 스레드가 없을 때까지 갱신을 원하는 쓰기 스레드가 버퍼를 기다리면 쓰기 스레드가 기아 상태에 빠진다.
- 처리율과 기아 현상 사이에서 균형을 잡으며 동시 갱신 문제를 피하는 해법이 필요하다.
- 식사하는 철학자들(Dining Philosophers)
- 철학자들이 둥근 식탁에 둘러 앉아 있고, 각 철학자의 왼쪽에는 포크가 놓여 있으며 식탁 가운데에 음식이 있는 상태다.
- 철학자들은 배가 고프지 않으면 생각하며 시간을 보내고, 배가 고프면 양손에 포크를 들고 음식을 먹는다. 양손에 포크를 쥐지 않으면 먹지 못한다.
- 철학자를 스레드로, 포크를 자원으로 바꿔서 생각해보면, 기업 애플리케이션에서 여러 프로세스가 자원을 얻으려 경쟁하며 겪는 문제가 된다.
- 주의해서 설계하지 않으면 데드락, 라이브락, 처리율 저하, 효율성 저하 등을 겪는다.
일상에서 접하는 대부분의 다중 스레드 문제는 조금씩 형태가 다르더라도 위 세 범주 중 하나에 속한다. 실전 문제에 부딪혔을 때 해결이 쉬워지도록, 위의 세 알고리즘을 공부하고 해법을 직접 구현해보자.
4. 동기화하는 부분을 작게 만들어라
- 동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾기 어려운 버그가 생기므로 공유 객체 하나에는 메서드 하나만 사용하도록 한다.
- 공유 클래스 하나에 동기화된 메서드가 여럿이라면, 구현이 올바른지 다시 한 번 확인하고 (1) 클라이언트에서 잠금, (2) 서버에서 잠금, (3) 연결 서버 중 하나의 방법을 택한다. 셋 다 공통적으로 클라이언트가 모든 메서드를 호출할 때까지 서버를 잠금하는 것이다. 어디에서 잠금 하느냐의 문제일 뿐.
- 자바에서 synchronized 키워드를 사용하여 설정한 락에 포함된 코드는 한 번에 한 스레드만 실행이 가능하다. 락은 스레드를 지연시키고 부하를 가중시키기 때문에 여기저기서 synchronized를 남발해선 안 된다. 그러나 임계영역은 반드시 락으로 보호해야 하므로, 코드를 짤 때는 임계영역 수를 최대한 줄여야 한다.
스레드 코드 테스트하기
문제를 노출하는 테스트 케이스를 작성하고, 프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라. 또한, 테스트가 실패하면 원인을 추적하라. 구체적인 지침은 다음과 같다.
1. 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라.
다중 스레드 코드가 종종 '말이 안 되는' 오류를 일으키긴 하지만, 시스템 실패를 단순한 '일회성' 문제로 치부하고 무시해서는 안 된다.
'일회성' 문제를 계속 무시한다면 잘못된 코드 위에 코드가 계속 쌓인다.
2. 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 말고, 먼저 스레드 환경 밖에서 코드가 제대로 도는지
반드시 확인해야 한다.
3. 다중 스레드를 쓰는 코드 부분을 유연하게 구현하라
- 다중 스레드를 쓰는 코드를
다양한 설정으로 실행하기 쉽게
구현한다. 한 스레드나 여러 스레드, 실행 중 스레드 수를 바꾸며 실행해보고, 스레드 코드를 실제 환경이나 테스트 환경에서 돌려보고, 테스트 코드를 다양한 속도로 돌려보고 반복 테스트가 가능하도록 테스트 케이스를 작성한다. 이와 같이 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현하라.
- 다중 스레드를 쓰는 코드 부분을 상황에 맞춰서 조정할 수 있게 작성한다. 적절한 스레드 개수를 파악하기 위해서는 상당한 시행착오가 필요하기에 스레드 개수를 조율하기 쉽게 코드를 구현한다. 프로그램 처리율과 효율에 따라 스스로 스레드 개수를 조율하는 코드도 고민한다.
4. 다양한 환경에서 돌려라
- 스와핑을 할 때면 프로세서 수보다 많은 스레드를 돌리기 때문에 스와핑이 잦을수록 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.
- 다중 스레드 코드는 플랫폼에 따라 다르게 돌아가기 때문에 처음부터 자주 모든 목표 플랫폼에서 코드를 돌린다.
- 코드에 보조 코드를 넣어 돌려서 강제로 실패를 일으키게 해본다. 직접 구현하거나 자동화할 수 있는데 직접 구현하는 것은 보조 코드의 삽입 위치를 찾아야 하고 그대로 남겨두면 성능이 떨어진다는 문제점이 있기에 AOF와 같은 도구를 사용해 보조 코드를 자동으로 추가하는 편이 좋다.
public class ThreadJigglePoint {
public static void jiggle(){ // 무작위로 sleep이나, yield를 호출하고 때로는 아무 동작도 하지 않는 함수.
// 배포 환경에서는 이 메서드를 비워두고 사용할 수 있따.
}
}
public synchronized String nextUrlOrNull() {
if(hasNext()) {
ThreadJigglePoint.jiggle();
String url = urlGenerator.next();
ThreadJigglePoint.jiggle();
updateHasNext();
ThreadJigglePoint.jiggle();
return url;
} // 다양한 위치에서 jiggle 메서드를 호출함으로써 코드를 흔들어 스레드를 매번 다른 순서로 실행하게 만든다.
}
다중 스레드 코드는 올바로 구현하기 어렵다. 다중 스레드 코드를 작성한다면 각별히 깨끗하게 코드를 짜야 한다. 앞서 언급한 방법을 철저히 지키면서 깔끔한 접근 방식을 취한다면 코드가 올바로 돌아갈 가능성이 높아질 것이다.
'STUDY > Books' 카테고리의 다른 글
[Clean Code] 12장 창발성 (0) | 2021.06.05 |
---|---|
[Clean Code] 11장 시스템 (0) | 2021.06.04 |
[Clean Code] 10장 클래스 (0) | 2021.05.30 |
[Clean Code] 9장 단위 테스트 (0) | 2021.05.25 |
[Clean Code] 8장 경계 (0) | 2021.05.24 |