////
Search
Duplicate
🌥️

13장. 동시성

이 장에서 알아볼 것은 다음과 같다.
여러 스레드를 동시에 작동시키는 이유에 대해서 알아보자.
동시에 여러 스레드를 동시에 돌릴 때 어려운 이유도 알아보자.
⇒ 이런 어려움에 대처하고 깨끗한 코드를 작성하는 방법도 제안해준다고 한다.
동시성을 테스트하는 방법과 문제점을 논해보자.
: 동시성이 무엇인지 궁금한 정도라면 이 장만으로 충분하지만 좀 더 깊이 이해하고 싶다면 동시성 2도 읽어보라

동시성이 필요한 이유?

: 동시성은 결합을 없애는 전략이다. 즉 무엇언제를 분리하는 전략이다. 스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다.
⇒ 뭔가 앞서 시스템에서의 제작과 사용을 분리하라는 것과 유사한 느낌을 받았다.
: 아마 두 가지 관점을 분리해서 유지한다면 이점을 획득할 수 있다는 것을 의미하는 것 같긴 하다. 여기선 구조, 효율 측면에서 이점을 획득할 수 있다고 했다.
: 구조적인 측면에서 프로그램은 거대한 루프 하나가 사이클을 순환하는 것이 아니라 작은 협력 프로그램 여럿이 같이 일하는 것처럼 보인다. 이는 하나의 잘 구성된 시스템처럼 보이며 이해하기 쉽고 문제의 분리도 쉽게 도와준다.
: 동시성은 구조적 개선뿐만이 아닌 응답 시관과 작업 처리량 개선이라는 요구사항, 즉 성능으로 인해 동시성이 필요해지기도 한다.
⇒ 짧은 식견으로 예를 하나 들어보자면 Java Stream API도 프로그래머, 사용자들이 다루는 데이터가 점차 증가하니, 이런 부분에서 동시성, 병렬성을 돕기 위해 생긴 라이브러리 아니던가.
미신과 오해
: 동시성은 무조건 적용하면 좋아보인다. 하지만 동시성엔 하나의 큰 단점이 있는데, 바로 어렵다는 것이다. 각별히 주의하지 않으면 성능 이득보려다가 데이터 안전성이 작살나리라.
: 다음은 동시성의 일반적인 미신과 오해다.
동시성은 항상 성능을 높여준다.
⇒ 여러 스레드가 해당 작업을 공유할 수 있으며 여러 스레드가 동시에 처리해야할 정도로 각 스레드에 할당된 작업량이 충분히 많은 경우에만 성능이 높아진다.
동시성을 구현해도 설계는 변하지 않는다.
⇒ 단일 스레드와 다중 스레드는 설계가 판이하게 다르다. 일반적으로 무엇언제를 분리하면 시스템 구조는 크게 달라진다
웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다.
Stream API도 그렇고 MySQL 8.0부터 자리잡은 동시성 제어 기법인 MVCC도 그렇고 정확하게 이해하지 않고 사용하기만 한다면 당신은 항상 문제가 발생할 여지가 있는 코드를 작성하고 있을 것이다.
: 다음은 동시성과 관련된 타당한 생각 몇 가지다.
동시성은 다소 부하를 유발한다.
⇒ 성능 측면에서 부하가 걸리며, 코드도 더 짜야한다.
동시성은 복잡하다.
⇒ 진짜 복잡하다. syncronizedAtomic이니 쉽지 않다.. 정말..
일반적으로 동시성 버그는 재현하기 어렵다.
⇒ 트러블 슈팅하다가 일회적인 문제로 결론내려 무시될 가능성이 있다.
동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.
⇒ 동기화 기법이니 상호 배제니 이런 것들을 고려해서 설계 전략을 다시 짜야한다.

난관

: 그렇다면 동시성을 구현하기 어려운 이유는 무엇일까? 예시를 하나 들어보자.
public class X { private int lastIdUsed; public int getNextId() { return ++lastIdUsed; } }
Java
복사
: 인스턴스 X를 생성하고 lastIdUsed 필드를 42로 설정한 다음, 두 스레드가 해당 인스턴스를 공유한다.
: 이제 두 개의 스레드가 getNextId()를 호출한다고 가정해보자. 결과는 다음 셋 중 하나가 될 것이다.
Thread 1getNextId()를 통해 43을 받는다. 이후 Thread 2getNextId()를 통해 44를 받는다.
Thread 2getNextId()를 통해 43을 받는다. 이후 Thread 1getNextId()를 통해 44를 받는다.
Thread 1getNextId()를 통해 43을 받는다. 이후 Thread 2getNextId()를 통해 43을 받는다. 그러면 lastIdUsed는 43이 될 것이다.
: 두 스레드가 같은 변수를 동시에 참조하면 세 번째와 같이 놀라운 결과가 발생한다. 물론 대부분의 경우, 올바른 결과를 반환하지만 문제는 잘못된 결과를 내놓는 일부 경로다.
: 이렇게 특정한 변수에 동시성이 발생(여러 스레드가 자원을 공유)한다면 잠재적인 버그 발생기로 전락할 것이다. 심지어 단순한 코드임에도 불구하고 말이다.
: 그래서 우리는 동시성 코드가 발생시키는 문제로부터 시스템을 격리하는 원칙과 기술을 알고 있어야 한다.

동시성 방어 원칙

단일 책임 원칙
: 동시성 관련 코드는 다른 코드와 분리해야 한다는 뜻이다.
: 무엇, 언제를 분리했듯이 언제도 지금? 나중에? 나중에 언제?로 분리하는 것이 좋다는 뜻일까?
자료 범위를 제한하라.
: 임계 영역은 동시성 프로그래밍에서 여러 스레드나 프로세스가 공유 자원에 접근하는 코드 영역을 의미한다. 앞서 getNextId()와 같이 말이다.
: 이는 동일 메모리 영역에 대한 두 스레드의 서로 간 간섭으로 위 예시와 같은 예측하지 못하는 오류를 만들어낸다.
: 이런 문제를 해결하는 방안으로 synchronized 키워드를 사용하라고 권장한다.
: 또한 임계영역의 수를 줄이는 방법 또한 권장한다.
⇒ 자료를 캡슐화하고, 공유 자료를 최대한 줄여라.
자료 사본을 사용하라.
: 공유 자료를 줄이려면 처음부터 공유하지 않는 방법이 가장 좋다.
: 각 스레드가 객체를 복사해 사용한 후 한 스레드가 해당 사본에서 결과를 가져오는 방법도 가능하다.
: 공유 객체를 사용해야만 한다면 자료 사본을 만들어서 사용하는 것이 Lock 통해 자료에 접근 권한을 제어하는 것보다 더 실용적일 수 있다.
스레드는 가능한 독립적으로 구현하라.
: 다른 스레드와 자료를 공유하지 않게 격리시켜라.
: 각 스레드는 각자가 맡은 클라이언트 요청 하나를 처리한다. 모든 자료는 비공유 출처에서 가져오며 스레드 로컬에 저장한다. 그러면 각 스레드는 세상에 혼자인 것처럼 동작할 수 있다.
: HttpServlet 클래스에서 파생한 클래스는 모든 정보를 doGetdoPost 매개변수로 받는다. 그래서 각각이 독립된 채로 어느 로컬 변수에도 의존성을 가지지 않는다.

라이브러리를 이해하라

: 자바 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() 등과 같은 메서드를 추가해 코드를 다양한 순서로 실행해본다.
: 각 메서드는 스레드가 실행되는 순서에 영향을 미친다. 따라서 버그가 확인될 가능성도 높아진다.

결론

: 다중 스레드 코드는 올바르게 구현하기 어렵다. 간단했던 코드여도 다중 스레드 개념과 공유 자료를 추가하면 악몽으로 변한다.
: 당신이 다중 스레드 코드를 작성한다면 다음과 같은 사항들을 고려하라.
단일 책임 원칙을 준수한다.
동시성 오류를 일으키는 잠정적인 원인을 철저히 이해한다.
사용하는 라이브러리와 기본 알고리즘을 이해한다.
보호할 코드 영역을 찾아내는 방법과 특정 코드 영역의 동시성을 보장하는 방법을 이해한다.
어떻게든 문제는 생기므로 문제를 일회성으로 치부하지 말아라.