[java] 쓰레드 기본

기본적으로 서버 프로그램의 경우 많은 동시에 많은 사용자의 요청을 처리해야 하므로 멀티 쓰레드로 동작한다
우리가 매번 사용하는 톰캣 또한 사용자의 요청을 모두 쓰레드가 처리하는, 멀티 쓰레드 구조이다
그래므로 쓰레드에 대한 지식은 필수이다
라고 말했지만 나는 겉핥기밖에 모르는 것 같아서 다시 기초부터 공부하고 있고, 이를 정리하고자 한다

쓰레드에 대한 기본 지식은 구글에 검색하면 아주 아주 잘 설명된 글들이 많으니 그것을 참조하면 되고, 여기서는 자바에서 쓰레드를 사용하는 법에 대해 정리하겠다

쓰레드 구현

자바에서 쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다
자바에서는 상속이 비싼 행위이기 때문에 보통은 Runnable 인터페이스를 구현하여 쓰레드를 구현한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyThread1 extends Thread {
@Override
public void run() {
/* 쓰레드에서 실행할 내용 */
}
}

class MyThread2 implements Runnable {
@Override
public void run() {
/* 쓰레드에서 실행할 내용 */
}
}

class Main {
public static void main(String[] args) {
MyThread1 myThread1 = new MyThread1(); // Thread 상속 시
Thread myThread2 = new Thread(new MyThread2()); // Runnable 구현시

// 쓰레드 실행
myThread1.start();
myThread2.start();
}
}

Thread 클래스를 상속받은 경우 바로 인스턴스를 생성하면 되고, Runnable 인터페이스를 구현한 경우 Thread 클래스의 인자로 넘겨주면 된다
그리고 start() 메서드를 호출해서 쓰레드를 실행시키고 있음을 볼 수 있다

참고로 한번 사용한(start()가 이미 호출된) 쓰레드는 재사용 할 수 없다
두 번 이상 호출 시 IllegalThreadStateException이 발생한다

앞서 우리가 구현했던 run 메서드는 단순히 수행할 태스크만을 작성하는 부분이고, 실제로 쓰레드를 실행시키려면 위와 같이 start 메서드를 통해서 실행해야 한다
start 메서드 호출 시 쓰레드가 작업을 실행하는데 필요한 호출 스택을 생성하고 run 메서드를 실행하게 된다

!(thread call stack)[https://joont92.github.io/temp/thread-call-stack.jpg]

위는 메인 메서드에서 2개의 쓰레드를 실행시켰을 때의 모습이다
보다시피 각 쓰레드들은 모두 별개의 작업이고, 스케줄링의 대상이 된다
스케줄러가 정한 실행순서에 따라 각 쓰레드들을 돌면서 연산을 수행하게 될 것이고, 수행이 끝난 쓰레드들은 호출스택이 비워지면서 먼저 종료가 될 것이다

CPU는 기본적으로 쓰레드를 기반으로 스케줄링을 한다

확인 필요(+ JVM 쓰레드 스케줄러)
즉, 메인 메서드가 수행을 마쳤다 하더라도 쓰레드가 남아있다면 프로그램은 종료되지 않음을 뜻한다
실행중인 사용자 쓰레드가 하나도 없을 경우 프로그램은 종료된다

그리고 언급했다시피 각각의 쓰레드들은 각각 별개의 작업흐름이기 때문에 한 쓰레드에서 예외가 발생해도 다른 쓰레드의 실행에는 영향을 미치지 않게 된다

싱글 쓰레드와 멀티 쓰레드

방금 아주 간단하게 멀티 쓰레드를 구성해봤는데,
사실 단순히 CPU만 사용해서 계산하는 작업을 수행할 경우 위와 같이 멀티쓰레드로 작업을 수행하는것이 더 비효율적이다
쓰레드간 context switching 비용이 발생하기 때문이다

하지만 쓰레드 내에서 연산 이외의 작업을 수행할 경우(CPU 이외의 자원을 사용할 경우), 멀티 쓰레드 프로세스가 훨씬 효율적이다
예를 들면 파일이나 네트워크 I/O 작업등이 있게 되는데, 특정 쓰레드가 이러한 작업을 수행하고 있을 경우 CPU는 이를 기다리지 않고 다른 쓰레드의 작업을 수행하면 되기 때문이다
만약 싱글 쓰레드였다면 I/O 작업이 끝날 때 까지 CPU가 대기해야 했을 것이다

쓰레드 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있고, 이 우선순위에 따라 스케줄러가 할당하는 시간이 달라진다
쓰레드의 우선순위가 같다면 CPU는 각 쓰레드에게 거의 같은 양의 시간을 할당하지만, 우선순위가 다르다면 CPU는 우선순위가 높은 쓰레드에게 더 많은 작업시간을 할당한다
즉 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 처리할 수 있다
(예를 들면 채팅을 처리하는 쓰레드는 쓰레드는 파일을 전송하는 쓰레드보다 우선순위가 높아야한다)

1
2
3
4
5
6
7
Thread thread1 = new Thread(new MyThread());
Thread thread2 = new Thread(new MyThread());
thread1.setPriority(7);
thread2.setPriority(5);

thread1.start();
thread2.start();

쓰레드가 가질 수 있는 우선순위의 범위는 1~10까지 이며, 숫자가 높을수록 우선순위가 높다
참고로 이 우선순위 값은 절대적인 것이 아니라 상대적인 값이다
값이 1정도 차이나는 경우에는 별 차이가 없지만, 2 이상 차이가 나면 실행시간에 많은 차이가 발생하게 된다

쓰레드의 우선순위는 따로 지정해주지 않으면 쓰레드를 생성한 쓰레드로부터 상속을 받게 된다(main 쓰레드는 우선순위가 5이다)

데몬 쓰레드

쓰레드는 사용자 쓰레드와 데몬 쓰레드 2종류가 있다
지금까지 언급했던 것은 전부 사용자 쓰레드이고, 데몬 쓰레드의 경우 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드를 말한다
보조 역할을 하는 쓰레드이므로 일반 쓰레드가 모두 종료되면 데몬 쓰레드 또한 강제적으로 종료된다(더이상 필요없기 때문에)
데몬 쓰레드의 예로는 가비지 컬렉터, 자동저장, 화면 자동갱신 등이 있다

쓰레드를 데몬 쓰레드로 생성시키고 싶다면 아래와 같이 setDaemon() 메서드만 실행시켜주면 된다

1
2
3
4
Thread thread = new Thread(new MyThread());
thread.setDaemon(true); // start 전에 해줘야함

thread.start();

이 외에도 데몬 쓰레드가 생성한 쓰레드도 자동으로 데몬 쓰레드가 된다

데몬 쓰레드는 기본적으로 무한루프와 조건문을 이용해서 작성된다
(예시에서 봤듯이 가비지 컬렉터, 자동저장 등은 계속해서 상태를 체크해야하는 작업이다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AutoSaveDaemonThread implements Runnable {
@Override
public void run() {
while(true) {
try {
Thread.sleep(3 * 1000);
} catch(InterruptedException e) {
// do nothing
}

if(autoSave) {
autoSave();
}
}
}
}

JVM은 기본적으로 가비지 컬렉션, 이벤트처리, 그래픽처리와 같이 프로그램이 실행되는데 필요한 보조작업을 수행하는 데몬 쓰레드들을 자동적으로 실행시킨다

쓰레드 실행제어(Welcome To Hell!)

앞서 쓰레드의 우선순위로 쓰레드간 실행을 제어하긴 했지만, 사실 이것만으로는 부족하다
효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 주어진 자원을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다
이를 위해 자바에서는 쓰레드를 컨트롤 할 수 있는 기능을 제공하는데, 이를 알기전에 먼저 쓰레드의 상태에 대해 알고 있어야한다

thread states

  • NEW

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

  • RUNNABLE

    start()를 호출했다고 바로 실행되는 것이 아니라, 큐의 구조로 된 실행 대기열에 저장되어 대기하다가 자기 차례가 되면 실행한다

  • RUNNING

    실행대기열에 있던 쓰레드가 자기 차례가 되어 실행중인 상태

  • WAITING

    실행중인 쓰레드가 특정 메서드(sleep(), wait(), join() 등)에 의해 일시정지 상태가 된 것을 말한다
    지정된 일시정지 시간이 다 되거나 특정 메서드(notify(), interrupt()) 호출이 발생하면 다시 RUNNABLE 상태로 돌아가 실행대기열에 저장된다

  • BLOCKED

    ?

  • TERMINATED

    쓰레드의 작업이 종료된 상태를 말한다
    작업이 종료된 쓰레드는 소멸되므로 사실상 쓰레드가 이 상태로 있는것은 아니다

아래는 쓰레드의 상태를 제어하는 주요 메서드들에 대한 설명이다

start

위에서 언급한 것처럼, 쓰레드를 실행시키는 메서드이다
바로 RUNNING 상태가 되지는 않고 RUNNABLE 상태로 되었다가 자기 차례가 되면 RUNNING 상태가 된다

join

지정한 쓰레드의 작업이 끝날 때 까지 기다리는 메서드이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Thread thread1 = new Thread(new MyThread());
Thread thread2 = new Thread(new MyThread());

try {
thread1.start();
thread1.join(); // 1

thread2.start();
thread2.join(); // 2
} catch(InterruptedException e) {
// do nothing
}

System.out.println("job ended"); // 3

join() 메서드 실행 시, 기다리는 대상은 해당 쓰레드를 실행한 쓰레드이다
thread1, thread2를 실행한 쓰레드를 메인 쓰레드라고 가정한다면,

  1. main thread는 thread1 을 실행하고, thread1 의 작업이 끝날 때 까지 기다린다

메서드의 인자로 밀리초나 나노초를 줘서 특정 시간 동안만 기다리게도 설정할 수 있다

  1. thread1의 작업이 끝난 뒤 mani thread는 thread2 를 실행하고, thread2 의 작업이 끝날 때 까지 기다린다
  2. thread2의 작업이 끝나고 나면 main thread의 마지막 부분을 실행하고, main thread는 종료된다

보다시피 join 메서드는 한 쓰레드의 작업 중간에 다른 쓰레드의 작업이 필요할 경우 유용하게 사용할 수 있다

sleep

지정한 시간동안 작업을 일시정지 상태로 들어가는 메서드이다
static 메서드로 제공되며(인스턴스 메서드는 deprecated 되었다), 이 메서드를 호출한 쓰레드가 지정한 시간만큼 일시정시 상태가 된다

1
2
3
4
5
6
7
8
Thread thread = new Thread(new MyThread());
thread.start();

try {
Thread.sleep(5000);
} catch(InterruptedException e) {
//
}

5000 millisecond 이기 때문에 5초간 WAITTING 상태가 된다
5초가 지난 후 다시 RUNNABLE 상태가 되어 실행대기열에 저장된다

yield

RUNNABLE 상태로 들어가면서 다른 쓰레드에게 작업을 양보하는 메서드이다
이 또한 static 메서드로 제공되며, 이 메서드를 호출한 쓰레드가 RUNNABLE 상태로 들어가게 된다

다른 메서드들과의 차이점은 WAITING 상태로 들어가지 않고 바로 RUNNABLE 로 들어간다는 점이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyThread implements Runnable {
@Override
public void run() {
// 1000번 loop
for(long i = 0; i < 1000; i++) {
}
System.out.println("sub thread has been terminated");
}
}

class Main {
public void static main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();

Thread.yield();
System.out.println("main thread has been terminated");
}
}

원래라면 main thread 종료 문구 -> sub thread 종료 문구 의 순서로 출력되어야 하지만,
main thread 에서 yield 로 다른 쓰레드에게 실행을 양보함으로써 sub thread가 먼저 종료됨을 볼 수 있다

쓰레드의 실행을 양보하고 RUNNABLE 상태로 들어간 것이기 때문에,
sub thread의 작업 시간이 CPU 스케줄링에서 쓰레드에 할당하는 작업 시간보다 길 경우, main thread 종료 문구가 먼저 출력될 것이다
WAITING 상태로 들어가는 join 과는 다르다

suspend, resume, stop

이 3개의 메서드는 쓰레드를 교착상태(dead-lock)에 빠트릴 수 있어 deprecated 되었다
그렇다고 사용 못하는 것은 아니고, 아래와 같이 작성하여 구현할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class MyThread implements Runnable {
private boolean suspended = false;
private boolean stopped = false;

private Thread thread;

public MyThread(String name) {
thread = new Thread(this, name);
}

@Override
public void run() {
while(!stopped) {
if(!suspended) {
try {
//
} catch(InterruptedException e) {
System.out.println(name + " interrupted");
}
} else {
Thread.yield();
}
}
}

public void suspend() {
suspended = true;
thread.interrupt();
}

public void resume() {
suspended = false;
}

public void stop() {
stopped = true;
thread.interrupt();
}

public void start() {
thread.start();
}
}

stopped, suspended flag 값으로 기존의 suspend(), stop() 메서드를 구현하였다
게다가 suspend 상태일때는 Thread.yield() 를 발생시켜 불필요한 while 문을 돌지 않도록 하였으며,
stop()suspend() 시에 interrupt()를 발생시켜 thread가 WAITING 상태라면 깨워서 RUNNABLE 상태로 가도록 설정하였다
(interrupt() 시에 WAITING 상태가 아니라면 아무일도 일어나지 않는다)

쓰레드의 동기화

멀티 쓰레드 환경의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유하기 때문에, 서로의 작업에 영향을 줄 수 있다
자바의 경우, 힙 영역의 경우 JVM 프로세스가 공유하는 영역이고, 스택 영역의 경우 각 쓰레드마다 별개로 가지는 영역이다
그러므로 우리는 항상 힙 영역의 데이터를 사용할 경우 주의를 기울여야 한다
아래는 힙 영역의 데이터를 공유함으로써 문제가 발생하는 것에 대한 예시이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Account {
int amount;

public Account(int amount) {
this.amount = amount;
}

public void withdraw(int money) {
if(amount >= money) {
amount-=money;
}
}
}

class MyThread implements Runnable {
private Account account;

public MyThread(Account account) {
this.account = account;
}

@Override
public void run() {
account.withdraw(1000);
}
}

class Main {
public void static main(String[] args) {
Account account = new Account(1000);

Thread thread1 = new Thread(new MyThread(account));
Thread thread2 = new Thread(new MyThread(account));

thread1.start();
thread2.start();
}
}

잔고 1000원의 계좌를 생성하고, 1000원을 출금하는 쓰레드를 2개 생성하여 동시에 실행시켰다

결과로 항상 0원이 나올것이라 생각했지만, 돌려보면 -1000원이 나올수도 있다
(위 정도의 작업에서는 거의 무조건 0원이 나오므로, if 문 안에 Thread.sleep 을 조금 걸고 테스트해보면 확인할 수 있다)

이는 thread1과 thread2가 account 객체를 공유하기 때문에 발생하는 문제이다
예를 들면 thread1이 if문을 통과하고 아직 amount를 차감하지 않은 상태에서, thread2가 if문을 통과하는 경우가 될 것이다
이런 상황이 발생하면 결국 두 쓰레드 모두 amount 값을 차감하게 되고, 우리는 음수 결과값을 받아보게 되는 것이다

쓰레드 프로그램 작성시에 스택내의 변수만을 사용하여 위와 같은 상황을 안 만들면 되지 않느냐고 생각할 수 있지만,
로직을 처리하다 보면 결국에는 위와 같이 공용 변수에 접근하는 순간이 발생하므로, 위와 같은 현상은 피할 수 없다

그러므로 위 withdraw 같은 메서드들은 하나의 쓰레드에서 접근하고 있을 경우 다른 쓰레드에서 접근하지 못하도록 락을 걸어 데이터가 틀어지는 것을 방지해야 하는데, 이를 동기화라고 한다
자바에서는 snychronized 키워드를 사용해 이를 구현할 수 있고, 방법은 아래와 같이 메서드에 synchronized 를 선언하는 방법과, synchronized block을 선언하는 방법이 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* synchronized method
**/
public synchronized void withdraw(int money) {
if(amount >= money) {
amount-=money;
}
}

/**
* synchronized block
**/
public void withdraw(int money) {
synchronized(this) {
if(amount >= money) {
amount-=money;
}
}
}

그리고 이런식의 동기화 블록을 사용해서 프로그램을 작성시에는 교착상태(dead-lock)을 항상 주의해줘야 한다
만약 thread가 락을 건 상태에서 정지되거나 종료된다면 이를 기다리는 다른 쓰레드들이 전부 데드락에 빠지기 떄문이다
이같은 이유로 suspend(), stop() 메서드등이 deprecated 되었다

wait, notify

한 쓰레드가 객체에 lock을 걸 경우, 이 객체를 사용하려는 다른 쓰레드들은 lock 이 풀릴떄까지 무작정 같이 기다려야하는 비효율이 발생하게 된다
이 같은 상황을 방지하기 위해 나온 메서드가 wait()notify()이다

wait()notify(), notifyAll()은 Object 클래스에 정의된 메서드이므로 모든 객체에서 호출이 가능하며, synchronized 블록 내에서만 사용할 수 있다
wait()이 호출되면 쓰레드는 이때까지 자기가 걸고있던 모든 락을 다 풀고, WAITING 상태로 전환되면서 wait()이 호출된 객체의 대기실에서 대기하게 된다
그러다가 다른 쓰레드에 의해 해당 객체의 notify()notifyAll()이 호출되면, 객체의 대기실에서 나와 다시 RUNNABLE 상태로 들어가게 된다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Account {
int amount;

public Account(int amount) {
this.amount = amount;
}

public synchronized void withdraw(int money) {
while(amount < money) { // 1
try {
wait(); // 2
} catch(InterruptedException e) { }
}

amount -= money;
}

public synchronized void deposit(int money) {
balance += money;
notifyAll(); // 3
}
}
  1. 전달받은 금액을 출금하기 위해 메서드에 락을 걸고 들어왔으나, 가지고 있는 잔고보다 출금하려는 금액이 더 많은 상황이다
  2. 그러므로 출금하지 못하고, Account 객체의 wait()를 호출한다
  • withdraw 에 걸고 있는 락이 풀리게 된다
  • 쓰레드는 WAITING 상태로 들어가게 된다
  • 쓰레드는 Account 객체 인스턴스의 대기실(waiting room)에서 기다리게 된다
  1. deposit 메서드가 호출되며 금액이 채워지고, notifyAll()을 호출한다
  • Account 객체 인스턴스의 대기실에 있는 모든 쓰레드들을 깨운다
  • 깨어난 쓰레드는 2번의 위치에서 다시 흐름을 이어가게 되는데, while 문이므로 다시 출금 가능한지 검사하게 된다

    if문 대신 while문을 사용한 이유이다

nofiy() 대신 notifyAll() 을 사용하나 사실 별 차이는 없지만,
notify()를 사용해서 한개의 쓰레드만 꺠울 경우 어떤 쓰레드가 꺠워질지 알수 없고,
이로인해 우선순위가 높은 쓰레드가 계속 pool에 머물게 될 수 도 있으므로, notifyAll()을 통해 모든 쓰레드를 꺠워서 스케줄러에 의해 처리되도록 해주는 것이 좋다

참고 :