- 작업을 순차적으로 처리하면 확장성(scalability)을 놓치고, 작업을 병렬로 처리하면 컨텍스트 스위칭에서 성능에 악영향을 줄 수 있다.
따라서 락을 놓고 경쟁하는 상황이 벌어지면 순차적으로 처리함과 동시에 컨텍스트 스위칭도 많이 일어나므로 확장성과 성능을 동시에 떨어뜨리는 원인이 된다. 즉 락 경쟁을 줄이면 줄일수록 확장성과 성능을 함께 높일 수 있다.
- 병렬 앱에서 확장성에 가장 큰 위협이 되는 존재는 바로 특정 자원을 독점적으로 사용하도록 제한하는 락이다.
- 락을 두고 발생하는 경쟁 상황에는 크게 두 가지를 생각해 볼 수 있다. 락을 얼마나 빈번하게 확보하려고 하는지, 한 번 확보하고 나면 해제할 때까지 얼마나 오래 사용하는지가 중요한 요인이다.
이 두 가지 요인을 곱한 값이 충분히 작을 값이라면, 락을 두고 경쟁하는 상황 때문에 확장성에 심각한 문제가 생기지는 않을 것이다.
반대로 락을 필요로 하는 굉장히 많은 수의 스레드가 경쟁을 하고 있다면 락을 확보하지 못한 다수의 스레드가 계속 대기 상태에 머물러야 하며, 특히 심각한 경우에는 작업할 내용이 쌓여 있음에도 불구하고 CPU 는 실제로 놀고 있을 가능성도 있다.
- 락 경쟁 조건을 줄일 수 있는 몇 가지 방법이 있다. 락을 확보한 채로 유지되는 시간을 최대한 줄여라. 락을 확보하고자 요청하는 횟수를 최대한 줄여라. 독점적인 락 대신 병렬성을 크게 높여주는 여러 가지 조율 방법을 사용하라.
* 11.4.1 락 구역 좁히기
- 락 경쟁이 발생할 가능성을 줄이는 효과적인 방법 가운데 하나는 바로 락을 유지하는 시간을 줄이는 방법이다. 락이 꼭 필요하지 않은 코드를 synchronized 블록 밖으로 뽑아내어 락이 영향을 미치는 구역을 좁히면 락을 유지하는 시간을 줄일 수 있다. 특히 I/O 작업과 같이 대기 시간이 발생할 수 있는 코드는 최대한 synchronized 블록 밖으로 끄집어내자.
- 스레드 안전성 위임(delegating thread safety) 방법을 사용해, 스레드 안전성이 확보된 클래스(Hashtable, synchronizedMap, ConcurrentHashMap) 을 사용하면 스레드 안전성을 모두 위임할 수 있다.
스레드 안전성 위임은 따로 동기화나 락에 대해 신경쓰지 않아도 좋으며, 그와 함께 락을 점유하는 시간을 최소화 하는 셈이다. 다른 개발자가 유지보수를 해야 할 상황이 발생해도, 락을 제대로 사용하지 못해 오류가 발생하는 경우를 미연에 방지할 수도 있다.
- synchronized 블록을 줄이면 줄일수록 앱의 확장성을 늘일 수 있다고는 하지만, 그렇다고 해서 단일 연산으로 실행돼야 할 명령어까지 synchronized 블록 밖으로 빼내거나 해서는 안 된다. synchronized 블록에서 동기화를 맞추는 데도 자원이 필요하기 때문에 하나의 synchronized 블록을 두 개 이상으로 쪼개는 일도 어느 한도를 넘어서면 성능 측면에서 오히려 악영향을 미칠 수 있다. 물론 가장 최적의 설정 역시 플랫폼 dependent 하기도 하다.
일반적으로 볼 때 대기 상태에 들어갈 수 있는 연산뿐만 아니라 아주 작은 연산까지 synchronized 블록 밖으로 빼내는 정도로 충분한다.
* 11.4.2. 락 정밀도 높이기
- 락을 점유하고 있는 시간을 최대한 줄이고, 락을 확보하기 위해 경쟁하는 시간을 줄일 수 있는 또 다른 방법으로는 스레드에서 해당 락을 덜 사용하도록 변경하는 방법이 있다.
락 분할(splitting) 과 락 스트라이핑(striping) 이 그 방법들이다.
두 가지 모두 하나의 락으로 여러 개의 상태 변수를 한번에 묶어두지 않고, 서로 다른 락을 사용해 여러 개의 독립적인 상태 변수를 각자 묶어두는 방법이다. 두 가지 기법을 활용하면 락으로 묶이는 프로그램의 범위를 조밀하게 나누는 효과가 있으며, 따라서 결국 앱의 확장성이 높아지는 결과를 기대할 수 있다.
하지만 반대로 락의 개수가 많아질수록 데드락이 발생할 위험도 높아진다는 점을 주의해야 한다.
- 락을 하나에서 둘로 분할하는 방법은 경쟁 조건이 아주 심하지는 않지만 그래도 어느 정도 경쟁이 발생하고 있는 경우에 가장 큰 효과를 볼 수 있다. 반대로 경쟁 상황이 거의 발생하지 않는 경우에는 락을 분할한다고 해서 큰 효과를 보지는 못하지만, 부하가 걸리면서 경쟁이 발생하기 시작했을 때 성능이 떨어지는 시점을 늦출 수도 있다.
어느 정도의 경쟁이 발생하는 상황에서 락을 두 개 이상으로 분할하면 대부분의 동기화 블록에서 락 경쟁이 일어나지 않도록 할 수 있으며, 따라서 처리량과 확장성의 측면에서 큰 이득을 얻을 수 있다.
* 11.4.3. 락 스트라이핑
- 락 분할 방법은 때에 따라 독립적인 객체를 여러 가지 크기의 단위로 묶어내고, 묶인 블록을 단위로 락을 나누는 방법을 사용할 수도 있는데, 이런 방법을 락 스트라이핑이라고 한다. ( lock striping )
예를 들어 ConcurrentHashMap 클래스가 구현된 소스코드를 보면 16개의 락을 배열로 마련해두고 16개의 락 각자가 전체 해시 범위의 1/16에 대한 락을 담당한다. 따라서 N번째 해시 값은 락 배열에서 N mod 16 의 값에 해당하는 락으로 동기화된다. ConcurrentHashMap 에서 사용하는 해시 함수가 적당한 수준 이상으로 맵에 들어 있는 항목을 분산시켜 준다는 가정하에 락 경쟁이 발생할 확률을 1/16으로 낮추는 효과가 있다.
결국 ConcurrentHashMap 은 최대 16개의 스레드에서 경쟁 없이 동시에 맵에 들어 있는 데이터를 사용할 수 있도록 구현돼 있는 셈이다. ( CPU 개수가 많은 하드웨어를 사용하는 경우 병렬성을 높이기 위해 락의 개수를 더 늘려볼 수도 있다. 하지만 적절한 수치 이상의 많은 경쟁 조건이 발생한다고 확인된 경우에만 기본값인 16보다 큰 값을 사용하자. )
-
락 스트라이핑을 사용하다 보면 여러 개의 락을 사용하도록 쪼개놓은 컬렉션 전체를 한꺼번에 독점적으로 사용해야 할 필요가 있을 수 있는데,
이런 경우에는 단일 락을 사용할 때보다 동기화시키기 어렵고 자원 소모도 많이 한다는 단점이 있다.
대부분의 작업을 처리할 때는 쪼개진 락 하나만 확보하는 것으로도 충분하지만,
ConcurrentHashMap 클래스에서 해시 공간의 크기를 늘리고 해시 함수를 새롭게 적용하는 작업과 같이 간혹 전체 컬렉션을 독점적으로 사용해야 하는 경우가 생긴다.
이런 경우에는 보통 쪼개진 락을 전부 확보한 이후에 처리하도록 구현한다.
* 11.4.4. 핫 필드 최소화
-
락 분할 방법과 락 스트라이핑은 여러 개의 스레드가 각자 방해받지 않으면서 독립적인 데이터를 사용할 수 있도록 해주기 때문에 앱의 확장성을 높여준다.
-
모든 연산에 꼭 필요한 변수가 있다면 락의 정밀도(granularity)를 세밀하게 쪼개는 방법을 적용할 수 없다.
성능과 확장성이 서로 공존하기 어렵게 만드는 또 다른 요인이다.
자주 계산하고 사용하는 값을 캐시에 저장해두도록 최적화한다면 확장성을 떨어뜨릴 수밖에 없는 핫 필드(hot fields) 가 나타난다.
-
성능의 측면에서 최적화라고 생각했던 기법,
즉 맵 내부의 항목을 캐시해두는 방법이 확장성의 발목을 잡는 셈이다.
모든 연산을 수행 할 때마다 한 번씩 사용해야 하는 카운터 변수와 같은 변수를 핫 필드라고 부른다.
* 11.4.5. 독점적인 락을 최소화하는 다른 방법
-
락 경쟁 때문에 발생하는 문제점을 줄일 수 있는 또 다른 방법은 좀 더 높은 병렬성으로 공유되는 변수를 관리하는 방법을 도입해 독점적인 락을 사용하는 부분을 줄이는 것이다.
예를 들어 병렬 컬렉션 클래스를 사용하거나, 읽기-쓰기(read-write) 락을 사용하거나
불변(immutable) 객체를 사용하고 단일 연산 변수를 사용하는 등의 방법이 여기에 해당한다.
-
ReadWriteLock 클래스를 사용하면 여러 개의 reader 가 있고 하나의 writer 가 있는 상황으로 문제를 압축할 수 있다.
여러 개의 스레드에서 공유된 변수의 내용을 읽어가려고 하고 대신 값을 변경하지는 못한다.
그리고 값을 변경할 수 있는 단 하나의 스레드는 값을 쓸 때 락을 독점적으로 확보한다.
ReadWriteLock 은 읽기 연산이 대부분을 차지하는 데이터 구조에 적용하기가 알맞으며,
전체적으로 독점적인 락을 사용하는 경우보다 병렬성 측면에서 확장성을 크게 높여준다.
만약 읽기 전용의 데이터 구조라면 불변 클래스의 형태를 유지하는 것만으로도 동기화 코드를 완전히 제거해 버릴 수 있다.
-
단일 연산 변수(atomic variable)를 사용하면 통계 값을 위한 카운터 변수나 일련번호 생성 모듈, 링크로 구성된 데이터 구조에서 첫 번째 항목을 가리키는 링크와 같은 "핫 필드"가 존재할 때
핫 필드의 값을 손쉽게 변경할 수 있게 해준다.
-
단일 연산 클래스는 숫자나 객체에 대한 참조 등을 대상으로 굉장히 정밀도가 높은 단일 연산 기능을 제공하며,
그 내부적으로는 CPU 프로세서에서 제공하는 저수준의 병렬처리 기능을 활용하고 있다.
작성 중인 클래스 내부에 다른 변수와의 불변조건에 관여하지 않는 핫 필드가 몇 개 정도 있다면
해당하는 핫 필드를 단일 연산 변수로 변경하는 것만으로도 확장성에 이득을 볼 수 있다.
( 클래스 내부에서사용하는 핫 필드의 개수를 줄이면 앱의 확장성을 더 높일 수 있다.
단일 연산 변수를 사용한다 해도 핫 필드와 관련해 소모되는 자원을 줄여줄 뿐 자원 소모를 아예 없애지는 못한다. )
* 11.4.6. CPU 활용도 모니터링
-
앱의 확장성을 테스트할 때 그 목적은 대부분 CPU 를 최대한 활용하는 데 있다.
유닉스 환경의 vmstat 이나 mpstat 과 같은 유틸, 윈도우 환경의 perfmon 과 같은 유틸을 사용하면 CPU 가 얼마나 열심히 일하는지를 확인할 수 있다.
-
만약 두 개 이상의 CPU 가 장착된 시스템에서 일부 CPU 만 열심히 일하고 나머지는 놀고 있다면,
가장 먼저 해야 할 일은 프로그램의 병렬성을 높이는 방법을 찾아 적용하는 일이다.
특정 CPU 만 열심히 일하는 경우는 상당 부분의 연산 작업이 특정 스레드에서만 일어난다는 것을 뜻하며,
따라서 CPU 가 여러 개 장착된 하드웨어를 앱에서 충분히 활용하지 못한다고 볼 수 있다.
-
CPU 를 충분히 활용하지 못하고 있다면,
일반적인 몇 가지 원인을 생각해 볼 수 있다.
부하가 부족하다.
I/O 제약
iostat 이나 perfmon 등의 유틸을 사용하면 앱의 성능 가운데 디스크 관련 부분이 얼마나 되는지를 살펴볼 수 있다.
네트웍의 트래픽 수준을 모니터링하면 대역폭을 얼마나 사용하고 있는지도 쉽게 파악할 수 있다.
외부 제약 사항
외부 데이터베이스 또는 웹 서비스 등을 사용하고 있다면 성능의 발목을 잡는 병목이 외부에 있을 가능성도 높다.
외부적인 부분은 프로파일러(profiler)를 활용하거나 데이터베이스 모니터링 도구를 사용한다.
락 경쟁
각종 프로파일링 도구를 활용하면 앱 내부에서 락 경쟁 조건이 얼마나 발생하는지 알아볼 수 있으며,
어느 락이 가장 빈번하게 경쟁의 목표가 되는지도 알 수 있다.
프로파일러를 사용하지 않는다 해도 랜덤 샘플링(random sampling), 즉 특정 시점에 스레드 상태를 덤프해서 락을 확보하기 위해 경쟁 중인 스레드가 어느 정도인지 확인할 수 있다.
특정 스레드가 락을 확보하기 위해 대기 중이라면 스레드 덤프를 뽑아 봤을 때 해당 스레드 부분에 "waiting to lock monitor..." 와 같은 메시지가 표시된다.
-
앱이 CPU 를 적절한 수준 이상으로 충분히 사용하고 있다고 생각되면,
여러 가지 모니터링 방법을 사용해서 CPU 를 추가했을 때 얼마나 이득을 볼 수 있을 것인지 예측해 볼 수 있다.
-
vmstat 으로 보는 화면의 한쪽 컬럼에는 실행 상태에 놓여 있지만 CPU 가 모자라 실행하지 못하는 스레드의 수가 표시된다.
만약 CPU 사용량이 지속적으로 높게 유지되면서 남는 CPU 가 나타나기를 기다리는 스레드가 많아진다면 CPU 를 더 장착하는 것으로 성능을 높일 수 있다.
* 11.4.7 객체 풀링은 하지 말자
-
초기 버전의 JVM 에서는 객체를 새로 메모리에 할당하는 작업과 가비지 컬렉션 작업이 상당히 느린 편이었지만, 그 이후에는 성능이 크게 개선됐다.
실제로 최근에 자바 프로그램에서 메모리를 할당하는 작업이 C 언어의 malloc 함수보다 빨라졌다.
-
예전에 객체 관련 할당과 제거 작업이 느렸을 때는 객체를 더 이상 사용하지 않는다 해도 가비지 컬렉터에 넘기는 대신 재사용할 수 있게 보관해두고, 꼭 필요한 경우에만 새로운 객체를 생성하는 객체 풀(object pool)을 많이 활용했었다.
이런 방법을 사용해 가비지 컬렉션에 소모되는 시간을 줄일 수 있다고는 하지만,
단일 스레드 앱에서 아주 무겁고 큰 객체를 제외하고는 일반적으로 성능에 좋지 않은 영향을 미치는 것으로 알려져 있다.
게다가 크기가 작거나 중간 크기인 객체를 풀로 관리하는 일은 오히려 상당한 자원을 소모하는 것으로 알려져 있다.
-
병렬 앱에서는 객체 풀링을 사용했을 때 훨씬 많은 비용을 지불해야 할 수도 있다.
-
스레드가 락 경쟁에 밀려 대기 상태에 들어가 기다리는 작업 흐름은 메모리에 객체를 할당하는 일보다 훨씬 자원을 많이 소모하는 일이기 때문에 아주 작은 양이라 해도 객체 풀 때문에 발생하는 락 경쟁 상황은 앱의 확장성에 지대한 영향을 미치는 병목이 될 수 있다.
( 경쟁 조건이 거의 발생하지 않는 동기화 기법이라 해도 메모리에 객체를 할당하는 것보다는 더 많은 자원을 소모한다. )
-
객체 풀 역시 성능을 최적화할 수 있는 방법 가운데 하나라고 생각하기도 하지만,
반대로 확장성에는 심각한 문제를 일으킬 수 있다.
객체 풀은 그것만의 적절한 용도가 있으며,
성능을 최적화하는 데 사용하기에는 그다지 적절한 방법이 아니다.
-
스레드 동기화하는 것보다 메모리에 객체를 할당하는 일이 훨씬 부담이 적다.
11.5. 예제 : Map 객체의 성능 분석
-
단일 스레드 환경에서 ConcurrentHashMap 은 동기화된 HashMap 보다 약간 성능이 빠르다.
하지만 병렬 처리 환경에서는 ConcurrentHashMap 의 성능이 빛을 발한다.
-
동기화된 HashMap 클래스가 속도가 떨어지는 가장 큰 이유는 물론 맵 전체가 하나의 락으로 동기화돼 있다는 점이고,
따라서 한 번에 단 하나의 스레드만이 맵을 사용할 수 있다.
ConcurrentHashMap 은 대부분의 읽기 연산에는 락을 걸지 않고 있으며 쓰기 연산과 일부 읽기 연산에는 락 스트라이핑을 활용하고 있다.
-
ConcurrentHashMap 과 ConcurrentSkipListMap 에 대한 결과를 보면 스레드 수가 늘어남에 따라 성능이 잘 따라와 준다.
synchronizedMap 으로 동기화된 맵이 보여주는 성능 수치는 그다지 훌륭하지 않다.
단일 스레드로 동작할 때에는 ConcurrentHashMap 과 대등한 속도를 보여주지만,
경쟁 조건이 발생하지 않는 상황에서 경쟁이 발생하는 상황으로 넘어가면
성능이 급격하게 저하하는 것을 볼 수 있다.
11.6. 컨텍스트 스위치 부하 줄이기
Summary
멀티스레드를 사용하는 큰 이유 중의 하나가 바로 다중 CPU 하드웨어를 충분히 활용하고자 하는 것이다.
병렬 처리 앱의 성능에 대해 논의하면서 실제적인 서비스 시간보다는 앱의 데이터 처리량이나 확장성을 좀 더 집중적으로 살펴봤다.
암달의 법칙에 따르면 앱의 확장성은 반드시 순차적으로 실행돼야만 하는 코드가 전체에서 얼마만큼의 비율을 차지하냐에 달렸다고 한다.
자바 프로그램의 내부에서 순차적으로 처리해야만 하는 가장 주요한 부분은 바로 독점적인 락을 사용하는 부분이기 때문에
락으로 동기화하는 범위를 세분화해 정밀도를 높이거나 락을 확보하는 시간을 최소한으로 줄이는 기법을 사용해 락을 최소화만 사용해야 한다.
그리고 독점적인 락 대신 독점적이지 않은 방법을 사용하거나 대기 상태에 들어가지 않는 방법을 사용하는 것도 중요하다.
atomic variable, concurrency, ConcurrentHashMap, concurrentskiplistmap, CPU, cpu 개수, cpu 활용도 모니터링, DB, delegating thread safety, GC, granularity, HashMap, Hashtable, hot fields, immutable, IO, io 제약, Java, lock splitting, lock striping, malloc, mpstat, object pool, perfmon, profiler, random sampling, read-write lock, Reader, readwritelock, scalability, synchronized, synchronizedMap, vmstat, waiting to lock monitor, writer, [Java Concurrency] 성능, 가비지 컬렉션, 객체 풀, 객체 풀링, 객체 풀링은 하지 말자, 객체 할당, 계산, 공존, 단일 연산, 단일 연산 변수, 단점, 데드락, 데이터베이스, 독점적 락 대신 병렬적 락 사용, 독점적인 락을 최소화하는 방법, 락 경쟁, 락 경쟁 조건, 락 경쟁 조건 줄이는 방법, 락 경쟁 줄이기, 락 구역 좁히기, 락 분할, 락 스트라이핑, 락 유지 시간, 락 정밀도 높이기, 락 확보 빈도, 락 확보 요청 횟수 줄이기, 락 확보 유지시간 줄이기, 락의 개수, 락의 정밀도, 무겁고 큰 객체, 병렬, 병렬성, 병목, 부하 부족, 불변 객체, 성능, 성능 개선, 순차적 처리, 스레드 안전성 위임, 악영향, 외부 제약 사항, 웹 서비스, 위험, 유닉스, 유틸, 읽기쓰기락, 자바 동시성, 자바 병렬, 자원 소모, 저수준 병렬처리, 전체 컬렉션 독점, 처리량, 켄텍스트 스위칭, 프로파일러, 플랫폼 dependent, 하나의 락, 핫 필드, 핫 필드 최소화, 확장성, 확장성 #2
댓글