본문 바로가기

개발/Linux & DevOps

Java Green Thread, Native Thread, Kernel Thread, User Thread + Jenkins Executor 최적화

서론

젠킨스에 Executor 를 몇개를 사용하는게 최적일까요?

조사를 하며 배운 점을 정리해보겠습니다.

뭔놈의 쓰레ㄱ... 아니 쓰레드가 이렇게 많은지

각 쓰레드의 의미, 역할을 Jenkins Executor 을 예제 삼아서 완벽하게 파악해보기!

완벽하게 파악하기 위해

다같이 숨참고 JAVA 다이브!

 

 


Java 동시처리에서 구분할 개념

Concurrency와 Parallelism:

Concurrency (동시성): 여러 작업을 번갈아가며 실행하는 것. 실제로 동시에 실행되지 않더라도, 사용자 입장에서는 동시에 실행되는 것처럼 보입니다.

Parallelism (병렬성): 여러 작업을 실제로 동시에 실행하는 것. 이는 멀티코어 CPU에서 가능합니다.

따라서 사람이 보기엔 같아보이지만, 실제로 어떻게 동작하는지는 구분이 필요합니다.

 

잠깐만, 가장 흔히 아는 Spring Boot Application 에서 동시처리

보통 CPU 는 적게는 수개, 많게는 수십개인데,

Spring Boot 서버에서는 수백개의 요청을 동시에 후루룩 처리합니다.

이게 어떻게 가능한지 궁금해져서 이것도 찾아봤습니다.

Spring Boot 애플리케이션이 CPU 코어 수보다 훨씬 많은 요청을 동시에 처리할 수 있는 이유는 다음과 같습니다:

a) 비동기 처리: Spring Boot는 비동기 프로그래밍 모델을 지원합니다. 이를 통해 I/O 작업 중 대기 시간을 효율적으로 활용할 수 있습니다.

b) 스레드 풀: 애플리케이션 서버(예: Tomcat)는 스레드 풀을 사용하여 요청을 처리합니다. 이를 통해 제한된 수의 스레드로 많은 요청을 관리할 수
있습니다.

c) 컨텍스트 스위칭: OS는 빠른 속도로 스레드 간 컨텍스트 스위칭을 수행하여, 적은 수의 CPU 코어로도 많은 작업을 처리할 수 있게 합니다.

d) I/O 바운드 작업: 대부분의 웹 요청은 I/O 바운드 작업이며, CPU를 지속적으로 사용하지 않습니다. 이로 인해 CPU가 다른 작업을 처리할 수 있는 여유가 생깁니다.

결론:

Spring Boot는 Java의 Native Thread (Kernel Thread)를 사용하여 병렬성을 확보하고, 동시에 비동기 프로그래밍 모델을 통해 동시성을 극대화합니다. 이를 통해 다수의 요청을 효율적으로 처리할 수 있습니다.

다시 본론인 Jenkins Executor

Jenkins Exector는 ExecutorService 클래스 내부에서 관리합니다.

ExecutorService에 대해 자세히 설명하고,

동시성과 병렬성 처리 여부를 확인한 후 Java의 스레드 관련 개념들과 Jenkins executor의 연관성을 설명해보죠.

  1. Jenkins executorService

Jenkins에서 executorService는 주로 java.util.concurrent.ExecutorService 인터페이스의 구현체를 사용합니다.

일반적으로 ThreadPoolExecutor 클래스를 기반으로 구현됩니다.

Jenkins의 실제 구현을 보면:

ExecutorService executorService = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<Runnable>()
);

이 구현은 동시성과 병렬성을 모두 지원합니다:

  • 동시성(Concurrency): 여러 작업을 번갈아가며 실행할 수 있습니다.
  • 병렬성(Parallelism): 멀티코어 환경에서는 여러 작업을 동시에 실행할 수 있습니다.

따라서 Jenkins의 executor는 동시성과 병렬성을 모두 처리할 수 있습니다.

  1. Java의 Kernel Thread와 User Thread 할당 방식

Java는 기본적으로 1:1 모델을 사용합니다. 즉, 각 Java 스레드(User Thread)는 하나의 OS 스레드(Kernel Thread)에 매핑됩니다.

이 방식은 다음과 같은 특징이 있습니다:

  • 직접적인 OS 자원 활용
  • 높은 동시성 지원
  • 스레드 간 전환 비용이 상대적으로 높음
  1. Green Thread와 Native Thread
  • Green Thread:
    • JVM에 의해 관리되는 사용자 수준 스레드
    • OS 커널의 관여 없이 JVM이 스케줄링
    • 현대 Java에서는 거의 사용되지 않음
  • Native Thread:
    • OS 커널에 의해 직접 관리되는 스레드
    • 현대 Java에서 기본적으로 사용되는 방식
    • OS의 스케줄링 알고리즘에 의존
  1. Jenkins Executor와의 연관관계

Jenkins executor는 Native Thread를 기반으로 동작합니다.

검증을 위해 Jenkins의 소스 코드에서 동시처리/병렬처리와 관련된 라이브러리 import 문을 발췌해보겠습니다.

Jenkins는 주로 java.util.concurrent 패키지의 클래스들을 사용합니다. 다음은 Jenkins 소스 코드에서 자주 볼 수 있는 import 문들입니다:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import hudson.util.DaemonThreadFactory;
import hudson.util.NamingThreadFactory;

import jenkins.util.InterceptingExecutorService;
import jenkins.util.TimerTask;

이러한 import 문들은 Jenkins의 여러 클래스에서 사용됩니다.

예를 들어, Jenkins의 핵심 클래스 중 하나인 hudson.model.Queue에서는 다음과 같은 코드를 볼 수 있습니다:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import jenkins.util.InterceptingExecutorService;

public class Queue {
    private final ExecutorService threadPoolForLoadingActivities;

    Queue(LoadBalancer loadBalancer) {
        this.threadPoolForLoadingActivities = new InterceptingExecutorService(
            Executors.newCachedThreadPool(new NamingThreadFactory(new DaemonThreadFactory(), "Jenkins queue load-activities"))
        );
        // ... 나머지 코드
    }

    // ... 나머지 메소드들
}

또한 Jenkins의 ExecutorService 관련 설정을 담당하는 jenkins.util.SystemProperties 클래스에서는 다음과 같은 코드를 볼 수 있습니다:

import java.util.concurrent.TimeUnit;

public class SystemProperties {
    public static int getExecutorServiceKeepAliveTime() {
        return getInteger(Queue.class.getName() + ".executorServiceKeepAliveTime", (int)TimeUnit.MINUTES.toSeconds(1));
    }

    // ... 나머지 메소드들
}

이러한 코드들은 Jenkins가 Java의 concurrent 패키지를 활용하여 동시처리와 병렬처리를 구현하고 있음을 보여줍니다.

특히 ExecutorService, ThreadPoolExecutor, ScheduledExecutorService 등의 클래스들은 Jenkins의 작업 실행과 스케줄링에 핵심적인 역할을 합니다.

(이 정보는 Jenkins의 공개된 소스 코드를 바탕으로 한 것입니다. 실제 구현은 Jenkins의 버전에 따라 약간의 차이가 있을 수 있습니다.)

그리하여 Jenkins executor는 Native Thread를 기반으로 동작하는 것이 검증됐습니다.

이는 다음과 같은 의미를 갖습니다:

  • 각 executor는 실제 OS 스레드에 매핑됩니다.
  • 멀티코어 환경에서 실제 병렬 처리가 가능합니다.
  • OS의 스레드 스케줄링을 직접 활용할 수 있습니다.

Jenkins는 executorService를 통해 효율적인 스레드 풀 관리를 수행합니다:

  • 작업 큐를 사용하여 동시에 실행할 수 있는 작업 수를 제한합니다.
  • 스레드 재사용을 통해 스레드 생성/소멸 비용을 줄입니다.
  • 작업 부하에 따라 동적으로 스레드 수를 조절할 수 있습니다.

결론적으로, Jenkins의 executor 시스템은 Java의 Native Thread 모델을 기반으로 하여 효율적인 동시성 및 병렬성 처리를 제공합니다.

이를 통해 Jenkins는 여러 작업을 효과적으로 관리하고 실행할 수 있습니다.

5. I/O Bound 인가??? CPU Bound 인가???

젠킨스가 처리하는 작업의 유형이 또 중요하겠죠!!
저희 회사에서 현재 젠킨스를 활용하는 작업은 스프링 배치를 사용한 정산 작업, 즉 데이터베이스 작업입니다.
또한 많은 회사들이 스프링배치 프로젝트로 만든 배치 서비스를 젠킨스를 활용해 관리합니다.  
데이터베이스의 작업은 I/O Bound 작업입니다.  

아 잠깐 I/O Bound 와 CPU Bound 를 짧게 설명하겠습니다.  

 

  • CPU bound 작업:
    • 주로 계산 집약적인 작업입니다.
    • 예: 복잡한 수학 계산, 이미지 처리, 암호화, 압축 등
    • 이러한 작업은 CPU 처리 능력에 의해 제한됩니다.
    • CPU 사용률이 높고, I/O 대기 시간이 적습니다.
  • I/O bound 작업:
    • 주로 입출력 작업에 시간을 많이 소비하는 작업입니다.
    • 예: 파일 읽기/쓰기, 네트워크 통신, 데이터베이스 쿼리 등
    • 이러한 작업은 I/O 장치의 속도에 의해 제한됩니다.
    • CPU 사용률이 낮고, I/O 대기 시간이 깁니다.

 

만약 젠킨스가 하는 일이 CPU Bound 작업이라면,
Native Thread 를 기반으로 하는 Executor 의 개수는 정확하게 CPU 코어 개수에 영향을 받습니다.  
BUT !!! 
I/O Bound 라고 하면 얘기가 달라집니다!
작업의 흐름 속에 Input 또는 Output 으로 맡겨진 상태일 땐 CPU가 대기상태가 됩니다.  
또한 현대의 자바는 비동기 I/O 를 지원하기 때문에, 일처리를 말겨놓고 CPU 가 묶여있지 않습니다.  
그럼 뽀로로 하는거죠! (노는게 제일좋아)

 

따라서 스프링 배치 스케줄러 젠킨스의 경우, Executor 를 더 늘려도 괜찮습니다.

예를 들어, 4개의 CPU 코어가 있는 시스템에서 20개의 I/O bound 작업을 실행한다고 가정해봅시다.
각 작업이 90% 의 시간을 I/O 대기에 사용한다면, 20개의 executor를 사용하더라도 CPU는 대부분의 시간 동안 20% 미만의 사용률을 보일 것입니다.

 

결론

Jenkins Executor 는 Native Thread 를 활용하므로 cpu core 개수의 영향을 받는 OS Thread 에 직접적으로 매핑됩니다.

그래서 CPU Bound 인 작업을 위주로 하는,
예를 들어 Gradle/Maven build, 코드 분석, 단위/통합 테스트, Docker 이미지 빌드(I/O 혼재)의 경우
Executor는 cpu core 의 개수 * 1.5 ~ 2배가 가장 적당합니다.

그리고 I/O Bound 인 작업을 위주로 하는,
예를 들어 Spring Batch, 헬스체크, 백업 및 마이그레이션 등 작업의 경우
Executor는 개수를 많이 늘려도 무관하며, 메모리 성능과 작업 성질 등을 고려해야 합니다.
정확히는 테스트를 통해 알아봐야 합니다.

끗!

반응형