kiwi

쓰레드

by 키위먹고싶다

1. 프로세스와 쓰레드

  • 프로세스와 쓰레드의 개념

프로세스란 실행중인 프로그램이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다. 프로세스는 프로그램을 실행하는데 필요한 데이터 + 메모리등의 자원 + 쓰레드로 구성된다.

프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다. 모든 프로세스에는 하나 이상의 쓰레드가 존재하고, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스'라고 한다.

쓰레드가 작업을 수행할때 개별적인 메모리 공간(호출스텍)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.

 

  • 멀티스테킹과 멀티 쓰레딩

멀티스테킹(다중작업)은 여러개의 프로세스가 동시에 실행될 수 있게 한다.

멀티쓰레딩은 하나의 프로세스 내에서 여러개의 쓰레드가 동시에 작업을 수행하는 것이다. 

채팅과 파일다운로드를 동시에 진행할 수 있다는 것이 멀티쓰레딩의 특징이다. 싱글쓰레드라면 파일 다운로드 동안 채팅을 하지 못한다. 여러 사용자에게 서비스해주는 서버 프로그램은 하나의 서버 프로세스가 여러개의 쓰레드를 생성해서 쓰레드와 사용자의 요청이 일대일로 처리되도록 해야한다. 그래서 밀티쓰레드가 필수적이다.

 

  • 멀티쓰레딩의 장단점

장점

- CPU의 사용률을 향상시킴.

- 자원을 효율적으로 사용할 수 있음.

- 사용자에 대한 응답성이 향상.(서버 프로그램)

- 작업이 분리되어 코드가 간결해짐.

 

단점

- 동기화문제

- 교착상태

여러 쓰레드가 한가지 프로세스 내에서 자원을 공유하면서 작업하므로, 이러한 문제점들이 발생할 수 있음.

 

싱클쓰레드보다 멀티쓰레드로한 작업이 더 오래걸릴 수 있는데, 그 이유는 쓰레드간의 작업전환(context switching)에 시간이 걸리기 때문이다. 또한 한쓰레드가 다른쓰레드의 작업이 끝날때까지 기다리는 대기시간이 발생하기 때문이다.

 

  • 싱글코어와 멀티코어

싱글코어일때 멀티쓰레드일 경우와 멀티코어일때 멀티쓰레드일 경우 차이가 있다.

싱클코어인 경우 하나의 코어가 두 작업(두개의 쓰레드)을 번갈아가면서 처리하기 때문에 두 작업이 겹치지 않는다.

그러나 멀티코어는 동시에 두쓰레드가 실행될 수 있으므로 두개의 작업이 겹칠수가 있는데, 이때 같은 자원을 공유한다면 두 쓰레드가 그 자원을 두고 경쟁한다. 

 

2. 쓰레드의 구현, 실행

쓰레드를 구현하는 방법은 두가지이다.

class ThreadEx1 {
    public static void main(String[] args) {
        ThreadEx1_1 t1 = new ThreadEx1_1();
        t1.start(); //start메서드가 쓰레드가 작업할 호출스텍을 따로 만들어서 run을 호출함.

//        ThreadEx1_2 t2 = new ThreadEx1_2();
        Thread t2 = new Thread(new ThreadEx1_2());
        t2.start();

    }
}

class ThreadEx1_1 extends Thread {

    public void run(){
        for(int i=0; i<5; i++){
            System.out.println(getName());
        }
    }

}

class ThreadEx1_2 implements Runnable {

    @Override
    public void run() {
        for(int i=0; i<5; i++){
            System.out.println(Thread.currentThread().getName());
        }
    }
}

Thread를 상속받는 법과, Runnable인터페이스를 구현 하는 법 두가지이다. 

Runnable인터페이스를 구현한 경우, Runnable인터페이스를 구현한 클래스의 객체를 생성하고, 

이 인스턴스를 Thread클래스의 생성자에 매개변수로 제공해야 한다.

 

Thread클래스를 상속받으면, 자손클래스는 Thread클래스의 메서드인 getName()을 직접 호출할 수 있지만,

Runnable인터페이스를 구현할 경우 Thread클래스의 static메서드인 currentThread()를 통해 (현재 실행중인 쓰레드를 반환) 쓰레드의 참조를 얻어와야 getName()을 호출할 수 있다. 

Thread의 생성자를 이용하거나 setName(String name)메서드를 통해 쓰레드의 이름을 지정할 수 있는데,

만약 쓰레드의 이름을 지정하지 않으면, 'Thread-번호'형식으로 이름이 지정된다.

 

쓰레드를 생성했다고 해서 쓰레드가 바로 실행되는 것이 아니라 start()메서드를 통해서 쓰레드가 실행된다. 

정확히는 곧바로 실행되는것이 아니라 실행대기 상태가 되었다가 자신의 차례가 되면 실행된다.(쓰레드의 실행순서에 의해서)

실행종료된 쓰레드는 다시 실행할 수 없다. 그래서 하나의 쓰레드에 대해 start()는 한번만 호출할 수 있다. 

종료된 쓰레드를 다시 실행하려면 쓰레드인스턴스를 다시 생성해서 start()를 호출해야 한다.

쓰레드를 다시 생성하지 않고, start()를 두번 호출하면 IllegalThreadStateException이 발생한다.

 

3. start()와 run()

main에서 새로운 쓰레드를 start()하면 쓰레드 개별 호출스텍을 생성해서 run()을 실행한다.

쓰레드는 독립적인 작업을 수행하기 위해 별도의 호출스텍이 필요하다.

새로운쓰레드가 실행될때마다 새로운 호출스텍이 생성되고 쓰레드의 작업이 완료되면 소멸됨.  

main쓰레드가 종료되어도 다른쓰레드가 작업중이면 프로그램은 종료되지 않는다.

실행중인 쓰레드가 하나도 없을 때 프로그램이 종료된다.

 

만약 start()를 통해 호출스텍을 생성하지 않고 run()을 실행하면 호출스텍 main위에 run()이 올라간다.

 

4. 쓰레드의 우선순위

쓰레드는 우선순위(priority)에 따라 얻는 실행시간이 달라진다. 쓰레드가 수행하는 작업의 중요도에 따라 우선순위를 두어 특정 쓰레드가 더 많은 작업시간을 갖게 하고 빨리 끝내게 할 수 있다.  

우선순위는 숫자가 클수록 높고, 만약 우선순위를 정하지 않았다면 그 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속된다. 쓰레드의 우선순위는 싱글코어에서만 효과 있고, 멀티코어에서는 차이가 거의 없다.

 

5. 쓰레드의 실행제어

 

  1. 쓰레드를 생성하고 start()호출하면 바로 실행되는것이 아니라 실행대기열에 저장되고, 자신의 차례가 될때까지 기다림. 먼저 실행대기열에 들어온 쓰레드가 먼저 실행(큐).
  2. 실행대기상태에 있다가 차례가 되면 실행.
  3. 실행시 시간이 다 되거나, yield()하면 다시 실행대기상태가 되고 그 다음 차례의 쓰레드가 실행됨.
  4. 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지 상태가 될 수 있음. I/O block은 입출력작업에서 발생하는 지연상태. (사용자의 입력을 기다리는 경우, 일시정지상태가 됐다가, 사용자 입력이 끝나면 다시 실행대기 상태가 됨.)
  5. time-Out(지정된 일시정지 시간 다 됨.), notify(), resume(), interrupt()가 호출되면 일시정지 상태를 벗어나 다시 실행대기열에 저장되고, 자신의 차례를 기다림.
  6. 실행을 마치거나, stop()호출 시 쓰레드가 소멸.
  • sleep()

- sleep()을 호출할땐 항상 예외처리 해야 함. 

- sleep()은 항상 현재 실행중인 쓰레드에 대해 작동해서 특정 쓰레드(참조변수)를 sleep()해도 영향 받는것은 실행중인 쓰레드임.

- sleep()은 static으로 선언, 'Thread.sleep(시간);' 형태로 많이 사용.  

 

  • interrupt()

sleep()에 의해 쓰레드가 일시 정지상태에 있을때 잠든 쓰레드를 깨워서 실행대기 상태로 만든다.

- 잠든 쓰레드를 interrupt()했는데 Isinterrupted상태가 false인 경우, sleep()에서 InterruptedException이 발생해서 interrupted의 상태가 false로 초기화 된것이다. 이럴 경우 catch블럭에 interrupt()를 넣어서 쓰레드의 interrupted의 상태를 true로 변경한다.   

 

  • suspend(), resume(), stop()

suspend()는 sleep()처럼 쓰레드를 멈추게 함. 

- suspend()에 의해서 멈춘 쓰레드는 resume()으로만 깨서 실행대기 상태가 됨.

- stop()은 쓰레드를 강제 종료시킴 

- suspend()와 stop()은 교착상태를 일으킬 수 있어서 사용이 권장되지 않음.

 

  • yield()

ㅎㅎㅎㅎㅎㅎ- yield()를 사용하면 다른쓰레드에게 실행시간을 양보하게 되므로, 의미없는 시간을 허비하지 않게 할 수 있음. 

stop()을 호출하고 나서 지   연시간이 생겼을 때 interrupt()를 호출하면, sleep()에서 발생한 InterruptedException때문에 즉시 일시정지 상태에서 벗어날 수 있어서 응답성이 좋아진다. yield()와 interrupt()를 활용해서 쓰레드의 효율을 향상시킬 수 있다.  

 

  • join()

join()은 자신이 하던 작업을 잠시 멈추고, 특정쓰레드가 지정된 시간동안 작업을 끝내도록 기다린다.

- sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try-catch로 감싸야 한다는 점에서 sleep()과 유사함.

- sleep()과 다른 점은 static메서드가 아니라 특정쓰레드에 대해 동작함.

- ex> main쓰레드를 다른 쓰레드의 작업이 끝날 때까지 종료시키고 싶지 않을때.

 

6. 쓰레드의 동기화

싱글쓰레드와 멀티쓰레드의 차이를 위에서 설명했는데, 멀티쓰레드의 경우 여러 쓰레드가 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 줄 수 있다. A쓰레드가 작업하다가 B쓰레드로 실행이 넘어갔을 때 A쓰레드가 사용하던 자원을 B가 변경할 경우, 다시 A쓰레드 차례가 되어 작업을 완료 한 경우 의도와 다른 결과가 될 수 있다. 이를 방지 하기 위해 한 쓰레드가 작업을 완료하기 전에 다른 쓰레드에 의해 방해받지 않도록 해야 하는데 '임계영역'과 '잠금(lock)'을 사용해서 이러한 문제를 해결 할 수 있다.  이 처럼 진행중인 쓰레드 작업을 다른 쓰레드가 간섭하지 못하게 막는 것을 '쓰레드의 동기화'라고 한다.

 

  • synchronized를 이용한 동기화
class ThreadEx21 {
    public static void main(String[] args) {
        Runnable r = new RunnableEx21();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account{
    private int balance = 1000;

    public int getBalance(){
        return balance;
    }

    public synchronized void withhdraw(int money){
        if(balance >= money){
            try{
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            balance -= money;
        }
    }
}

class RunnableEx21 implements Runnable {

    Account acc = new Account();

    @Override
    public void run() {
        while (acc.getBalance() > 0){
            int money = (int) (Math.random() * 3 + 1) * 100;
            acc.withhdraw(money);
            System.out.println("balance:" + acc.getBalance());
        }
    }
}

위 예시는 Account인스턴스(공유자원)를 두개의 쓰레드가 번갈아서 사용한다. withdraw(int moeny)메서드 반환타입 왼쪽에 synchronized키워드를 사용했는데 synchronized를 메서드에 사용하면 임계영역이 설정되고, 설정된 임계영역 메서드를 포함한 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다. lock을 획득한 쓰레드만 해당 영역내의 코드를 수행할 수 있으므로, 동기화 문제가 생길 수 있는 영역을 synchronized를 사용해서 임계영역으로 지정해 놓고, lock을 획득한 쓰레드만 임계영역을 실행하도록 해야한다.

 

위 예시는 계좌에서 돈을 출금하는 과정을 보여준다. 

잔고(balance)가 금액보다 크거나 같을 경우에 출금을 실행하는 if문에서 문제가 발생 할 수 있다.

만약 synchronized를 사용하지 않을 경우, 한 쓰레드가 withdraw(int money)메서드의 if문을 완료하고 돈을 출금하기 직전에 다른 쓰레드로 실행이 넘어가서 돈을 출금하는 작업을 실행하고 다시 기존 쓰레드의 차례가 되었을 때 돈을 출금하는 작업을 이어서 실행 한다면 이미 잔고가 빼려는 금액보다 작아도 출금될 수 있다. 

synchronized메서드를 사용해서 이 메서드 작업이 끝나고(출금까지) lock을 반납할때까지 다른 쓰레드가 수행하지 못하도록 하는 것이다. 

 

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

            }
            balance -= money;
        }
    }
}

메서드에 synchronized를 붙히는 방법외에도 synchronized블럭을 사용할 수 있는데, 

synchronized블럭의 매개변수를 작업이 실행될동안 다른쓰레드에서 사용할 수 없다.

 

Account의 balance의 접근제어자가 private인 이유는 쓰레드의 동기화를 해도 밖에서 값 자체를 바꾸는 것을 막을 수 없다.   

 

  • wait(), notify(), notifyAll()

동기화를 통해서 공유데이터를 보호할때, 특정쓰레드가 lock을 오랜시간동안 가지고 있으면 다른 작업이 원활하게 진행되지 않을 수 있다. 이때 동기화된 임계영역에서 작업을 더 이상 진행할 상황이 아니라면 wait()을 통해서 쓰레드의 lock을 반납하고, 다른 쓰레드가 lock을 통해 작업을 수행하게 한다. 다시 작업을 진행할 수 있을 때 notify()를 통해 lock을 얻어서 중단했던 쓰레드가 작업 할 수 있다. wait()을 호출하면 쓰레드가 객체의 waiting pool에 들어가서 대기한다. notify()를 호출하면 waiting pool에 있는 쓰레드들 중에서 임의의 쓰레드 한개만 lock을 받는다는 통지를 받는다. notifyAll()을 통해서 waiting pool에 있는 모든 쓰레드에게 통지를 해도 lock을 원하는 쓰레드가 가진다는 보장이 없다. 그래서 그 쓰레드가 lock을 획득할때까지 긴 시간이 걸릴 수 있다. waiting pool에서 대기중인 A쓰레드와 B쓰레드가 서로 lock을 획득하기 위해 '경쟁 상태'가 될 때, 이 두 쓰레드를 구별해서 통지 하는 것이 필요하다.

 

  • Lock과 Condition을 이용한 동기화
synchronized (lock){
    //임계영역    
}

synchronized블록은 자동으로 lock이 잠기고 풀리기 때문에 편리하며, 예외가 발생해도 lock이 자동으로 풀린다. 그러나 같은 메서드에서만 lock을 걸 수 있다는 제약이 있으며, 같은 메서드에 임계영역의 시작과 끝이 있어야 한다. 

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    //임계 영역
}finally{
    lock.unlock();
} 

그러나 Lock은 lock()으로 lock을 걸고, unlock()으로 lock을 해지하는 작업을 수동으로 처리 하며, 임계영역을 여러 메서드에 나눌 수 있다는 장점이 있다. 

'java.util.concurrent,locks'패키지가 제공하는 Lock클래스들은 3가지이다.

  • ReentrantLock
  • ReentrantReadWriteLock
  • StampedLock

앞서, wait()과 notify()를 사용해서 쓰레드의 lock을 반납하고, 반납한 lock을 얻을 수 있다고 했다.

그런데 notify()는 한 쓰레드에게 lock의 통지를 할 뿐, 운이 나쁘면 lock을 받아야 할 쓰레드가 오랫동안 기다려야 하는 '기아현상'을 발생시킬 수 있다. notifyAll()로 공유객체를 이용하는 쓰레드가 모두 lock의 통지를 받아도, 여러 쓰레드가 lock을 얻기 위해 '경쟁상태'가 발생한다.

이와 같은 현상을 개선하기 위해, 쓰레드를 구별해서 통지하는 것이 필요한데 Condition을 이용하면 쓰레드를 구별해서 통지할 수 있다.

 

Condition은 wait()과 notify()처럼 쓰레드의 종류를 구분하지 않고 공유 객체의 waiting pool에 몰아넣지 않는다.

쓰레드마다 Condition을 만들어서, 각각의 waiting pool에서 따로 기다리도록 한다.

이렇게 되면, 쓰레드의 종류에 따라 구분하여 통지를 할 수 있게 되므로, 쓰레드가 오랫동안 기다리거나, 경쟁할 가능성이 적어진다. 그러나 통지를 하는 것이지, 특정쓰레드를 선택하는것은 여전히 불가능하기 때문에 

완전히 이러한 문제점들을 해결할 가능성은 여전히 남아있다. 

 

 

 

 

 

 

 

 

블로그의 정보

kiwi

키위먹고싶다

활동하기