[Java] Thread

2021. 10. 19. 14:29Programming Languages/Java

Thread란?

쓰레드를 알아보기 전에 프로세스에 대해 먼저 이해하도록 하자. 프로세스(Process)는 간단하게 말하면 실행 중인 프로그램이다. 프로그램은 일반적으로 하드 디스크, SSD 등의 저장 장치에 저장되어 있다. 이 프로그램을 실행 시켜 사용하려면 우리는 보통 exe와 같은 실행 파일을 시작시킨다. 그러면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 메모리에 프로그램이 적재되고 우리가 프로그램을 사용할 수 있게 된다. 프로세스는 프로그램을 실행하는데 필요한 데이터, 메모리 공간, 기타 자원과 쓰레드로 구성되어 있다. 그리고 프로세스의 자원을 사용해서 실제로 작업을 수행하는 것이 쓰레드이다.

쓰레드는 한 프로세스에 여러 개가 존재할 수 있다. 한 프로세스에서 여러 쓰레드를 사용하는 프로세스를 멀티쓰레드 프로세스라고 한다. 모든 쓰레드들은 프로세스에서 공유되는 데이터와 힙 영역을 공유해서 사용한다. 그리고 각 쓰레드는 각자의 스택 영역을 가지고 작업한다. 작업장에 여러 사람이 공구를 공유하면서 자기 일을 처리한다고 생각하면 편하다.

그렇다면 무조건 쓰레드가 많은 것이 좋을까? 예를 들어 간단한 워드 작업을 하는 회사가 있다고 하자. 하루에 100줄을 작성하는 업무이다. 이 작업은 한 명이 해도 충분할 것 같다. 그런데 사장이 쓸데없이 직원을 100명이나 뽑아놓고 해당 직원들에게 한 줄씩 입력하도록 시킨다. 이는 너무 비효율적이다. 게다가 한 워드 파일에 100명이 동시에 작업할 수도 있으니 글이 제대로 작성될 리가 없다. 직원들은 하는 일 없이 월급만 받아가니 적자가 나고 회사는 망할 것이다. 이를 컴퓨터에 대입해서 생각하면 필요없이 많은 쓰레드는 필요없이 자원을 사용하고 있고 작업 또한 비효율적으로 처리된다. 이런 멀티쓰레드 방식으로 설계하면 싱글쓰레드일 때보다 더 성능이 악화된다.

쓰레드 구현 및 실행

Java에서 쓰레드를 구현하는 방법은 2가지가 있다.

1. Thread 클래스 상속

public class MyThread extends Thread {

    @Override
    public void run() {
        // run()을 오버라이딩해야 한다.
        // 작업 내용 작성
    }
}

2. Runnable 인터페이스 구현

public class MyRunnable implements Runnable {

    @Override
    public void run() {
        // run()을 오버라이딩해야 한다.
        // 작업 내용 작성
    }
}

3. 쓰레드 실행

쓰레드의 실행은 thread.start()를 호출하면 된다.

public class ThreadEx {

    public static void main(String[] args) {

        // Thread를 상속받으므로 MyThread 자체가 쓰레드 객체이다.
        MyThread th1 = new MyThread();

        /*
            Runnable은 새로 쓰레드 객체를 만들어야 하고 생성할 때 매개변수로 넘겨줘야 한다.
        */ 
        Runnable r = new MyRunnable();
        Thread th2 = new Thread(r);

        th1.start();
        th2.start();
    }
}

rRunnable 인터페이스만 구현했으므로 혼자 쓰레드를 실행할 수 없다. 새로운 쓰레드를 만들고 그 매개변수로 Runnable을 구현한 인스턴스를 넣어줘야 한다. 그러면 쓰레드 인스턴스는 start() 시 실행할 run()으로 생성자에 넘어온 Runnablerun()을 참조한다. 그 후에 thread.start()를 하면 매개변수로 넘겨준 Runnablerun()이 수행된다. thread.start()가 아니라 바로 run()을 실행하면 그냥 클래스의 메서드가 호출된 것과 똑같다.

쓰레드의 실행 제어

쓰레드는 여러 메서드를 통해 실행, 중지, 작업 대기 등의 상태로 변환될 수 있다. 몇 가지 메서드를 살펴보자.

sleep(long mills)

현재 쓰레드를 매개변수로 넣어준 시간만큼 대기 상태로 만든다. 시간이 끝나거나 interrupte()가 호출되면(InterruptedException 발생) 실행 대기 상태로 들어가 차례를 기다린다. sleep()은 항상 InterruptedException 예외를 처리해줘야 하므로 try - catch 문으로 처리해야 한다.

interrupte()

sleep(), wait(), join()으로 대기 상태에 있던 쓰레드에 InterruptedException을 발생시켜서 실행 대기 상태로 만든다.

  • void interrupte()
    • 쓰레드의 interrupte 상태를 false에서 true로 변경
  • boolean isInterrupted()
    • 쓰레드의 interrupte 상태를 반환
  • static boolean interrupted()
    • 현재 쓰레드의 interrupte 상태를 반환한 후 false로 변경

join(long mills)

현재 진행 중인 쓰레드의 작업을 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 하는 메서드이다. 매개변수로 시간을 넘겨주지 않으면 다른 쓰레드의 작업이 끝날 때까지 기다린다.

public class ThreadEx {

    public static void main(String[] args) {

        System.out.println("main start");

        Thread th = new MyThread();
        th.start();

        try {
            th.join();
        } catch (InterruptedException ie) {
        }

        System.out.println("main end");

    }
}

class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("thread start");
        for (int i = 0; i < 500; i++) {
            System.out.print("0w0");
        }
        System.out.println("thread end");
    }
}

MyThread의 작업이 끝나고 나서야 "main end"가 출력되는 것을 확인할 수 있다. 만약 join()을 호출하지 않았다면 바로 "main end"가 출력되고 main 쓰레드는 종료됐을 것이다.

yield()

현재 자신에게 주어진 남은 실행 시간을 다음 차례 쓰레드에게 양보한다. 1초의 시간을 할당받았는데 0.5초 동안 작업을 수행하고 yield()를 호출하면 나머지 시간을 다음 쓰레드에게 양보한다. 즉 다음 쓰레드는 1.5초의 실행 시간을 가지게 된다.

동기화

싱글쓰레드 프로세스인 경우 쓰레드가 하나만 동작하므로 동기화 처리를 하지 않아도 된다. 자원에 동시에 접근할 경우가 없기 때문이다. 그러나 멀티쓰레드 프로세스인 경우는 말이 달라진다. 위에서 말했던 것처럼 쓰레드는 프로세스의 데이터 영역과 힙 영역을 공유해서 사용한다. 그리고 멀티쓰레드는 CPU 코어와 쓰레드에 따라 동시에 수행될 수 있다. 그런데 만약 동시에 같은 데이터에 접근한다면 어떻게 될까? 물론 데이터를 읽기만 하는 작업은 상관없다. 그러나 데이터를 수정하는 작업에서 문제가 발생한다. A는 c = 10으로 읽고 작업하고 있었다. A의 실행 시간이 끝나고 B의 실행 시간이 되었다. B는 c = 0으로 바꿨다. 다시 A의 실행 시간이 됐다. c -= 10을 하고 출력했다. A는 이전에 10으로 읽고 10을 뺐으므로 0을 출력값으로 예상했지만 현재 c는 0이므로 -10이 출력된다. 이게 만약 계좌 잔액과 같은 상황이라고 생각해보면 아찔하다.

이러한 상황을 막기 위해 공유 변수에 접근할 때 먼저 들어간 쓰레드가 문을 잠근 뒤 자신의 작업이 끝나면 문을 열고 나오도록 하는 방법을 사용한다. 자바에서는 synchronized 키워드를 통해서 이 기능을 수행한다.

public synchronized void sub(int num) {
    this.count -= num;
}

이렇게 작성하면 해당 메서드를 수행하는 동안 다른 쓰레드에서는 이 메서드를 실행한다 하더라도 현재 쓰레드가 작업을 마칠 때까지 대기하게 된다. 메서드 대신 블럭 형태로 지정할 수도 있다.

public void sub(int num) {
    synchronized(this) {
        this.count -= num;
    }
}

synchronized 괄호 안에는 동기화할 객체를 넣어준다. 지금은 this이므로 이 메서드를 실행하는 인스턴스가 동기화된다.

인스턴스 메서드

이는 위 예제와 같다. 이 메서드를 실행한 인스턴스 기준으로 락이 걸린다. 즉 new로 두 개의 인스턴스가 있다면 락은 각각의 인스턴스가 갖는다.

인스턴스 메서드 블럭

이 또한 위에서 사용한 예제와 같다. 인자값으로는 락을 걸 객체가 들어온다. 여기서 this를 사용한다면 해당 인스턴스에 락을 걸어버린다. 즉 다른 메서드에서 synchronized 처리를 했다면 이 메서드들도 전부 락이 걸린다. 따라서 해당 인스턴스에서는 하나의 synchronized 메서드만 동작한다는 것이다. 아래 예제에서 만약 syncExam.add(3)을 호출했다면 다른 쓰레드에서는 해당 인스턴스의 sub()add() 둘 다 접근하지 못하게 되는 것이다. add()가 호출된 인스턴스 자체에 락이 걸렸기 때문이다.

public class SyncExam {

    private int count = 0;

    public synchronized void sub(int num) {
        this.count -= num;
    }

    public void add(int num) {
        synchronized(this) {
            this.count += num;
        }
    }
}

이러한 문제는 다른 객체를 생성하고 그 객체로 락을 걸어 해결할 수 있다. synchronized 블럭을 이렇게 사용하면 SyncExam의 인스턴스 기준이 아니라 lock 인스턴스가 기준이 되므로 쓰레드 A가 add()를 수행할 땐 addLock에 락을 걸고 들어가고 쓰레드 B는 add()는 동시에 수행할 수 없지만 sub()를 수행하면 subLock에 락을 걸고 들어가기 때문에 동시에 add()sub()를 수행할 수 있게 된다.

public class SyncExam {

    private int count = 0;
    private Object addLock = new Object();
    private Object subLock = new Object();

    public void sub(int num) {
        synchronized(subLock) {
            this.count -= num;
        }
    }

    public void add(int num) {
        synchronized(addLock) {
            this.count += num;
        }
    }
}

테스트 코드는 다음과 같다.

public class SyncEx {

    private static int count = 0;
    private Object addLock = new Object();
    private Object subLock = new Object();

    public static void main(String[] args) {

        SyncEx ex = new SyncEx();

        new Thread(() -> {
            for (int i = 1; i <= 100; i++) {
                ex.add(i);
            }
            System.out.println("==============");
            System.out.println("add end: " + count);
            System.out.println("==============");
        }).start();

        new Thread(() -> {
            for (int i = 1; i <= 100; i++) {
                ex.sub(i);
            }
            System.out.println("==============");
            System.out.println("sub end: " + count);
            System.out.println("==============");
        }).start();

    }

    /*
        public void sub(int num) {
           synchronized (subLock) {
               System.out.println("sub: " + num);
              count -= num;
          }
        }
    */

    /*
        public void add(int num) {
            synchronized (addLock) {
                System.out.println("add: " + num);
                count += num;
            }
        }
    */

    public synchronized void sub(int num) {
        System.out.println("sub: " + num);
        count -= num;

    }

    public synchronized void add(int num) {
        System.out.println("add: " + num);
        count += num;
    }
}

실제로 실행해보면 synchronized 블럭을 사용한 경우 거의 동시에 끝나는 반면 메서드에 synchronized를 메서드에 선언한 경우 두 쓰레드가 블로킹된 것 처럼 한 쪽이 먼저 끝나고 나서 나머지 한 쪽 작업이 수행되는 것을 확인할 수 있다.

스태틱 메서드

스태틱 메서드에서 synchronized는 클래스 객체에 락이 걸린다. JVM에서 클래스 객체는 클래스 당 한 개만 존재할 수 있다. 즉 스태틱 synchronized 메서드는 한 쓰레드에서만 실행 가능하다.

public static synchronized void sub(int num) {
    System.out.println("Sub: " + num);
}

스태틱 메서드 블럭

스태틱 메서드 블럭에는 클래스 객체를 넘겨준다. 그리고 그 클래스 객체에 대해 락을 건다. 즉 아래의 sub()add()는 같은 StaticSyncExam.class에 대해 락을 건 것이다. 따라서 여러 쓰레드에서 동시에 sub()add()를 호출할 수 없다.

public class StaticSyncExam {

    private static int count = 0;

    public static synchronized void sub(int num) {
            this.count -= num;
    }

    public static void add(int num) {
        synchronized(SyncExam.class) {
            this.count += num;
        }
    }
}

이를 해결하려면 인스턴스 메서드 블럭에서 했던 것처럼 객체를 만들고 그 객체로 락을 걸도록 한다.

public class StaticSyncExam {

    private static int count = 0;
    private static SubLock subLock = new SubLock();
    private static AddLock addLock = new AddLock();

    public static void sub(int num) {
        synchronized (SubLock.class) {
            this.count -= num;
        }
    }

    public static void add(int num) {
        synchronized(AddLock.class) {
            this.count += num;
        }
    }

    static class SubLock {
    }

    static class AddLock {
    }
}

스태틱 synchronized 메서드와 인스턴스 synchronized 메서드의 혼용

그렇다면 이 둘을 같이 쓰면 어떻게 될까?

public class MixSyncExam {

    private static String name;

    public static synchronized void sub() {
        name = "Sub";
        if (!name.equals("Sub")) {
            System.out.println("Name is not same");
        }
    }

    public static synchronized void add() {
        name = "Add";
        if (!name.equals("Add")) {
            System.out.println("Name is not same");
        }
    }

    public synchronized void mul(int num) {
        name = "Mul";
        if (!name.equals("Mul")) {
            System.out.println("Name is not same");
        }
    }
}

생각만 해봐도 벌써 이상할 거라고 예상할 수 있다. 스태틱 메서드의 동기화는 클래스 객체를 기준으로 락을 걸고 인스턴스 메서드의 동기화는 인스턴스를 기준으로 락을 건다는 것을 알고 있기 때문이다. 전체 코드는 다음과 같다.

public class SyncEx2 {

    public static void main(String[] args) {

        MixSyncExam ex2 = new MixSyncExam();

        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                MixSyncExam.sub();
            }
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                MixSyncExam.add();
            }
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                ex2.mul();
            }
        }).start();
    }
}

class MixSyncExam {

    private static String name;

    public static synchronized void sub() {
        name = "Sub";
        if (!name.equals("Sub")) {
            System.out.println("Name is not same");
        }
    }

    public static synchronized void add() {
        name = "Add";
        if (!name.equals("Add")) {
            System.out.println("Name is not same");
        }
    }

    public synchronized void mul() {
        name = "Mul";
        if (!name.equals("Mul")) {
            System.out.println("Name is not same");
        }
    }
}

코드를 실행시켜보면 "Name is not same"이 뜨는 것을 확인할 수 있다.

이런 식으로 공유 객체에서 static 메서드를 동기화처리해서 사용하면 위와 같은 문제가 간간히 발생하게 된다. 코드가 이미 복잡해졌다면 수정하기도 힘들다.

따라서 멀티쓰레드 환경에서는 가급적이면 공유 객체를 쓰지 않고 새로운 객체를 생성해서 사용하는 편이 안정적이다.

wait()

synchronized로 동기화해서 공유 데이터를 보호하는 것은 좋은 것이다. 그러나 특정 쓰레드가 락을 계속 독점하고 있으면 다른 쓰레드에서는 해당 자원에 계속 접근할 수 없게 된다. 그렇기 때문에 너무 오랫동안 락을 들고 있지 않도록 처리하는 것도 중요하다. 이를 위해 자바에서는 wait()을 지원한다. 이 메서드는 현재 쓰레드가 쥐고 있는 락을 내려놓고 대기 상태로 들어가도록 하는 메서드이다. 객체마다 waiting pool이 있는데 여기에 들어가는 것이다. 이 메서드는 Object 클래스에 정의되어 있다. 즉 모든 객체에서 사용 가능하다. 이렇게 대기 상태로 들어간 쓰레드는 나중에 다시 실행 준비 상태로 바꿔줘야 하는데 이때 사용되는 메서드가 nofity()이다.

notify()

이 메서드는 해당 객체의 waiting pool에 있는 쓰레드 중 랜덤하게 하나를 뽑아 그 쓰레드를 실행 준비 상태로 바꾸고 준비 큐에 넣는다. 위에서 wait()으로 쓰레드가 waiting pool에 들어가도록 만든다고 했는데 notify()로는 특정한 쓰레드를 깨울 수 없기 때문에 임의로 순서를 정할 수 없게 된다. 따라서 쓰레드가 기아 상태로 빠지는 것을 막을 수는 없다. 이를 위해서 지원하는 또 다른 메서드로 notifyAll()이 있는데 이는 wating pool에서 대기 중인 쓰레드를 전부 실행 준비 상태로 바꾸는 것이다. 이는 기아 상태로 빠지는 쓰레드를 없앨 수는 있지만 굳이 실행할 필요가 없는 쓰레드까지 전부 실행하게 되므로 쓰레드들이 경쟁 상태에 들어가서 성능이 느려질 수 있다.

그렇다면 원하는 쓰레드를 골라서 준비 상태로 바꿀 수는 없는 걸까? 아니다. 자바는 이를 위해서 LockCondition이라는 패키지를 제공한다. 이는 다음 포스팅에서 알아보도록 하자.