[TIL] 프로세스와 쓰레드
프로세스와 쓰레드
- 프로세스 : 운영체제로부터 자원을 할당받는 작업의 단위, 실행 중인 프로그램을 의미
- 쓰레드 : 프로세스가 할당받은 자원을 이용하는 실행의 단위, 프로세스내에서 일하는 일꾼(코드실행의 흐름)
OS가 프로그램 실행을 위한 프로세스를 할당해줄때 프로세스안에 프로그램 Code와 Data 그리고 메모리 영역(Stack, Heap)을 함께 할당해준다.
프로세스가 작업중인 프로그램에서 실행요청이 들어오면 쓰레드(일꾼)을 만들어 명령을 처리하도록 한다.
프로세스 안에는 여러 쓰레드(일꾼)들이 있고, 쓰레드들은 실행을 위한 프로세스 내 주소공간이나 메모리공간(Heap)을 공유받는다. 추가로, 쓰레드(일꾼)들은 각각 명령처리를 위한 자신만의 메모리공간(Stack)도 할당받는다.
Java는 이와 같이 JVM 프로세스 안에서 실행할 수 있는 쓰레드를 할당받는다.
싱글 쓰레드
프로세스 안에서 하나의 쓰레드만 실행되는 것
Java 프로그램의 경우 `main()` 메서드만 실행시켰을때 이것을 싱글 쓰레드라고 한다.
- Java 프로그램 main() 메서드의 쓰레드를 ‘메인 쓰레드’ 라고 부른다.
- JVM 의 메인 쓰레드가 종료되면, JVM 도 같이 종료된다.
멀티 쓰레드
프로세스 안에서 여러개의 쓰레드가 실행되는 것
하나의 프로세스는 여러개의 실행단위(쓰레드)를 가질 수 있으며 이 쓰레드들은 프로세스의 자원을 공유한다.
- 멀티 쓰레드 장점
- 여러개의 쓰레드(실행 흐름)을 통해 여러개의 작업을 동시에 할 수 있어서 성능이 좋아진다.
- 스택을 제외한 모든 영역에서 메모리를 공유하기 때문에 자원을 보다 효율적으로 사용할 수 있다.
- 응답 쓰레드와 작업 쓰레드를 분리하여 빠르게 응답을 줄 수 있다. (비동기)
- 멀티 쓰레드 단점
- 동기화 문제가 발생할 수 있다.
- 프로세스의 자원을 공유 하면서 작업을 처리하기 때문에 자원을 서로 사용하려고 하는 충돌이 발생하는 경우를 의미한다.
- 교착 상태(데드락)이 발생할 수 있다.
- 둘 이상의 쓰레드가 서로의 자원을 원하는 상태가 되었을 때 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태를 의미한다.
- 동기화 문제가 발생할 수 있다.
Thread
Java에서 제공하는 Thread 클래스를 상속받아 쓰레드를 구현한다.
public class TestThread extends Thread {
@Override
public void run() {
// 쓰레드 수행작업
}
}
...
TestThread thread = new TestThread(); // 쓰레드 생성
thread.start() // 쓰레드 실행
`run()` 메서드를 통해서 쓰레드가 수행할 작업을 작성하고, `start()` 메서드로 쓰레드를 실행한다.
Runnable
Java에서 제공하는 Runnable 인터페이스를 사용하여 쓰레드를 구현해준다.
public class TestRunnable implements Runnable {
@Override
public void run() {
// 쓰레드 수행작업
}
}
...
Runnable run = new TestRunnable();
Thread thread = new Thread(run); // 쓰레드 생성
thread.start(); // 쓰레드 실행
이것도 Thread와 똑같이 사용하면 된다.
그렇다면 Thread를 직접 상속받아 사용하는 방법이 더 간단해 보이는데 왜? Runnable을 사용하여 쓰레드를 구현하는 방법이 있을까?
바로 클래스와 인터페이스 차이 때문이다. Java는 다중 상속을 지원하지 않는다.
그렇기 때문에 Thread를 상속 받아 처리하는 방법은 확장성이 매우 떨어진다.
반대로 Runnable은 인터페이스이기 때문에 다른 필요한 클래스를 상속 받을 수 있기 때문에 확장성에 유리하다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
int sum = 0;
for (int i = 0; i < 50; i++) {
sum += i;
System.out.println(sum);
}
System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
};
Thread thread1 = new Thread(task);
thread1.setName("thread1");
Thread thread2 = new Thread(task);
thread2.setName("thread2");
thread1.start();
thread2.start();
}
}
람다식 또는 익명함수를 사용하여 쓰레드를 구현할 수 있다.
데몬 쓰레드와 사용자 쓰레드
데몬 쓰레드
보이지 않는곳(background) 에서 실행되는 낮은 우선순위를 가진 쓰레드
- 보조적인 역할을 담당하며 대표적인 데몬 쓰레드로는 메모리 영역을 정리해주는 가비지 컬렉터(GC)가 있다.
public class Main {
public static void main(String[] args) {
Runnable demon = () -> {
for (int i = 0; i < 1000000; i++) {
System.out.println("demon");
}
};
Thread thread = new Thread(demon);
thread.setDaemon(true); // true로 설정시 데몬스레드로 실행됨
thread.start();
for (int i = 0; i < 100; i++) {
System.out.println("task");
}
}
}
`setDaemon()` 메서드를 true로 설정하면 데몬 쓰레드로 실행된다.
우선 순위가 가장 낮기 때문에 `task`가 100번 반복되고 나면 프로그램이 종료된다.
사용자 쓰레드
보이는 곳(foreground) 에서 실행되는 높은 우선순위를 가진 쓰레드
- 프로그램 기능을 담당하며 대표적인 사용자 쓰레드로는 메인 쓰레드
- 사용자 쓰레드 만드는법 : 기존에 만들었던 쓰레드들이 다 사용자 쓰레드
JVM은 사용자 쓰레드의 작업이 끝나면 데몬 쓰레드도 자동으로 종료시켜 버립니다.
쓰레드의 우선 순위
쓰레드 작업의 중요도에 따라서 쓰레드의 우선순위를 부여할 수 있다.
- 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있다.
쓰레드는 생성될때 우선순위가 정해진다.
- 이 우선순위는 우리가 직접 지정하거나 JVM에 의해 지정될 수 있다.
우선순위는 아래와 같이 3가지 (최대/최소/보통) 우선순위로 나뉜다.
- 최대 우선순위 (MAX_PRIORITY) = 10
- 최소 우선순위 (MIN_PRIORITY) = 1
- 보통 우선순위 (NROM_PRIORITY) = 5
- 기본 값이 보통 우선순위입니다.
더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능한다.
이 우선순위의 범위는 OS가 아니라 JVM에서 설정한 우선순위이다. 쓰레드 우선순위는 `setPriority()` 메서드로 설정
우선순위가 높다고 반드시 쓰레드가 먼저 종료되는 것은 아님!
확률이 높은거지 반드시 먼저 종료가 되는 것은 아니기 때문에 여러번 실습해 보시면 좋다.
쓰레드는 실행과 대기를 반복하며 `run()` 메서드를 수행
`run()` 메서드가 종료되면 실행이 멈춤
- 음악을 듣다 일시정지를 하는 것과 마찬가지로 쓰레드도 일시정지 상태를 만들 수 있다. (2)
- 일시정지 상태에서는 쓰레드가 실행을 할 수 없는 상태가 된다.
- 쓰레드가 다시 실행 상태로 넘어가기 위해서는 우선 일시정지 상태에서 실행대기 상태로 넘어가야 한다. (3)
상태 | Enum | 설명 |
객체생성 | NEW | 쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태 |
실행대기 | RUNNABLE | 실행 상태로 언제든지 갈 수 있는 상태 |
일시정지 | WAITING | 다른 쓰레드가 통지(notify)할 때까지 기다리는 상태 |
일시정지 | TIMED_WAITING | 주어진 시간 동안 기다리는 상태 |
일시정지 | BLOCKED | 사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태 |
종료 | TERMINATED | 쓰레드의 작업이 종료된 상태 |
쓰레드 제어
sleep()
현재 쓰레드를 지정된 시간동안 멈추게 한다.
- sleep()은 쓰레드 자기자신에 대해서만 멈추게 할 수 있다. (특정 쓰레드를 지목해서 멈추게 하는 것은 불가능)
try {
Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
e.printStackTrace();
}
- 예외처리를 해야한다.
- sleep 상태에 있는 동안 interrupt() 를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있다.
interrept()
일시정지 상태인 쓰레드를 실행대기 상태로 만든다.
`sleep()` 실행 중 `interrupt()`가 실행되면 예외가 발생합니다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("task : " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "Thread");
thread.start();
thread.interrupt();
System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
}
}
join()
정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다린다.
- 시간을 지정하지 않았을 때는 지정한 쓰레드의 작업이 끝날 때까지 기다린다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
Thread.sleep(5000); // 5초
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(task, "thread");
thread.start();
long start = System.currentTimeMillis();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// thread 의 소요시간인 5000ms 동안 main 쓰레드가 기다렸기 때문에 5000이상이 출력됩니다.
System.out.println("소요시간 = " + (System.currentTimeMillis() - start));
}
}
시간을 정하지 않았기 떄문에 thread가 작업이 끝날 때까지 main 쓰레드는 기다리기 떄문에 5초 이상이 출력되게 된다.
yield()
남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행대기 상태가 된다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
}
} catch (InterruptedException e) {
Thread.yield();
}
};
Thread thread1 = new Thread(task, "thread1");
Thread thread2 = new Thread(task, "thread2");
thread1.start();
thread2.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread1.interrupt();
}
}
thread1과 thread2가 같이 1초에 한번씩 출력되다가 5초뒤에 thread1에서 InterruptedException이 발생하면서 `Thread.yield();` 이 실행되어 thread1은 실행대기 상태로 변경되면서 남은 시간은 thread2에게 리소스가 양보된다.
synchronized
멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있습니다. 이로인해서 장애나 버그가 발생할 수 있다.
- 이러한 일을 방지하기 위해 한 쓰레드가 진행중인 작업을 다른 쓰레드가 침범하지 못하도록 막는 것을 '쓰레드 동기화(Synchronization)'라고 한다.
- 동기화를 하려면 다른 쓰레드의 침범을 막아야하는 코드들을 ‘임계영역’으로 설정하면 된다.
- 임계영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능하다.
- 즉, 임계영역은 한번에 한 쓰레드만 사용이 가능하다.
wait(), notify()
침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.
- 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행 할 수 있게 되고,
- 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
- 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.
wait()
- 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
notify()
- 해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.
public class Main {
public static String[] itemList = {
"MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
};
public static AppleStore appleStore = new AppleStore();
public static final int MAX_ITEM = 5;
public static void main(String[] args) {
// 가게 점원
Runnable StoreClerk = () -> {
while (true) {
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.restock(itemList[randomItem]);
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
}
};
// 고객
Runnable Customer = () -> {
while (true) {
try {
Thread.sleep(77);
} catch (InterruptedException ignored) {
}
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.sale(itemList[randomItem]);
System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
}
};
new Thread(StoreClerk, "StoreClerk").start();
new Thread(Customer, "Customer1").start();
new Thread(Customer, "Customer2").start();
}
}
class AppleStore {
private List<String> inventory = new ArrayList<>();
public void restock(String item) {
synchronized (this) {
while (inventory.size() >= Main.MAX_ITEM) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 재입고
inventory.add(item);
notify(); // 재입고 되었음을 고객에게 알려주기
System.out.println("Inventory 현황: " + inventory.toString());
}
}
public synchronized void sale(String itemName) {
while (inventory.size() == 0) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 없기 때문에 고객 대기중
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
while (true) {
// 고객이 주문한 제품이 있는지 확인
for (int i = 0; i < inventory.size(); i++) {
if (itemName.equals(inventory.get(i))) {
inventory.remove(itemName);
notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
return; // 메서드 종료
}
}
// 고객이 찾는 제품이 없을 경우
try {
System.out.println(Thread.currentThread().getName() + " Waiting!");
wait();
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}