쓰레드

1. 프로세스와 쓰레드

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다.

멀티쓰레딩

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

CPU의 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업을 모두 동시에 수행되는 것처럼 보이게 한다.

멀티쓰레딩의 장점

  • CPU의 사용률을 향상시킨다.

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

  • 사용자에 대한 응답성이 향상된다.

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

멀티쓰레딩의 단점

여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화, 교착상태와 같은 문제들을 고려해서 신중히 프로그래밍해야 한다.


2. 쓰레드의 구현과 실행

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

Thread클래스를 상속받으면 다른 클래스를 상속 받을 수 없기 때문에, Runnable인터페이스를 구현하는 방법이 일반적이다.

  1. Thread클래스 상속

class MyThread_1 extends Tread {
	public void run() { /* 작업내용 */ }
}
  1. Runnable인터페이스 구현

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

인스턴스 생성

  1. Thread클래스 상속

MyThread_1 t1 = new MyThread_1();
  1. Runnable인터페이스 구현

Runnable r = new MyThread_2();
Thread t2 = new Thread(r);

Thread t2 = new Thread(new MyThread_2()); // 위의 두 줄을 한 줄로 간단히

쓰레드의 실행 - start()

start()를 호출하면 일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행한다.

하나의 쓰레드에 대해 start()가 한번만 호출될 수 있다.


3. start()와 run()

main메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐이다.

반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가게 한다.

main쓰레드

main메서드의 작업을 수행하는 쓰레드

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


4. 싱글쓰레드와 멀티쓰레드

싱글 코어에서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티쓰레드보다 싱글쓰레드로 프로그래밍하는 것이 더 효율적이다.

두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드 프로세스보다 멀티 프로세스가 더 효율적이다.


5. 쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.


6. 쓰레드 그룹

서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로, 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있다.

자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킨다.


7. 데몬 쓰레드(daemon thread)

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

일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.

데몬쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.

데몬 쓰레드의 예

  • 가비지 컬렉터

  • 워드프로세서의 자동저장

  • 화면 자동 갱신 ...


8. 쓰레드의 실행제어

쓰레드의 스케줄링과 관련된 메서드

메서드설명

static void sleep(long millis)

지정된 시간(천분의 일초 단위)동안 쓰레드를 일시정지시킨다. 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 된다.

void join()

지정된 시간동안 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료된면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.

void interrupt()

sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 된다.

void stop()

쓰레드를 즉시 종료시킨다.

void suspend()

쓰레드를 일시정지시킨다. resume()을 호출하면 다시 실행대기상태가 된다.

void resume()

suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다.

static void yield()

실행 중에 자신에게 주어진 실행시간을 다릉 쓰레드에게 양보하고 자신은 실행대기 상태가 된다.

쓰레드의 상태

상태설명

NEW

쓰레드가 생성되고 아직 start()가 호출되지 않은 상태

RUNNABLE

실행 중 또는 실행 가능한 상태

BLOCKED

동기화 블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)

WAITING, TIMED_WAITING

쓰레드의 작업이 종료되지 않았지만 실행가능하지 않은 일시정지상태. TIMED_WAITING은 일시정지시간이 지정된 경우를 의미한다.

TERMINATED

쓰레드의 작업이 종료된 상태

쓰레드 상태 변화과정

  1. 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장되어 자신의 차례가 될 때까지 기다려야 한다. 실행대기열은 큐와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.

  2. 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.

  3. 주어진 실행시간이 다 되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행 상태가 된다.

  4. 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지 상태가 될 수 잇다. I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 잇는데, 이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기 상태가 된다.

  5. 지정된 일시정지 시간이 다 되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.

  6. 실행을 모두 마치거나 stop()이 호추로디면 쓰레드는 소멸한다.

번호의 순서대로 쓰레드가 수행되는 것은 아니다.


9. 쓰레드의 동기화

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게된다. 이러한 일을 방지하기 위해 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 '임계 영역'과 '잠금'이라는 개념이 필요하다.

공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 그리고 해당 쓰레드가 임계 여역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 된다.

한 쓰레드가 실행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화(synchronization)'라고 한다.

syncronized를 이용한 동기화

  1. 메서드 전체를 임계 영역으로 지정

public synchronized void calcSum() {
	//...
}
  1. 특정한 영역을 임계 영역으로 지정

synchronized(객체의 참조변수) {
	//...
}

wait()과 notify()

객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하기 위해 고안되었다.

동기환된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출해 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행랗 수 있게 한다.

wait()이 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.

notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.

notifyAll()은 기다리고 있는 모든 쓰레드에게 통보를 하지만, 그래도 lock을 얻을 수 있는 것은 하나의 쓰레드일 뿐이고 나머지는 다시 기다리게 된다.

waiting pool은 객체마다 존재하므로 notifyAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당된다.

기아 현상과 경쟁 상태

  • 기아 현상 어떤 쓰레드는 게속 통지를 받지 못하고 오랫동안 기다리게 되는 것

  • 경쟁상태 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것

Lock과 Condition을 이용한 동기화

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

  2. ReetrantReadWriteLock 읽기를 위한 lock과 쓰기를 위한 lock을 제공한다.읽기 lock이 걸려 있어도 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있다. 그러나 읽기 상태에서 쓰기 lock을 거는 것을 허용되지 않는다.

  3. StampedLock lock을 걸거나 해지할 때 '스탬프(long타입의 변수값)'사용하며, 읽기와 쓰기를 위한 lock외에 '낙관적 읽기 lock'이 추가된 것이다. '낙관적 읽기 lock'은 쓰기 lock에 의해 바로 풀린다. 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.

예시)

ReentrantLock lock = new ReentrantLock();

lock.lock();
//임계 영역
lock.unlock();

Condition

쓰레드의 종류에 따라 나누어 '경쟁상태'를 개선한다.

ReentrantLock lock = new ReentrantLock();

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

volatile

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

그러다보니 도중에 메모리에 저자오딘 변수의 값이 변경되어쓴ㄴ데도 캐시에 저장된 값이 갱신되지 않아서 값이 달라지는 경우가 발생한다.

이 때 volatile을 변수 앞에 붙이면 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어온다.

synchronized블럭을 사용해도 블러긍로 들어가거나 나올 때, 캐시와 메모리간의 동기화가 이루어지기 때문에 같은 효과를 얻을 수 있다.

long과 double을 원자화

JVM은 데이터를 4byte단위로 처리하기 때문에 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없다. 변수의 값을 읽는 과정에서 다른 쓰레드가 끼어들 여지가 있다.

다른 쓰레드가 끼어들지 못하게 하려고 synchronized블럭으로 감쌀 수도 있지만, 변수 선언할 때 volatile을 붙여도 된다.

fork & join 프레임웍

하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.

fork() : 해당 작업을 쓰레드 풀의 작업 큐에 넣는다. 비동기 메서드 join() : 해당 작업의 수행이 끌날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다. 동기 메서드

Last updated