나만 몰랐던 '스레드'

cutiepazzipozzi·2023년 4월 1일
2

지식스택

목록 보기
15/35
post-thumbnail

함수형 프로그래밍을 공부하다가 병렬 프로그래밍의 장점을 가진다길래 정확히 무엇인지 찾아보다가 스레드를 여러개 활용해 여러 작업을 동시에 실행토록 한다고 하여 무엇인지 정확히 몰라서 정리해보는 스레드.

일단 스레드가 뭐죠?

A thread is a thread of execution in a program.
Oracle 공식 문서에 나온 말이다. 잉? 스레드는 프로그램에서 실행의 스레드(수명?)이다? 그래서 다음 줄을 읽어보자. JVM이 동시 다발적인 실행을 위해 많은 스레드를 가지도록 한단다.

그래서 나온 정의는, 프로세스 안에서 프로그램이 여러개 실행되도록 만들어주는 흐름의 단위이다!

(다른 해석) 프로세스 내에서 실제로 작업을 수행하는 주체

프로세스

= 실행중인 프로그램(독립적인!)
= 프로그램이 운영체제에 의해 메모리 공간을 할당받아 실행중인 것

아래의 장점과 비슷한 멀티스레드 사용 이유

  • 메모리 공간과 시스템 자원 소모가 줄어듦
  • 스레드 간의 통신에서 전역 변수 공간 or 힙 영역을 이용한 데이터 주고받기 가능
  • 시스템의 처리량 향상, 응답시간 단축

멀티스레드(1프로세스多스레드)일때의 장점

  1. 역할이 분할되어 코드가 간결해지며 응답성(사용자에게 주는)이 향상
  2. CPU 사용률 향상
    but 멀티스레드 프로그래밍을 할 때 교착 상태를 조심해야 함!!

스레드의 동기화

멀티스레드일 때 스레드가 다른 자원을 공유하기 때문에 서로에게 영향을 줄 수 있어 한 스레드가 진행중인 작업을 다른 스레드가 간섭하지 못하도록 하는 것동기화라고 한다.

하나의 예시로 들면 음식이라는 자원을 요리사와 고객이 공유하는데, 요리사가 음식을 만들어서 고객에게 제공하기 전에 먼저 고객이 주방으로 가서 찾아오는 경우는 없이 않은가?! 당연스레 요리사가 음식을 만들어 제공할 때 까지 고객은 기다린다.

여기서 사용되는 키워드가 synchronized, lock 인터페이스이다.

스레드가 락이 걸린 상태로 오랫동안 대기하지 않기 위해 만들어진 메서드가 있다! 이들 모두 synchronized 블록 안에서만 쓰인다.

  • wait() = 이 메서드가 호출되면, 실행 중이던 스레드는 waiting pool에서 호출을 기다린다
  • notify() = 해당 객체의 waiting pool에 있는 일부 스레드에게만 통보한다.

=> 근데 위의 두 개는 문제가 있다! waiting pool안의 스레드를 구별하지 못한다!
(아마도 생각이지만 wait는 특정 스레드가 실행 중에 락을 풀고 waiting pool로 들어간 뒤 다른 스레드가 실행되고 나서 불려야 하는데 그렇지 못하고 랜덤으로 호출되어 계속 기다리는 상황이 발생하기 때문에 그런 듯.,,)

=> 그래서 Condition이 등장!! (난 뭔가 했어,,)
다른 객체끼리는 다른 waiting pool(=대기실..?)을 만들어 각각의 대기실에서 호출해주면 된다. 그리고 메서드는 await()signal()로 wait메서드와 notify 메서드를 대신한다.

//condition 사용 예시
private ReentrantLock lock = new ReentrantLock();

private Condition forDoctor = lock.newCondition();
private Condition forNurse = lock.newCondition();
  • notifyAll()= 모든 스레드에게 통보를 하지만 lock을 얻고 나오는 스레드는 하나이다.

교착상태(DeadLock)

이 동기화 과정에서,
= 두 개의 스레드에서 서로가 가진 락이 해제되기를 기다리는 상태
= 어떤 작업도 실행되지 못하고 무한정 대기하는 상태

  • 발생 조건
    이 네 개의 조건이 모두 만족될 때 교착상태가 발생!!
  1. 상호 배제 (한 자원에 대해 여러 스레드 동시 접근 불가)
  2. 점유와 대기 (자원을 가진 상태에서 다른 스레드가 사용하는 자원 반납을 기다림)
  3. 비선점 (다른 스레드의 자원을 실행 중간에 가져올 수 없음)
  4. 환형대기 (각 스레드가 순환적으로 다음 스레드가 요구하는 자원을 가짐)
  • synchronized 키워드
    => 다른 스레드가 자원을 사용하는 동안 또다른 스레드가 자원을 변경할 수 없도록 스레드에 대한 동기화를 걸어주는 키워드
synchronized(Ex1) {statement;} //Ex1이 동기화
//따라서 statement 코드가 실행되는 동안 Ex1은 다른 스레드에서 사용할 수 X

public class Ex1 {
//이렇게 synchronized가 메서드에 쓰이면
//해당 메서드가 하나의 스레드에서만 불리도록 해줌
	synchronized public void method() {
    	//statement;
    }
}

이때 Ex1에서 누군가 method함수를 사용하고 있고, 다른 메서드가 추가로 존재한다면 다른 메서드에도 접근할 수 없다.
(Ex1 자체를 하나의 동시 개체로 인식)
그렇기 때문에 당연히 생성자에서 synchronized 키워드를 쓸 수 없겠죵

public class DeadlockEx1 {  
  public static void main(String[] args) {  
    final String resource1 = "hi";  
    final String resource2 = "hello";  
    
    // t1 tries to lock resource1 then resource2  
    Thread t1 = new Thread() {  
      public void run() {  
      //resource1이 동기화 되어 내부의 동작이 끝날 때 까지 lock
          synchronized (resource1) {  
           System.out.println("Thread 1: locked resource 1"); 
  
           try { Thread.sleep(100);} catch (Exception e) {}  
  
           synchronized (resource2) {  
           //resource2가 동기화되어 아래의 메시지 출력이 끝날 때 까지 lock
            System.out.println("Thread 1: locked resource 2");  
           }  
         }  
      }  
    };  
  
    // t2 tries to lock resource2 then resource1  
    Thread t2 = new Thread() {  
      public void run() {  
        synchronized (resource2) {  
          System.out.println("Thread 2: locked resource 2");  
  
          try { Thread.sleep(100);} catch (Exception e) {}  
  
          synchronized (resource1) {  
            System.out.println("Thread 2: locked resource 1");  
          }  
        }  
      }  
    };  
  
      
    t1.start();  
    t2.start();  
  }  
}    

위의 예시 코드는 4가지의 발생 조건을 모두 만족시키기 때문에 교착 상태가 된다.

  1. 상호 배제
    = 1 리소스 1 프로세스
    ex. resource1과 resource2를 스레드가 동시에 사용할 수 없도록
  1. 점유와 대기
    = 어떤 프로세스가 하나 이상의 리소스를 가지면서 다른 프로세스가 가지는 리소스를 기다림
    ex. th1은 resource1락을 가지면서 resource2락을 원하고, th2는 resource2락을 가지면서 resource1락을 원한다

  2. 비선점
    = 프로세스가 태스크를 마친 뒤 리소스를 자발적으로 반환할 때 까지 기다림
    (스레드 우선순위의 기본값은 norm_priority를 따름)
    ** 스레드.setPriority(Thread.NORM_PRIORITY)을 통해 스레드의 우선처리 순서를 설정

  3. 환형대기(Circular Wait)
    = Hold and Wait 관계의 프로세스들이 서로를 기다림
    ex. th1은 resource2의 락을 기다리고, th2는 resource1의 락을 기다린다

  • lock
    => synchronized 키워드와 유사한 스레드 동기화 메커니즘으로, 더 정교한 방식의 동기화를 가능케 한다.
    (synchronized는 하나의 메서드 내에서만 lock을 걸어줄 수 있고, 스레드를 구분해 알려줄 수 X) 이게 차이!!! 기억!!
    => 또한 synchronized 블록을 활용해 구현되기 때문에 synchronized는 완전히 사라지지 않는다.
// synchronized를 활용해 만든 Lock
public class Counter {
	private int count = 0;
    public int count() {
    	synchronized(this) {
        	return ++count;
            //하나의 스레드가 이 메서드에 접근해 수행됨
        }
    }
}

//Lock을 사용해 작성해본 코드
//java.util.concurrent.locks 패키지
pubilc class Counter {
	private Lock lock = new Lock();
    private int count = 0;
    
    public int count() {
    	lock.lock(); //lock 인스턴스에 락을 걸어 
        int newC = ++count;
        lock.unlock(); //unlock메서드가 호출될 때까지 다른 스레드를 블록시킴
        return newC;
    }
}

그러나 실제로 vsc에서 Lock lock = new Lock()로 선언했을 때, 오류가 발생한다. 왜??????? lock은 interface이기 때문에 객체로 선언할 수 없기 때문!! 그래서

class LockEx1 {
    private final ReentrantLock lock = new ReentrantLock();
    public void outer() {
        lock.lock();
        inner();
        lock.unlock();
    }
    public synchronized void inner() {
        lock.lock();
        System.out.println("lock된 상태입니다");
        lock.unlock();
    }
}

요런식으로 클래스로 선언하여 lock의 동작을 확인한다.

=> [종류]
1. ReentrantLock(가장 일반적, 상호 배타적 Lock)
= lock 인터페이스의 구현 클래스
=> 재진입 가능
=> 동기화의 시작과 끝을 지정할 수 있음(lock(), unlock()을 통해)

class X {
   private final ReentrantLock lock = new ReentrantLock();
	// ReentrantLock은 private final로 써주는 쪽이 좋다

   public void m() {
     lock.lock();  // block until condition holds
     try {
       // ... method body
     } finally {
       lock.unlock()
       //try안의 활동이 끝나면 그 안의 코드가 잘못될 수 있어도 
       //실행은 종료되어야 하므로 finally 안으로 들어감
     }
   }
 }

=> isHeldByCurrentThread(): 현재 스레드가 락을 얻었는지
=> nowCondition(): 해당 lock에서 사용할 Condition 인스턴스

  1. ReentrantReadWriteLock
    = lock 인터페이스의 구현 클래스
    => 읽기 Lock과 쓰기 Lock이 따로 존재
    (읽는 다고 해서 객체의 내용이 변경되지 않으니 스레드의 공유가 가능하지만 쓰기는 배타적이다.)
    => 또한 읽기 Lock이 실행중이면 쓰기Lock을 사용할 수 없다.

  2. StampedLock
    = ReentrantReadWriteLock에 낙관적인 lock 기능 추가
    이렇게 세가지가 있는데, 이는 추후 정리해보도록 하겠다.

교착상태를 피하려면?

=> 교착 상태가 완전히 해결될 순 없으나 아래의 방법을 통해 피할 수 있다. (사실 1, 2는 당연한 말,, 같기도,,,)

  1. 중첩 락을 피하라:
    많은 스레드에게 락을 제공하는 것을 피해야 한다.

  2. 불필요한 락을 없애라:
    락은 중요한 스레드에게만 주어져야 한다.

  3. 스레드의 join메서드를 활용해라:
    교착상태를 하나의 스레드가 다른 스레드의 종료까지를 기다리는 상황에서 일어난다. 따라서 join메서드(다른 스레드의 종료까지 기다리고 순차적으로 실행됨)를 활용한다. (스레드가 가질 수 있는 최대한의 시간을 사용)

스레드의 실행

스레드를 실행하기 위한 방식에는 두 가지가 있다.

  1. thread 클래스를 상속받기
    extends를 통해 클래스를 상속받은 뒤 run메서드를 오버라이딩하여 생성한다. 보통은 start메서드를 통해 시작된다.

  2. Runnable 인터페이스를 활용하기(@FunctionalInterface)
    Runnable은 몸체가 없는 메서드 run만 가지는 인터페이스이다.
    (= 함수형 인터페이스, 추상 메서드가 오직 하나인 인터페이스)
    여기서 주의점은 Thread t1 = new Thread(new RunnableEx())이런 식으로 thread의 생성자로 Runnable 인터페이스를 구현한 객체를 넘긴다.

  • 그러나 thread 클래스를 상속받으면 다른 클래스는 상속받을 수 없기 때문에(자바는 다중 상속이 불가), 보통은 Runnable 인터페이스를 사용한다.
    (또 Thread 클래스를 상속받으면 코드를 다 갖고 있기 때문에 메모리나 시간이 많이 쓰인다는 단점도 있음)

Thread의 메서드 run(), join(), sleep()

  • 새로 생성된 스레드가 run메서드를 시작시킨다.
# 1번의 방법(Thread 상속)을 통해 run 메서드 실행
# 그러나 1~10까지의 스레드가 병렬적으로 실행되기 때문에 그 순서 보장 X
class Ex1 extends Thread {
    private int num;
    public Ex1(int num) {
        this.num = num;
    }
    public void run() {
        System.out.println(num+" 이 시작");
    }
}

# 2번의 방법(Runnable)을 통해 run 메서드 실행
class Ex2 implements Runnable {
    public void run() {
        for(int i = 1; i <= 10; i++) {
            System.out.println(Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch(Exception e) {
                e.printStackTrace();
            }
        } 
    } 
}

class Main {
    public static void main(String[] args) {
        for(int i = 1; i <= 10; i++) {
            Thread th = new Ex1(i);
            //10개의 스레드를 생성
            th.start();
        }
        //하나의 스레드를 생성
        Thread th2 = new Thread(new Ex2());
        th2.start();
    }
}

 for(int i = 0; i < 5; i++) {
            th = new Thread(()-> System.out.println("스레드 이름: "+Thread.currentThread().getName()));
            th.start(); //멀티스레드
        }
        
 for(int i = 0; i < 5; i++) {
            th = new Thread(()-> System.out.println("스레드 이름: "+Thread.currentThread().getName()));
            th.run();  //싱글스레드
        }

다만 run메서드를 각각의 객체에 구현해 놓았지만 main 함수에서 호출할 때에는 start()를 호출해야 한다.
(run()을 그대로 실행하면 싱글 스레드나 다름이 없음)
(해당 메서드의 실행을 각자의 스레드에서 실행시키기 위해
= run을 직접 호출하면 메인 스레드에서 객체의 메서드를 호출하는 것과 다름이 없기 때문에,
JVM을 활용한 start 메서드를 호출해야 한다)

//스레드 메서드의 작동 원리
public synchronized void start() {
    if (threadStatus != 0) //스레드가 실행 가능한 상태라면
        throw new IllegalThreadStateException();

    group.add(this); //스레드 그룹에 추가
    //여기서 그룹은 ThreadGroup 클래스

    boolean started = false;
    try {
    	//start0은 native 메서드임
        start0(); //JVM이 스레드를 실행 -> Runnable로 상태 변경
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        
        }
    }
}
  • join메서드는 스레드가 종료될 때까지 기다리게 하는 메서드이다.
# Ex1 클래스는 위와 동일하며 join메서드를 걸지 않았을 때
public static void main(String[] args) {
        Thread t1 = new Ex1(1);
        t1.start();
        System.out.println("끝");
}
//출력
끝
1이 시작
2번째 시작

# join메서드를 걸었을 때
public static void main(String[] args) {
        Thread t1 = new Ex1(1);
        t1.start();
        try {
            t1.join();
            System.out.println("끝!");
        } catch(Exception e) {
            System.out.println("에러어어");
        }
}
//출력
1이 시작
2번째 시작
끝!

스레드는 메인이 종료되어도 백그라운드에서 실행될 가능성이 있기 때문에 반드시 다음 로직 수행 전에 join메서드를 사용해야 한다.

  • sleep()는 현재 스레드를 잠시 멈춰놓기 위해 사용한다.

이들의 단점

  • 스레드의 생성 및 종료 시 오버헤드 발생
  • 스레드 관리가 어려움

++(아래는 쓰임의 이유를 더 찾아보고 추가로 작성하겠다.)
Thread.Builder를 사용하여 ThreadFactory를 만들 수 있음
ThreadFactory는 스레드의 생성을 위임받는다. 많은 스레드를 생성해야 할 때 그 안의 필드 값을 위임시켜 깔끔한 코드를 생성하는데 도움이 될 수 있,,!

 class SimpleThreadFactory implements ThreadFactory {
   public Thread newThread(Runnable r) {
     return new Thread(r);
   }
 }

참고

https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/Thread.html
https://wikidocs.net/230
https://ko.wikipedia.org/wiki/%EC%8A%A4%EB%A0%88%EB%93%9C_(%EC%BB%B4%ED%93%A8%ED%8C%85)
http://www.tcpschool.com/java/java_thread_concept
https://mangkyu.tistory.com/258
https://math-coding.tistory.com/175
https://opentutorials.org/module/1226/8028
https://jenkov.com/tutorials/java-concurrency/locks.html
https://parkcheolu.tistory.com/24
https://www.javatpoint.com/deadlock-in-java
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/ReentrantLock.html
https://cano721.tistory.com/166

profile
노션에서 자라는 중 (●'◡'●)

0개의 댓글