GN⁺: 3,200% CPU 사용률
(josephmate.github.io)- 과거에 내 시스템의 CPU 사용률이 3,200%에 달해 32코어가 모두 가득 찼음
- Java 17 런타임을 사용하고 있었고, 스레드 덤프에서 CPU 시간을 확인하여 CPU 시간으로 정렬하니 유사한 스레드들이 다수 발견
- 문제의 코드 분석
- 스택 트레이스를 통해
BusinessLogic
클래스의 29번째 줄을 확인 - 해당 코드는
unrelatedObjects
리스트를 반복하면서relatedObject
의 값을treeMap
에 삽입하는 형태 - 이는 반복문 내부에서
unrelatedObject
를 사용하지 않는 비효율적인 코드
- 스택 트레이스를 통해
코드 수정 및 테스트
- 불필요한 반복문을 제거하고
treeMap.put(relatedObject.a(), relatedObject.b());
한 줄로 수정 - 수정 전후로 단위 테스트를 수행하였으나, 문제를 재현할 수 없었음
-
treeMap
과unrelatedObjects
의 크기가 각각 1,000,000개 이상이어도 문제가 발생하지 않음
문제의 원인 발견
-
treeMap
이 여러 스레드에서 동시 접근되고 있었으며, 동기화가 되어 있지 않았음 - 이는 여러 스레드가
TreeMap
을 동시에 수정하면서 발생하는 문제였음
실험을 통한 문제 재현
- 여러 스레드가 공유된
TreeMap
을 무작위로 업데이트하는 실험을 진행 -
try-catch
블록을 사용하여NullPointerException
을 무시하도록 설정 - 실험 결과, CPU 사용률이 500%까지 상승하는 현상을 확인
결론
- 동기화되지 않은
TreeMap
의 동시 수정은 심각한 성능 문제를 야기할 수 있음 - 이러한 문제를 방지하기 위해
TreeMap
을 동기화하거나ConcurrentMap
과 같은 스레드 안전한 컬렉션을 사용하는 것이 권장됨
Hacker News 의견
-
레이스 컨디션이 데이터 손상이나 데드락을 일으킨다고 생각했지만, 성능 문제도 유발할 수 있다는 점은 생각하지 못했음. 데이터가 무한 루프를 생성하는 방식으로 손상될 수 있음
- 프로젝트에서 오류나 이상 행동, 경고는 원칙적으로 수정해야 한다고 생각함. 이는 관련 없는 문제를 유발할 수 있기 때문임
- Java의 핵심 컬렉션은 설계상 스레드 안전하지 않다는 것이 잘 알려져 있음. OP는 코드의 다른 부분에서도 여러 스레드가 컬렉션을 조작하는지 확인해야 함
- TreeMap을 Collections.synchronizedMap으로 감싸거나 ConcurrentHashMap으로 전환하여 필요할 때 정렬하는 것이 가장 쉬운 해결책임
- 개별 맵 작업은 스레드 안전하게 만들 수 있지만, 연속적인 작업이 스레드 안전한지는 확신할 수 없음. TreeMap을 소유한 객체가 스레드 안전한지 확신할 수 없음
- 논란의 여지가 있는 해결책으로 방문한 노드를 추적하는 것은 좋지 않은 방법임. 컬렉션은 여전히 스레드 안전하지 않으며, 다른 미묘한 방식으로 실패할 수 있음
- 세부 사항에 주의하는 개발자는 스레드와 TreeMap의 조합을 알아차리거나, 정렬된 요소가 필요하지 않다면 TreeMap을 사용하지 않도록 제안할 수 있음. 그러나 이번 경우에는 그렇지 않았음
- 문제는 컬렉션의 계약을 위반한 것이며, TreeMap을 HashMap으로 바꿔도 여전히 잘못된 것임
-
여러 스레드가 작동하는 코드에서는 모든 객체를 불변으로 만들고, 불변으로 만들 수 없는 객체는 작고 자급자족하며 엄격히 통제된 섹션으로 제한하는 것이 유일한 확실한 전략임
- 이러한 원칙을 따르며 핵심 모듈을 다시 작성했으며, 이는 지속적인 문제의 원천에서 가장 탄력적인 코드베이스 섹션 중 하나로 변모했음
- 이러한 지침이 마련되어 코드 리뷰가 훨씬 쉬워졌음
-
"ssh에 거의 접속할 수 없었다"는 언급은 대학원 시절 Sun UltraSparc 170을 사용했던 상황을 떠올리게 함
- 새로운 사용자나 학생이 병렬로 작업을 수행하려고 했고, 큰 텍스트 파일을 줄 번호에 따라 여러 섹션으로 나누고, 각 섹션을 병렬로 처리했음
- 많은 RAM이 사용되었고, 스왑 시도는 동일한 파일의 다른 섹션을 읽기 위해 격렬하게 탐색되었음
- 콘솔에서 로그인 프롬프트를 얻을 수 없었지만, 이미 로그인된 세션이 있었고, 루트 세션을 얻어 문제를 해결할 수 있었음
- 시스템의 한계를 이해하지 못한 것이 문제였음
-
코드가 단순히 다음과 같이 줄일 수 있음
- 원래 코드는 <i>unrelatedObjects</i>가 비어있지 않을 때만 <i>treeMap.put</i>을 수행함. 이는 버그일 수도 있음
- <i>a</i>와 <i>b</i>가 매번 동일한 값을 반환하는지 확인해야 하며, <i>treeMap</i>이 맵처럼 작동하는지 확인해야 함
-
무한 루프를 얻는 또 다른 방법은 일관된 전체 순서를 구현하지 않는 <i>Comparator</i> 또는 <i>Comparable</i> 구현을 사용하는 것임
- 이는 동시성과 관련이 없으며, 특정 데이터와 처리 순서에 따라 발생할 수 있음
-
증가하는 카운터를 사용하여 사이클을 감지하고, 트리 깊이나 컬렉션 크기를 초과하면 예외를 던지는 방법을 고려할 수 있음
- 이는 거의 메모리나 CPU 오버헤드를 요구하지 않으며, 더 수용될 가능성이 높음
-
Java에서 스레드 안전하지 않은 객체에 대해 동시 작업을 수행하는 것은 가장 흥미로운 버그를 생성함
-
보호되지 않은 TreeMap이 3,200%의 활용도를 유발할 수 있는지에 대한 질문이 있음
- 2009년경에 유사한 문제를 본 적이 있으며, 이는 여전히 발생할 수 있음
- 데이터 레이스가 약간 나쁘다고 생각하는 사람들에게는 실망스러움
-
저자는 Poison Pill의 한 종류를 발견했음. 이는 이벤트 소싱 시스템에서 더 일반적이며, 만나는 모든 것을 죽이는 메시지임
- 데이터 구조가 불법 상태에 도달하면, 모든 후속 스레드는 동일한 논리 폭탄에 갇히게 됨
-
스레드에서의 예외는 절대적인 문제임
- C++, select(), 스레드가 예외를 휘두르는 공포의 버그 사냥 이야기가 있음