멀티쓰레드 프로그래밍
목표
- 자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.
학습할 것
- Thread 클래스와 Runnable 인터페이스
- 쓰레드의 상태
- 쓰레드의 우선순위
- Main 쓰레드
- 동기화
- 데드락
Thread 클래스와 Runnable 인터페이스
쓰레드를 구현하는 방법은 Thread 클래스를 상속하거나 Runnable 인터페이스를 구현하는 방법이 있습니다. Thread 클래스를 상속하게 되면 다른 클래스를 상속할 수 없게 되므로 일반적으로 Runnable 인터페이스를 구현해서 쓰레드를 구현합니다.
- Thread 클래스를 상속
class MyThread extends Thread { public void run() {} // Thread 클래스의 run()을 오버라이딩 }
- Runnable 인터페이스를 구현
class MyThread implements Runnable { public void run() {} // Runnable 인터페이스의 run()을 구현 }
Runnable 인터페이스를 구현하기 위해 run 메소드를 채워넣으면 됩니다.
실제 호출 예제
public class Main {
public static void main(String[] args) {
ThreadEx1 t1 = new ThreadEx1();
Runnable r = new ThredEx2();
Thread t2 = new Thread(r);
t1.start();
t2.start();
}
}
class ThreadEx1 extends Thread {
public void run() {
for(int i=0; i<5; i++) {
System.out.println(getName());
}
}
}
class ThredEx2 implements Runnable {
public void run() {
for(int i=0; i<5; i++) {
System.out.println(Thread.currentThread().getName());
}
}
}
쓰레드를 실행시키려면 start()를 호출해야만 쓰레드가 실행됩니다.
- start() 호출
- 실행대기 상태로 전환
- 실행대기 중인 쓰레드가 없으면 실행상태로 전환
한 가지 유의점으로, 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없습니다. 다시 실행시키기 위해서는 새로운 쓰레드를 생성 후 start()을 호출해야 합니다. 한 쓰레드에서 start()를 두 번 호출시에는 IllegalThreadStateException이 발생합니다.
쓰레드의 상태
쓰레드의 상태
- NEW
- 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
- RUNNABLE
- 실행 중 또는 실행 가능한 상태
- BLOCKED
- 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
- WAITING, TIMED_WAITING
- 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은(unrunnable) 일시정지 상태. TIMED_WAITING은 일시정지시간이 지정된 경우를 의미한다.
- TERMINATED
- 쓰레드의 작업이 종료된 상태
참고 - 쓰레드의 상태는 Thread의 getState()메서드를 호출하여 확인할 수 있습니다.
쓰레드의 스케줄링 관련 메서드
- sleep
- static void sleep(long millis)
- static void sleep(long millis, int nanos)
- 지정된 시간(1/1000초) 동안 쓰레드를 일시정지시킵니다.
- 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 됩니다.
- join
- void join()
- void join(long millis)
- void join(long millis, int namos)
- 지정된 시간동안 쓰레드가 실행되도록 합니다.
- 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속합니다.
- void interrupt()
- sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만듭니다.
- 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 됩니다.
- void stop()
- 쓰레드를 즉시 종료시킵니다.
- void suspend()
- 쓰레드를 일시정지시킵니다. resume()을 호출하면 다시 실행대기상태가 됩니다.
- void resume()
- suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만듭니다.
- static void yield()
- 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 됩니다.
쓰레드의 우선순위
쓰레드는 우선순위(priority)라는 속성(멤버변수)를 가지고 있습니다. 이 우선순위를 어떻게 주느냐에 따라 쓰레드의 우선순위를 정할 수 있습니다.
쓰레드 우선순위관련 메서드와 상수는 아래와 같습니다.
void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPriority() // 쓰레드의 우선순위를 반환한다.
public static final int MAX_PRIORITY = 10 // 최대우선순위
public static final int MIN_PRIORITY = 1 // 최소우선순위
public static final int NORM_PRIORITY = 5 // 보통우선순위
한 가지 유의점은, 쓰레드의 우선순위는 생성시키는 쓰레드의 우선순위를 상속받는 다는 점 입니다. main 메서드의 우선순위는 5이므로, 생성되는 default 쓰레드의 우선순위 또한 5입니다.
Main 쓰레드
Main 쓰레드를 이해하기 위해서는 쓰레드가 어떤 식으로 동작하는지 이해해야 합니다.
- 최초 Main 메서드에서 start()를 실행합니다.
- start()는 쓰레드 실행에 필요한 호출 스택(call stack)을 생성합니다.
- 호출 스택에서 run()을 호출하고 첫 번째로 run()이 올라가게 됩니다.
- 이제 Main 쓰레드와 새로 만들어진 호출 스택, 총 2개의 호출 스택이 있으므로 스케줄러가 정한 순서에 의해 번갈아 가며 실행됩니다.
프로그램 종료 시점
한 가지 유의할 점은 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다는 것입니다.
이 말은 Main 쓰레드가 종료되었더라도 호출 스택에 다른 쓰레드가 남아있다면 프로그램은 아직 종료되지 않는 다는 점입니다.
public class Main {
public static void main(String[] args) {
Thread t3 = new Thread(new ThreadEx3());
t3.start();
}
}
class ThreadEx3 implements Runnable {
@Override
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
} catch(Exception e) {
e.printStackTrace();
}
}
}
// output
java.lang.Exception
at ThreadEx3.throwException(Main.java:33)
at ThreadEx3.run(Main.java:29)
at java.base/java.lang.Thread.run(Thread.java:829)
예제에서 보듯이 첫 번째 호출 스택이 run임을 알 수 있습니다. 우리는 Main메서드가 정상적으로 종료되었다는 사실을 알 수 있습니다.
start메서드와 run메서드
start()를 run()으로 바꾸어 실행해 보겠습니다.
public class Main {
public static void main(String[] args) {
Thread t3 = new Thread(new ThreadEx3());
t3.run();
}
}
// output
java.lang.Exception
at ThreadEx3.throwException(Main.java:33)
at ThreadEx3.run(Main.java:29)
at java.base/java.lang.Thread.run(Thread.java:829)
at Main.main(Main.java:6)
run()을 실행시, 새로운 쓰레드가 생성되지 않았기 때문에, Main메서드를 exception에서 볼 수 있습니다. Main 메서드의 호출 스택에서 실행되었음을 알 수 있습니다.
동기화
멀티 쓰레드 환경에서 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 됩니다.
예를 들면 쓰레드1이 작업하던 도중 다른 쓰레드2에게 제어권을 넘어갔을 때, 쓰레드1이 작업하던 공유 데이터를 쓰레드2가 임의로 변경하였다면, 다시 쓰레드1이 제어권을 받아서 나머지 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수 있습니다.
이를 방지하기 위해 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드의 의해 방해받지 않도록 해야 합니다. 이를 위해 ‘임계 영역(critical section)’과 ‘잠금(락, lock)’ 개념이 도입되었습니다.
어떤식으로 사용되는지 순서를 살펴보면
- 공유 데이터를 사용하는 코드영역을 임계 영역으로 지정합니다.
- 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 합니다.
- 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 됩니다.
이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(Synchronization) 이라고 합니다.
synchronized를 이용한 동기화
synchronized 키워드를 이용하여 동기화를 할 수 있습니다.
- synchronized 블럭 장점 & 단점
- 장점
- 자동적으로 lock이 잠기고 풀려 편리
- synchronized 블럭 내에서 예외 발생해도 lock이 자동적으로 풀림
- 단점
- 같은 메서드 내에서만 lock을 걸 수 있음
- 장점
두 가지 방식이 있습니다.
- 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum() { // 임계 영역(critical section) }
- 특정한 영역을 임계 영역으로 지정
synchronized (객체의 참조변수) { // 임계 영역(critical section) }
첫 번째 방법은 메서드 앞에 synchronized를 붙여 메서드 전체를 임계 영역으로 설정합니다. 쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환합니다.
두 번째 방법은 메서드 내의 코드 일부를 블럭{}
으로 감싸고 블럭 앞에 ‘synchronized(참조변수)’를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 합니다.
이 블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납합니다.
- 두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리가 해야 할 일은 임계 영역만 설정해주는 것뿐입니다.
- 모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 Lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있습니다.
- 다른 쓰레드들은 lock을 얻을 때까지 기다리게 됩니다.
임계 영역은 멀티쓰레드 프로그래밍의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해서 효율적인 프로그램이 되도록 해야합니다.
class Example {
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 void withdraw(int money) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
balance -= money;
}
}
}
class RunnableEx21 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("balance: " + acc.getBalance());
}
}
}
위 예제는 account에서 balance를 확인하고 임의 금액을 withdraw하는 예제인데 실제 실행결과(잔금)로 음수가 나올 수 있습니다.
(if (balance >= money)
잔고가 출금 금액보다 커야함에도 불구하고!)
balance: 600
balance: 400
balance: 300
balance: 0
balance: -100
- 한 쓰레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 다른 쓰레드가 끼어들어서 출금을 먼저 했기 때문입니다.
그렇기 때문에 잔고를 확인하는 if문과 출금하는 문장은 하나의 임계 영역으로 묶여져야 합니다.
동기화 방법은 아래처럼 간단히 구현할 수 있습니다.
public synchronized void withdraw(int money) {
if (balance >= money) {
try { Tread.sleep(1000); }
catch (Exception e) {}
balance -= money
}
}
한 쓰레드에 의해서 먼저 withdraw()가 호출되면, 이 메서드가 종료되어 lock이 반납될 때까지 다른 쓰레드는 withdraw()를 호출하더라도 대기상태에 머물게 됩니다.
- 메서드 앞에 synchronized를 붙이는 대신, synchronized 블럭을 사용할 수도 있습니다.
public void withdraw(int money) { synchronized(this) { if (balance >= money) { try { Tread.sleep(1000); } catch (Exception e) {} balance -= money } } }
한 가지 유의사항은
private int balance = 1000;
, Account클래스의 인스턴스 변수인 balanced의 접근제어자가 private이라는 점입니다. private이 아니라면, 외부에서 직접 접근이 가능하기 때문에 아무리 동기화를 해도 이 값의 변경을 막을 수가 없습니다.
Lock과 Condition을 이용한 동기화
동기화할 수 있는 방법은 synchronized 블럭 외에도 ‘java.util.concurrent.locks’ 패키지가 제공하는 lock클래스들을 이용하는 방법이 있습니다.(JDK1.5부터)
- lock클래스의 종류
ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타 lock ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock StampedLock : ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
- ReentrantLock
- 가장 일반적인 lock입니다.
- ‘reentrant(재진입할 수 있는)’ 단어의 의미처럼 ‘wait()’, ‘notify()’처럼, 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있습니다.
- ReentrantReadWriteLock
- 읽기를 위한 lock과 쓰기를 위한 lock을 제공합니다. 읽기를 할 때는 읽기 lock을 걸고, 쓰기를 할 때는 쓰기 lock을 겁니다. (ReentrantLock은 배타적인 lock이라서 무조건 lock이 있어야만 임계영역의 코드를 수행할 수 있음)
- ReentrantReadWriteLock은 읽기 lock이 걸려 있으면 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있습니다.
- 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않습니다.
- 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않습니다. 반대로 쓰기 lock이 걸린 상황에서 읽기 lock을 거는 것도 허용되지 않습니다.
- StampedLock
- lock을 걸거나 해지할 때 ‘스탬프(long 타입의 정수값)’를 사용합니다.
- 읽기와 쓰기를 위한 lock외에 ‘낙관적 읽기 lock(optimistic reading lock)’이 추가됩니다.
- 읽기 lock이 걸려있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야 하지만 ‘낙관적 읽기 lock’은 쓰기 lock에 의해 바로 풀립니다.
- 낙관적 읽기에 실패하면, 읽기 lock을 얻어서 다시 읽어 와야 합니다. 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 겁니다.
StampedLock을 이용한 낙관적 읽기의 예를 보면 아래와 같습니다.
int getBalance() {
long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 lock을 건다.
int curBalance = this.balance; // 공유 데이터인 balance를 읽어온다.
if (!lock.validate(stamp)) { // 쓰기 lock에 의해 낙관적 읽기 lock이 풀렸는지 확인
stamp = lock.readLock(); // lock이 풀렸으면, 읽기 lock을 얻으려고 기다립니다.
try {
curBalance = this.balance; // 공유 데이터를 다시 읽어온다.
} finally {
lock.unlockRead(stamp); // 읽기 lock을 푼다.
}
}
return curBalance; // 낙관적 읽기 lock이 풀리지 않았으면 곧바로 읽어온 값을 반환
}
데드락 (교착상태)
두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태를 가리킨다.
교착 상태의 조건
- 상호배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다.
- 점유대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다.
- 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다.
- 순환대기(Circular wait) : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다.
이 조건 중에서 한 가지라도 만족하지 않으면 교착 상태는 발생하지 않는다. 이중 순환대기 조건은 점유대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위 4가지 조건은 서로 완전히 독립적인 것은 아니다.
출처
자바의 정석 https://ko.wikipedia.org/wiki/%EA%B5%90%EC%B0%A9_%EC%83%81%ED%83%9C