쓰레드


들어가기 앞서.


이미지 업로드시 속도 문제와 만나다.

기존의 이미지 업로드의 경우 아래와 같이 작동한다.

이미지 업로드 클릭 → 썸네일 생성 + 이미지 업로드 (이미지 갯수만큼 반복)

그러나 여기에는 치명적인 단점이 있었다. 용량이 큰 이미지 파일의 갯수가 30개, 40개 혹은

그 이상일 경우에는 업로드 속도가 눈에 띄게 느려졌기 때문이다.


처음에는 for문으로 뭔가를 해보려고 했지만, 근본적인 문제가 해결되지는 않았다.

그러다가 썸네일을 뭔가 백그라운드에서 만들면 좋을거 같은데…🤔 라는 생각이 들어

구글 검색창에다가 자바에서 백그라운드로 작업하기 라는 검색어로 구글링을 하다가

쓰레드 에 대해서 알게되었다.


업로드 속도를 개선해보자!!

쓰레드 사용하여 개선된 코드의 동작방식은 아래와 같다.

이미지 업로드 클릭 → 이미지 업로드 (썸네일 생성은 서브쓰레드가 수행)

join() 메서드를 사용하지 않으면, 메인 쓰레드는 서브 쓰레드가 일을 마칠 때 까지 기다리지 않기

때문에 사용자 입장에서 보다 빠르게 이미지 업로드를 할 수 있다. 물론 서브쓰레드가 끝나기 전 까지

썸네일 이미지는 보이지는 않지만, 이미지는 정상적으로 업로드가 되는걸 확인할 수 있었다.


로컬 환경에서 성능 비교 테스트

• 포스트맨 응답 시간을 성능의 지표로 삼았다.

성능 비교 영상

[ 개선 전 ]



[ 개선 후 ]



추가 학습 후 느낀점

쓰레드를 추가학습 하는 과정에서 OS적으로 공부해야 할 부분이 많이 보였다.

DeadLock

Process Management

Thread Management

이 포스팅을 작성한 이후 O프로세스와 쓰레드를 OS 측면에서 정리해보았다.

복습하면서 느낀 부분인데 OS 스케쥴링 관련해서도 포스팅을 하면서 OS 지식을 쌓아봐야겟다.


추가 학습 : 쓰레드

쓰레드는 OS에 종속적이다. 스케쥴러가 쓰레드의 순서를 결정한다.


프로세스와 쓰레드

프로세스 : 쓰레드 = 공장 : 일꾼

하나의 새로운 프로세스를 생성하는 것보다, 하나의 새로운 쓰레드를 생성하는 것이

더 적은 비용이 든다. 프로세스는 실행 중인 프로그램, 자원과 쓰레드로 구성되고

쓰레드는 프로세스 내에서 실제 작업을 수행한다. 모든 프로세스는 최소한 하나의

쓰레드를 가지고 있다.


CGI와 Servlet
CGI는 싱글쓰레드 기반으로 사용자의 요청마다 프로세스를 생성한다.

반면에 Java Servlet은 멀티스레드를 지원하기 때문에 새로운 요청이 

들어오면 해당 요청에 대한 쓰레드를 생성한다. 


멀티쓰레드의 장단점

멀티쓰레드 자체는 어렵지 않지만, 멀티쓰레드가 효율적으로 돌아가게 하는게 어렵다.

장점

• 시스템 자원을 보다 효율적으로 사용할 수 있다.

• 사용자에 대한 응답성(responseness) 향상

• 작업이 분리되어 코드가 간결해 진다.


단점

동기화(Synchronization) 에 주의해야 한다.

교착상태(Dead-lock) 가 발생하지 않도록 주의해야 한다.

기아현상 이 발생하지 않도록 주의해야 한다. (특정 쓰레드가 작업할 기회가 없는것.)

• 각 쓰레드가 효율적으로 고르게 실행될 수 있게 해야 한다.


쓰레드의 구현과 실행


Thread 구현

[ Thread클래스를 상속 ]

class MyThread extends Thread {
    public void run() {
        /* 작업 내용 */
    };
}

MyThread t1 = new MyThread();       // 쓰레드 생성
t1.start();                         // 쓰레드 실행


[ Runnable 인터페이스를 구현 ]

자바는 단일 상속이기 때문에 쓰레드를 구현할 때는 인터페이스를 구현하는게 좋다.

이렇게 하면 다른 클래스를 상속 받을 수 있기 때문이다.

public interface Runnable {
    public abstract void run();
}


class MyThread implements Runnable {
    public void run() {
        /* 작업 내용 */
    };
}


Runnable r = new MyThread();
Thread t2 = new Thread(r);      // Runable을 매개변수로 같는 Thread생성자.
t2.start();                     // 쓰레드 실행


Thread 실행

[ start ]

start() 메소드를 호출한다고 해서 바로 쓰레드가 실행되는 것은 아니다. 그리고 먼저 시작했다고

해서 꼭 먼저 수행되는 것도 아니다. 쓰레드를 실행할지 말지는 OS 스케쥴러가 실행 순서를 관리한다.

JVM은 OS에 독립적이긴 하지만, 몇 가지 종속적인 부분이 있는데 그 중에 하나가 바로 쓰레드 이다.


[ start와 run]

public class ex1 {
    public static void main(String[] args) {
        ThreadEx1 t1 = new ThreadEx1();
        t1.start();
    }
}


마지막 4. Call Stack을 보면 main과 run은 각각 서로 다른 스택에 위치하게 된다.

즉, 서로 독립적으로 작업을 수행한다.


만약 아래의 코드처럼 작성하게 된다면,

public class ex1 {
    public static void main(String[] args) {
        ThreadEx1 t1 = new ThreadEx1();
        t1.start();
    }
}

1번 Call Stack에 start 대신 run이 쌓이게 되며, 결과적으로는 그냥 하나의

쓰레드를 사용하는 것과 같다.


main 쓰레드


main 쓰레드란?

main() 메서드의 코드를 수행하는 쓰레드

• 쓰레드는 사용자 쓰레드, 데몬 쓰레드 두 종류가 있다.


main 메서드가 실행이 종료되어도 아직 실행중인 스레드가 남아있다면 프로그램은 종료되지 않는다.


싱글쓰레드와 멀티쓰레드


작업시간

멀티쓰레드가 싱글쓰레드 보다 작업시간이 더 소요되는데, 그 이유는 OS 스케쥴러 알고리즘에 의해 번갈아가면서

쓰레드가 작업을 한다. 이때 작업하는 쓰레드가 바뀔 때 문맥교환(Context Switching) 이 일어나기 때문이다.


결과적으로 보면 시간은 멀티쓰레드가 더 걸리는데 왜 멀티쓰레드를 쓰는 것일까?

하나는 작업을 동시에 진행할 수 있다는 점이고, 다른 하나는 작업을 좀 더 효율적으로

할 수 있다는 점이다.


쓰레드의 I/O Blocking


I/O Blocking

싱글쓰레드의 경우에 외부 장치의 입출력이 있는 경우에, 값을 넣을 때 동안 기다리는 구간이 발생한다.

이 때 blocking이 걸리게 되므로 뒤에 있는 작업도 대기 상태가 된다.

반면에 멀티쓰레드 환경에서는 main 쓰레드가 blocking에 걸려도 나머지 쓰레드는 본인의 일을 수행한다.

이렇게 되면 보다 자원을 효율적으로 사용할 수 있다.


위 그림을 첫 번째를 보면 사용자로 부터 입력을 기다리는 Blocking 구간에서는 아무런 작업을 하지

않는다. 반면에 두 번째를 보면 A에서 IO가 발생하는 순간, B가 작업을 시작한다.

이러한 부분에서 자원을 보다 효율적으로 사용하는 것을 확인할 수 있다.


쓰레드의 우선순위


알아보기

쓰레드는 우선순위라는 속성을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이

달라진다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정

쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.


근데 우선순위 속성으로 지정하는 순위는 그냥 단순히 희망사항이다. 왜냐면 쓰레드의 실행 관리는

OS 스케쥴러가 하기 때문이다. OS 스케쥴러는 그냥 개발자가 정해놓은 순서를 참고만 할 뿐이다.

따라서 우선순위가 높다고 무조건 일찍 끝나는건 아니다.


쓰레드 그룹


알아보기

• 서로 관련된 쓰레드그룹으로 묶어서 다루기 위한 것.

• 모든 쓰레드는 반드시 하나의 쓰레드 그룹에 포함되어 있어야 한다.

• 쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 main 쓰레드 그룹에 속한다.

• 자신을 생성한 쓰레드(부모 쓰레드)의 그룹과 우선순위를 상속받는다.


데몬 쓰레드


데몬 쓰레드란?

데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다.

일반 쓰레드가 모두 종료되면 데몬쓰레드는 강제적으로 자동 종료되는데, 그 이유는 데몬 쓰레드는

일반쓰레드의 보조역할을 수행하므로 일반 쓰레드가 모두 종료되면 데몬쓰레드의 존재의 의미가 없기

때문이다. 데몬쓰레드의 예로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.


데몬 쓰레드는 일반쓰레드의 작성방법과 실행방법이 같다. 다만 쓰레드를 생성한 다음 start() 메서드를

호출하기 전에 setDaemon(true)를 호출하기만 하면 된다. 그렇지 않으면 IllegalThreadStateException

발생한다. 그리고 데몬쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다는 점도 알아두면 좋다.


구현하기

데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하다가 특정조건이 만족되면

작업을 수행하고 다시 대기하도록 작성한다.

여기서 무한루프로 작성해도 되는 이유는 데몬 쓰레드의 경우에는 일반 쓰레드가 모두 종료되면

자동적으로 종료되기 때문이다.

public void run() {
    while (true) {
        try {
            Thread.sleep(3*1000);
        } catch (InterruptedException e) {
            if (autoSave) {
                autoSave();
            }
        }
    }
}


쓰레드의 상태 & 실행제어


쓰레드의 상태

상태설명
NEW쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE실행 중 또는 실행 가능한 상태
BLOCKED동기화블럭에 의해서 일시정지된 상태(Lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING쓰레드의 작업이 종료되지는 않았지만, 실행가능하지 않은 일시정지 상태
TERMINATED쓰레드의 작업이 종료된 상태


쓰레드의 실행제어

실행제어 메서드들을 활용해서 보다 효율적인 프로그램을 작성할 수 있다.

메서드설명
static sleep()지정된 시간동안 일시정지
join()다른 쓰레드가 작업하는 것을 기다린다.
interrupt()일시정지된 상태를 실행대기 상태로 만든다.
stop()쓰레드 즉시 종료
suspend()일시정지
resume()재개
static yield()양보


sleep()yield() 메서드는 자기 자신에서만 호출이 가능하다.

왜냐면 다른 쓰레드를 내가 일시정지 시키고, 다른 쓰레드로 하여금 양보를 시킬 수 없기 때문이다.


[ suspend, resume, stop ]

교착상태란?


두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로

아무것도 완료하지 못하는 상태를 가리킨다. 여기에 자세한 정리를 해두었다.


suspend(), resume(), stop() 메소드의 경우는 교착상태를 유발할 수 있기 때문에

사용을 권장하지 않는다. 되도록 안쓰는게 좋다. 사용해야 할 경우에는 별도의 커스텀 메서드를

구현하면 된다.


[ interrupt, yield ]

yield()interupt()를 적절히 사용하면 응답성효율을 높일 수 있다.

만약 일시정지된 상태의 쓰레드의 경우에는 시간이 주어지더라도 작업수행을 할 수 없다.

이때 주어진 시간동안 아무것도 안하면서 시간만 보내는데 이를 Busy-Waiting이라고 한다.

이렇게 시간을 낭비하고 있을 때 yield() 메서드를 통해서 작업시간을 양보하게 되면

응답성효율을 높일 수 있다.


yield()는 OS 스케쥴러에게 통보를 해주는 것이기 때문에 반드시 동작된다는 보장은 없다.


쓰레드 동기화


쓰레드의 동기화

쓰레드의 동기화란, 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 말한다.

간섭을 방지하기 위해서 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록

하는 것이 필요한데, 그래서 도입된 개념이 바로 임계 영역(Critical Section)잠금(Lock)이다.


동기화를 위해서 간섭받지 않아야 하는 문장들을 임계 영역으로 설정한다.

이 임계영역은 을 얻은 하나의 쓰레드만 출입이 가능하다.


모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계영역의

코드를 수행할 수 있다. 그리고 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.


간단하게 예시를 들면, 내가 자주가는 코인노래방이 적절한 예시가 되지 않을까 싶다.

내가 방에 들어가서 돈을 넣는 순간 그 공간은 오직 나만을 위한 공간이다.(1인실의 경우)

그러면 다른 사람들은 내가 노래를 부르고 있을 때 그방에 동전을 넣을 수도 들어올 수도 없다.


Synchronized를 이용한 동기화

[ 1. 메서드 전체를 임계영역으로 설정 ]

public synchronized void exampleMethod() {
    //...
}


[ 2. 특정한 영역만 임계영역으로 설정 ]

synchronized(this) {
    //...
}


임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 것는

것 보단, synchronized 블럭으로 임계 영역을 최소화 해서 보다 효율적인 프로그램을 짜야 한다.


예제 코드
public class ThreadSync {

    public static void main(String[] args) {
        Runnable r = new RunnableEx();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money){

        synchronized(this) {
            if(balance >= money) {
                try {
                    Thread.sleep(1000);
                } catch(InterruptedException e) {

                }
                balance -= money;
            }
        }
    }
}

class RunnableEx implements Runnable {
    Account acc = new Account();

    public void run() {
        while(acc.getBalance() > 0) {
            int money = (int)(Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println(Thread.currentThread().getName()+" => "+acc.getBalance());
        }
    }
}


wait, notify

• 동기화의 효율을 높이기 위해 사용하는 메서드이다.

• Object 클래스에 정의되어 있으며, 동기화 블록 내에서만 사용할 수 있다.

wait() - 객체의 lock을 풀고 쓰레드를 해당 객체의 waiting pool에 넣는다.

notify() - waiting pool에서 대기중인 쓰레드 중의 하나를 깨운다.

notifyAll() - waiting pool에서 대기중인 모든 쓰레드를 깨운다.


예제 설명


자바의 정석 ThreadWaitEx1 ~ 4의 예제를 이해하려고 정리했다.

[ ThreadWaitEx1 ]

우선 ThreadWaitEx1 에서는 동기화가 이뤄지지 않았을 때의 예를 다뤘다.

이때 요리사가 테이블에 요리를 추가하는 과정에서 손님이 바로 음식을 먹어버리는 경우에는

ConcurrentModificationException이 발생했다. 그리고 손님A가 음식을 입에 넣는 순간

손님B가 뻇어서 먹는 경우에는 IndexOutOfBoundException이 발생했다.

Table 객체를 여러 쓰레드가 공유하기 때문에 작업중에 끼어들기가 발생한 것이다.


[ ThreadWaitEx2 ]

1번 예제의 문제를 보완하고자 Symchronized 블럭을 사용해 동기화 처리를 진행하였다.

예외는 발생하지 않지만 음식이 없어서 손님이 음식을 기다리는 과정에서, 손님은 Lock을

건채로 계속 기다리게 된다. 이렇게 되면 요리사가 Table의 Lock을 얻지 못하기 때문에

음식을 만들어서 추가할 수가 없다.


[ ThreadWaitEx3 ]

2번 예제의 문제를 보완하고자 wait()notify()를 사용했다.

이 방법을 사용하게 되면 음식이 없어서 손님이 기다릴 때, wait()을 호출해서 기다리는

손님의 lock을 빼앗고 대기상태로 돌릴 수 있다. 이렇게 되면 요리사가 해당 Lock을 얻게

되고 요리를 만들어서 테이블에 추가할 수 있다.

2번 예제와 달리 한 쓰레드가 Lock을 오래 쥐는 일이 없어지기 때문에 보다 효율적이다.


그러나 여기서도 문제가 있는데, wait()notify()의 대상이 불분명하다는 점이다.

왜냐면 하나의 객체에는 하나의 waiting pool만 존재하기 때문에, 이 한 곳에 모든 쓰레드가

들어가게 된다.


만일 테이블의 음식이 줄어서 notify()가 호출되었다면, 요리사 쓰레드가 통지를 받아야

한다. 하지만 이 메서드는 단지 waiting pool에 있는 쓰레드 중 하나를 임의로 선택하는 것이지

요리사 쓰레드만 콕 집어서 선택하는 것이 아니다. 운이 좋아서 요리사 쓰레드를 선택할 수도

있지만, 최악의 경우에는 요리사 쓰레드를 계속 선택을 못할 수도 있다.

이렇게 쓰레드가 계속해서 waiting Pool에서 기다리기만 하는 현상을 기아 현상이라고 한다.


이 현상을 막기 위해서는 notifyAll()을 사용해야 한다. 그러나 이 방식 또한 문제가 있다.

notifyAll()은 모든 쓰레드에게 통보하는 것인데, 이렇게 되면 손님 쓰레드와 요리사 쓰레드가

Lock을 얻기위해 경쟁을 하게 된다. 이 현상을 경쟁 상태(race condition)이라고 한다.


Lock과 Condition을 사용한 동기화

동기화할 수 있는 방법은 synchronized 블럭 외에도 java.util.concurrent.locks 패키지가

제공하는 lock 클래스들을 이용하는 방법이다.

Lock과 Synchronized 블럭의 차이점


• Synchronized 블럭은 메서드 내에 완전히 포함된다.

반면 Lock API의 lock()unlock() 등 별도의 메서드로 수행할 수 있다.


• Synchronized 블럭은 공정성(fairness)을 지원하지 않는다. 일다 Lock이 해제되면 모든 쓰레드가 lock을

획득할 수 있으며 기본 설정을 지정할 수 없다. 반면 Lock API는 공정성(fairness) 속성을 지정할 수 있다.

이 설정으로 하여금 대기 시간이 긴 쓰레드가 Lock을 얻을 수 있도록 해준다.


• Synchronized 블럭에 접근을 할 수 없다면, 쓰레드는 차단된다. Lock API는 tryLock() 이라는 메서드를

제공해준다. 쓰레드는 lock을 가질 수 있거나, 다른 쓰레드가 lock을 가지고 있지 않을 때 lock을 얻을 수 있다.

이 메서드는 다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리지 않는다.


• Synchronized 블록에 대한 액세스를 획득하기 위해 “대기” 상태에 있는 스레드는 중단될 수 없다.

Lock API는 lock을 기다리고 있을 때 스레드를 중단하는 데 사용할 수 있는 lockInterruptibly() 메서드를 제공한다.



[ Lock 클래스의 종류 ]

◎ ReentrantLock

특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있다.

private ReentrantLock lock = new ReentrantLock();

private Condition forCook = lock.newCondition();
private Condition forChef = lock.newCondition();


◎ ReentrantReadWriteLock

ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고

읽기를 수행할 수 있다. 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가

되지 않는다. 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않는다.


◎ StampedLock

StampedLock은 lock을 걸거나 해지할 때 스탬프(long 타입의 정수형)을 사용하며,

읽기와 쓰기를 위한 lock외에 Optimistic reading lock(낙관적 읽기 락)이 추가된 것이다.

무조건 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 lock을 거는 것이다.

IT에서 비관적과 낙관적


참고한 블로그

• 비관적(Pessimisitc) : 문제가 생길게 뻔해! → 미리 처리하자!

• 낙관적(Optimistic) : 문제가 안생길 수도 있어! → 문제가 생기면 그때 처리하자!


volatile

volatile 사용안하는 경우 이미지로 설명


참고한 유튜브 영상

CPU는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다.

다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어온다.


메인 메모리에 공유되는 변수가 하나 있다.


각각의 코어의 캐시저장소에 공유된 변수를 저장한다.


2번 쓰레드가 값을 변경하면 우선 적으로 2번 코어의 캐시메모리에서만 변경이 일어난다.


메인메모리의 값은 바로 변경되지 않고, 시간이 지난 후에야 변경된다.

그러난 코어1의 캐시메모리에는 값이 변경되지 않는다.


volatile 사용하는 경우 이미지로 설명


메인 메모리에서 직접 변수를 읽고 쓰는 작업을 진행한다.


volatile 키워드를 사용하면 해당 변수에 대한 읽기나 쓰기가 원자화된다.

원자화가 된다는 의미는 작업을 더 이상 나눌 수 없게 한다는 의미인데, Synchronized 블럭도 일종의

원자화라고 할 수 있다. 즉, Synchronized 블럭은 여러 문장을 원자화함으로써 쓰레드의 동기화를 구현한

것이라고 보면 된다.


volatile 키워드를 사용하면 코어의 캐시메모리에 데이터를 저장하지 않고 바로 메인 메모리에

접근하기 때문에 최적화가 이루어지지 않는다. 즉, 성능상으로 이점은 없다는 말이기도 하다.

하지만, 변수의 값이 모든 쓰레드에서 일치해야 하는 상황일 때는 volatile을 사용하면 매우

좋을거 같다.


그리고 변수에 volatile을 적용했다고 해서, 이 변수를 사용하는 메서드에서 Synchronized로

동기화를 해주지 않으면 안된다. 아래의 예시를 보자.

volatile long balance;

synchronized int getBalance() {
    return balance;
}

synchronized void withDraw(int money) {
    if (balance >= money) {
        balance -= money;
    }
}

getBalance()를 synchronized 블럭으로 동기화작업을 해주지 않으면, withDraw() 메소드가

호출되어 객체의 lock을 걸고 작업을 수행하는 중인데도 getBalance()가 호출되는 것이 가능해진다.

출금이 진행될 때는 작업이 완료될 때 까지 기다렸다가, 잔고를 조회해야 한다.

따라서 꼭 Synchronized 블럭으로 동기화를 진행해주어야 한다.