본문 바로가기

Java/병렬 프로그래밍

자바 병렬 프로그래밍 - 성능, 확장성

스레드를 사용하는 가장 큰 목적은 성능을 높이려는 것이다.

스레드를 사용하면 시스템 자원을 훨씬 효율적으로 사용 할 수 있고, 

어플리케이션이 시스템의 자원을 최대한 사용할 수 있게 한다.

또한 기존 작업이 진행되고 있어도 또 다른 작업을 실행 할 수 있어 응답 속도가 향상 된다.

 

이번장에서는 병렬 프로그램의 성능을 분석하고 모니터링하고 그 결과로 성능을 향상 시킬 수 있는 방법을 알아보자

 

 

성능에 대해

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

작업이 CPU, 메모리, 네트웍 속도, DB 처리 속도 등 자원을 사용하면서 부족한 부분이 있을 수 있다.

작업 실행에 충분치 못한 자원때문에 성능이 떨어진다면 작업의 성능이 해당 자원에 좌우된다고 할 수 있다.

 

여러개의 스레드를 사용할때는 성능상의 비용을 지불해야하는데

- 스레드간의 작업 내용을 조율하는데 필요한 오버헤드(락 걸기 등등)

- 컨텍스트 스위칭

- 스레드 생성과 제거

- 스레드 스케줄링

모두 비용이면 이 비용을 지불해도 스레드를 효율적으로 사용하면 응답성도 높아지고 성능도 좋아진다.

하지만 잘못 설계하면 순차적 작업 처리 프로그램보다 느려질 수도 있다.

 

 

더 나은 성능을 목표로 하려면 병렬로 프로그램이 동작할때 2가지 부분을 우선적으로 생각해야 한다.

- 확보할 수 있는 자원을 최대한 활용해야 하고

- 남는 자원이 생길때마다 그 자원 역시 최대한 활용 할 수 있어야 한다.

 

- 성능 대 확장성

 

어플리케이션의 성능은 여러 자료로 측정할 수 있는데,

서비스 시간, 대기 시간을 보면 얼마나 빠른지 성능을 볼 수 있고

용량, 처리량을 보면 얼마나 많은 양을 일 할 수있는지 알 수 있다.

 

단일 스레드 환경 어플리케이션은 별 다른 튜닝을 하지 않아도

다중 티어 어플리케이션보다 성능이 좋은 경우가 많지만 (서로 다른 티어 끼리 주고받는 도중에 발생하는 네트웍 시간 지연 현상 같은게 없이 때문에)

최대 부하를 넘어서는 작업을 만나면 처리 용량을 단시간에 처리 할 수 없기 때문에 아무리 시스템 자원을 투입하더라도

서비스 시간이 계속 길어져 버린다.

 

따라서 얼마나 빠르게 라는 측면보다 얼마나 많이 측면, 즉 확장성과 용량이라는 측면이 훨씬 중요하게 생각되는 경우가 많다.

 

이번장에서는 이 확장성을 중점적으로 보자

 

- 성능 트레이드 오프 측정

 

최적화 기법을 너무 이른 시점에 적용하지 말아야 한다.

일단 제대로 동작하게 만들고 난 다음에 빠르게 동작하도록 최적화해야 하며, 예상한 것보다 심각하게 성능이 떨어지는 경우에만 최적화 기법을 적용하자

 

성능을 높이기 위해 안전성을 떨어뜨리는것은 최악이다.

 

암달의 법칙

암달의 법칙을 사용하면 병렬 작업과 순차 작업의 비율에 따라 하드웨어 자원을 추가로 투입했을때 이론적으로

속도가 얼마나 빨라질지에 대한 예측 값을 얻을 수 있다.

 

순차적으로 실행해야 되는 작업의 비율이 F 고 하드웨어에 꽂혀있는 프로세서의 개수를 N 이라고 하면

다음 수식에 해당하는 정도까지 속도를 증가시킬 수 있다.

 

 

프로세서인 N 이 무한대면 속도 증가량은 최대 1/F 이다.

순차적 실행 부분이 전체 50% 라면 프로세서를 아무리 많이 꽂아도 겨우 2배 빨라질수있는것이다.

암달의 법칙을 이용하면 순차 처리 하는 부분이 많아질때 얼마나 느려지는지 수치화 할 수 있다.

 

하드웨어에 CPU 가 10개 꽂혀있으면 10%의 순차작업을 갖고 있는 프로그램은 

계산해보면 5.3배가 나오는데 하드웨어가 10개니까 0.53 즉 53퍼의 CPU 활용도를 갖는다.

 

만약 100개를 꽂으면 9.2배 가 나오면 하드웨어가 100개니까 0.092 즉 9퍼의 활용도를 갖는다

즉 9.2배의 속도를 증가시키려면 하드웨어가 너무 비효율적으로 돌아간다.

 

순차적으로 실행해야하는 부분이 조금이라도 늘면 프로세스의 비해 얻을 수 있는 속도 증가량이 크게 떨어진다.

 

작업 큐에 대한 순차적 처리 하는 코드 예시

 

작업 큐에서 작업을 하나씩 봅는 부분이 순차 처리해야 한다.

모든 작업 스레드가 작업 큐를 쓸 수 있기 때문에 , 적당한 동기화 작업이 선행 되어야 한다.

 

LinkedList 보다는 LinkedBlockingQueue 를 사용하면 대기하는 시간이 훨씬 적게 들지만,

어찌됐던 간에 순차적으로 처리해야만 한다.

 

 

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

 

둘 다 스레드의 안전성은 보장 되는 Queue 인데,

하나는 SynchronizedLise 오 하나는 ConcurrentLinkedQueue 클래스 를 썻다.

단순히 적절한 큐 사용만으로도 확장성을 크게 늘릴 수 있다.

 

ConcurrentLinkedQueue 는 개별 포인터에 대한 업데이트 연산만 순차적으로 처리하면 되기 때문에

성능이 좋다

 

- 정성적인 암달의 법칙 적용 방법

 

순차처리 돼는 비율을 알지 못해도 암달의 법칙을 사용할 수 있다.

락의 적용 범위를 줄이는 방법

앞으로 락 분할 방법(하나의 락을 두개로 분리)과 락 스트라이핑(하나의 락을 여러개로 분리)

에 대해서 알아볼것이다 

락의 범위를 줄이는 방법을 암달의 법칙으로 보면,

락을 두개로 분할하는 정도로는 다수의 프로세서를 충분히 활용하기 어렵다는 결론을 얻는다.

하지만 락 스트라이핑 방법을 사용하면 프로세스 개수에 따라서 분할 개수를 같이 증가시킬 수 있어

확장성을 얻는데 훨씬 믿을만한 방법이라고 할 수 있다.

 

스레드와 비용

 

실행 스케줄링과 스레드 간의 조율을 하다보면 성능에 부정적인 비용이 발생한다.

따라서 스레드를 쓰려면 병렬로 실행하느라 드는 비용을 넘어서야 성능을 향상 시킬 수 있다.

 

- 컨텍스트 스위칭

 

CPU 개수보다 실행 중인 스레드의 개수가 많으면 

운영체제가 특정 스레드의 실행 스케줄을 선점하고 다른 스레드가 실행 될 수 있도록 스케줄을 잡는다.

이렇게 하나의 스레드가 실행되다가 다른 스레드가 실행되는 순간 컨텍스트 스위칭이 일어난다.

 

컨텍스트 스위칭의 상세한 구조를 보면 현재 실행중인 스레드의 실행 상태를 보관해두고,

다음 번에 실행되기로 스케줄된 스레드의 실행 상태를 다시 읽어들인다.

 

컨텍스트 스위칭은 공짜로 일어나지 않는다.

스레드 스케줄링을 하려면 운영체제와 JVM 내부의 공용 자료 구조를 다뤄야 한다.

운영체제와 JVM 역시 스레드가 사용하는 것과 같은 CPU 를 사용하고 있는데,

따라서 운영체제나 JVM 이 CPU 를 많이 사용할 수록 스레드가 사용할 CPU 가 줄어든다.

 

물론 CPU 만 그런게 아니고 메모리에서도 다른 스레드를 실행하려고 할때 해당 스레드가 사용하던 데이터가 

캐시 되어있지 않을 수도 있어서 저장소를 가면서 성능이 느려지기도 한다.

 

- 메모리 동기화

 

동기화에 필요한 비용은 여러 곳에서 발생한다.

synchronized , voliatile 키워드를 사용하면 가시성을 통해 메모리 배리어(barrier) 명령어를 사용할 수 있는데,

배리어는 캐시를 플러시하거나 하드웨어와 관련된 쓰기를 늦출수도 있는데

배리어를 사용하면 컴파일러가 제공하는 최적화 기법을 쓸 수 없어서 간접적인 성능 문제를 발생시킨다.

 

동기화가 성능에 미치는 영향을 파악하려면?

동기화 작업이 경쟁적인지 비경쟁적인지 확인해야 한다.

synchronized 키워드는 비경쟁적인 경우에 최적화 되어있고 비경쟁적이면서 동기화 방법은 성능에 그다지 큰 영향은 없다.

 

최근 JVM 은 대부분 다른 스레드와 경쟁할 가능성이 없다고 판단하면 해당 락을 걸지 않는다.

 

허술한 JVM 은 Vector 에 add 하는 부분과 toString 을 사용하는 곳에서 총 4번 락을 잡았다 놓았다 할테지만

정교한 JVM 에서는 외부에 유출이 돼지 않고 메소드 내부에서만 실행되는걸 확인하고

락을 잡지 않고 빠르게 실행시킨다.

 

유출분석을 하지 않으면 연달아 있는 락은 하나의 락으로 묶는 락 확장 기능을 사용하기도 한다.

 

 

- 블로킹

비경쟁 조건 동기화 작업은 JVM 내부에서 처리할 수 있지만

경쟁 조건 동기화 작업은 운영체제가 관여 할 수도 있다.

 

락을 놓고 경쟁하고 있다면 

락을 확보 못한 스레드는 대기 상태에 있어야 하는데

JVM 은 스레드를 대기 상태에 둘때 2가지 방법을 쓴다.

 

1. 스핀대기- 락을 확보할때까지 계속 재시도

2. 운영체제 제공하는 기능을 사용해 스레드를 실제 대기 상태로 두는 방법

 

대기시간이 짧을 땐 스핀대기 , 길땐 운영체제 기능이 효율적인 편이다.

대부분 운영체제 기능을 호출 하는 방법을 쓴다.

 

 

락 경쟁 줄이기

작업을 순차 처리하면 확장성을 놓치고

작업을 병렬로 처리하면 컨텍스트 스위칭에서 성능에 악영향을 준다.

근데 락을 놓고 경쟁하면 순차적으로 처리되면서 컨텍스트 스위칭도 많이 발생해서

확장성과 성능을 동시에 떨어뜨린다.

따라서 락 경쟁을 줄여야 한다.

 

락을 쓰는 분명한 이유가 있지만 (공유된 데이터가 망가지지 않게 보호) 그에 따라 지속적으로 경쟁되는 상황에선

확장성에 문제가 생긴다.

 

락을 두고 경쟁하는 상황에는 크게 2가지 원인이 있을수있다.

- 락을 얼마나 빈번하게 확보하려고 하는지,

- 락을 한번 확보하면 얼마나 오래 사용하는지

 

이 두 요인의 값이 충분히 작다면 심각한 문제는 발생하지 않지만 

반대라면 계속 대기 상태에 머물르고 심각하면 경우에는 작업할 내용이 쌓였음에도 CPU 는 놀고 있을 수 있다.

 

락 경쟁 조건 줄일 수 있는 방법

- 락 확보 한 채로 유지 되는 시간 줄이기

- 락 확보 요청 횟수 줄이기

- 병렬성을 높여주는 방법으로 조율하자

 

- 락 구역 좁히기

 

락 유지 시간을 줄이는건 효과적이다.

필요하지 않은 코드를 synchronized 블록 밖으로 뽑아내서 시간을 줄일 수 있다.

 

서로 확보하고 하는 락을 특정 스레드가 오래 잡으면 확장성이 매우 떨어진다.

필요 이상으로 락을 잡고 있는 모습

실제 필요한 부분은 Map.get 메소드 호출부분 뿐이다.

 

점유 시간을 엄청나게 줄일 수 있다.

암달의 법칙을 통해 보면 순차적으로 처리 돼야 하는 코드의 양이 줄어들어서 확장성을 저해하는 요소를 줄일 수 있다.

 

또한 synchronized 블록에서 동기화를 맞추는 것도 자원이 필요하니

불필요하게 synchronized 블록을 두 개 이상으로 쪼개는 일도 한도를 넘으면 성능에 오히려

악영향을 끼친다.

 

- 락 정밀도 높이기

 

점유하는 시간을 최대한 줄여서 경쟁 시간을 줄일 수 있는 방법으로 스레드에서 해당 락을 덜 사용하도록 변경 하는 방법이 있다.

이 방법이 락 분할과 락 스트라이핑 방법이다.

 

두가지 모두 하나의 락으로 여러개의 상태 변수를 한번에 묶지 않고

서로 다른 락을 사용해 여러개의 독립적인 상태 변수를 각자 묶어두는 방법이다.

이걸 쓰면 확장성이 높아지지만 대신 락의 개수가 많아질수록 데드락이 발생할 위험이 있다.

 

하나의 락으로 사용하는 경우

하나의 락으로 users 와 queries 를 락시키고 있다.

 

락을 분할 시켜서 대기 상태에 들어가는 경우가 크게 줄어든다.

 

- 락 스트라이핑

 

만약 경쟁 조건이 굉장히 심한 락을 두개로 분할하면 

결국 경쟁이 심한 락이 두개가 생기는 모양새가 될 수 있다.

 

락 분할 방법은 떄에 따라 독립적인 객체를 여러가지 크기의 단위로 묶어내고 

묶인 블록을 단위로 락을 나누는 방법을 사용할 수 있는데

이걸 락 스트라이핑 이라 한다.

N_LOCKS 개의 락을 생성하고

락이 각자의 범위에 해당하는 해시 공간에 대한 동기화를 담당한다.

 

- 핫 필드 최소화

 

자주 사용하고 모든 연산을 수행할 때마다 한 번씩 사용해야 하는 카운터 변수를 핫필드라 한다.

 

핫필드 부분을 최소화하자

 

 

- 독점적인 락을 최소화하는 다른 방법

 

높은 병렬성으로 공유된 변수를 도입해서 독점적인 락을 사용하는 부분을 줄이는 것이다.

예를들면 병렬 컬렉션 클래스를 사용하거나, 읽기 - 쓰기 락을 사용하거나 불변 객체를 사용하고 단일 연산 변수를 사용하는 방법이 있다.

 

ReadWriteLock 클래스를 사용하면 여러개의 Reader 와 하나의  Writer 가 있는 상황 문제를 압축할 수 있다.

값을 변경 할 수 있는 하나의 스레드만 값을 쓸때 락을 독점적으로 확보한다.

여러개의 스레드는 값을 읽기만 할 수 있다.

 


단일 연산 변수(atomic variable)를 사용하면 통계 값을 위한 카운터 변수나 
일련번호 생성 모듈  같은 핫 필드가 존재할 때 핫 필드의 값을 손쉽게 변경할 수 있게 해준다.

핫필드를 단일 연산 변수로 변경한는 것만으로도 확장성에 이득을 볼 수 있다.


- CPU 활용도 모니터링


어플리케이션의 확장성을 테스트 할 때 그 목적은 대부분 CPU 를 최대한 활용하는데 있다.
유닉스 vmstat, mpstat 유틸리티나 윈도우 환경의 perfmon 과 같은 유틸리티를 사용하면
CPU 가 얼마나 열심히 일하는지 볼 수 있다.

2개 이상의 CPU 에서 일부 CPU 만 열심히 일하고 있다면?
병렬성을 높이는 방법을 찾아 적용해야한다.
특정 스레드만 연산 작업이 발생하는건 CPU 가 여러개인 하드웨어를 잘 활용 못하고 있는것
CPU 를 충분히 활용 못하는 몇가지 원인을 생각해 볼 수 있다.

- 부하가 부족하다 : CPU 사용량을 측정할 만큼 충분한 부하 상황을 못만들때


- I/O 제약 : iostat 이나 perfmon 으로 어플리케이션 중 디스크 관련 부분이 얼마나 되는지 알 수 있다.
네트웤의 트래픽 수준을 모니터링 하면 대역폭을 얼마나 사용하고 있는지 쉽게 알 수 있다.

외부 제약 사항 : 외부 DB 또는 웹 서비스를 이용하고 있다면 성능의 발목을 잡는 병목이 외부에 있을 수 있다.

- 락 경쟁 : 각종 프로파일링 도구를 활용하면 어플리케이션 내부에서 락 경쟁 조건이 얼마나 발생하는지 알아 볼 수 있으며, 어느 락이 가장 빈번하게 경쟁 목표가 되는지 알 수 있다.

 

 

스레드를 4개만 활용하는 어플리케이션에서 CPU 가 8개라면 CPU 를 제대로 활용하지 못하는 것이다.

vmstat으로 보는 화면의 한쪽 컬럼에는 CPU 가 모자라 실행하지 못하는 스레드의 수를 볼 수 있다.

CPU 사용량이 지속적으로 높게 유지되면서 기다리는 스레드가 많다면

CPU 를 추가 하는것만으로도 성능을 향상 시킬 수 있다.

 

 

- 객체 풀링은 하지 말자

 

초기 버전의 JVM 은 새로운 객체의 메모리 할당 작업과 가비지 컬렉션 작업이 느린 편이었지만,

이후 성능이 크게 개선돼서 C언어의 malloc 보다 빨라졌다.

예전에 객체 할당과 제거 작업이 느렸을때는 객체를 더이상 사용하지 않아도 바로 가비지 컬렉션에 두지 않고 

재사용할 수 있게 보관했고 꼭 필요한 경우에만 새로운 객체를 생성하는 객체 풀을 활용했다.

 

근데 이 객체 풀링을 병렬 어플리케이션에서 사용하려면 훨씬 많은 비용을 지불해야한다.

스레드에서 공통의 객체 풀 하나를 놓고 재사용하면 동기화 방법을 사용해야 하고

락을 확보하기 위해 스레드가 대기 상태에 들어간다

확장성에 문제가 생길 수 있으니 이런 경우엔 객체 풀을 사용하면 안된다.

 

 

Map 객체의 성능 분석

ConcurrentHashMap 은 병렬처리 환경에서 빛을 발한다.

특히 맵 내부에 있는 값을 가져가는 연산이라면 아주 빠른 속도를 낸다.

 

동기화된 HashMap 클래스가 속도가 떨어지는 이유는 맵 전체가 하나의 락으로 동기화 돼기 때문이다.

 

ConcurrentHashMap 은 대부분의 읽기 연산에는 락을 걸지 않고 

쓰기 연산과 일부 읽기 연산에는 락스트라이핑을 사용한다 .

이런 기법으로 다수의 스레드가 동시에 ConcurrentHashMap 을 사용할 수 있다.

 

 스레드가 추가 될수록 성능도 함께 증가한다

하지만 한 번 경쟁이 발생하기 시작하면 연산에 필요한 시간의 대부분이

컨텍스트 스위칭과 스케줄링에 필요한 대기 시간으로 소모되어서 스레드를 추가해도 성능을 올리지 못한다.

 

컨텍스트 스위치 부하 줄이기

실행과 대기의 두가지 상태를 옮겨 다니는 것을 컨텍스트 스위칭이라고 한다.

대기 상태에 들어가기 쉬운경우를 예를들어보자

컨텍스트 스위치 횟수를 줄이면 서버의 처리량에 어떤 변화가 생기는지,

요청을 처리할때 로그를 출력하는 경우를 생각해보자

 

2가지 방법이 있을 수 있는데

하나는 그냥 println 으로 호출을해서 로그를 출력시키는 경우와

두번째로는 로그 출력만을 전담하는 백그라운드 스레드에서 출력하는 경우다

두가지 방법은 큰 성능 차이가 있다.

 

로그 출력 기능에 걸리는 시간은 항상 I/O 스트림 관련 작업 시간이 포함된다.

즉 I/O 작업 대기 시간까지 해당 작업 시간에 포함이 된다.

 

운영체제는 I/O 작업이 끝날때까지 스레드를 대기 상태에 넣고 있다가

마무리 되면 다른 스레드가 아직 동작 중일 수 있고 해당 그레드가 할당받은 시간까지는 계속 실행이 된다.

그러면 서비스 시간이 좀 더 늘어나는 셈이다.

 

또한, 다수의 스레드가 동시다발적으로 로그 메시지를 출력하려고 하면 메시지를 출력하는 

출력스트림을 두고 락 경쟁이 발생할 수 도 있다.

그럼 블로킹의 경우와 마찬가지로  스레드가 락을 확보하기 위해 대기 상태에 들어가면서 컨텍스트 스위치가 발생한다.

컨텍스트 스위치가 빈번하게 발생하면 서비스 시간은 점점 늘어난다.

 

이런 요청을 스레드 외부에서 I/O 작업을 뽑아내서 요청을 처리시켜 평균 시간을 줄일 수 있다.

 

 

요약 

멀티스레드를 사용하는 가장 큰 이유는 다중 CPU 하드웨어를 충분히 활용하고자 하는것

암달의 법칙에 따르면 어플리케이션의 확장성은 반드시 순차적으로 실행되어야 하는 코드가

얼마만큼의 비율을 차지 하는지에 달렸다고 한다.

자바에서 순차 처리 부분은 독점적인 락을 사용하는 부분이기 때문에 

락으로 동기화하는 범위를 세분화해 정밀도를 높이거나 락을 확보하는 시간을 최소화 시켜 사용하자

그리고 독점적인 락 대신 독점적이지 않은 방법을 사용하거나 대기 상태에 들어가지 않는 방법을

사용하는 것도 중요하다