10주차 과제 : 멀티쓰레드 프로그래밍

Lee·2021년 1월 22일
1
post-thumbnail
post-custom-banner

멀티쓰레드 프로그래밍

자바에서 제공하는 멀티쓰레드 프로그래밍에 대해 공부해보자 📖

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

지난 9주차 과제 회고 ✍️

Exception을 계속 throw 하다보면 결국 main 메소드에서 처리를 해야 하는데, 여기서도 던지면 jvm이 어떤 방식으로 throw를 처리하는지에 대한 질문이였다.

간단하게 정리하자면, 해당 쓰레드는 예외를 던지고 종료가 된다.

하필 이번주 스터디 주제가 멀티쓰레드 프로그래밍이기 때문인진 몰라도 저 의미가 너무나도 궁금했다. 🤔
TMI : 이번에 작성한 예제는 필자가 맥날 알바생이기 때문에 관련 예제가 햄버거와 관련되어있습니다..

프로세스(Process) 📌

실행중인 프로그램을 뜻한다. 쉽게 확인할 수 있는 방법은 작업 관리자에 들어가 프로세스 텝을 확인하면 내 컴퓨터에 얼마나 많은 프로세스들이 있는지 확인할 수 있다.

쓰레드(Thread) ⭐️

프로세스 내에서 실행되고 있는 흐름의 단위이다.

멀티태스킹(Multi-tasking) ⭐️

여러 개의 프로세스를 동시에 실행하는 것을 의미한다. 현재 사용하고 있는 윈도우나 맥 OS 처럼 여러 개의 프로그램을 동시에 사용할 수 있는 이유가 바로 멀티태스킹 환경을 지원해주고 있기 때문이다.

멀티쓰레드(Multi-Thread) ⭐️

하나의 프로세스 안에 여러 개의 쓰레드가 있는 것을 멀티쓰레드라고 한다. 이해하기 쉽게 표현하자면 필자가 알바하는 맥도날드로 설명해보겠다. 빅맥 세트를 주문하면 주문과 동시에 버거는 그릴에서 만들어지고, 음료는 카운터, 감자튀김은 튀김기 앞에서 동시다발적으로 만들어진다. 즉, 주문이라는 프로세스 안에 햄버거, 음료, 감자튀김이 만들어지는 흐름이 생성되는 것이다.

멀티쓰레드의 장점

  • CPU의 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리되어 코드가 간결해진다.

멀티쓰레드의 단점

여러 개의 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에 동기화(synchronization), 교착상태(deadlock)와 같은 문제가 발생할 확률이 높다.

Thread 생성하기 ✍️

자바에선 쓰레드를 생성하는 방법이 2가지가 있다. 하나는 Thread 클래스를 상속받아서 사용하는 것이고, 나머지 하나는 Runnable 인터페이스를 구현하는 방법이다.

Extends Thread

// java.lang.Thread 클래스를 상속받아 사용자 정의 Thread 클래스를 생성할 수 있다. 
class Hamburger extends Thread {

  // 이 클래스에서 상위 클래스인 Thread의 run() 메소드를 재정의를 해야 Thread의 실행부분을 작성할 수 있다.
    @Override
    public void run() {
        super.run();
        System.out.println("Hamburger 나왔습니다.");
    }
}

public class ThreadExample{

    public static void main(String[] args) {
        Hamburger hamburger = new Hamburger(); // Thread 객체를 생성한 후
        hamburger.start(); // start() 메소드를 호출하면 thread가 실행된다. 여기서 start() 메소드는 쓰레드 객체의 run() 메소드를 호출한다.
    }
}
Hamburger 나왔습니다.

implements Runnable interface

// Runnable 인터페이스를 implements 키워드를 이용하여 구현하면 된다.
class Hamburger implements Runnable {

    @Override
    public void run() {
        System.out.println("Hamburger 나왔습니다.");
    }
}


public class MultiThreadExample{

    public static void main(String[] args) {
        // Runnable 인터페이스를 구현해 Thread를 만들 경우
        // 객체 생성을 Thread 클래스 타입으로 만들어 줘야 한다.
        // run() 메소드의 트리거 역할을 하는 start()는 Thread 클래스에 정의되어있기 때문에 반드시 객체 생성시 Thread 타입으로 만들어야 한다.
        Thread hamburger = new Thread(new Hamburger());
        hamburger.start(); 
    }
}
Hamburger 나왔습니다.

조금 더 생산적인 방법 💡

Thread 클래스를 상속받아서 만들거나, Runnable 인터페이스를 구현하여 만들 수 있는 것도 배웠다. 그렇다면 이 두 가지의 방법중 어떤 방법이 조금 더 좋을까? 개인적인 생각이지만 어떻게 쓰레드를 이용할 것인가에 따라 다른 것 같다. 즉 개발환경에 맞게 사용해야 한다고 생각한다. 하지만 대부분의 사람들이 Runnable 인터페이스를 구현하는 방식을 선택한다. 그 이유는 아마도 클래스의 상속을 여전히 사용할 수 있기 때문에 더 선호하지 않을까?라는 생각을 조심스럽게 해본다. 🤔

start(), run() ⭐️

  • start() -> 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성하는 것
  • run() -> start()로 생성된 호출스택에 run()가 첫 번째로 저장되는 과정

조금 더 쉽게 설명하자면 빅맥 세트를 시켰을 때, 햄버거는 그릴에서, 음료수는 카운터에서, 감자튀김은 튀김기 앞에서 만들어진다고 했었다. 이와 마찬가지로 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택(그릴, 카운터, 튀김기)을 필요로 한다. 그 후 주문한 순서에 맞게끔 번갈아 가면서 음식을 제조하면 된다.

Thread State ⭐️

자바에선 쓰레드의 상태를 6가지의 상태로 정의하였으며, 열거형으로 제공하고 있다. (스스로 번역한 거라 의미에 차이가 좀 있습니다..😅)

  • NEW
    • A thread that has not yet started is in this state
  • RUNNABLE
    • A thread executing in the Java virtual machine is in this state
  • BLOCKED
    • A thread that is blocked waiting for a monitor lock is in this state
  • WAITING
    • A thread that is waiting indefinitely for another thread to perform a particular action is in this state
  • TIMED_WAITING
    • A thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state.
  • TERMINATED
    • A thread that has exited is in this state.

Thread 클래스에서 제공하는 메소드인 getState() 를 이용하면 현재 쓰레드의 상태를 알 수 있다.

이미지 출처 : Thread State

New

  • 쓰레드가 아직 시작하지 않은 상태를 의미한다.
class Hamburger implements Runnable {

    @Override
    public void run() {
        System.out.println("Hamburger 나왔습니다.");
    }
}


public class MultiThreadExample{

    public static void main(String[] args) {

        // start() 메소드를 호출하기 전에 getState 메소드를 호출해서 확인해보자
      
        Thread hamburger = new Thread(new Hamburger());
        System.out.println(hamburger.getState());
        hamburger.start();
    }
}
NEW // 호출스택을 아직 생성하지 않았기 때문에 NEW라는 결과가 나온다
Hamburger 나왔습니다.

RUNNABLE

  • 쓰레드가 자바 가상 머신에서 실행 대기 중이거나 실행 중인 상태를 의미한다.
class Hamburger implements Runnable {

    @Override
    public void run() {
        System.out.println("Hamburger 나왔습니다.");
    }
}


public class MultiThreadExample{

    public static void main(String[] args) {

        Thread hamburger = new Thread(new Hamburger());
        System.out.println(hamburger.getState());
        hamburger.start();
        System.out.println(hamburger.getState());
    }
}

RUNNABLE이 먼저 출력된 이유는? 🤔

  • start() 호출한다고 해서 바로 실행되는 것이 아니라 실행대기열에 저장된 후 실행된다. 이때 실행 대기중인 상태기 때문에 RUNNABLE이 먼저 출력되는 것이다.
NEW
RUNNABLE
Hamburger 나왔습니다.

BLOCKED

  • 하나의 쓰레드가 동기화 영역에 들어가면 해당 쓰레드가 작업이 종료될 때 까지 동기화 블럭에 접근할 수 없는 상태를 의미한다.
  • 더 쉽게 이야기하면 동기화 블럭에 의해서 일시정지된 상태이다.
class Order implements Runnable {

    @Override
    public void run() {
        makeFood();
    }

    public static synchronized void makeFood() {
        while (true) {
            // 주문을 받고 음식을 만들고 있다는 가정 하에
            // 만약 새로운 주문이 들어온다면 먼저 주문이 들어온 음식이 나올 때 까지
            // 새로운 음식은 나올 수가 없다
        }
    }
}

public class MultiThreadExample{
    public static void main(String[] args) throws InterruptedException {

        Thread order = new Thread(new Order());
        Thread newOrder = new Thread(new Order());

        order.start();
        newOrder.start();

        Thread.sleep(1000);


        System.out.println(order.getState()); // RUNNABLE
        System.out.println(newOrder.getState()); // BLOCKED
        System.exit(0);
    }
}
 
RUNNABLE
BLOCKED

WAITING

  • 다른 스레드가 특정 작업을 수행하는 중 기존에 작업 중이던 쓰레드가 잠시 멈추는 것을 의미한다.
class OrderEdit implements Runnable {

    @Override
    public void run() {
        try {
            Thread.sleep(3000);
            System.out.println("주문한 음식에 요청사항이 생겼을 때");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            e.printStackTrace();
        }
        // 요청사항에 대해 먼저 처리한다.
        // 요청사항이 끝날 때 까지 기존에 조리하던 음식은 잠시 멈춘 상태로 기다린다.
        System.out.println("기존에 주문은 잠시 : " + WaitingStateExample.order.getState());
    }
}

public class WaitingStateExample implements Runnable{

    public static Thread order;

    public static void main(String[] args) {
        order = new Thread(new WaitingStateExample()); // 주문을 받는다.
        order.start(); // 받은 주문을 토대로 음식을 만들기 시작한다.
    }

    @Override
    public void run() {
        Thread orderEdit = new Thread(new OrderEdit()); // 음식을 만드는 와중에 요청사항이 발생했다.
        orderEdit.start(); // 요청사항 대로 조리방법을 변경했다.

        try {
            orderEdit.join(); // 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            e.printStackTrace();
        }
    }
}
주문한 음식에 요청사항이 생겼을 때
기존에 주문은 잠시 : WAITING

TIMED_WAITING

  • 지정된 시간 내에 다른 쓰레드가 특정 작업을 수행하기를 기다리는 경우
class PresentFood implements Runnable {

    @Override
    public void run() {
        try {
            Thread.sleep(5000);
            System.out.println("주문하신 음식 나왔습니다.");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            e.printStackTrace();
        }
    }
}

public class TimedWaitingStateExample {
    public static void main(String[] args) throws InterruptedException{
        Thread presentFood = new Thread(new PresentFood());
        presentFood.start();

        // 음식이 만드는 시간이 5초라고 하면 해당 시간 내에 주문한 손님은 
        // '주문하신 음식 나왔습니다.'라는 종업원에 멘트를 기다리는 경우이다.
        Thread.sleep(1000);
        System.out.println(presentFood.getState());
    }
}
TIMED_WAITING
주문하신 음식 나왔습니다.

TERMINATED

  • 쓰레드가 종료된 상태를 의미한다.
public class TerminatedExample implements Runnable{
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new TerminatedExample());
        thread.start();

        thread.sleep(1000);
        System.out.println(thread.getState());
    }

    @Override
    public void run() {
    
    }
}
TERMINATED

Thread scheduling ⭐️

쓰레드의 상태와 관련된 메소드들이다.

  • sleep() : 주어진 시간 동안 일시 정지 상태가 되고 다시 실행 대기 상태로 돌아간다.
  • join() : 특정 쓰레드가 다른 쓰레드의 완료를 기다리게 하는 것
  • interrupt : sleep(), join()에 의해 일시정지상태인 쓰레드를 실행대기상태로 만드는 것
  • stop() : 쓰레드를 즉시 종료시킨다.
  • suspend() : 쓰레드를 일시정지시킨다.
  • resume() : suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기 상태로 만드는 것
  • yield() : 실행 중에 다른 쓰레드에게 양보하고 실행대기상태가 되는 것

resume(), stop(), suspend()는 Thread를 교착상태로 만들기 쉽기 때문에 자바에서 deprecated 시켜버렸다.

Thread Priority ⭐️

특정 작업을 할 때, 다른 작업보다 먼저 처리되어야 하는 경우가 있는데 이때, 우선순위를 설정하여 중요한 작업부터 실행될 수 있게끔 할 수 있다. 예를 들어 맥도날드에서 빅맥 세트를 주문한다고 하면, 햄버거 -> 감자튀김 -> 음료 순으로 나와야 한다.

하지만 이 프로그램은 실행할 때 마다 우선 순위와 상관없이 랜덤으로 만들어진다. 이렇게 되면 어떤 음식이 우선 순위가 가장 높은지 모른다.

public class Mcdonalds {
    public static void main(String[] args) {
        try {
            HamburgerSetCook hamburgerSetCook = new HamburgerSetCook();
            new Thread(hamburgerSetCook, "Hamburger").start();
            new Thread(hamburgerSetCook, "FrenchFries").start();
            new Thread(hamburgerSetCook, "Drink").start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class HamburgerSetCook implements Runnable {

    @Override
    public void run() {

        cookingFood(Thread.currentThread().getName());

    }

    private void cookingFood(String name) {
        System.out.println(name + "가 제조되고 있습니다. 잠시만 기다려주세요.");
    }

}
FrenchFries가 제조되고 있습니다. 잠시만 기다려주세요.
Drink가 제조되고 있습니다. 잠시만 기다려주세요.
Hamburger가 제조되고 있습니다. 잠시만 기다려주세요.

우선순위 설정 예제

  • 우선순위는 1~10까지 존재하며 설정하지 않은 경우 default 값으로 5이다.
  • setPriority 메소드를 이용하여 우선순위를 바꿀 수 있다.
  • 하지만 설정한다고 한들, 반드시 우선순위대로 작업한다는 보장은 없다.
public class Mcdonalds {
    public static void main(String[] args) {
        try {

            Thread hamburger = new Thread(new HamburgerSetCook(), "Hamburger");
            Thread frenchFries = new Thread(new HamburgerSetCook(), "FrenchFries");
            Thread drink = new Thread(new HamburgerSetCook(), "Drink");

            // 우선순위는 1~10까지 존재하며 설정하지 않은 경우에 기본은 5이다.
            // setPriority 메소드를 이용하여 우선순위를 바꿀 수 있다.
            // 하지만 설정한다고 한들, 반드시 우선순위대로 작업한다는 보장은 없다.
            hamburger.setPriority(10);
            frenchFries.setPriority(5);
            drink.setPriority(2);

            hamburger.start();
            frenchFries.start();
            drink.start();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class HamburgerSetCook implements Runnable {


    @Override
    public void run() {

        cookingFood(Thread.currentThread().getName());

    }

    private void cookingFood(String name) {
        System.out.println(name + "가 제조되고 있습니다. 잠시만 기달려주세요.");
    }

}
Hamburger가 제조되고 있습니다. 잠시만 기달려주세요.
FrenchFries가 제조되고 있습니다. 잠시만 기달려주세요.
Drink가 제조되고 있습니다. 잠시만 기달려주세요.

Main Thread ⭐️

자바에서 메인 메소드를 통해 프로그램이 실행되면 하나의 쓰레드가 시작되는데 이를 메인 쓰레드 라고 부른다. 여태까지 이 챕터를 공부하지 않았음에도 불구하고 꾸준히 쓰레드를 사용하고 있었다는 뜻이다.

우리가 만든 프로그램을 실행하면 JVM에선 자동으로 메인 쓰레드를 생성해준다.

  • currentThread() 를 호출하면 해당 쓰레드의 참조값을 가져올 수 있다.
  • getName()를 호출하면 currentThreadI()로 가져온 현 쓰레드의 Name 값을 알 수 있다.
public class MainThreadTest {
    public static void main(String[] args) {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName());
    }
}
main

동기화 ⭐️

멀티쓰레드 프로그래밍의 특징은 하나의 프로세스를 동시에 여러 쓰레드가 접근한다는 의미이다. 다시 이야기해보면 하나의 데이터를 공유해서 작업한다는 이야기이다.

만약 쓰레드 A가 작업하던 도중에 다른 쓰레드인 B에게 제어권이 넘어가면, 쓰레드 A가 작업하려던 공유데이터를 쓰레드 B가 임의로 변경하게 되고, 이에 따른 결과물이 예상했던 것과 다른 결과가 나올 경우가 있다.

더 쉽게 이야기하자면 한 방아 여러 사람이 컴퓨터 하나를 함께 쓰는 것과 동일하다 A라는 사람이 문서작업 도중 잠시 자리를 비우면 다른 사람이 컴퓨터 앞에 앉아서 문서를 지울 수도 있다는 것이다.

이를 방지하기 위해 특정 쓰레드가 진행중일 때 다른 쓰레드가 접근하지 못하도록 막는 것을 동기화라고한다.

synchronized를 이용한 동기화

자바에서는 synchronzied를 통해 해당 쓰레드와 관련된 공유데이터에 lock을 걸어서 먼저 작업 중이던 쓰레드가 작업을 완전히 마칠 때까지 다른 쓰레드가 접근해도 공유데이터가 변경되지 않도록 보호하는 역할을 한다.

두 가지 방법으로 synchronized 사용하기

  • 특정한 객체에 lock을 걸때
synchronized (객체의 참조변수) {
  // 참조변수로 들어온 객체는 synchronized 블록에 들어오면 lock에 잠기고, 블록이 종료가 되면 lock이 해제된다.
  // 잠긴 사이에는 다른 쓰레드가 접근할 수 없다.
}
  • 메소드에 lock을 걸때
public synchronized void example() {
  // 한 쓰레드가 synchronized 메소드를 호출해서 수행하고 있으면, 이 메소드가 종료될 때 까지 다른 쓰레드는 이 메소드를 호출할 수 없다.
}

아래에 예제는 실제로 맥도날드에서 일을 하면서 단체 주문으로 버거 10개를 만들라는 지시가 떨어진 상태를 프로그래밍적으로 한번 만들어봤다. 이때 크루들이 동시다발적으로 일하는 상황을 동기화로 작성하였다.

ps. 얄팍한 코딩사전에 나온 예제를 스리슬쩍 바꿔봤다..😅

public class Mcdonalds {
    public static void main(String[] args) {
        try {
            HamburgerCook hamburger = new HamburgerCook(10);
            new Thread(hamburger, "BicMac").start();
            new Thread(hamburger, "MacChicken").start();
            new Thread(hamburger, "MacSpicy").start();
            new Thread(hamburger, "EggBulgogi").start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class HamburgerCook implements Runnable {

    private int hamburgerCount;
    private String[] grill = {"_", "_", "_", "_"}; // 그릴판에 최대 4개의 햄버거를 만들 수 있다.

    public HamburgerCook(int count) {
        hamburgerCount = count;
    }

    @Override
    public void run() {
        while (hamburgerCount > 0) {

            synchronized (this) { // 한번에 하나의 쓰레드만 접근할 수 있다.
                hamburgerCount--;
                System.out.println("현재 만들어야 하는 버거의 갯수 : " + hamburgerCount);
            }

            for (int i = 0; i < grill.length; i++) {
                if (!grill[i].equals("_")) {
                    continue;
                }

                synchronized (this) {
                    grill[i] = Thread.currentThread().getName();
                    System.out.println(grill[i] + "버거를 만드는 중 입니다.");
                }

                // 버거를 만드는 데 대략 2초가 걸린다고 가정
                try {
                    Thread.sleep(2000);
                } catch (Exception e) {
                    e.printStackTrace();
                }


                synchronized (this) { // 다 만든 햄버거는 다시 그릴판을 비운다. 이때도 한번에 하나의 쓰레드만 접근할 수 있다.
                    System.out.println(Thread.currentThread().getName() + "버거가 다 만들어졌습니다.");
                    System.out.println("-------------------------------------------------------");
                    grill[i] = "_";
                }
                break;
            }

            // 새로운 버거를 만들기 위한 준비시간
            try {
                Thread.sleep(Math.round(1000 * Math.random()));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
현재 만들어야 하는 버거의 갯수 : 9
BicMac버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 8
EggBulgogi버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 7
MacSpicy버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 6
MacChicken버거를 만드는 중 입니다.
BicMac버거가 다 만들어졌습니다.
-------------------------------------------------------
EggBulgogi버거가 다 만들어졌습니다.
-------------------------------------------------------
MacSpicy버거가 다 만들어졌습니다.
-------------------------------------------------------
MacChicken버거가 다 만들어졌습니다.
-------------------------------------------------------
현재 만들어야 하는 버거의 갯수 : 5
MacChicken버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 4
BicMac버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 3
MacSpicy버거를 만드는 중 입니다.
현재 만들어야 하는 버거의 갯수 : 2
EggBulgogi버거를 만드는 중 입니다.
MacChicken버거가 다 만들어졌습니다.
-------------------------------------------------------
현재 만들어야 하는 버거의 갯수 : 1
MacChicken버거를 만드는 중 입니다.
BicMac버거가 다 만들어졌습니다.
-------------------------------------------------------
MacSpicy버거가 다 만들어졌습니다.
-------------------------------------------------------
현재 만들어야 하는 버거의 갯수 : 0
BicMac버거를 만드는 중 입니다.
EggBulgogi버거가 다 만들어졌습니다.
-------------------------------------------------------
MacChicken버거가 다 만들어졌습니다.
-------------------------------------------------------
BicMac버거가 다 만들어졌습니다.
-------------------------------------------------------

Process finished with exit code 0

교착상태 (dead-lock) ⭐️

  • 둘 이상의 쓰레드가 lock을 획득하기 위해서 기다리는데, 이 lock을 잡고 있는 쓰레드도 똑같이 다른 lock을 기다리며 서로 블록 상태에 놓이는 것을 말한다. 즉 다수의 쓰레드가 같은 lock을 동시에, 다른 명령에 의해 획득하려는 시도가 발생할 경우에 생긴다.
public class TestThread {
    public static Object Lock1 = new Object();
    public static Object Lock2 = new Object();

    public static void main(String args[]) {
        ThreadDemo1 T1 = new ThreadDemo1();
        ThreadDemo2 T2 = new ThreadDemo2();
        T1.start();
        T2.start();
    }

    private static class ThreadDemo1 extends Thread {
        public void run() {
            synchronized (Lock1) {
                System.out.println("Thread 1: Holding lock 1...");

                try { Thread.sleep(10); }
                catch (InterruptedException e) {}
                System.out.println("Thread 1: Waiting for lock 2...");

                synchronized (Lock2) {
                    System.out.println("Thread 1: Holding lock 1 & 2...");
                }
            }
        }
    }
    private static class ThreadDemo2 extends Thread {
        public void run() {
            synchronized (Lock2) {
                System.out.println("Thread 2: Holding lock 2...");

                try { Thread.sleep(10); }
                catch (InterruptedException e) {}
                System.out.println("Thread 2: Waiting for lock 1...");

                synchronized (Lock1) {
                    System.out.println("Thread 2: Holding lock 1 & 2...");
                }
            }
        }
    }
}
Thread 1: Holding lock 1...
Thread 2: Holding lock 2...
Thread 1: Waiting for lock 2...
Thread 2: Waiting for lock 1...

참고자료 🧾

post-custom-banner

0개의 댓글