•
이 장에서 알아볼 것은 다음과 같다.
◦
여러 스레드를 동시에 작동시키는 이유에 대해서 알아보자.
◦
동시에 여러 스레드를 동시에 돌릴 때 어려운 이유도 알아보자.
⇒ 이런 어려움에 대처하고 깨끗한 코드를 작성하는 방법도 제안해준다고 한다.
◦
동시성을 테스트하는 방법과 문제점을 논해보자.
: 동시성이 무엇인지 궁금한 정도라면 이 장만으로 충분하지만 좀 더 깊이 이해하고 싶다면 동시성 2도 읽어보라
동시성이 필요한 이유?
: 동시성은 결합을 없애는 전략이다. 즉 무엇과 언제를 분리하는 전략이다. 스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다.
⇒ 뭔가 앞서 시스템에서의 제작과 사용을 분리하라는 것과 유사한 느낌을 받았다.
: 아마 두 가지 관점을 분리해서 유지한다면 이점을 획득할 수 있다는 것을 의미하는 것 같긴 하다. 여기선 구조, 효율 측면에서 이점을 획득할 수 있다고 했다.
: 구조적인 측면에서 프로그램은 거대한 루프 하나가 사이클을 순환하는 것이 아니라 작은 협력 프로그램 여럿이 같이 일하는 것처럼 보인다. 이는 하나의 잘 구성된 시스템처럼 보이며 이해하기 쉽고 문제의 분리도 쉽게 도와준다.
: 동시성은 구조적 개선뿐만이 아닌 응답 시관과 작업 처리량 개선이라는 요구사항, 즉 성능으로 인해 동시성이 필요해지기도 한다.
⇒ 짧은 식견으로 예를 하나 들어보자면 Java Stream API도 프로그래머, 사용자들이 다루는 데이터가 점차 증가하니, 이런 부분에서 동시성, 병렬성을 돕기 위해 생긴 라이브러리 아니던가.
•
미신과 오해
: 동시성은 무조건 적용하면 좋아보인다. 하지만 동시성엔 하나의 큰 단점이 있는데, 바로 어렵다는 것이다. 각별히 주의하지 않으면 성능 이득보려다가 데이터 안전성이 작살나리라.
: 다음은 동시성의 일반적인 미신과 오해다.
•
동시성은 항상 성능을 높여준다.
⇒ 여러 스레드가 해당 작업을 공유할 수 있으며 여러 스레드가 동시에 처리해야할 정도로 각 스레드에 할당된 작업량이 충분히 많은 경우에만 성능이 높아진다.
•
동시성을 구현해도 설계는 변하지 않는다.
⇒ 단일 스레드와 다중 스레드는 설계가 판이하게 다르다. 일반적으로 무엇과 언제를 분리하면 시스템 구조는 크게 달라진다
•
웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다.
⇒ Stream API도 그렇고 MySQL 8.0부터 자리잡은 동시성 제어 기법인 MVCC도 그렇고 정확하게 이해하지 않고 사용하기만 한다면 당신은 항상 문제가 발생할 여지가 있는 코드를 작성하고 있을 것이다.
: 다음은 동시성과 관련된 타당한 생각 몇 가지다.
•
동시성은 다소 부하를 유발한다.
⇒ 성능 측면에서 부하가 걸리며, 코드도 더 짜야한다.
•
동시성은 복잡하다.
⇒ 진짜 복잡하다. syncronized니 Atomic이니 쉽지 않다.. 정말..
•
일반적으로 동시성 버그는 재현하기 어렵다.
⇒ 트러블 슈팅하다가 일회적인 문제로 결론내려 무시될 가능성이 있다.
•
동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.
⇒ 동기화 기법이니 상호 배제니 이런 것들을 고려해서 설계 전략을 다시 짜야한다.
난관
: 그렇다면 동시성을 구현하기 어려운 이유는 무엇일까? 예시를 하나 들어보자.
public class X {
private int lastIdUsed;
public int getNextId() {
return ++lastIdUsed;
}
}
Java
복사
: 인스턴스 X를 생성하고 lastIdUsed 필드를 42로 설정한 다음, 두 스레드가 해당 인스턴스를 공유한다.
: 이제 두 개의 스레드가 getNextId()를 호출한다고 가정해보자. 결과는 다음 셋 중 하나가 될 것이다.
•
Thread 1이 getNextId()를 통해 43을 받는다. 이후 Thread 2가 getNextId()를 통해 44를 받는다.
•
Thread 2가 getNextId()를 통해 43을 받는다. 이후 Thread 1이 getNextId()를 통해 44를 받는다.
•
Thread 1이 getNextId()를 통해 43을 받는다. 이후 Thread 2도 getNextId()를 통해 43을 받는다. 그러면 lastIdUsed는 43이 될 것이다.
: 두 스레드가 같은 변수를 동시에 참조하면 세 번째와 같이 놀라운 결과가 발생한다. 물론 대부분의 경우, 올바른 결과를 반환하지만 문제는 잘못된 결과를 내놓는 일부 경로다.
: 이렇게 특정한 변수에 동시성이 발생(여러 스레드가 자원을 공유)한다면 잠재적인 버그 발생기로 전락할 것이다. 심지어 단순한 코드임에도 불구하고 말이다.
: 그래서 우리는 동시성 코드가 발생시키는 문제로부터 시스템을 격리하는 원칙과 기술을 알고 있어야 한다.
동시성 방어 원칙
•
단일 책임 원칙
: 동시성 관련 코드는 다른 코드와 분리해야 한다는 뜻이다.
: 무엇, 언제를 분리했듯이 언제도 지금? 나중에? 나중에 언제?로 분리하는 것이 좋다는 뜻일까?
•
자료 범위를 제한하라.
: 임계 영역은 동시성 프로그래밍에서 여러 스레드나 프로세스가 공유 자원에 접근하는 코드 영역을 의미한다. 앞서 getNextId()와 같이 말이다.
: 이는 동일 메모리 영역에 대한 두 스레드의 서로 간 간섭으로 위 예시와 같은 예측하지 못하는 오류를 만들어낸다.
: 이런 문제를 해결하는 방안으로 synchronized 키워드를 사용하라고 권장한다.
: 또한 임계영역의 수를 줄이는 방법 또한 권장한다.
⇒ 자료를 캡슐화하고, 공유 자료를 최대한 줄여라.
•
자료 사본을 사용하라.
: 공유 자료를 줄이려면 처음부터 공유하지 않는 방법이 가장 좋다.
: 각 스레드가 객체를 복사해 사용한 후 한 스레드가 해당 사본에서 결과를 가져오는 방법도 가능하다.
: 공유 객체를 사용해야만 한다면 자료 사본을 만들어서 사용하는 것이 Lock 통해 자료에 접근 권한을 제어하는 것보다 더 실용적일 수 있다.
•
스레드는 가능한 독립적으로 구현하라.
: 다른 스레드와 자료를 공유하지 않게 격리시켜라.
: 각 스레드는 각자가 맡은 클라이언트 요청 하나를 처리한다. 모든 자료는 비공유 출처에서 가져오며 스레드 로컬에 저장한다. 그러면 각 스레드는 세상에 혼자인 것처럼 동작할 수 있다.
: HttpServlet 클래스에서 파생한 클래스는 모든 정보를 doGet과 doPost 매개변수로 받는다. 그래서 각각이 독립된 채로 어느 로컬 변수에도 의존성을 가지지 않는다.
라이브러리를 이해하라
: 자바 5는 동시성 측면에서 이전 버전보다 많이 나아졌다. 물론 지금의 우리는 동시성을 Stream API를 이용해 편하게 구현할 수 있다.
스레드 환경에 안전한 컬렉션이나 라이브러리를 사용하라. 즉 thread-safe한 컬렉션을 사용하라 ⇒ LocalDateTime같은
⇒ 일부 클래스 라이브러리는 thread-safe하지 못하다.
: 알고 써라 너가 쓰는 라이브러리가 동시성 측면에서 어떤 문제를 야기할 수 있는지!
•
스레드 환경에 안전한 컬렉션
: 더그 리가 Cuncurrent Programming in Java라는 책을 집필하며 스레드 환경에서 안전한 컬렉션 클래스를 몇 개 구현했는데, 나중에 java.util.concurrent 패키지에 추가되었다.
: 해당 패키지가 제공하는 클래스들은 다중 스레드 환경에서 사용해도 안전하며 성능또한 우수하다.
: 그 예로 ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 빠르다. 동시/읽기 쓰기를 지원하며 복합 연산을 다중 스레드 상에서 안전하게 구현한 메서드로 제공한다.
: 자바 5를 살펴본다면 ConcurrentHashMap부터 살펴보자.
◦
동시성 설계를 지원하고자 자바 5에 추가된 다른 클래스들
▪
ReentrantLock : 한 메서드에서 잠그고 다른 메서드에서 푸는 락
▪
Semaphore : 전형적인 세마포어로 count가 있는 락
▪
CountDownLatch : 지정한 수만큼 이벤트가 발생하고 나서야 대기 중인 스레드를 모두 해제하는 락, 모든 스레드에게 동시에 공평하게 시작할 기회를 준다.
⇒ 언어가 제공하는 동시성 클래스들을 검토해보자. java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks
실행 모델을 이해하라
: 다중 스레드 애플리케이션을 분류하는 방식은 여러가진데, 다음과 같은 몇 가지 기본 용어부터 이해해보자.
•
한정된 자원
: 다중 스레드 환경에서 사용하는 자원으로 크기나 숫자가 제한적이다. Database Connection Pool, 길이가 일정한 읽기/쓰기 버퍼 등이 예다.
•
상호 배제
: 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다.
•
기아
: 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다.
⇒ 항상 짧은 스레드에게 우선순위를 준다면 짧은 스레드가 지속적으로 이어질 경우, 긴 스레드는 기아 상태에 빠진다.
•
데드락(식사하는 철학자 문제)
: 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못하고 죽어버린다.
: 서로 자원이 비는 것을 기다리며 더 이상 처리가 진행되지 않는 상태, 나한테 총이 있고 총알이 필요한데, 상대한텐 총알이 있고 총이 없다.
•
라이브락(굶주림)
: 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 진행하려 하지만 공명으로 인해, 굉장히 오랫동안 혹은 영원히 진행하지 못한다.
: 만약 철학자가 원하는 포크가 계속해서 비지 않는다면 들고 있는 포크를 내려놓는다면?
: 계속해서 포크를 들고, 다음 포크가 없으니 내려놓고 동작이 반복될 것이다.
: 데드락은 외나무 다리에서 서로 비키려하지 않는 것이고 라이브락은 서로 거울처럼 같은 방향으로 피해주는 것
•
생산자-소비자
: 생산자 스레드는 정보를 생성하 buffer나 queue에 넣는다. 하나 이상의 소비자 스레드가 대기열에서 해당 정보를 가져와 처리한다.
: 생산자 스레드와 소비자 스레드가 사용하는 대기열은 한정된 자원이다.
◦
생산자 스레드 : 대기열에 빈 공간이 있어야 정보를 채움, 즉 빈 공간이 생길 때까지 기다린다.
◦
소비자 스레드 : 대기열에 정보가 있어야만 가져옴, 즉 정보가 있을 때까지 기다린다.
: 대기열을 올바르게 사용하고 싶다면 생산자 스레드와 소비자 스레드는 서로에게 시그널을 보낸다.
: 잘못 하면 두 스레드 모두 동작이 가능함에도 불구하고 동시에 서로에게서 시그널을 기다릴 가능성이 존재한다.
•
읽기-쓰기
: 읽기 스레드는 공유 자원을 읽는 친구고, 쓰기 스레드는 공유 자원을 쓰는 친구다.
: 이런 경우, 처리율이 문제의 핵심이다.
: 특정 스레드, 만약 읽기 스레드의 처리율을 보장한다면 해당 정보는 최신 상태임을 보장하기 힘들 것이고 쓰기 스레드의 처리율을 보장한다면 읽기 스레드의 성능을 보장하기 힘들 것이다.
: 따라서 대개는 두 스레드의 요구를 적절히 만족시켜 두 스레드의 처리율도 향상시키고자 한다.
•
식사하는 철학자들
: 둥근 식탁에 한 무리의 철학자들이 둘러 앉았다. 각 철학자의 왼쪽에는 포크가 있는데, 각 철학자들은 양손에 포크가 있어야 음식을 먹을 수 있다.
: 여기서 상황에서 철학자를 스레드로, 포크를 자원으로 바꾸어 생각해보게 되면, 여러 프로세스가 자원을 얻으려 경쟁하는 상황이 된다.
: 즉, 주의해서 설계하지 않으면, 데드락, 라이브락, 처리율 저하, 효율성 저하등을 겪기 때문에 설계할 때 각별한 주의가 필요하다는 것이다.
동기화하는 메서드 사이에 존재하는 의존성을 이해하라.
: 공유 자료를 동기화하는 메서드 사이에 의존성이 존재한다면 동시성 코드에 찾아내기 어려운 버그가 생긴다. 즉 synchronized라는 키워드가 붙는 메소드들 간의 의존성 말이다.
⇒ 해당 키워드를 사용하면 자바는 스레드 간 동기화를 수행한다. 메서드나 변수에 키워드로 선언가능하며 해당 메소드나 변수 블록에 접근할 수 있는 스레드가 하나 뿐임을 보장하기 위한 처리들을 수행한다.
: 공유 객체 하나에는 메서드 하나만을 사용하는 것을 권장한다. 하지만 여러 메서드가 필요한 상황도 생긴다. 그럴 땐, 다음 세 가지 방법을 고려하자.
•
클라이언트에서의 잠금 : 클라이언트에서 요청을 시작하기 전, 서버를 잠근다. 버스 좌석을 선택하고 예매를 시작하면 좌석을 잠그듯이 말이다.
•
서버에서 잠금 : 서버에다 서버를 잠그고 모든 메서드를 호출한 후, 잠금을 해제하는 메서드를 구현한다. 클라이언트는 이 메서드를 호출한다.
⇒ 추상화 수준이 저렇게 표현되었을 뿐이지, 굉장히 복잡한 코드일 것이다. 버스 좌석 잠금을 클라이언트의 요청에 따른 서버가 처리하는 것 같다.
•
연결 서버 : 잠금을 수행하는 중간 단계를 생성한다. 서버에서 잠금 방식과 유사하지만 원래 서버는 변경하지 않는다.
⇒ 사실 뭔 이야긴지 모르겠다..
동기화하는 부분을 작게 만들어라.
: 자바에서 synchronized 키워드를 사용하면 락을 건다. 같은 락으로 감싼 모든 코드 영역은 한 번에 한 스레드만 접근이 가능하도록 보장된다.
: 락은 스레드의 처리를 지연시키고 JVM에 부하를 준다. 그러므로 여기저기서 synchronized를 남발하는 코드는 바람직하지 않다. 그러나 앞서 우리는 임계영역은 반드시 보호해야 한다 했다.
: 그렇다면 우리가 할 수 있는 방법은 공유 자원을 최대한 줄이고 공유 자원에 대해선 synchronized나 다른 thread-safe 또는 동시성이 보장되는 메소드를 사용하는 것이다.
올바른 종료 코드는 구현하기 어렵다.
: 어떠한 프로그램이 종료하기 위해선 많은 동시성 이슈가 존재할 수 있다.
: 이런 다중 스레드 코드가 종료되어야하는 것이라면 시간을 투자해 올바르게 구현하는 것이 좋다.
: 요즘 종료되는 코드가 잘 있나?싶긴 한데, 동시성 이슈로 데드락이 걸려 프로그램이 올바르게 종료되지 못하고 구천을 떠돌며 당신을 저주할 수 있으니 잘 설계하거나 자신 없다면 이미 나온 좋은 알고리즘을 검토하자.
스레드 코드 테스트하기
: ! 올것이 왔다!
: 스레드가 하나인 코드는 충분한 테스트를 통해 그 코드의 버그 발생가능성을 줄일 수 있다. 그런데 같은 코드와 같은 자원을 사용하는 스레드가 둘 이상이 된다면 상황은 급격하게 복잡해진다.
: 저자는 문제를 노출하는 테스트 케이스를 작성하라고 한다. 프로그램 설정과 시스템 설정, 부하를 바꿔가며 테스트해보기를 권장하며 테스트가 실패하는 경우, 다시 돌렸더니 성공하네?라는 이유로 그냥 넘어가면 절대 안 된다고 한다.
: 다음은 저자의 구체적인 지침 몇 가지다.
•
말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
: 다중 스레드를 다루는 코드는 때때로 말도 안 되는 오류를 발생시킨다. 스레드 코드에 잠입한 버그는 어쩌다 한 번 등장하기 때문에 많은 개발자가 단순한 일회성 문제로 치부하고 무시한다.
: 일회성 문제란 존재하지 않는다고 생각하자. 일회성 문제를 계속 무시한다면 잘못된 코드 위에 코드가 계속 쌓인다.
⇒ 일회성이라고 치부하지 말고 해결해야할 문제라고 생각하라.
•
다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자.
: 스레드 환경 밖에서 코드가 제대로 도는 지 반드시 확인하라. 각각의 스레드가 정상적으로 수행되는지부터 확인하자
•
다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라
: 다중 스레드를 쓰는 코드를 다양한 설정으로 실행하기 쉽게 구현하라.
◦
한 스레드로 실행하거나, 여러 스레드로 실행하거나, 실행 중 스레드 수를 바꿔볼 수 있게 해라.
◦
스레드 코드를 실제 환경이나 테스트 환경에서 돌려본다.
◦
테스트 코드를 빨리, 천천히, 다양한 속도로 돌려본다.
◦
반복 테스트가 가능하도록 테스트 케이스를 작성한다. 멱등성을 보장해라.
⇒ 가급적이면 환경에 영향을 받지 않고 쉽게 끼워 넣을 수 있게 코드를 구현해라.
•
다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라
: 스레드 개수를 조율하기 쉽게 구현하라. Database의 Connection Pool의 경우, 단순히 인자로 넘겨 생성되는 수를 쉽게 제어할 수 있다.
•
프로세서 수보다 많은 스레드를 돌려보라
: 스레드를 스와핑할 때도 문제가 발생한다. 스와핑이 발생하려면 프로세서 수보다 많은 스레드를 돌려라, 잦을수록 하나의 프로세서의 스레드가 자주 변경되므로 임계영역을 빼먹거나 데드락을 발생시키는 코드를 쉽게 찾을 수 있다.
•
다른 플랫폼에서 돌려보라
: 운영체제마다 스레드를 처리하는 정책이 다르다. 윈도우에서 실행했던 다중 스레드 코드가 리눅스에서도 기대한대로 동작할 것이라는 보장은 없다.
: 따라서 코드가 동작할 가능성이 있는 모든 플랫폼에서 테스트를 수행하는 것이 좋다.
•
코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해봐라
: 앞서 말했듯 다중 스레드 코드의 오류가 발생할 확률은 높지 않다. 일회성으로 치부하고 넘어갈 수도 있는 만큼 그 오류를 재현하기도 힘들다.
: 이렇게 드물게 발생하는 오류를 좀 더 확정적으로 일으킬 수 있게할 방법은 없을까? 보조 코드를 추가해 코드가 실행되는 순서를 조작해보자.
: Object.wait(), Object.sleep(), Object.yield(), Object.priority() 등과 같은 메서드를 추가해 코드를 다양한 순서로 실행해본다.
: 각 메서드는 스레드가 실행되는 순서에 영향을 미친다. 따라서 버그가 확인될 가능성도 높아진다.
결론
: 다중 스레드 코드는 올바르게 구현하기 어렵다. 간단했던 코드여도 다중 스레드 개념과 공유 자료를 추가하면 악몽으로 변한다.
: 당신이 다중 스레드 코드를 작성한다면 다음과 같은 사항들을 고려하라.
•
단일 책임 원칙을 준수한다.
•
동시성 오류를 일으키는 잠정적인 원인을 철저히 이해한다.
•
사용하는 라이브러리와 기본 알고리즘을 이해한다.
•
보호할 코드 영역을 찾아내는 방법과 특정 코드 영역의 동시성을 보장하는 방법을 이해한다.
•
어떻게든 문제는 생기므로 문제를 일회성으로 치부하지 말아라.