JAVA

싱글쓰레드와 멀티쓰레드

mukom 2022. 10. 10. 19:31

1. Process 와 Thread

 

프로세스와 쓰레드는 자주 혼용되는 단어이다.

 

프로세스는 '실행 중인 프로그램'을 말하고,

프로세스가 프로그램을 실행하는 데에 필요한 작업을 수행하는 것을 쓰레드라고 한다.

 

이런 상태를 프로그램이라고 하고, 이걸 실행시켜 작업 관리자에서 확인할 수 있는 상태가 바로 프로세스이다.

모든 프로세스에는 작업을 수행하는 쓰레드가 있고, 이는 최소 하나 이상의 쓰레드를 가질 수 있다.

즉 두 개 이상의 쓰레드를 가진 상태를 '멀티 쓰레드(Multi-Threaded)' 라고 하는 것이고,

이 프로세스를 '멀티 쓰레드 프로세스'라고 한다.

 

하나의 프로세스가 가질 수 있는 쓰레드의 개수는 명시적으로 제한되어 있지 않지만,

하나의 쓰레드는 각각 개별적인 메모리 공간(호출 스택)을 필요로 하기 때문에

프로세스의 메모리 한계에 따라 생성 가능한 쓰레드의 수가 결정될 수 있다.

 

 

2. Multi-Tasking 과 Multi-Threading

 

현대의 많은 OS 는 대부분 멀티 태스킹(다중 작업)을 지원하고 있기 때문에

여러 개의 프로세스가 동시에 실행 가능하다.

 

멀티 쓰레딩은 앞서 설명했듯 하나의 프로세스에서 두 개 이상의 쓰레드가 작업하는 것을 말한다.

 

멀티 태스킹 👉 여러 개의 프로세스가 동시에 실행
멀티 쓰레딩 👉 여러 개의 쓰레드가 동시에 작업

 

하지만 CPU 의 코어(core) 는 한 번에 하나의 작업만 수행할 수 있기에

실제로 동시에 작업 가능한 개수는 코어의 개수와 동일하다.

 

그렇다면 이러한 환경에서 멀티 태스킹의 멀티 쓰레딩은 어떻게 작업하는 것일까?

 

바로 각 코어가 사람이 인지하기 힘든 아주 짧은 시간 동안 여러 작업(쓰레드)를 번갈아 작업함으로써,

우리가 느끼기에 동시에 작업 중인 것처럼 보이게 하는 것이다.

 

이것을 바로 context-switching(작업 전환) 이라고 하며 이는 후설하도록 한다.

 

 

3. 멀티 쓰레딩의 장단점

멀티 쓰레딩의 장점 👍
1️⃣ CPU의 사용률을 향상 시킨다.
2️⃣ 자원(데이터, 메모리 등)을 보다 효율적으로 사용할 수 있다.
3️⃣ 사용자에 대한 응답성이 향상된다.
4️⃣ 작업이 분리되어 코드가 간결해진다. 
멀티 쓰레딩의 단점 👎
1️⃣ 자원 공유로 인한 동기화(synchronization) 문제가 발생할 수 있다.
2️⃣ 자원 점유에 대한 반납이 제대로 이뤄지지 않으면 교착상태(deadlock)이 발생한다.

 

 

4. 쓰레드 구현과 실행

 

쓰레드를 구현하는 방법은 다음과 같이 두 가지가 있다.

1️⃣ Thread 클래스 상속
class ThreadTest extends Thread {
          // Thread 클래스의 run() 메서드 오버라이딩
          public void run() {   }
}
2️⃣ Runnable 인터페이스 구현
class ThreadTest implements Runnable {
          // Runnable 인터페이스의 run() 추상 메서드 구현 
          public void run() {   }
}

두 가지 방법 중 인터페이스를 구현하는 방법이 보다 일반적인 쓰레드 구현 방법이다.

인터페이스를 이용한 구현 방법은 코드의 재사용성이 높고 일관성을 유지할 수 있기 때문에 

객체지향적인 방법이라고 할 수 있기 때문이다.

 

하지만 두 방법 모두 run() 메서드를 쓰레드의 작업 내용에 따라 구현된다는 점에서는 공통점을 가진다.

 

그렇다면 호출 방식에서는 어떨까?

class ThreadTest_1 {
	public static void main(String args[]) {
    	
        // Thread 클래스 상속 받은 클래스 인스턴스
        ThreadClass t1 = new ThreadClass();
        
        // Runnable 인터페이스 구현한 클래스 인스턴스
        Runnable rn = new RunnableInterface();
        Thread t2 = new Thread(rn);
        
        // 위의 두 줄을 이렇게 한 줄로 표현 가능
        Thread t2 = new Thread(new RunnableInterface());
    }
}

예시 코드를 살펴보면,

 

Thread 클래스를 상속받은 클래스의 인스턴스를 생성하는 경우

그대로 해당 클래스 타입의 생성자를 호출하여 생성할 수 있다.

 

하지만 Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성하는 경우는 조금 다르다.

Runnable 인터페이스 타입으로 인스턴스를 생성하고 이렇게 생성된 인스턴스를,

Thread 클래스 타입의 생성자 호출의 매개변수로 넘기고 있다.

인스턴스 생성까지 해보았으니 이번에는 실행을 시켜보자.

class ThreadTest_1 {
	public static void main(String args[]) {
    	
        // Thread 클래스 상속 받은 클래스 인스턴스
        ThreadClass t1 = new ThreadClass();
        
        // Runnable 인터페이스 구현한 클래스 인스턴스
        Runnable rn = new RunnableInterface();
        Thread t2 = new Thread(rn);
        
        // 참조 변수 t1의 start() 메서드 호출
        t1.start();
        // 참조 변수 t2의 start() 메서드 호출
        t2.start();
    }
}

각각의 참조 변수를 start() 메서드를 통해 쓰레드가 실행되도록 하였다.

 

하지만 이 코드 만으로는 어떤 쓰레드가 언제 실행되었는지 확인할 수 없기 때문에 

쓰레드가 실행될 때 자신의 이름을 출력할 수 있도록 코드를 바꿔보자.

class ThreadTest_1 {
	public static void main(String args[]) {
    	
        // Thread 클래스 상속 받은 클래스 인스턴스
        ThreadClass t1 = new ThreadClass();
        
        // Runnable 인터페이스 구현한 클래스 인스턴스
        Runnable rn = new RunnableInterface();
        Thread t2 = new Thread(rn);
        
        // 참조 변수 t1의 start() 메서드 호출
        t1.start();
        // 참조 변수 t2의 start() 메서드 호출
        t2.start();
    }
}

class ThreadClass extends Thread {
	public void run() {
    	for(int i = 0; i < 10; i++) {
        	// Thread 클래스의 getName() 메서드 호출(인스턴스 메서드)
        	System.out.println(getName());
        }
    }
}

class RunnableInterface implements Runnable {
	public void run() {
    	for(int i = 0; i < 10; i++) {
        	// Thread 클래스의 return type 이 Thread 인
            // currentThread() 메서드 호출(클래스 메서드)
            // 반환값에 getName() 메서드 호출 
        	System.out.println(Thread.currentThread().getName());
        }
    }
}

 

📌 쓰레드의 이름 지정

쓰레드는 생성 시에 이름을 임의로 지정해 준다.

지정된 형식은 "Thread-번호" 이며, 원하는 이름을 지정하고 싶다면 다음과 같은 방법으로 할 수 있다.

1️⃣ Thread(Runnable target, String name)
2️⃣ Thread(String name)
3️⃣ void setName(String name)

 

 

5. run() 과 start() 메서드

 

run() 메서드와 start() 메서드는 어떤 차이가 있을까?

 

먼저 예시를 통해 알아보자.

 

첫번째 예시는 start() 메서드를 사용한 예시이다.

class ThreadTest2 {
	public static void main(String args[]) throws Exception{
    	
        ThreadClass t1 = new ThreadClass();
        t1.start();
    }
}

class ThreadClass extends Thread {
	public void run() {
    	throwException();
    }
    
    public void throwException() {
    	try {
        	// 예외 발생
        	throw new Exception();
        } catch (Exception e) {
        	e.printStrackTrace();
        }
    }
}

두 번째 예시는 run() 메서드를 사용한 예시이다.

class ThreadTest2 {
	public static void main(String args[]) throws Exception{
    	
        ThreadClass t1 = new ThreadClass();
        t1.run();
    }
}

class ThreadClass extends Thread {
	public void run() {
    	throwException();
    }
    
    public void throwException() {
    	try {
        	// 예외 발생
        	throw new Exception();
        } catch (Exception e) {
        	e.printStrackTrace();
        }
    }
}

이 두 예시 모두 임의로 예외를 발생시켜 호출 스택의 상태를 확인해 볼 수 있도록 하였다.

 

출력 화면의 가장 큰 차이점은 호출 스택의 첫 번째 메서드가 main 인지 run 인지 그 차이에서 온다.

 

run() 메서드를 실행시켰을 때에는 main 메서드가 보이지만 start() 메서드를 실행시켰을 때에는 보이지 않는다.

그 이유는 바로 start() 메서드와 run() 메서드의 기능적 차이에서 온다.

 

run() 메서드는 실행 시에 호출 스택에서는 기존의 main 쓰레드 안에 main 메서드를 호출에서 호출된다.

호출 스택
throwException()
run()
main()

이와 달리 start() 메서드는 실행 시 호출 스택에 자신의 쓰레드를 새로 만들고

새로 만들어진 쓰레드에 run() 을 실행시킨 후 본인은 main 쓰레드에서 사라진다.

호출 스택
start() throwException()
main() run()

Exception의 printStackTrace() 메서드의 출력 화면에서 위와 같이 나타났던 이유가 바로

이렇게 Exception이 실행된 쓰레드의 호출 스택만을 보여주기 때문이다.

 

그래서 start() 메서드 사용 시에는 main 쓰레드의 내용을 볼 수 없었던 것이고,

run() 메서드 사용 시에만 main() 메서드를 확인 가능했던 것이다.

 

📌 쓰레드의 종료

쓰레드에서 더는 실행할 메서드가 없는 경우 쓰레드 자체가 종료되는데,

위의 예시에서 start() 메서드를 호출하고 그 뒤 할 일이 없어진 main 쓰레드는 종료된다.

 

 

 

6. 싱글 쓰레드와 멀티 쓰레드

앞서 한 내용을 다시 정리해보자.

 

싱글 쓰레드는 하나의 프로세스가 하나의 쓰레드를 가진 상태를 말하는 것이고,

멀티 쓰레드는 하나의 프로세스가 두 개 이상의 쓰레드를 가진 상태를 말하는 것이다.

 

자 그럼 하나의 프로세스가 두 개의 작업을 쓰레드에게 할당하여 처리하도록 해보자.

 

여기서 작업하게 될 일은 하나는 '-' 출력 다른 하나는 '|' 출력을 하는 일이다.

 

이를 싱글 쓰레드로 구현하였을 때를 예상해보자.

쓰레드가 하나이기 때문에 작업에 대해서도 하나를 끝내고 다른 하나를 작업해야 한다.

그렇다면 '-'가 모두 출력되고 나서 '|' 가 출력될 것이다.

class SingleTreadTest {
	public static void main(String[] args) {
    	new SingleTreadClass("-").run();
        new SingleTreadClass("|").run();
    }
}

class SingleTreadClass extends Thread {
	
    public SingleTreadClass(String name) {
    	super(name);
    }
	
	@Override
	public void run() {
    	long start = System.currentTimeMillis();
        for(int i = 0 ; i < 5000 ; i++) {
        	System.out.print(getName());
        }
        System.out.println(getName() + " 의 소요시간 :: " + (System.currentTimeMillis() - start ) + "ms" );
    }
}

📌 console 출력 시 옆으로 넘어가지 않게 출력하는 방법

[word wrap] 버튼을 활성화 해주면 스크롤이 넘어가지 않는다.

 

 

다음은 멀티 쓰레드로 출력해보자.

앞서 설명했듯이 멀티 쓰레드는 여러 개의 쓰레드가 서로 번갈아 가면 작업을 수행하려고 하기 때문에

작업 전환(context-switching)이 발생할 것이다.

class MultiTreadTest {
	public static void main(String[] args) {
    	new MultiTreadClass("-").start();
        new MultiTreadClass("|").start();
    }
}

class MultiTreadClass extends Thread {
	
    public MultiTreadClass(String name) {
    	super(name);
    }
	
	@Override
	public void run() {
    	long start = System.currentTimeMillis();
        for(int i = 0 ; i < 5000 ; i++) {
        	System.out.print(getName());
        }
        System.out.println(getName() + " 의 소요시간 :: " + (System.currentTimeMillis() - start ) + "ms" );
    }
}

 

출력 결과를 살펴보자.

작업을 동시에 작업하는 멀티 쓰레드가 시간적으로 더 빠르게 끝낼 수 있을 것이라고 생각했지만,

싱글 쓰레드가 월등히 빠른 결과를 보여줬다.

 

어떻게 싱글 쓰레드가 멀티 쓰레드보다 빠를 수 있었을까?

바로 작업 전환에 그 비밀이 있다.

 

쓰레드는 여러 개가 되면 각자의 작업을 수행하기 위하여 실행에 대한 점유를 하기 위해

굉장히 빈번한 작업 전환이 이뤄졌음을 예제를 통해 확인할 수 있었다.

이러한 작업 전환은 기존에 어디까지 작업했음을 기억하는 PC(Program Counter) 의 정보를

읽어오는 시간까지 들여야 하기 때문에  싱글 쓰레드에 비하여 상대적인 시간 차이가 발생한 것이다.

 

따라서 예제와 같이 간단한 연산을 하는 경우에서는 싱글 쓰레드가 멀티 쓰레드에 비하여

성능면으로 우월하다는 것이다.

 

멀티 쓰레드가 성능면으로 싱글 쓰레드를 압도하는 경우는 멀티 태스킹을 요구하는 환경이 될 수 있다.

 

만일 싱글 쓰레드 환경에서 내가 채팅(작업 1)과 프린트(작업 2)를 수행하려고 해보자.

싱글 쓰레드는 혼자 작업하기 때문에 먼저 들어온 작업이 끝나기 전까지 그 다음 작업을 수행할 수 없다.

즉, 내가 채팅 프로세서를 종료하지 않으면 작업 1이 끝나지 않았다고 판단한 쓰레드가 

작업 2인 프린트를 작동시킬 수 없다는 것이다.

이는 그만큼 작업을 수행하고자 하는 대기 시간이 길어질 수 있다는 치명적인 단점이 생긴다.

 

하지만 멀티 쓰레드 환경에서는 이같은 문제가 쉽게 해결될 수 있다.

내가 채팅을 치다가 멈추면 쓰레드는 작업 2를 실행시키게 된다.

프린트를 하는 중에도 내가 채팅을 보내는 작업을 한다면 

다시 프린트를 멈추고 작업 1을 이어하게 된다.

이게 바로 효율적인 CPU 의 사용인 것이다.

각각의 작업은 싱글 쓰레드 때와 달리 작업 시간이 현저히 줄어들 수 있게 된다

🔷싱글 쓰레드 : '작업 1'이 끝나기 전까지 '작업 2'는 대기한다.
                           '작업 2'가 중간에 낄 수 없다.
                           연산이 어려운 작업일수록 오래 걸릴 수 있다.
🔷멀티 쓰레드 : '작업 1'의 수행 도중 '작업 2'가 번갈아 작업할 수 있다.
                           연산이 쉬운 작업일수록 오히려 오래 걸릴 수 있다.