[Java] 10주차 과제: 멀티쓰레드 프로그래밍
by Roel Downey
스터디 링크 : 링크
Thread 클래스와 Runnable 인터페이스
먼저 프로세스와 쓰레드에 대해 알아보자.
Process란?
- 실행 중인 프로그램
- 사용자가 작성한 프로그램이 운영체제에 의해 메모리 공간을 할당 받아 실행 중인 것을 뜻함. 이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원 그리고 쓰레드로 구성이 된다.
Threas란?
- 프로세스 내에 실제로 작업을 수행하는 주체를 의미한다.
- 모든 프로세서에는 1개 이상의 쓰레드가 존재하여 작업을 수행한다.
- 두 개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스 라고 한다.
- 경량 프로세스라도 불리며 가장 작은 실행 단위이다.
멀티 프로세스와 멀티 스레드의 비교
공통점 : 멀티 프로세스와 멀티 스레드는 양쪽 모두 여러 흐름이 동시에 진행된다
차이점 : 멀티 프로세스에서 각 프로세스는 독립적으로 실행되며, 각각 별개의 메모리를 차지
멀티 스레드는 프로세스 내의 메모리를 공유해 사용 할 수 있다.
프로세스 간의 전환 속도보다 스레드간의 전환 속도가 빠르다.
동시성(Concurrency) 과 병렬성(Parallelism)
멀티 쓰레드가 실행 될 때 이 두가지 중 하나로 실행된다.
이것은 cpu의 코어의 수와도 연관이 있는데, 하나의 코어에서 여러 쓰레드가 실행되는 것을 동시성,
멀티 코어를 사용할 때 각 코어별로 개별 쓰레드가 실행 되는것을 병렬성 이라고 한다.
만약 코어의 수가 쓰레드의 수보다 많다면, 병렬성으로 쓰레드를 실행하면 되는데
코어의 수보다 쓰레드의 수가 더 많을 경우 동시성을 고려하지 않을 수 없다.
동시성을 고려한다는 것은 하나의 코어에서 여러 쓰레드를 실행 할 때 병렬로 실행하는 것 처럼 보이지만
사실을 병렬로 처리하지 못하고 한 순간에는 하나의 쓰레드만 처리 할 수 있어서 번갈아 가면서 처리하게 되는데
그 번갈아 가면서 수행하는게 워낙 빠르기 때문에 각자 병렬로 실행 되는 것 처럼 보일 뿐이다.
쓰레드를 생성하는 방법은 크게 두가지 방법이 있다.
1. Runnable 인터페이스를 사용
2. Thread 클래스를 사용
- Thread 클래스는 Runnable 인터페이스를 구현한 클래스이므로 어떤것을 적용하느냐의 차이이다.
그럼 어떤걸 사용 해야할까?
Thread 클래스가 다른 클래스를 확장 할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 되고, 그렇지 않은 경우 Thread 클래스를 사용하는 것이 편하다.
public class ThreadCreation extends Thread{
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
ThreadCreation threadCreation = new ThreadCreation();
threadCreation.start();
}
}
public class ThreadCreation {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}).start();
}
}
쓰레드의 상태
Thread.State
상태 | 의미 |
New | 쓰레드 객체는 생성되었지만, 아직 시작되지 않은 상태 |
Runnable | 쓰레드가 실행중인 상태 |
Blocked | 쓰레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태 |
Waiting | 쓰레드가 대기중인 상태 |
Timed_Waiting | 특정 시간만큼 쓰레드가 대기중인 상태 |
Terminated | 쓰레드가 종료된 상태 |
Thread 상태 확인 메소드
리턴타입 | 메소드 이름 및 매개 변수 | 설명 |
void | checkAccess() | 현재 수행중인 쓰레드가 해당 쓰레들를 수정할 수 있는 권한이 있는지를 확인한다. 만약 권한이 없다면 SecurityException 예외 발생 |
boolean | isAlive() | 쓰레드가 살아있는지 확인한다. 해당 쓰레드의 run() 메소드가 종료되었는지 안 되었는지 확인 |
boolean | isInterrupted() | run() 메소드가 정상적으로 종료되지 않고, interrupt() 메소드의 호출을 통해서 종료되었는지를 확인하는데 사용 |
static boolean | interrupted() | 현재 쓰레드가 중지 되었는지 확인 |
Thread static 메소드 목록
리턴 타입 | 메소드 이름 및 매개 변수 | 설명 |
static int | activeCount() | 현재 쓰레드가 속한 쓰레드 그룹의 쓰레드 중 살아있는 쓰레드의 개수를 리턴 |
static Thread | currentThread() | 현재 수행중인 쓰레드의 객체를 리턴 |
static void | dumpStack() | 콘솔 창에 현재 쓰레드의 스택 정보를 출력 |
Main Thread
Java는 실행 환경인 JVM(Java Virtual Machine)에서 돌아간다. 이것이 하나의 프로세스이고 Java를 실행하기 위해 실행하는 main 메소드가 메인 쓰레드이다.
public class ThreadCreation {
public static void main(String[] args) {
}
}
public static void main(String[] args) { } 메인쓰레드이고 메인 쓰레드의 시작점을 선언하는 것이다.
다른 쓰레드를 실행하지 않고 main() 메소드만 실행하는것을 싱글 쓰레드 애플리케이션 이라고 한다.
public class ThreadCreation {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
Thread.sleep(10000L);
System.out.println(Thread.currentThread().getName());
}
}).start();
}
}
위의 코드를 실행하면 main 끝나고 10초 후에 Thread-0이 끝나게 된다.
그렇지만 이걸 데몬쓰레드로 넣는다면 main이 끝날때 같이 끝난다.
public class ThreadCreation {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
Thread thread = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
Thread.sleep(10000L);
System.out.println(Thread.currentThread().getName());
}
});
thread.setDaemon(true);
thread.start();
}
}
데몬 쓰레드는 부모쓰레드에 메인쓰레드 자기 파생시킨 메인쓰레드가 끝나면 같이 끝난다.
start()와 run()
쓰레드를 실행하기 위해서는 start 메서드를 통해 해당 쓰레드를 호출해야 한다. start 메서드는 쓰레드가 작업을 실행할 호출 스택을 만들고 그 안에 run 메서드를 올려주는 역할을 한다.
위 예제에서 start를 호출하지 않고 run을 호출하면, 새로운 호출 스택이 생성되지 않기 때문에, 그냥 한 메서드 안에서 코드를 실행하는 것과 같다.
thread1.run();
thread2.run();
실행결과
00000000000...0001111111111...111
한번 사용한 쓰레드는 재사용할 수 없다. start()를 호출해서 쓰레드를 한 번 실행했다면, 해당 쓰레드를 다시 실행하기 위해서는 쓰레드를 다시 생성해서 start()를 호출해야 한다. 생성은 한번하고 start를 두 번 호출하면 IllegalThreadStateException이 발생한다.
동기화(Synchronization)
synchronized
멀티 쓰레드 프로세스에서는 여러 프로세스가 메모리를 공유하기 때문에, 한 쓰레드가 작업하던 부분을 다른 쓰레드가 간섭하는 문제가 생길 수 있다. 어떤 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 하는 작업을 동기화라고 한다.
동기화를 하려면 다른 쓰레드가 간섭해서는 안 되는 부분을 임계 영역(critical section)으로 설정해 주어야 한다. 임계 영역 설정은 synchronized 키워드를 사용한다.
// 메서드 전체를 임계영역으로 설정
public synchronized void method1 () {
......
}
// 특정한 영역을 임계영역으로 설정
synchronized(객체의 참조변수) {
......
}
먼저 메서드의 반환 타입 앞에 synchronized 키워드를 붙여서 메서드 전체를 임계 영역으로 설정할 수 있다. 쓰레드는 synchronized 키워드가 붙은 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock(자물쇠)을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.
두 번째로 메서드 내의 코드 일부를 블록으로 감싸고 블록 앞에 synchronized(참조 변수)를 붙이는 방법이 있다. 이때 참조 변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다. 이 영역으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고 블록을 벗어나면 lock을 반납한다.
lock
lock은 일종의 자물쇠 개념이다. 모든 객체는 lock을 하나씩 가지고 있다. 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다. 한 객체의 lock은 하나밖에 없기 때문에 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.
임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 lock을 거는 것보다 synchronized 블록으로 임계 영역을 최소화하는 것이 좋다.
동기화하지 않아서 문제가 발생하는 경우
public class ThreadDemo {
public static void main(String[] args) {
Runnable r = new ThreadEx_1();
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 ThreadEx_1 implements Runnable {
Account account = new Account();
@Override
public void run() {
while (account.getBalance() > 0) {
// 100, 200, 300 중 임의의 값을 선택해서 출금
int money = (int) (Math.random() * 3 + 1) * 100;
account.withdraw(money);
System.out.println("balance: " + account.getBalance());
}
}
}
실행 결과
balance: 800
balance: 800
balance: 700
balance: 700
balance: 500
balance: 600
balance: -100
balance: -100
분명 잔고는 음수가 되지 않도록 설계했는데 음수가 나왔다. 왜냐하면 쓰레드 하나가 if문을 통과하면서 balance를 검사하고 순서를 넘겼는데, 그 사이에 다른 쓰레드가 출금을 실시해서 실제 balance가 if문을 통과할 때 검사했던 balance보다 적어지게 된다. 하지만 이미 if문을 통과했기 때문에 출금은 이루어지게 되고 음수가 나오는 것이다. 이 문제를 해결하려면 출금하는 로직에 동기화를 해서, 한 쓰레드가 출금 로직을 실행하고 있으면 다른 쓰레드가 출금 블록에 들어오지 못하도록 막아줘야 한다.
public class ThreadDemo {
public static void main(String[] args) {
Runnable r = new ThreadEx_1();
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 ThreadEx_1 implements Runnable {
Account account = new Account();
@Override
public void run() {
while (account.getBalance() > 0) {
// 100, 200, 300 중 임의의 값을 선택해서 출금
int money = (int) (Math.random() * 3 + 1) * 100;
account.withdraw(money);
System.out.println("balance: " + account.getBalance());
}
}
}
위 코드는 실행해도 음수가 나오지 않는다.
DeadLock(교착상태)
데드락은 한 자원을 여러 시스템이 사용하려고 할 때 발생할 수 있다.
데드락이 발생하는 경우
Process1과 Process2가 모두 자원 A, B가 필요한 상황이라고 가정하자.
Process1은 A에 먼저 접근하고 Process2는 B에 먼저 접근했다. Process1과 Process2는 각각 A와 B의 lock을 가지고 있는 상태이다. 이제 Process1은 B에 접근하기 위해 B의 락이 풀리기를 대기한다. 동시에 Process2는 A에 접근하기 위해 A의 락이 풀리기를 대기한다. 서로 원하는 리소스가 상대방에게 할당되어 있기 때문에 두 프로세스는 무한히 대기 상태에 있게 되는데, 이를 데드락이라고 한다.
데드락은 한 시스템 내에서 다음의 네 가지 조건이 동시에 성립할 때 발생한다. 아래 네 가지 조건 중 하나라도 성립하지 않도록 만든다면 교착 상태를 해결할 수 있다.
1) 상호 배제 (Mutual exclusion)
- 자원은 한 번에 한 프로세스만이 사용할 수 있어야 한다.
2) 점유 대기 (Hold and wait)
- 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있어야 한다.
3) 비선점 (No preemption)
- 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없어야 한다.
4) 순환 대기 (Circular wait)
- 프로세스의 집합 {P0, P1, ,…Pn}에서 P0는 P1이 점유한 자원을 대기하고 P1은 P2가 점유한 자원을 대기하고 P2…Pn-1은 Pn이 점유한 자원을 대기하며 Pn은 P0가 점유한 자원을 요구해야 한다.
(출처: https://jwprogramming.tistory.com/12 [개발자를 꿈꾸는 프로그래머])
wait() & notify()
동기화를 하게 되면 하나의 작업을 하나의 쓰레드밖에 하지 못하기 때문에 작업 효율이 떨어질 수밖에 없다. 이때 동기화의 효율을 높이기 위해서 wait(), notify()를 이용한다.
메서드 | 설명 |
void wait() void wait(long timeout) void wait(long timeout, int nanos) |
객체의 락을 풀고 쓰레드를 해당 객체의 waiting pool에 넣는다. |
void notify() | waiting pool에서 대기 중인 쓰레드 하나를 깨운다. |
void notifyall() | waiting pool에서 대기 중인 모든 쓰레드를 깨운다. |
wait과 notify는 Object 클래스에 정의되어 있으며, 동기화 블록 내에서만 사용할 수 있다.
동기화된 임계 코드 영역의 작업을 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어서 해당 객체에 대한 작업을 수행할 수 있게 된다.
나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 된다.
class Account {
private int balance = 1000;
public synchronized void withdraw(int money) {
//잔고가 출금액보다 적어 인출을 할 수 없다.
while (balance < money) {
try {
// 해당 객체의 락을 풀고 waiting pool에서 대기
wait();
} catch (InterruptedException e) {}
}
balance -= money;
}
public synchronized void deposit(int money) {
// 돈을 입금하고 waiting pool의 쓰레드에 통보
balance += money;
notify();
}
}
만약 잔고가 모자라서 출금을 할 수 없는 경우, 다른 쓰레드가 입금을 할 수 있도록 객체에 대한 락을 풀고 waiting pool에서 기다린다.
deposit을 수행하는 쓰레드는 해당 객체의 락을 얻어 잔고를 채우고 waiting pool에서 대기 중인 쓰레드에게 다시 작업을 수행하라고 통보한다.
대기하던 쓰레드는 다시 락을 얻어 인출 로직을 수행한다.
java.util.concurrent.locks
JDK 1.5부터 synchronized 외에 동기화를 구현할 수 있는 방법이 추가되었다. 'java.util.concurrent.locks' 패키지의 Lock 클래스들을 이용하는 방법이다. synchronized로 동기화를 하면 자동으로 락이 걸리고 풀리지만 같은 메서드 내에서만 lock을 걸 수 있다는 불편함이 있다. 그럴 때 lock 클래스를 이용한다. lock 클래스의 종류는 다음 세 가지가 있다.
ReentrantLock //재진입이 가능한 lock, 가장 일반적인 배타 lock
ReentrantReadWriteLock //읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock //ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
기본적인 메서드
void lock() //lock을 잠근다
void unlock() //lock을 해지한다
boolean isLocked() //lock이 잠겨있는지 확인한다
ReentrantLock은 가장 일반적인 락이다. '재진입할 수 있는'이라는 말이 붙는 이유는 wait & notify에서 살펴봤듯이 특정 조건에서 락을 풀었다가 나중에 다시 와서 락을 걸 수 있기 때문이다.
ReentrantReadWriteLock은 읽기를 위한 락(ReadLock)과 쓰기를 위한 락(WriteLock)을 제공한다(static class로 구현되어있음). ReentrantLock은 무조건 lock이 있어야만 임계 영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 락이 걸려 있으면, 다른 쓰레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다. 그러나 읽기 락이 걸린 상태에서 쓰기 락을 거는 것은 허용되지 않는다. 반대의 경우도 마찬가지이다.
StampedLock은 락을 걸거나 해지할 때 '스탬프(long 타입의 정수 값)'를 사용하며, ReentrantReadWriteLock에 '낙관적 읽기 락(optimistic reading lock)'이 추가된 형태이다. 읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하는데 비해 낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다. 코드로 살펴보면 다음과 같다.
StampedLock lock = new StampedLock();
......
int getBalance() {
long stamp = lock.tryOptimisticRead(); //낙관적 읽기 락을 건다.
int currentBalance = this.balance; //공유 데이터인 balance를 읽어온다.
if (!lock.validate(stamp)) { //쓰기 락에 의해 낙관적 읽기 락이 풀렸는지 확인
stamp = lock.readLock(); //락이 풀렸으면, 읽기 락을 얻으려고 기다린다.
try {
currentBalance = this.balance; //공유 데이터를 다시 읽어온다.
} finally {
lock.unlockRead(stamp); //읽기 락을 푼다.
}
}
return currentBalance; // 낙관적 읽기 락이 풀리지 않았으면 곧바로 읽어온 값을 반환
}
이렇게 무조건 읽기 락을 거는 게 아니라 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 락을 건다.
추가적으로, 임계 영역 내에서 리턴을 할 경우 unlock이 되지 않기 때문에 lock 클래스를 사용하는 경우 try-finally문을 이용해서 코드 수행이 끝나면 무조건 unlock을 하도록 만들어준다.
Lock 클래스들은 생성자에 boolean을 추가해서 공정(fair) 처리를 해줄 수 있다.
ReentrantLock()
ReentrantLock(boolean fair)
생성자의 매개변수를 true로 주면 lock이 풀렸을 때 가장 오래 기다린 쓰레드가 락을 획득할 수 있게 공정하게 처리한다. 그러나 이 과정에서 어떤 쓰레드가 오래 기다렸는지 확인하는 과정을 거쳐야하므로 성능은 떨어진다.
Condition
synchronized로 동기화를 구현한 후 wait & notify를 사용하면, 해당 객체의 waiting pool에서 대기 중인 임의의 쓰레드 혹은 쓰레드 전체를 깨우는 방법밖에 없다. 그러나 ReentrantLock으로 동기화를 구현하고 Condition을 이용하면 내가 원하는 쓰레드를 깨울 수 있다.
예를 들어, 음식을 만드는 요리사(COOK) 쓰레드와 음식을 소비하는 손님(CUST) 쓰레드가 있고, 두 쓰레드가 음식이 올라가는 테이블(Table)을 공유한다고 할 때, 테이블에 음식을 추가하는 코드를 다음과 같이 구현할 수 있다.
...
public synchronized void add(String dish) {
while(dishes.size() > MAX_FOOD) { // 테이블에 올라갈 수 있는 최대 음식 수
try {
wait(); //COOK 쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {}
}
dishes.add(dish);
notify(); // 기다리고 있는 CUSTOMER를 깨운다.
}
...
public static void main(String[] args) {
Table table = new Table();
new Thread(new Cook(table), "COOK1").start();
new Thread(new Customer(table), "CUST1").start();
new Thread(new Customer(table), "CUST2").start();
}
테이블 위에 음식이 최대 숫자로 올라가 있으면 더 이상 음식을 올릴 수 없으므로 wait을 호출해 COOK 쓰레드를 기다리게 한다. 또 테이블에 음식이 없으면 손님이 음식을 먹을 수 없으므로 CUST를 기다리게 한 후 음식을 채워 넣으면 CUST를 깨우도록 구현했다.
이렇게 하면 Table 객체의 waiting pool에는 요리사와 손님 모두 대기할 수 있기 때문에 notify 메서드 만으로는 내가 원하는 쓰레드(손님 or 요리사)를 선택해서 깨울 수 없다. 이때 ReentrantLock과 Condition을 이용하면 된다.
private ReentrantLock lock = new ReentrantLock(); //lock 생성
// 생성된 lock으로 condition 생성
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
...
public void add(String dish) {
lock.lock();
try {
while(dishes,size() > MAX_FOOD) {
try {
forCook.await(); // COOK 쓰레드를 기다리게한다.
} catch (InterruptedException e) {}
}
dishes.add(dish);
forCust.signal(); // 기다리고 있는 CUST를 깨운다.
} finally {
lock.unlock();
}
}
이렇게 lock으로부터 newCondition을 생성하고, wait()과 notify() 대신 await()와 signal()을 이용하면 원하는 쓰레드를 기다리게 하고 깨울 수 있다.
'Java' 카테고리의 다른 글
[Java] 12주차 과제: 애노테이션 (0) | 2021.02.16 |
---|---|
[Java] 11주차 과제: Enum (0) | 2021.02.04 |
[Java] 9주차 과제: 예외 처리 (0) | 2021.01.18 |
[Java] 8주차 과제: 인터페이스 (0) | 2021.01.05 |
[Java] 7주차 과제: 패키지 (0) | 2020.12.28 |
블로그의 정보
What doing?
Roel Downey