프로세스란, 간단히 말해서 실행 중인 프로그램이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원을 할당받아 프로세스가 된다.
프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드다.
그래서 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스 라고 한다.
쓰레드가 작업을 수행하는데 개별적인 메모리공간(호출스택)을 필요로 하기 때문에, 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드 수가 결정된다.
우리가 사용하는 대부분의 OS는 멀티태스킹(다중작업)을 지원하기 때문에 여러개의 프로세스가 동시에 실행될 수 있다.
멀티 프로세싱 vs 멀티 태스킹 [출처]
멀티 프로세싱 (Multi Processing) 이란, 여러 개의 CPU 코어가 동시에 작업을 처리하는 것을 의미한다. 여러 개의 프로세서가 병렬로 작업을 수행하므로, 단일 프로세스보다 빠른 처리 속도를 보장할 수 있다.
멀티 태스킹 (Multi Tasking) 은 단일 CPU에서 여러 개의 작업을 동시에 처리하는 것을 의미한다. 하나의 CPU가 여러 작업들을 번갈아가며 처리하므로, 여러 개의 작업을 동시에 수행하는 것처럼 보이게 된다.
따라서, 둘은 여러 작업에 대해서 동시에 처리하는 목적은 비슷하지만, 멀티 태스킹은 하나의 CPU에서 여러 개의 작업을 처리하는 반면, 멀티 프로세싱은 여러 개의 CPU가 각각의 작업을 처리하는 것이라는 차이점이 있다.
둘은 서로 다른 동시 처리 방법이기 때문에 이 둘을 조합하여 더욱 더 시너지 효과를 누릴 수 있다.
멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다. CPU의 코어가 한번에 단 하나의 작업만 수행할 수 있으므로 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.
하지만, 쓰레드 수는 언제나 코어의 개수보다 많기 때문에, 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.
우리가 메신저로 채팅하면서 파일을 다운로드 받거나 음성대화를 할 수 있는 것이 가능한 이유가 바로 멀티쓰레드로 작성되어 있기 때문이다. 만약 싱글쓰레드로 작성되어있다면 파일을 다운받는 동안 다른 작업을 할 수 없을 것이다.
멀티쓰레딩에서 발생할 수 있는 동기화, 교착상태 와 같은 문제들을 고려해서 프로그래밍 해야한다.
쓰레드를 구현하는 방법은 두가지가 있다.
class MyThread extends Thread{
public void run() {
// Thread 클래스의 run() 오버라이딩
}
}
class MyThread implements Runnable {
public void run() {
//Runnable 인터페이스의 run() 구현
}
}
Runnable 인터페이스는 오로지 run()만 정의되어있는 간단한 인터페이스이다.
public interface Runnable {
public abstract void run();
}
그냥 저 run() 만 만들어주면 된다.
🤷♀️ 그렇다면 추상 메서드로 run() 밖에 존재하지 않는 Runnable은 왜 사용하는 것일까?
Thread를 바로 사용하려면 상속을 받아야 한다. 자바는 다중 상속을 허용하지 않기 때문에 Thread 클래스를 바로 상속받는 경우 다른 클래스를 상속받지 못한다.
하지만 Runnable 인터페이스를 구현한 경우에는 다른 인터페이스를 추가로 구현할 수 있을 뿐만 아니라, 다른 클래스도 상속받을 수 있다.
따라서 클래스의 확장성이 중요하다면 Runnable 인터페이스를 구현해 Thread에 주입해 주는것이 더 좋아 보인다.
Runnable 인터페이스를 구현하는 방법은 재사용성이 높고, 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법이라 할 수 있다.
이 두가지 경우의 인스턴스 생성 방법이 다른데,
MyThread t1 = new Thread();
Runnable r = new MyThread();
Thread t2 = new Thread(r);
//한줄로 표현
Thread t2 = new Thread(new MyThread());
Thread 클래스에서는 매개변수로 받은 Runnable 객체를 참조변수에 저장시켜서 사용한다. 이를통해 따로 run() 메서드를 구현하지 않고도 Runnable 객체를 통해서 외부에서 run() 메서드를 받아서 사용할 수 있다.
또한 현재 쓰레드의 이름을 받아오는 부분에도 차이가 있다.
Thread 를 상속받은 경우에는 자손클래스에서 조상인 Thread 클래스의 메서드인 getName() 메서드를 직접 호출해서 사용하면 현재 쓰레드의 이름을 구할 수 있다.
Runnable 인터페이스를 구현한 경우에는 Thread 클래스의 static 메서드인 current Thread() 를 호출하여 쓰레드에 대한 참조를 얻어와야한다.
static Thread currentThread() //현재 실행중인 쓰레드의 참조 반환
String getName() //쓰레드의 이름을 반환
Runnable 인터페이스를 구현한 경우엔 멤버가 run() 밖에 없기 때문에 Thread.currentThread().getName()
과 같이 해야한다.
class MyThread implements Runnable{
public void run(){
for(int i=0; i<5; i++){
Thread.currentThread().getName();
}
}
}
쓰레드의 이름은 생성자나 setName() 메서드를 사용해서 지정 또는 변경할 수 있다. 따로 쓰레드의 이름을 지정하지 않으면 Thread-번호 의 형식으로 이름이 지정된다.
쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다. start()를 호출해야 쓰레드가 실행된다.
t1.start();
또한, start()가 호출됐다고 해서 바로 실행되는 것이 아니라, 일단 실행대기 상태에 있다가 자신의 차레가 되어야 실행되는데, 실행대기중이 쓰레드가 없으면 곧바로 실행상태가 된다.
쓰레드의 start() 는 한번만 호출할 수 있다. 다시말해 한번 실행이 종료된 쓰레드는 다시 실행할 수 없다는 의미이다.
만약 두번 이상 start() 를 호출하는 경우에는 IllegalThreadStateException 이 발생하게 된다. 그렇기 때문에 동일한 작업을 여러번 수행하기 위해서는 매번 새로운 쓰레드를 생성하여 실행시켜야 한다.
main 메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것이다.
start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음 run을 호출해서 생성된 호출스택에 run()이 첫 번째로 올라가게 한다.
호출스택에서는 가장 위에 있는 메서드가 현재 실행중인 메서드이고 나머지 메서드들은 대기상태에 있다는 것을 기억하고 있을 것이다. 그러나 위의 그림에서와 같이 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있다는 것을 알 수 있다.
스케줄러는 시작되었지만 아직 종료되지 않은 쓰레드들의 우선순위를 고려하여 실행 순서와 실행시간을 결정하고, 각 쓰레들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다.
이때 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 자신의 차례가 돌아올 때까지 대기상태(Blocked) 에 있게 되며, 작업을 마친 쓰레드, 즉 run()의 수행이 종료된 쓰레드는 스택이 모두 비워지면서 이 쓰레드가 사용하는 스택은 사라진다(Dead).
📌 Thread의 실행 제어
쓰레드는 다음과 같이 5가지의 상태를 가지고 있다.
- NEW : 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
- RUNNABLE : 실행 중 또는 실행 가능 상태
- BLOCKED : 동기화 블록에 의해 일시정지된 상태(lock이 풀릴 때까지 기다림)
- WAITING, TIME_WAITING: 실행 가능하지 않은 일시정지 상태
- TERMINATED : 쓰레드 작업이 종료된 상태
main메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드라고 한다.
지금까지는 main 메서드가 수행을 마치면 프로그램이 종료되었으나, 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않는다.
실행중인 사용자 쓰레드가 하나도 없을때 프로그램은 종료된다.
class ThreadEx2 {
public static void main(String args[]) throws Exception {
ThreadEx2_1 t1 = new ThreadEx2_1();
t1.start();
}
}
class ThreadEx2_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
} catch(Exception e) {
e.printStackTrace();
}
}
}
실행결과
java.lang.Exception
at ThreadEx2_1.throwException(ThreadEx2.java:15)
at ThreadEx2_1.run(ThreadEx2.java:10)
start()를 통해 새로 생성한 쓰레드에서 고의로 예외를 발생시켰다.
호출스택의 첫번째 메서드가 main 메서드가 아니라 run메서드인 것을 확인하자.
한 쓰레드가 예외를 발생해서 종료되어도 다른 쓰레드의 실행에는 영향을 미치지 않는다. main쓰레드의 호출스택이 없는 이유는 main쓰레드가 이미 종료되었기 때문이다.
class ThreadEx3 {
public static void main(String args[]) throws Exception {
ThreadEx3_1 t1 = new ThreadEx3_1();
t1.run();
}
}
class ThreadEx3_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
} catch(Exception e) {
e.printStackTrace();
}
}
}
쓰레드를 새로 생성하지 않았다. run()을 호출했을 뿐이다. 아래그림에서 main메서드가 포함되어있을을 확인하자.
하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작하지만, 두 개의 쓰레드로 작업 하는 경우에는 짧은 시간동안 2개의 쓰레드가 겁나 빨리 번갈아가면서 작업을 수행해서 동시에 두작업이 처리되는 척 하는 거다. (이것을 동시성 이라고 한다)
이렇게 같은 자원을 사용해서 하나의 쓰레드로 두개의 작업을 수행한 시간과 두개의 쓰레드로 두개의 작업을 수행한 시간은 거의 같다. 오히려 두개의 쓰레드로 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 걸리게 되는데, 그 이유는 쓰레드간의 작업전환 context switching에 시간이 걸리기 때문이다.
(작업 전환을 할 때는 현재 진행 중인 작업의 상태 정보를 저장하고 읽어 오는 시간이 소요된다.)
💁♀️ 컨텍스트 스위칭에 대해서는 블로그 글을 적어 두었으니 참고 바람
그래서 싱글코어에서 단순히 CPU만을 사용하는 계산 작업이라면 멀티쓰레드보다 싱글쓰레드로 프로그래밍 하는 것이 효율적이다.
싱글 코어로 두 개의 쓰레드를 실행하는 경우 | 멀티 코어로 두 개의 쓰레드를 실행하는 경우 |
실행중인 프로그램은 OS 프로세스 영향을 받기 때문에 실행시마다 다른 결과를 얻을 수 있다. JVM의 쓰레드 스케줄러에 의해서 어떤 쓰레드가 얼마동안 실행될 것인지 결정되는 것과 같이 프로세스도 프로세스 스케줄러에 의해서 실행순서와 실행시간이 결정되기 때문에 매 순간 상황에 따라 프로세스에 해당되는 실행시간이 일정하지 않고 쓰레드에게 할당되는 시간 역시 일정하지 않게 된다. 그래서 쓰레드가 이러한 불확실성을 가지고 있다는 것을 염두에 두어야 한다.
자바가 OS 독립적이라고 하지만 실제로는 OS 종속적인 부분이 몇가지 있는데 쓰레드도 그 중 하나이다.
하지만 만약 두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드보다 멀티쓰레드가 더 효율적이다.
ex) 사용자로부터 데이터를 입력받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요로 하는 경우가 이에 해당한다.
싱글쓰레드: 만일 사용자로부터 입력받는 작업(A)과 화면에 출력하는 작업(B)을 하나의 쓰레드로 처리한다면 사용자가 입력을 마칠 때까지 아무 일도 하지 못하고 기다리기만 해야한다.
멀티쓰레드: 그러나 두 개의 쓰레드로 처리한다면 사용자의 입력을 기다리는 동안 다른 쓰레드가 작업을 처리할 수 있기 때문에 보다 효율적인 CPU사용이 가능하다.