쓰레드의 개념과 이해

· CS, Java

자바는 쓰레드를 다룰 수 있는 다양한 클래스를 제공한다. 처음 자바로 쓰레드를 다룰 때는 수많은 클래스들의 특징과 차이점을 잘 모르니 어떤 클래스를 써야하는지 엄청 헷갈린다. 검색해서 나오는 예제마다 구현 방법도 다르다. 한 번에 “빡” 공부해두지 않으면 계속 헷갈리기 쉽상이다.

커널쓰레드와 자바쓰레드

이론부터 공부해보고자 한다. 프로세스와 쓰레드의 차이 같은 기본적인 특징은 생략했다.

커널 레벨 쓰레드와 유저 레벨 쓰레드

커널 쓰레드는 스케줄링 주체가 커널이다. 커널이 쓰레드를 관리한다. 유저 어플리케이션이 만들어낸 쓰레드를 말한다. 커널 입장에서는 그냥 하나의 프로세스로만 본다. 학부 OS 강의 때 처음 커널 쓰레드와 유저 쓰레드를 들었을 때는 이게 무슨 말인지 감이 안왔었다. 지금은 커널이 만들어내는 쓰레드냐, 어플리케이션이 만들어내는 쓰레드냐의 차이로 이해하고 있다. 그리고 유저 쓰레드가 실행되기 위해서는 커널 쓰레드에 매핑되어야 한다는 점.

매핑 방식

매핑에는 1:1, 1:N, N:M이 있다.

쓰레드의 매핑

그러면 JVM은 자바 쓰레드를 어떻게 취급할까

과거 - Green Thread 방식

과거에는 N:1로 매핑되었다. 커널 쓰레드 한개에 유저 쓰레드 여러 개가 붙는 방식이다. 비효율적인 방식처럼 보이지만, 당시에는 나름 이유가 있었다. 과거에는 어차피 싱글 코어 컴퓨터가 대부분이였다. 그 당시 커널 쓰레드를 여러 개 쓰기에는 너무 무거웠었기 때문에, 이 방식을 채택한 것으로 보인다.

현재 - Native Thread 방식

현재는 N:M으로 매핑된다. 유저 레벨 쓰레드와 커널 레벨 쓰레드의 매핑을 조율해주는 친구가 있다.

LWP

LWP(Light Weight Process)라고 하는 친구가 있다. LWP는 자바 쓰레드를 커널 쓰레드에 매핑한다. 자바 쓰레드는 커널 쓰레드에 바인딩되어 있지 않은 상태(unbound)가 디폴트다.
LWP는 Solaris Thread Library에 C++로 구현되어 있다.
자바 소스 코드를 실행하면, JNI(Java Native Interface)를 통해 LWP가 실행된다.

LWP

Native Thread는 Solaris Threads Library Level의 C++ Thread 객체가 OS Level Kernel Thread를 소유하게 된 상태를 의미한다. Thread 인스턴스는 Kernel Level Thread (OS Thread)를 멤버변수로 가지고 있다.

Java Thread를 사용할 때, start() / run() / join() 등의 메소드들은 OS에 종속적이다. OS에 SystemCall을 보내서 OS에 해당 기능을 요청하는 형태이기 때문이다. Java Thread가 start()라는 자바 코드를 실행하면 JNI를 실행하게 되고, JNI가 JVM과 C++ 라이브러리를 통해 OS에 시스템 콜을 요청하는 형태로 동작한다.

동작 요약

자바쓰레드의 실제 구현

자바 쓰레드의 공부하기 위한 “기초”를 클래스 7개로 요약했고, 7개의 클래스를 3개의 분류로 나누었다.

Thread & Runnable

태초에 Thread와 Runnable이 있었다.

class ThreadExample extends Thread {
    public void run() {
        // 쓰레드에서 동작시킬 코드들
    }
}
...
ThreadExample threadExample = new ThreadExample();
threadExample.start()
class RunnableExample implements Runnable {
    public void run() {
        // 쓰레드에서 동작시킬 코드들
    }
}
...
RunnableExample runnableExample = new RunnableExample();
Thread t = new Thread(runnableExample);
t.start();

Thread와 Runnable의 한계점

위와 같은 치명적인 한계점을 극복하고자, Java 5에서 Future, Callable, ExecutorService, Executor가 등장했다.

Callable

package java.util.concurrent;

@FunctionalInterface
public interface Callable<V> { // Generic V로 리턴값의 타입을 설정
    V call() throws Exception;
}

Future

package java.util.concurrent;

public interface Future<V> {
    boolean cancel(boolean var1); // 작업을 취소시키고, 취소 여부를 booelan으로 반환. cancel 이후에 isDone()은 항상 true를 반환
    boolean isCancelled(); // 작업의 취소 여부 리턴
    boolean isDone(); // 작업 완료 여부를 리턴
    V get() throws InterruptedException, ExecutionException;
    // 블로킹 방식으로 결과를 가져옴
    V get(long var1, TimeUnit var3) throws InterruptedException, ExecutionException, TimeoutException;
}

Future와 Callable이 헷갈릴 수 있는데, 요약하자면 Callable은 “실행”될 코드고, Future는 Callable이 실행된 이후 반환될 “정보”다.

Future와 Callable 예제

Future와 Callable의 존재가 헷갈릴 수 있으니 예제를 통해 알아보자.

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        Callable<Integer> hello = new RandomNumberReduce();
        Future<Integer> helloFuture = executorService.submit(hello); 
        // Callable을 실행해서 결과를 Future에 저장하는 것

        System.out.println(helloFuture.isDone()); // false가 나옴
        helloFuture.get(); // 여기서 블락킹이 된다!
        System.out.println(helloFuture.isDone()); // true가 나옴
    }
}

class RandonNumberReduce implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        System.out.println("Callable Start");
        Thread.sleep(2);
        System.out.println("Callable End");
        return new Random.nextInt(100);
    }
}

// 결과
// false
// Callable Start
// Callable End
// true

Java5 쓰레드 기술의 한계

Future의 한계점

내 생각으로는 Future를 언급할 때 계속 Blocking 된다는 이야기가 나오는게 불편했다.
Future.get()을 쓰면 Blocking한 뒤, 데이터를 받아온다고 하니…

JS 같은 언어에서는 비동기로 callback 함수를 넘겨주면 해결되는 문제였으니 말이다.
그래서 생각보다 구린데?라고 생각하고 있었는데 이걸 해결한게 또 있었다.
CompletableFuture라는 친구가 있었다.

Executor 인터페이스

Executor는 단순 인터페이스이므로, executor를 통해 새로운 쓰레드를 실행하고 싶다면, 오른쪽 코드와 같이 execute 메소드 안에서 Thread 객체를 생성해 start를 호출해주어야 한다.

public interface Executor {
    void execute(Runnable command);
}
void executorRun() {
    final Runnable runnable = () ->
        System.out.println("Thread: " + Thread.currentThread.getName());

    Executor executor = new StartExecutor();
    executor.execute(runnable);
}
...
static class StartExecutor implements Executor {
    @Override
    public void execute(final Runnable runnable) {
        new Thread(command).start();
    }
}

ExecutorService 인터페이스

public interface ExecutorService extends Executor {
    void shutdown();
    List<Runnable> shutdownNow();
    boolean isShutdown();
    boolean isTerminated();
    boolean awaitTermination(long var1, TimeUnit var3) throws InterruptedException;
    <T> Future<T> submit(Callable<T> var1);
    <T> Future<T> submit(Runnable var1, T var2);
    Future<?> submit(Runnable var1);
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> var1) throws InterruptedException;
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> var1, long var2, TimeUnit var4) throws InterruptedException;
    <T> T invokeAny(Collection<? extends Callable<T>> var1) throws InterruptedException, ExecutionException;
    <T> T invokeAny(Collection<? extends Callable<T>> var1, long var2, TimeUnit var4) throws InterruptedException, ExecutionException, TimeoutException;
}

ExecutorService 인터페이스가 없다면?

ExecutorService 비동기 작업의 주요 메소드

public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    Instant start = Instant.now();

    Callable<String> hello = () -> {
        Thread.sleep(1000L);
        final String result = "Hello";
        System.out.println("result = " + result);
        return result;
    }

    Callable<String> world = () -> {
        Thread.sleep(2000L);
        final String result = "World";
        System.out.println("result = " + result);
        return result;
    }

    String result = executorService.invokeAny(Arrays.asList(hello, world));
    System.out.println("result = " + result + " time = " + Duration.between(
        start, Instant.now()).getSeconds()
    );

    List<Future<String>> futures = executorService.invokeAll(Arrays.asList(hello, world));
    for(Future<String> f : futures) {
        System.out.println(f.get());
    }
    executorService.shutdown();
}

Executors

앞에서 Executor, ExecutorService는 쓰레드 풀을 위한 인터페이스라고 했다. 인터페이스는 구현체가 필요하다. 직접 쓰레드를 다루는 구현체를 만들기는 번거롭다. 그래서 구현체를 만들어둔 팩토리 클래스가 등장했는데, 이게 바로 Executors다. Executors를 활용하면 Executor, ExecutorService 등의 인터페이스를 구현한 쓰레드 풀을 손쉽게 만들 수 있다.

public class Executors {
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
    }

    public static ExecutorService newWorkStealingPool(int parallelism) {
        return new ForkJoinPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, (Thread.UncaughtExceptionHandler)null, true);
    }

    public static ExecutorService newWorkStealingPool() {
        return new ForkJoinPool(Runtime.getRuntime().availableProcessors(), ForkJoinPool.defaultForkJoinWorkerThreadFactory, (Thread.UncaughtExceptionHandler)null, true);
    }

    public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(), threadFactory);
    }

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));
    }
    ...

출처