멀티쓰레드 프로그래밍(Multi Thread Programming)이란?

  동시에 여러개의 Thread가 수행되는 프로그래밍이다.

  Thread는 각각의 작업공간(context)를 갖는다.

  공유자원이 있는 경우에는 race condition이 발생하게 된다.

  Critical Section에 대한 동기화(Synchronization)의 구현이 필요하다.

 

임계영역(Critical Section)이란?

  두개 이상의 Thread가 동시에 접근하게 되는 리소스다.

  Critical section에 동시에 Thread가 접근하게 되면 실행결과를 보장할 수 없게 된다.

  그래서 Thread간의 순서를 맞추는 동기화(Synchronization)이 필요하다.

 

동기화(Synchronization)이란?

  임계영역(Critical Section)에 여러 Thread가 접근하는 경우 한 Thread가 수행하는 동안 공유자원을 lock하려

  다른 Thread의 접근을 막는다.

  그렇기때문에 동기화를 잘못 구현하게 되면 교착상태(Deadlock)에 빠질 수 있다.

 

 

교착상태(Deadlock)이란?

  t1과 t2라는 Thread가 존재한다고 한다고 가정한다.

  t1은 t2가 끝날때까지 대기하도록 하는 코드가 중간에 있고 t2도 t1이 끝날때까지 대기하도록 하는 코드가 있다고 하면

  이 두 Thread는 처리하지 못하고 계속해서 대기하게 된다.

  t1은 t2가 끝나지 않았으니 대기하게 되고 t2역시 t1이 끝나지 않았으니 기다리게 되며 교착상태에 빠지게 된다.

  이렇게 서로 무한정으로 대기하고 있는 상태를 교착상태(Deadlock)이라고 한다.

  이것을 방지하기 위해 synchronized 메서드에서는 다른 synchronized 메서드를 부르지 않는것이 좋다.

 

예제코드

 

class Bank {
  private int money = 10000;
  
  public void saveMoney(int save) {
    int m = this.getMoney();
    
    try {
      Thread.sleep(3000);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
    
    setMoney(m + save);
  }
  
  public void minusMoney(int minus) {
    int m = this.getMoney();
    
    try {
      Thread.sleep(200);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
    
    setMoney(m - minus);
  }
  
  public int getMoney() {
    return money;
  }
  
  public void setMoney(int money) {
    this.money = money;
  }
}


class Park extends Thread {
  public void run() {
    System.out.println("start save");
    SyncTest.myBank.saveMoney(3000);
    System.out.println("save Money " + SyneTest.myBank.getMoney());
  }
}


class Parkwife extends Thread {
  public void run() {
    System.out.println("start minus");
    SyncTest.myBank.minusMoney(1000);
    System.out.println("minus money " + SyneTest.myBank.getMoney());
  }
}


public class SyncTest {
  
  public static Bank myBank = new Bank();
  
  public static void main(String[] args) throws InterruptedException {
    Park p = new Park();
    p.start();
    
    Thread.sleep(200);
    
    Parkwife pw = new Parkwife();
    pw.start();
  }
}

  Thread에서 가장 많이 보이는 예제인 금액 인출 예제다.

  통장에는 현재 10,000원이 있고 Park은 3,000원을 입금하고 Parkwife는 1,000원을 출금하려 한다.

  그럼 예상되는 결과값은 Park이 먼저 실행되니까 3,000원을 입금해서 save money 13,000이 출력된 후

  Parkwife가 1,000원을 출금해서 minus money 12,000이 출력될 것이라고 생각할 수 있다.

  하지만 결과값은

  start save

  start minus

  minus money 9000

  save money 13000

  이렇게 출력된다.

  출력 순서로만 본다면 save가 시작 된 후에 save에 대한 결과가 나오기 전에 minus가 수행되었다.

  그리고 minus가 먼저 수행되고 save가 수행되었는데 그래도 결과값은 10,000원에 3,000원을 더한 13,000원이

  출력된다.

  

  코드를 보면 save는 sleep으로 3초를 대기하도록 되어있고 minus는 0.2초를 대기하도록 되어있다.

  그럼 save는 일단 수행하자마자 getMoney()로 10,000원을 가져온다. 그리고 3초간의 대기상태에 빠지게 된다.

  그 사이 minus가 수행되며 똑같이 getMoney()로 10,000원을 가져온다. 그리고 minus는 0.2초간의 대기상태에

  빠지게 되고 대기시간이 짧다보니 save의 대기시간이 채 끝나기도 전에 1,000원을 빼주며 처리를 마무리하고

  wife의 run메서드의 마지막 출력문을 출력하게 되어 minus money 9000을 출력하게 된다.

  그 후에 대기하고 있던 save는 3초의 대기시간이 지난 후에 처리하게 되는데 이때 처리하는 m의 값은

  wife가 인출하고 난 뒤 잔액인  9,000원이 아닌 10,000의 값을 그대로 갖고 있게 되고 그 값에서 3,000을 save하게

  된다. 그래서 save money 13000 을 출력하게 되는것이다.

 

  현실에서의 상황으로 생각해보자면 Park이 은행에 가서 은행 직원이랑 얘기하느라 시간을 좀 더 쓰게 되었고

  Parkwife는 바로 인출해갔다면 인출후의 잔액이 바로 동기화가 되어 Park이 입금하는 금액과 더해져야 한다.

  즉, 이 예제에서는 동기화처리가 전혀 되지 않은 상태이기 때문에 두 Thread가 수행되는 동안 각자의 기준에서만

  결과값을 출력했다는 것이다.

  이렇게 되면 원하는 결과값을 출력할 수 없고 이게 처리 중간과정이었다면 뒤의 후폭풍이 심할 수 있기 때문에

  동기화가 꼭 필요하고 중요한 부분이다.

 

 

  위 예제는 아래와 같이 두가지 방식으로 수정할 수 있다.

// synchronized 메서드 방식

class Bank {
  private int money = 10000;
  
  public synchronized void saveMoney(int save) {
    int m = this.getMoney();
    
    try {
      Thread.sleep(3000);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
    
    setMoney(m + save);
  }
  
  public synchronized void minusMoney(int minus) {
    int m = this.getMoney();
    
    try {
      Thread.sleep(200);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
    
    setMoney(m - minus);
  }
  
  public int getMoney() {
    return money;
  }
  
  public void setMoney(int money) {
    this.money = money;
  }
}


class Park extends Thread {
  public void run() {
    System.out.println("start save");
    SyncTest.myBank.saveMoney(3000);
    System.out.println("save money " + SyncTest.myBank.getMoney());
  }
}


class Parkwife extends Thread {
  public void run() {
    System.out.println("start minus");
    SyncTest.myBank.minusMoney(1000);
    System.out.println("minus money " + SyncTest.myBank.getMoney());
  }
}


public class SyncTest {
  
  public static Bank myBank = new Bank();
  
  public static void main(String[] args) {
    Park p = new Park();
    p.start();
    
    Thread.sleep(200);
    
    Parkwife pw = new Parkwife();
    pw.start();
  }
}

  이렇게 saveMoney와 minusMoney에 synchronized를 붙여준다.

  그럼 이 두 메서드가 속해있는 Bank에 lock이 걸리게 된다.

  하나를 수행하게 되면 다른 Thread의 접근을 막아주는 것이다.

  Park이 수행중이라면 Parkwife는 접근할 수 없게 되는 것이다.

 

  그럼 결과값으로

  start save
  start minus
  save money 13000
  minus money 12000

  이렇게 출력된다.

  처음과 같이 save가 수행되고 처리되기 전에 minus가 실행되었지만 synchronized에 의해 접근할 수 없어서

  save가 끝날때까지 대기하게 되고 save money 13000을 출력해서 처리가 마무리 된 다음에야 수행되어서

  minus money 12000을 출력하는 것이다.

 

//synchronized 수행문(block) 방식

class Bank {
  
  private int money = 10000;
  
  public void saveMoney(int save) {
    
    synchronized(this) {
      int m = this.getMoney();
      
      try {
        Thread.sleep(3000);
      }catch (InterruptedException e) {
        e.printStackTrace();
      }
      
      setMoney(m + save);
    }
  }
  
  public void minusMoney(int minus) {
    
    synchronized(this) {
      int m = this.getMoney();
      
      try {
        Thread.sleep(200);
      }catch (InterruptedException e) {
        e.printStackTrace();
      }
      
      setMoney(m - minus);
    }
  }
  
  public int getMoney() {
    return money;
  }
  
  public void setMoney(int money) {
    this.money = money;
  }
}


class Park extends Thread {
  public void run() {
    System.out.println("start save");
    SyncTest.myBank.saveMoney(3000);
    System.out.println("save money " + SyncTest.myBank.getMoney());
  }
}


class Parkwife extends Thread {
  public void run() {
    System.out.println("start minus");
    SyncTest.myBank.minusMoney(1000);
    System.out.println("minus money " + SyncTest.myBank.getMoney());
  }
}


public class SyncTest {
  
  public static Bank myBank = new Bank();
  
  public static void main(String[] args) throws InterruptedException {
    Park p = new Park();
    p.start();
    
    Thread.sleep(200);
    
    Parkwife pw = new Parkwife();
    pw.start();
  }
}

  이렇게 block방식을 사용할 수 있다.

  block은 메서드 구현부에 작성해 주면 된다.

  이렇게 둘다 block으로 구현해도 되고 saveMoey는 block방식 minusMoney는 메서드 방식으로 구현해도 된다.

  하지만 saveMoney에는 구현했지만 minusMoney에서는 구현하지 않았다면 제대로 동기화되지 않는다.

  block 부분에서 this에 lock을 걸고자 하는 객체를 써주면 되는데 여기서는 Bank에 lock을 걸어야 하므로

  this로 쓴것이다.

public void saveMoney(int save) {
  synchronized(this) {
    int m = this.getMoney();
    
    try {
      Thread.sleep(3000);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
    
    setMoney(m + save);
  }
}

public synchronized void minusMoney(int minus) {
  
  int m = this.getMoney();
  
  try {
    Thread.sleep(200);
  }catch (InterruptedException e) {
    e.printStackTrace();
  }
  
  setMoney(m - minus);
}

  이렇게 작성할 수 있다.

 

  그리고 run메서드에서 lock을 걸수도 있는데

class Park extends Thread {
  public synchronized void run() {
  }
}

  이렇게 run에다가 걸면 리소스에 lock을 걸겠다는 것이기 때문에 소용이 없다.

  만약 run메서드에서 lock을 걸고 싶다면

class Park extends Thread {
  public void run() {
    synchronized(SyncTest.myBank) {
      //구현부
    }
  }
}

  이런 형태로 구현하면 된다.

  이렇게 run메서드에 lock을 걸었을때의 차이점으로는 지금까지의 예제 출력문을 보면 start save   start minus

  이렇게 시작했었다. 즉, save가 시작되고 minus도 실행되었지만 대기하게 되었다는 건데 run메서드에 lock을 걸게되면

  save가 끝나고 난 뒤에 minus가 수행되게 된다.

  그래서 start save    save money 13000   start minus    minus money 12000   이렇게 출력된다.

  run에 lock이 걸려있으니 start minus 조차 출력하지 못하고 대기하게 되기 때문이다.

//run에서 lock

class Bank {
  
  private int money = 10000;
  
  public void saveMoney(int save) {
    
      int m = this.getMoney();
      
      try {
        Thread.sleep(3000);
      }catch (InterruptedException e) {
        e.printStackTrace();
      }
      
      setMoney(m + save);
  }
  
  public void minusMoney(int minus) {
    
      int m = this.getMoney();
      
      try {
        Thread.sleep(200);
      }catch (InterruptedException e) {
        e.printStackTrace();
      }
      
      setMoney(m - minus);
  }
  
  public int getMoney() {
    return money;
  }
  
  public void setMoney(int money) {
    this.money = money;
  }
}


class Park extends Thread {
  public void run() {
    synchronized(SyncTest.myBank) {
      System.out.println("start save");
      SyncTest.myBank.saveMoney(3000);
      System.out.println("save money " + SyncTest.myBank.getMoney());
    }
  }
}


class Parkwife extends Thread {
  public void run() {
    synchronized(SyncTest.myBank) {
      System.out.println("start minus");
      SyncTest.myBank.minusMoney(1000);
      System.out.println("minus money " + SyncTest.myBank.getMoney());
    }
  }
}


public class SyncTest {
  
  public static Bank myBank = new Bank();
  
  public static void main(String[] args) throws InterruptedException {
    Park p = new Park();
    p.start();
    
    Thread.sleep(200);
    
    Parkwife pw = new Parkwife();
    pw.start();
  }
}

 

 

wait()

  리소스가 더이상 유효하지 않은 경우 리소스가 사용가능할 때까지 Thread를 non-runnable상태로 전환한다.

  wait 상태가 된 Thread는 notify가 호출될때까지 기다린다.

 

notify() / notifyAll()

  notify()는 wait상태인 Thread중 한 Thread를 runnable한 상태로 깨운다.

  notifyAll()은 wait상태인 모든 Thread가 runnable한 상태가 되도록한다.

  notify()보다는 notifyAll()을 사용하기를 권장한다고 한다.

  특정 Thread가 통지를 받도록 제어하는 것은 어려우므로 모두 깨운 후 스케쥴러에 CPU를 점유하도록 하는 것이

  좀 더 공평하기 때문이다.

 

예제코드

 

import java.util.ArrayList;

class Library {
  
  public ArrayList<String> books = new ArrayList<String>();
  
  public Library() {
    books.add("Java 1");
    books.add("Java 2");
    books.add("Java 3");
    books.add("Java 4");
    books.add("Java 5");
    books.add("Java 6");
  }
  
  public synchronized String rentBook() {
    Thread t = Thread.currentThread();
    
    String title = books.remove(0);
    System.out.println(t.getName() + " : " + title + " rent");
    return title;
  }
  
  public synchronized void returnBook(String title) {
    Thread t = Thread.currentThread();
    
    books.add(title);
    System.out.println(t.getName() + " : " + title + " return");
  }
}


class Student extends Thread {
  
  public void run() {
    
    try {
      String title = LibraryMain.library.rentBook();
      sleep(5000);
      LibraryMain.library.returnBook(title);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}


public class LibraryMain {

  public static Library library = new Library();
  
  public static void main(String[] args) {
    Student std1 = new Student();
    Student std2 = new Student();
    Student std3 = new Student();
    
    std1.start();
    std2.start();
    std3.start();
  }
}

  결과값은

  Thread-0 : Java 1 rent

  Thread-2 : Java 2 rent

  Thread-1 : Java 3 rent

  Thread-0 : Java 1 return

  Thread-1 : Java 3 return

  Thread-2 : Java 2 return

  이렇게 출력된다.

  순서는 실행할때마다 조금씩 달라지기도 한다.

  이 예제코드에서 shared resource가 되는것은 library다.

  동시에 빌릴일은 별로 없겠지만 synchronized는 rent와 return에 걸어주는것이 좋다.

 

  그럼 이제 여기서 학생이 6명이고 책이 3권밖에 없다고 하면 이 코드를 그대로 사용했을 때

  IndexOutOfBoundException 이 발생한다.

  학생 6명이 책을 빌려달라고 했으나 앞에 있는 학생 3명이 책을 다 빌려갔고 남은 3명은 빌릴 책이 존재하지 않기

  때문에 이런 오류가 발생한다.

  이런 경우에는 리소스가 가능하지 않으면 빌리지 못하도록 하면 되는데 이럴 때 wait()과 notify()를 사용한다.

 

/*
  책은 3권이지만 학생이 6명이라면
  notify()를 사용한 예제
*/

import java.util.ArrayList;

class Library {
  
  public ArrayList<String> books = new ArrayList<String>();
  
  public Library() {
    books.add("Java 1");
    books.add("Java 2");
    books.add("Java 3");
  }
  
  public synchronized String rentBook() throws InterruptedException {
    
    Thread t = Thread.currentThread();
    
    if(books.size() == 0) {
      System.out.println(t.getName() + " waiting start");
      wait();
      System.out.println(t.getName() + " waiting end");
    }
    
    String title = books.remove(0);
    System.out.println(t.getName() + " : " + title + " rent");
    return title;
  }
  
  public synchronized void returnBook(String title) {
    
    Thread t = Thread.currentThread();
    
    books.add(title);
    notify();
    
    System.out.println(t.getName() + " : " + title + " return");
  }
}


class Student extends Thread {
  
  public void run() {
    
    try {
      String title = LibraryMain.library.rentBook();
      
      if(title == null) return;
      
      sleep(5000);
      LibraryMain.library.returnBook(title);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}


public class LibraryMain {
  
  public static Library library = new Library();
  
  public static void main(String[] args) {
    Student std1 = new Student();
    Student std2 = new Student();
    Student std3 = new Student();
    Student std4 = new Student();
    Student std5 = new Student();
    Student std6 = new Student();
    
    std1.start();
    std2.start();
    std3.start();
    std4.start();
    std5.start();
    std6.start();
  }
}

  결과값은 

  Thread-0 : Java 1 rent

  Thread-4 : Java 2 rent

  Thread-5 : Java 3 rent

  Thread-3 waiting start

  Thread-2 waiting start

  Thread-1 waiting start

  Thread-4 : Java 2 return

  Thread-3 waiting end

  Thread-3 : Java 2 rent

  Thread-5 : Java 3 return

  Thread-0 : Java 1 return

  Thread-1 waiting end

  Thread-1 : Java 3 rent

  Thread-2 waiting end

  Thread-2 : Java 1 rent

  Thread-2 : Java 1 return

  Thread-1 : Java 3 return

  Thread-3 : Java 2 return

  이렇게 출력된다.

  이것도 물론 순서는 차이가 있을 수 있다.

 

  만약 책이 없을 때 그냥 아예 빌리지 못하게 할거였으면 rent와 run에서 books.size가 0일때 null을 리턴하도록

  했으면 뒤에 3명의 학생은 아예 처리가 안되도록 할 수도 있다.

  하지만 기다렸다가 빌리겠다고 한다면 대기하도록 해야 하기 때문에 이렇게 처리한다.

  rent에서 books.size가 0이면 책의 재고가 없는 것이므로 wait()으로 대기하도록 한다.

  그리고 다른 Thread가 반납하게 되면 notify()로 대기상태인 Thread를 깨워줌으로써 책을 빌릴 수 있도록

  해준다.

 

 

  notifyAll은 다음과 같이 쓴다.

/*
  책은 3권이지만 학생은 6명이다.
  notifyAll()사용
*/

import java.util.ArrayList;

class Library {
  
  public ArrayList<String> books = new ArrayList<String>();
  
  public Library() {
    books.add("Java 1");
    books.add("Java 2");
    books.add("Java 3");
  }
  
  public synchronized String rentBook() throws InterruptedException {
    Thread t = Thread.currentThread();
    
    while(books.size() == 0) {
      System.out.println(t.getName() + " waiting start");
      wait();
      System.out.println(t.getName() + " waiting end");
    }
    
    String title = books.remove(0);
    System.out.println(t.getName() + " : " + title + " rent");
    return title;
  }
  
  public synchronized String returnBook(String title) {
    Thread t = Thread.currentThread();
    
    books.add(title);
    notifyAll();
    System.out.println(t.getName() + " : " + title + " return");
  }
}


class Student extends Thread {
  
  public void run() {
    
    try {
      String title = LibraryMain.library.rentBook();
      
      if(title == null) return;
      
      sleep(5000);
      LibraryMain.library.returnBook(title);
    }catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}


public class LibraryMain {
  
  public static Library library = new Library();
  
  public static void main(String[] args) {
    Student std1 = new Student();
    Student std2 = new Student();
    Student std3 = new Student();
    Student std4 = new Student();
    Student std5 = new Student();
    Student std6 = new Student();
    
    std1.start();
    std2.start();
    std3.start();
    std4.start();
    std5.start();
    std6.start();
  }
}

  결과값은

  Thread-0 : Java 1 rent
  Thread-5 : Java 2 rent
  Thread-3 : Java 3 rent
  Thread-4 waiting start
  Thread-2 waiting start
  Thread-1 waiting start
  Thread-5 : Java 2 return
  Thread-0 : Java 1 return
  Thread-1 waiting end
  Thread-1 : Java 2 rent
  Thread-2 waiting end
  Thread-2 : Java 1 rent
  Thread-4 waiting end
  Thread-4 waiting start
  Thread-3 : Java 3 return
  Thread-4 waiting end
  Thread-4 : Java 3 rent
  Thread-1 : Java 2 return
  Thread-2 : Java 1 return
  Thread-4 : Java 3 return

  이렇게 출력된다.

  역시 순서는 바뀔 수 있다.

 

  notify()를 사용한 경우는 하나씩 깨어나기 때문에 잘 되면 다행이지만 공정하지 못할 수 있다.

  notifyAll()을 사용하면 대기하고 있던 모든 Thread가 깨어난다.

  그럼 반환된 책은 하나인데 모든 스레드가 깨어나면 처음과 같이 IndexOutOfBoundException이 발생할 수 있다.

  그래서 rentBook에서 wait을 걸어주는 부분을 if문이 아닌 while문으로 작성해준다.

  그래서 깨어난 Thread가 못빌리는 상태라면 다시 wait으로 들어갈 수 있도록 해줘야 한다.

  

  결과값을 보면 Thread4, 2, 1이 대기상태로 들어간다.

  그 후에 Thread5와 0이 책을 반납했다. 이때 반납처리가 되면서 대기중이던 4, 2, 1은 모두 깨어난 상태가 되고

  그 중에서 Thread1과 2는 wait이 풀리자마자 도서를 빌렸다.

  하지만 제일 마지막에 깨어난 Thread4는 깨어나서 waiting end를 찍고 책을 빌리려 했지만 books.size가 0이 

  되었으므로 while문을 벗어나지 못하고 다시 wait에 들어가게 된다.

 

  

 

 

레퍼런스

패스트캠퍼스 올인원 패키지 - 자바 객체지향프로그래밍

'JAVA' 카테고리의 다른 글

Servlet&JSP에서 파일 업로드 처리  (0) 2023.10.20
쓰레드(Thread)  (0) 2021.02.13
입출력스트림(IOStream)  (0) 2021.02.12
스트림(Stream)  (0) 2021.02.11
람다식(Lambda)  (0) 2021.02.10

+ Recent posts