OOM: unable to create new native thread 추적하기
1) 서론
개인적으로 개발하던 애플리케이션에서 OOM이 발생했습니다.
현상과 문제 되는 코드는 명확하게 발견했는데요.
원인을 찾아나가는 과정 중에 배운 것을 공유합니다.
2) OutOfMemoryError 발생
JVM 기반 애플리케이션에서 발생했고, 더 이상 요청을 받을 수 없는 상태가 되었습니다.
Caused by: java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
이유는 명확했습니다.
스레드풀 생성에 singleton pattern이 적용되지 않았습니다. 한번만 초기화되고, 이후에는 초기화된 인스턴스를 재사용했어야 합니다. 하지만 요청마다 계속해서 생성된 것이 원인입니다.
문제가 되는 스레드풀을 여러 번 생성해 보겠습니다.
Kotlin function으로 작성했기 때문에 호출할 때마다 ThreadPoolExecutor 클래스를 초기화하게 됩니다. 즉 생성한 인스턴스를 어딘가에 할당하고, 재사용할 수 없는 구조입니다. 아래는 Java로 decompile 한 예시입니다.
Local PC 사양 상 아래와 같이 제한하여 재현해 봅니다
- 4천 회 반복하여 스레드 풀 생성
- 생성된 스레드풀의 스레드 활성화
ExecutorService 인터페이스를 구현한 스레드 풀은 기본적으로 lazy initialization 입니다. 생성 시점에는 단순히 core, max thread pool size를 설정할 뿐 스레드를 생성하지 않습니다. 그렇기 때문에 print문을 이용해서 task를 전달하고, thread active 시킵니다.
수행한 테스트를 Profiling 합니다.
몇 가지 특징을 볼 수 있는데요.
- pool-XX-thread 이름의 스레드들이 지속적으로 생성된 것을 확인할 수 있습니다
- 스레드 풀 생성하는 getThreadPool() 함수에서만 2.61MB 메모리 사용했습니다
Active thread는 어떻게 되었을까요?
- 테스트 수행 전 JVM에서 관리하는 actvie thread는 11개입니다
- 스레드 풀 생성 후 task를 할당했을 때 active thread는 4012개입니다
정리해 보겠습니다.
TPS 4천 일 때 초당 메모리를 2.61MB 사용, 스레드를 4012개 사용하게 됩니다. 만약 60초가 지속된다면 약 160MB 메모리 할당, 12만 개의 스레드를 생성하게 됩니다.
당연히 OOM이 발생할 수밖에 없습니다.
3) 그런데 왜 native thread 생성하는 걸까요?
발생한 에러를 볼 때 메시지가 조금 특이합니다. Natvie thread를 생성할 수 없다고 합니다.
Caused by: java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
여기서 Native thread는 Kernal thread 입니다. JVM은 스레드를 사용할 때 OS의 kernal thread를 사용합니다. 즉 JVM 애플리케이션에서 스레드를 무한정 만들고, OS에서 active 할 수 있는 thread 수를 넘어간다면 더 이상 JVM에서는 더 이상 할당받을 스레드가 없어지기 때문에 에러가 발생합니다.
OpenJDK8 소스를 직접 확인해 봅니다.
아래의 사진에서 쓰레드 풀에서 task 할당받고, Thread.start() 호출하면 내부적으로 C++로 구현된 start0() native method를 호출하는 것을 확인할 수 있습니다.
아래와 같이 JVM_StartThread 호출합니다.
아래 두 개의 코드를 확인했을 때 natvie thread는 OS에서 생성된 Kernel thread임을 알 수 있습니다.
그리고 주석을 참고했을 때 너무 많은 스레드가 active 상태이기 때문에 메모리가 부족하면 OS thread가 null 일 수 있음을 경고하고 있습니다. 그리고 이때 OOM을 발생시키도록 권장하고 있습니다.
아래에서 다시 최초로 호출한 caller JVM_StartThread 메서드로 돌아옵니다.
위에서 언급한 과정들에서 native thread를 생성하지 못했다면 unable to create new native thread 에러 메시지를 발생시키는 것을 확인할 수 있습니다.
위 코드의 전체적인 흐름은 아래와 같습니다.
조금 복잡하니 정리해 봅니다.
- JVM에서 사용하는 user level thread는 OS kerneal thread를 사용합니다.
- 지나치게 많은 kernel thread의 active 상태로 인해서 메모리가 부족하다면 JVM은 Kernel thread를 할당받을 수 없습니다.
- JVM은 스레드 생성에 실패하면 OOM을 발생시킨다.
4) 그래서 왜 쓰레드를 생성하지 못 하나요?
특이한점은 CPU, Memory 시스템 자원의 임계치가 도달하지 않았고 정상인데, 쓰레드를 생성하지 못 하고 있었습니다.
CPU, Memory 가용치가 남았는데 무엇이 문제일까요?
ulimit 명령어를 활용해서 프로세스별 자원을 확인합니다.
$ ulimit -su -S && ulimit -su -H
# 최소 (S)
-s: stack size (kbytes) 8176 // 프로세스별 stack 사이즈
-u: max user processes 2666 // 한명의 user가 사용할 수 있는 쓰레드 수
# 최대 (H)
-s: stack size (kbytes) 65520
-u: max user processes 2666
Maximum user processes은 각 OS user가 생성할 수 있는 최대 쓰레드 개수인데요. 만약 A프로세스 10개를 spring 계정이 모두 생성하게 된다면, A프로세스 10개는 최대 쓰레드를 2666개까지만 사용할 수 있습니다.
초과하여 생성 시도하는 경우에는 쓰레드를 더 이상 생성할 수 없습니다. 그리고 앞서 본 것처럼 JVM 입장에서는 OS thread가 NULL이고 에러를 생성하게 됩니다.
즉 CPU, Memory 자원의 할당량이 정상적일 때는 active thread 개수를 확인하는것이 중요합니다.
5) 결론
개발을 할 때 시스템 자원의 낭비가 되지 않도록 하는 것은 매우 중요합니다. 당연하게 여기던 것을 실수로 놓칠 수 있고, 큰 장애가 발생할 수도 있습니다.
이러한 실수를 방지하기 위해서는 꼼꼼히 개발하는 것도 중요하지만, 모니터링을 통해서 미리 감지하고 예방하는 것도 중요하다고 생각합니다.
불필요한 장애가 발생했지만 이로 인해서 많이 배울 수 있었습니다.