본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java Concurrency] 성능, 확장성 #1

by 돼지왕 왕돼지 2017. 5. 3.
반응형

 [Java Concurrency] 성능, 확장성


3-티어 모델, amdahl's law, concurrency, concurrentlinkedqueue, Context Switching, CPU, escape analysis, GC, i/o 처리장치, Java, jvm, linkedblockingqueue, LinkedList, lock coarsening, lock elision, memory barrier, nlogn, o(n), o(nlogn), scalability, synchronized, synchronized 블록, trade off, volatile, [Java Concurrency] 성능, 가시성, 간접적 성능, 개발 비용, 경쟁 가능성, 경쟁적, 공용 자료 구조, 단일 스레드, 대기, 대기 시간, 동기화, 동기화된, 디스크, 락, 락 걸기, 락 대기 시간, 락 스트라이핑, 락 제거, 락 확장, 로컬 변수, 메모리, 메모리 동기화, 메모리 배리어, 메모리 베리어, 명령어 재배치, 명확한 수치, 명확한 요구 사항, 모듈, 버스, 병렬 작업, 보관, 부하, 블로킹 i/o, 비경쟁적, 비용, 빠르다, 상태 변수 값, 서비스 시간, 성능, 성능 대 확장성, 성능 트레이드 오프, 속도 얼마나 빨라질지, 속도 증가량, 순차 작업, 순차적 실행, 숨겨진 비용, 스레드, 스레드 생성, 스레드 제거, 스레드와 비용, 스케줄링, 스케줄링 부하, 스케쥴링, 신호 보내기, 실제 측정, 실행 상태, 실행 시간 보장 정책, 안전성, 알고리즘, 암달의 법칙, 역효과, 예측 값, 오버헤드, 용량, 운영체제, 유지 보수, 유지 보수 비용, 유출 분석, 응답 속도, 이론적, 인터럽트, 자바 병렬, 자원, 작업, 작업 비율, 작업 큐, 재배치, 적용 시점, 조건, 지연, 처리량, 최대 활용, 최악, 최적화, 추측, 측정, 캐시, 캐시 메모리, 컨텍스트 스위칭, 컴파일러, 케시, 큐, 키워드, 튜닝, 트래픽, 트레이드 오프, 프레임 워크, 프레임웍, 프로세서 개수, 확장성, 확장성의 한계, 활동성, 효율


-
스레드를 사용하는 가장 큰 목적은 바로 성능을 높이고자 하는 것이다.
스레드를 사용하면 시스템의 자원을 훨씬 효율적으로 활용할 수 있고, 앱으로 하여금 시스템이 갖고 있는 능력을 최대한 사용하게 할 수 있다.
그와 동시에 기존 작업이 실행되고 있는 동안 새로 등록된 작업을 즉시 실행할 수 있는 준비를 갖추고 있기 때문에 앱의 응답 속도를 향상시킬 수 있다.


-
성능을 높이는 방법은 대부분 앱의 내부 구조를 복잡하게 만들어야 하는 경우가 많고,
따라서 안전성과 활동성에 문제가 생길 가능성도 적지 않다.
최악의 경우에는 성능을 높이기 위해 적용한 프로그래밍 기법 때문에 프로그램의 다른 부분에서 역효과를 가져오거나 성능상에 문제를 일으킬 수도 있다.


-
성능 때문에 안전성을 해칠 수는 없다.
일단 프로그램이 정상적으로 동작하도록 만들어 놓고 난 다음, 빠르게 동작하도록 만드는 편이 낫다.



11.1. 성능에 대해


-
성능을 높인다는 것은 더 적은 자원을 사용하면서 더 많은 일을 하도록 한다는 말이다.


-
여러 개의 스레드를 사용하려 한다면 항상 단일 스레드를 사용할 때보다 성능상의 비용을 지불해야만 한다.
스레드 간의 작업 내용을 조율하는 데 필요한 오버헤드( 락 걸기, 신호보내기, 메모리 동기화하기 등 )도 이런 비용이라고 볼 수 있고, 컨텍스트 스위칭이 자주 발생한다는 점, 스레드를 생성하거나 제거하는 일이 빈번하다는 점, 여러 스레드를 효율적으로 스케줄링해야 한다는 등의 부분도 모두 비용이라고 할 수 있다.


-
더 나은 성능을 목표로 해서 프로그램이 병렬도 동작하도록 만들 때는 두 가지 부분을 우선적으로 생각해야 한다.
먼저 프로그램이 확보할 수 있는 모든 자원을 최대한 활용해야 하고,
남는 자원이 생길 때마다 그 자원 역시 최대한 활용할 수 있도록 해야 한다.
CPU 가 최대한 바쁘게 동작해야 한다는 것이 그 의미이다.



* 11.1.1. 성능 대 확장성

-
서비스 시간, 대기 시간은 특정 작업을 처리하는 속도가 "얼마나 빠르냐"를 말해준다.
용량, 처리량은 동일한 자원을 갖고 있을 때 "얼마나 많은" 양의 일을 할 수 있는지 알려준다.


-
확장성(scalability)는 CPU, 메모리, 디스크, I/O 처리 장치 등의 추가적인 장비를 사용해 처리량이나 용량을 얼마나 쉽게 키울 수 있는지를 말한다.


-
튜닝 작업을 하는 경우에 그 목적은 어쨌건 동일한 일을 더 적은 노력으로 하고자 하는 것이다.
예를 들어 이미 계산했던 결과를 캐싱해서 실행 속도를 높이거나
O(n^2)의 시간이 걸리는 알고리즘을 O(nlogn) 시간에 처리할 수 있는 알고리즘으로 바꾸는 등의 작업이 바로 성능 튜닝이다.
확장성을 목표로 튜닝을 한다면 처리해야 할 작업을 병렬화해 시스템의 가용 자원을 더 많이 끌어다 사용하면서 더 많은 일을 처리할 수 있도록 하는 방법을 많이 사용하게 된다.


-
3-티어 모델에서는 시스템의 확장성을 높이도록 변경하려 할 때 성능의 측면에서 손해를 많이 본다.
단일 구조의 앱은 서로 다른 티어 간에 작업을 주고받는 도중에 발생하는 네트웍 시간 지연 현상도 없을 것이고,
연산 작업을 서로 다른 추상적인 계층을 통과시켜가며 처리하는 데 드는 부하가 적기 때문에 당연한 결과라고 볼 수 있다.



* 11.1.2. 성능 트레이드 오프 측정

-
공학적인 모든 선택의 순간에는 항상 트레이드 오프(trade off)가 존재하기 마련이다.


-
최적화 기법을 너무 이른 시점에 적용하지 말아야 한다.
일단 제대로 동작하게 만들고 난 다음에 빠르게 동작하도록 최적화해야 하며,
예상한 것보다 심각하게 성능이 떨어지는 경우에만 최적화 기법을 적용하는 것으로도 충분하다.


-
특정 방법이 다른 방법보다 빠르다고 말하기 전에 다음과 같은 질문에 대답해 볼 필요가 있다.

"빠르다" 단어가 무엇을 의미하는가?
어떤 조건을 갖춰야 이 방법이 실제로 빠르게 동작할 것인가? 부하가 적을 때? 아니면 부하가 걸릴 때? 데이터가 많을 때? 적을 때? 이런 질문에 대한 대답에 명확한 수치를 보여줄 수 있는가?
위의 조건에 해당하는 경우가 얼마나 많이 발생하는가? 이 질문의 대답에 대한 명확한 수치를 보여줄 수 있는가?
조건이 달라지는 다른 상황에서도 같은 코드를 사용할 수 있는가?
이 방법으로 성능을 개선하고자 할 때, 숨겨진 비용, 즉 개발 비용이나 유지 보수 비용이 증가하는 부분이 어느 정도인지? 과연 그런 비용을 감수하면서까지 성능 개선 작업을 해야 하는가?


-
성능을 높이기 위해 안전성을 떨어뜨리는 것은 최악의 상황이며, 결국 안전성과 성능 둘 다를 놓치는 결과를 얻을 뿐이다.


-
성능을 튜닝하는 모든 과정에서 항상 성능 목표에 대한 명확한 요구 사항이 있어야 하며,
그래야 어느 부분을 튜닝하고 어느 시점에서 튜닝을 그만 둬야 하는지 판단할 수 있다.
또한 매우 실제적인 환경에서 실제와 같은 사용자 부하의 특성을 동일하게 나타낼 수 있는 성능 측정 도구가 있어야 한다.

성능 튜닝 작업을 한 다음에는 반드시 원하는 목표치를 달성했는지 다시 한번 측정 값을 뽑아내야 한다.


-
특별히 성능을 높이기를 원하지 않는다면,
안전성이나 유지보수 문제에 대한 비용을 일부러 지불해 가면서 성능을 높일 필요가 없다.
더군다나 비용을 지불해도 원하는 결과를 충분히 얻을 수 없다면,
더더욱 비용을 지불할 필요가 없다.


-
추측하지 말고, 실제로 측정해보라.





11.2. 암달의 법칙


-
암달의 법칙( Amdahl's law )을 사용하면 병렬 작업과 순차 작업의 비율에 따라 하드웨어 자원을 추가로 투입했을 때 이론적으로 속도가 얼마나 빨라질지에 대한 예측 값을 얻을 수 있다.
순차적으로 실행돼야 하는 작업의 비율을 F 라고 하고, 하드웨어에 꽂혀 있는 프로세서의 개수를 N이라고 할 때, 다음 수식에 해당하는 정도까지 속도를 증가시킬 수 있다.

속도증가량 <= 1 / ( F + ( 1 - F ) / N )

N이 무한대까지 증가할수록 속도 증가량은 1/F 까지 증가한다.


-
암달의 법칙을 활용하면 작업을 순차적으로 처리하는 부분이 많아질 때 느려지는 정도가 얼마만큼인지를 수치화할 수 있다.


-
CPU 활용도는 속도 증가량을 프로세서의 개수로 나눈 값이라고 정의한다.
암달의 법칙에 따르면 프로세서의 개수가 증가하면 할수록, 순차적으로 실행해야 하는 부분이 아주 조금이라도 늘어나면
프로세서 개수에 비해 얻을 수 있는 속도 증가량이 크게 떨어진다.


-
작업 큐와 관련된 부분은 프로그램이 순차적으로 처리될 수밖에 없다.

작업큐로 LinkedBlockingQueue 를 사용하고 있다면, 큐에서 작업을 뽑아낼 때 대기하는 시간이 동기화된 LinkedList 를 사용할 때보다 훨씬 적게 든다.
LinkedBlockingQueue 는 동기화된 LinkedLIst 보다 훨씬 확장성이 좋은 알고리즘을 사용하고 있기 때문이다.
어찌됐건 간에 데이터를 한 군데에 공유해두고 사용하는 모든 부분은 항상 순차적으로 처리해야만 한다.

모든 작업이 끝난 이후에 작업마다 쌓여 있는 결과를 취합하는 과정 역시 순차적으로 처리해야만 하는 부분이다.


-
모든 병렬 프로그램에는 항상 순차적으로 실행돼야만 하는 부분이 존재한다.
만약 그런 부분이 없다고 생각한다면, 프로그램 코드를 다시 한 번 들여다보라.



* 11.2.1. 예제 : 프레임웍 내부에 감춰져 있는 순차적 실행 구조

-
동기화된 LinkedList 에서는 추가 작업과 삭제 작업이 모두 순차적으로 처리돼야 하지만,
ConcurrentLinkedQueue 에서는 개별 포인터에 대한 업데이트 연산만 순차적으로 처리하면 된다.



* 11.2.2. 정성적인 암달의 법칙 적용 방법


-
프로그램 내부에서 순차적으로 처리돼야 하는 부분의 비율을 정확하게 알아내는 일이 쉽지는 않지만,
그 비율을 알지 못한다 해도 경우에 따라 암달의 법칙을 유용하게 사용할 수 있다.


-
수백 개 또는 수천 개의 프로세서가 동작하는 상황까지 가정한 상태에서
프로그램의 알고리즘을 평가한다면,
어느 시점쯤에서 확장성의 한계가 나타날 것인지를 예측해 볼 수 있다.


-
락을 두 개로 분할하는 정도로는 다수의 프로세서를 충분히 활용하기 어렵다는 결론을 얻을 수 있다.
하지만 락 스트라이핑 방법을 사용할 때는 프로세서의 수가 늘어남에 따라 분할 개수를 같이 증가시킬 수 있기 때문에
확장성을 얻을 수 있는 훨씬 믿을만한 방법이라고 할 수 있다.



11.3. 스레드와 비용


-
단일 스레드 프로그램은 스케줄링 문제가 발생하지도 않거니와 동기화 문제나 그에따른 부하도 발생하지 않는다.
게다가 내부 자료의 일관성을 유지하기 위해 락으로 동기화할 필요도 없다.
실행 스케줄링과 스레드 간의 조율을 하다 보면 성능에 부정적인 비용이 발생한다.
따라서 스레드를 사용하는 경우, 병렬로 실행함으로써 얻을 수 있는 이득이 병렬로 실행하느라 드는 비용을 넘어서야 성능을 향상시킬 수 있다.



* 11.3.1. 컨텍스트 스위칭

- CPU 개수보다 실행 중인 스레드의 개수가 많다고 하면, 운영체제가 특정 스레드의 실행 스케줄을 선점하고 다른 스레드가 실행될 수 있도록 스케줄을 잡는다.
이처럼 하나의 스레드가 실행되다가 다른 스레드가 실행되는 순간 컨텍스트 스위칭(context switching) 이 일어난다.
컨텍스트 스위칭이 일어나늣 상세한 구조를 보면,
먼저 현재 실행중인 스레드의 실행 상태를 보관해두고,
다음 번에 실행되기로 스케줄된 다른 스레드의 실행 상태를 다시 읽어들인다.


-
컨텍스트 스위칭은 단숨에 공짜로 일어나는 일이 아니다.
스레드 스케줄링을 하려면 운영체제와 JVM 내부의 공용 자료 구조를 다뤄야 한다.
운영체제나 JVM 이 CPU 를 많이 사용하면 할수록 실제 프로그램 스레드가 사용할 수 있는 CPU 의 양이 줄어든다.


-
컨텍스트가 변경되면서 다른 스레드를 실행하려면 해당 스레드가 사용하던 데이터가 프로세서의 캐시 메모리에 들어 있지 않을 확률도 높다.
그러면 캐시에서 찾지 못한 내용을 다른 저장소에서 찾아와야 하기 때문에 원래 예정된 것보다 느리게 실행되는 셈이다.
이런 경우에 대비하고자 대부분의 스레드 스케줄러는 실행 대기 중인 스레드가 밀려 있다고 해도,
현재 실행 중인 스레드에게 최소한의 실행 시간을 보장해주는 정책을 취하고 있다.
그러면 컨텍스트 스위칭에 들어가는 시간과 비용을 나누는 효과를 볼 수 있고, 그 결과 인터럽트를 받지 않고 실행할 수 있는 최소한의 시간을 보장받기 때문에 전체적인 성능이 향상되는 효과를 볼 수 있다.
( 물론 응답 속도에는 어느 정도 손해를 감수해야 한다. )


-
스레드가 실행하다가 락을 확보하기 위해 대기하기 시작하면, 일반적으로 JVM 은 해당 스레드를 일시적으로 정지시키고 다른 스레드가 실행되도록 한다.
특정 스레드가 빈번하게 대기 상태에 들어간다고 하면 스레드별로 할당된 최소 실행 시간조차 사용하지 못하는 경우도 있다.
대기 상태에 들어가는 연산을 많이 사용하는 프로그램(블로킹 I/O 를 사용하거나, 락 대기 시간이 길거나, 상태 변수의 값을 기다리는 등)은 CPU 를 주로 활용하는 프로그램보다 컨텍스트 스위칭 횟수가 훨씬 많아지고,
따라서 스케줄링 부하가 늘어나면서 전체적인 처리량이 줄어든다.



* 11.3.2. 메모리 동기화

-
synchronized 와 volatile 키워드를 사용해 얻을 수 있는 가시성을 통해 메모리 배리어(memory barrier)라는 특별한 명령어를 사용할 수 있다.
메모리 베리어를 사용하면 컴파일러가 제공하는 여러 가지 최적화 기법을 제대로 사용할 수 없게 돼 간접적인 성능 문제를 가져올 수 있다.
메모리 배리어를 사용하면 명령어 재배치를 대부분 할 수 없게 되기 때문이다.


-
동기화가 성능에 미치는 영향을 파악하려면 동기화 작업이 경쟁적인지, 비경쟁적인지 확인해야 한다.


-
최근에 사용하는 JVM 은 대부분 다른 스레드와 경쟁할 가능성이 없다고 판단되는 부분에 락이 걸려 있다면
최적화 과정에서 해당 락을 사용하지 않도록 방지하는 기능( lock elision )을 제공하기도 한다.


-
훨씬 정교하게 만들어진 JVM 의 경우에는 유출 분석(escape analysis)을 통해 로컬 변수가 외부로 공개된 적이 있는지 없는지, 다시 말해 해당 변수가 스레드 내부에서만 사용되는지를 판단하기도 한다.


-
유출 분석을 사용하지 않는 경우라면, 락 확장(lock coarsening), 즉 연달아 붙어 있는 여러 개의 synchronized 블록을 하나의 락으로 묶는 방법을 사용하기도 한다.


-
경쟁 조건에 들어가지 않는 동기화 블록에 대해서는 그다지 걱정하지 않아도 좋다.
동기화 블록의 기본적인 구조가 상당히 빠르게 동작할 뿐만 아니라
JVM 수준에서 동기화와 관련한 추가적인 최적화 작업을 진행하기 때문에 동기화 관련 부하를 줄이거나 아예 없애주기도 한다.
대신 경쟁 조건이 발생하는 동기화 블록을 어떻게 최적화할지에 대해서 고민하자.


-
특정 스레드에서 진행되는 동기화 작업으로 인해 다른 스레드의 성능이 영향을 받을 수 있다.
동기화 작업은 공유돼 있는 메모리로 통하는 버스에 많은 트래픽을 유발하기 때문이다.
공유 메모리로 통하는 버스는 제한적인 대역폭을 갖고 있으며,
여러 개의 프로세서가 공유한다.
특정 스레드가 동기화 작업을 진행하느라 공유 메모리로 통하는 버스의 대역폭을 꽉 잡고 있다면,
동기화 작업을 진행해야 할 다른 스레드는 성능이 떨어질 수밖에 없다.



* 11.3.3. 블로킹

-
경쟁하지 않는 상태에서의 동기화 작업은 전적으로 JVM 내부에서 처리할 수 있다.
하지만 경쟁 조건이 발생할 때에는 동기화 작업에 운영체제가 관여해야 할 수 있는데, 운영체제가 관여하는 부분은 모두 일정량의 자원을 소모한다.

JVM 은 스레드를 대기 상태로 둘 때 두 가지 방법을 사용할 수 있는데,
첫 번째 방법은 스핀 대기(spin waiting), 즉 락을 확보할 때까지 계속해서 재시도하는 방법이고,
두 번째 방법은 운영체제가 제공하는 기능을 사용해 스레드를 실제 대기 상태로 두는 방법이다.

두 개의 방법 가운데 어느 쪽이 효율적이냐 하는 문제의 답은 컨텍스트 스위칭에 필요한 자원의 양과, 락을 확보할 때까지 걸리는 시간에 크게 좌우된다.
대기 시간을 놓고 보면, 대기 시간이 짧은 경우에는 스핀 대기 방법이 효과적이고,
대기 시간이 긴 경우에는 운영체제의 기능을 호출하는 편이 효율적이라고 한다.

대부분의 경우에는 운영체제의 기능을 호출하는 방법을 사용한다.


-
스레드가 대기 상태에 들어갈 때는 두 번의 컨텍스트 스위칭 작업이 일어나며,
이 과정에는 운영체제와 각종 캐시 등의 모듈이 연결돼 있다.

첫번째 컨텍스트 스위칭은 실행하도록 할당된 시간 이전에 대기 상태에 들어가느라 발생하는 것이고,
두 번째는 락이나 기타 필요한 조건이 충족됐을 때 다시 실행 상태로 돌아오는 컨텍스트 스위칭이다.




반응형

댓글