들어가기 앞서

스레드(thread)의 사전적 정의를 살펴보면 '실, 가닥'이라는 것을 알 수 있는데, 컴퓨터 세계에서 스레드를 떠올리면 아래와 같은 이미지일 것입니다. 여기서 프로세스는 간단히 말하면 실행 중인 프로그램을 말합니다.

프로세스는 위와 같이 여러 개의 실행 흐름, 즉 여러 개의 스레드를 가질 수 있습니다. 지금까지 우리가 본 프로그램은 어떤 작업을 수행하면 그 작업이 끝날 때까지 기다려야 다른 작업을 수행할 수 있었습니다. 이는 우리가 여태껏 하나의 스레드만 사용했기 때문입니다. 하지만 어떤 작업을 하면서 다른 작업을 동시에 수행하고 싶을 때는 어떻게 해야 할까요? 그러면 여러 개의 스레드, 즉 멀티스레드(multi-thread)를 사용하면 되지 않을까요? 수많은 프로그래밍 언어에서 멀티스레드를 생성하는 기능을 지원하며, 물론 자바에서도 이를 지원하고 있습니다.

스레드(Thread)

자바에서 스레드를 만드는 방법부터 알아보도록 하겠습니다. 스레드를 만드는 대표적인 방법으로는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있습니다.

스레드 만들기

Thread 클래스를 상속받기

Thread를 상속받고 나서 run() 메서드를 오버라이딩해야 합니다. 스레드를 새로 만들고 시작하려면 start() 메서드를 호출해야 하는데, 이 메서드는 내부적으로 run() 메서드를 호출하기 때문입니다. 따라서 메인 스레드의 메인 메서드 역할과 비슷하다고 할 수 있습니다.

메인 스레드(main thread)

자바 프로그램을 시작하면 JVM은 Main이라는 스레드를 하나 만듭니다. 이렇게 만들어진 Main 스레드가 하는 첫 번째 일은 메인 메서드를 찾아서 실행하는 것입니다. 이 스레드를 메인 스레드라고 부릅니다.

예제 코드를 보며 이어서 설명하도록 하겠습니다.

class ThreadA extends Thread {
	public void run() {
		for (int i = 0; i < 500; i++) {
			System.out.println("ThreadA 스레드에서 실행되고 있습니다.");
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

public class JavaTutorial28 {
    public static void main(String[] args) {
    	ThreadA thread = new ThreadA();
    	thread.start();
    	
    	try {
	    	for (int i = 0; i < 1000; i++) {
	    		System.out.println("메인 스레드에서 실행되고 있습니다.");
	    		Thread.sleep(1000);
	    	}
    	} catch (InterruptedException e) {
			e.printStackTrace();
		}
    }
}

16~17행을 먼저 보겠습니다. 스레드 객체를 만들고 스레드를 시작시키기 위해서 start() 메서드를 호출해야 합니다.

ThreadA thread = new ThreadA();
thread.start();

이어서 5~9행을 보면 다음과 같은 코드를 볼 수 있습니다. Thread.sleep() 메서드는 현재 실행 중인 스레드를 지정한 시간 동안 일시 중지시키는 기능을 합니다. 여기서 '현재 실행 중인 스레드'는 Thread.sleep() 메서드를 호출한 스레드를 말하며, 시간은 밀리초(ms) 기준이므로 여기서 1000ms는 1초가 됩니다. 그리고 try~catch문으로 예외 처리를 해준 이유는 Thread.sleep() 메서드가 InterruptedException 예외를 던지는데 이 예외가 checked exception이기 때문입니다. 이 예외가 무엇인지는 아래에서 다시 설명하도록 하겠습니다.

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

Runnable 인터페이스 구현하기

이번에는 Runnable 인터페이스를 구현하여 스레드를 만드는 방법을 살펴보도록 하겠습니다. 맨 위에서 살펴본 코드에서 아래의 부분만 변경하면 됩니다.

class ThreadA implements Runnable {
	/* ... */
}

public class JavaTutorial28 {
    public static void main(String[] args) {
    	Thread thread = new Thread(new ThreadA());
    	/* ... */
    }
}

Runnable 인터페이스를 살펴보면 추상 메서드인 run()를 멤버로 갖고 있습니다. 이 인터페이스를 구현하는 클래스는 이 메서드를 구현해야 합니다. 그리고 Thread 클래스의 생성자에는 Runnable 타입의 매개변수를 받아서 스레드를 생성하는 코드가 있습니다. 이렇게 스레드를 만들면 마찬가지 방법으로 start() 메서드를 호출하고 스레드를 시작합니다.

public interface Runnable {
    public abstract void run();
}

Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없는데, Runnable 인터페이스를 구현하면 다른 클래스를 상속받을 수 있다는 장점이 있습니다.

스레드의 우선순위

모든 자바 스레드는 저마다 가장 낮은 1부터 가장 높은 10까지의 우선순위를 가지고 있습니다. 개발자는 어떤 스레드를 우선해서 작업할 것인가에 따라서 스레드의 우선순위를 달리 지정할 수 있습니다. 스레드를 만들면 자신을 만든 부모 스레드의 우선순위를 따라가며, 메인 스레드의 우선순위는 5로 지정되어 있으므로 메인 스레드에서 생성한 자식 스레드의 우선순위 역시 5로 지정되어 있습니다. 스레드의 우선순위를 지정하려면 아래와 같이 setPriority() 메서드를 호출하면 됩니다.

// 우선순위는 1(Thread.MIN_PRIORITY)이 최소이고 10(Thread.MAX_PRIORITY)이 최대이다.
스레드명.setPriority(우선순위);
// 스레드의 우선순위를 가져오려면 getPriority() 메서드를 호출한다.
System.out.println(스레드명.getPriority());

실행 가능한 스레드 중 우선순위가 가장 높은 스레드가 먼저 실행될 것 같지만, 항상 그런 것은 아닙니다. 사용하고 있는 운영체제나 가상 머신의 버전이나 종류에 따라서 결과가 달라지므로 언제나 동일한 결과가 나올 것이라는 보장은 할 수 없습니다. 아래에 있는 예제 코드를 컴파일 후 실행하여 결과를 살펴보면 이를 확인하실 수 있습니다.

class ThreadA implements Runnable {
	private String name;
	
	public ThreadA(String name) {
		this.name = name;
	}
	
	public void run() {
		for (int i = 0; i < 50; i++) {
			System.out.println(name + " (우선순위: " + Thread.currentThread().getPriority() + ")");
		}
	}
}

public class JavaTutorial28 {
    public static void main(String[] args) {
    	Thread threadA = new Thread(new ThreadA("Thread-0"));
    	Thread threadB = new Thread(new ThreadA("Thread-1"));
    	Thread threadC = new Thread(new ThreadA("Thread-2"));
    	
    	threadA.setPriority(Thread.MAX_PRIORITY); // 10
    	threadB.setPriority(Thread.NORM_PRIORITY); // 5
    	threadC.setPriority(Thread.MIN_PRIORITY); // 1
    	
    	threadA.start();
    	threadB.start();
    	threadC.start();
    }
}

스레드 우선순위에 의존하지 마세요.

오라클에서는 아래와 같이 설명하고 있습니다.

자바의 jang.lang.Thread.setPriority를 사용할 때 주의해야 합니다. 스케줄링 알고리즘에서 낮은 우선순위의 스레드들에게 CPU 시간을 할당하지 않고 해당 스레드들을 지연시킬 수도 있기 때문에, 스레드 우선순위에 따라서 원치 않거나 예상치 못한 결과가 일어날 수 있습니다. 게다가 운영체제와 JVM 간에 결과가 다를 수도 있습니다. 자바 API 스펙(specification)에는 "모든 스레드에는 우선순위가 있으며, 높은 우선순위의 스레드가 낮은 우선순위의 스레드보다 먼저 실행된다."라고 되어 있습니다. setPriority() 메서드로 설정한 우선순위는 실행 중인 스레드들 사이에 CPU 시간을 서로 나누어 쓰는 스레드 스케줄링 알고리즘에서 사용될지도 모르는 매개변수입니다. JVM이나 운영체제가 이 알고리즘을 제어할 수도 있습니다. 스레드 스케줄링 알고리즘은 일반적으로 운영체제마다 다르며, 운영체제와 JVM의 릴리스 사이에 바뀔 수도 있다는 사실을 인지해야 합니다.

인터럽트(Interrupt)

interrupt의 사전적 정의는 '끊다, 중단하다'인데, 인터럽트는 스레드가 자신이 하고 있는 일을 멈추고 다른 일을 해야 한다는 '알림'입니다. 스레드에 인터럽트를 보내도 프로그래머가 별다른 처리를 해주지 않으면 이를 무시할 수 있습니다. 모든 스레드에는 인터럽트 상태를 나타내는 boolean형의 필드가 있으며, 인터럽트 상태의 기본값은 false입니다.

interrupted()

현재 스레드의 인터럽트 상태를 반환하는 메서드입니다. 인터럽트 상태가 true면 이를 false로 설정합니다.

public static boolean interrupted() {
    Thread t = currentThread();
    boolean interrupted = t.interrupted;
    if (interrupted) {
        t.interrupted = false;
        clearInterruptEvent();
    }
    return interrupted;
}

isInterrupted()

현재 스레드의 인터럽트 상태를 반환하는 메서드입니다. interrupted()와는 다르게 이 메서드는 인터럽트 상태를 변경하지 않습니다.

public boolean isInterrupted() {
    return interrupted;
}

interrupt()

interrupt() 메서드를 호출하여 해당 스레드에 인터럽트를 보내면 그 스레드의 인터럽트 상태는 true가 됩니다. 다시 한번 말씀드리지만, 멈추고 싶은 스레드에 인터럽트를 보냈다고 하더라도 스레드는 멈추지 않습니다. 아래와 같이, 스레드 내부에서 인터럽트 상태를 주기적으로 확인하여 프로그래머가 이를 처리해야 합니다.

public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        System.out.println("스레드가 실행 중입니다.");
    }
    System.out.println("스레드가 종료되었습니다.");
}

하지만 예외로 sleep(), wait(), join() 등의 메서드는 우리가 따로 검사를 하지 않아도 내부에서 주기적으로 인터럽트 상태를 검사하며, 인터럽트 상태가 true가 되면 InterruptedException 예외를 발생시킵니다.

class Worker implements Runnable {
    public void run() {
        try {
        	// 10초 동안 스레드의 실행을 멈춘다.
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			System.out.println("인터럽트가 발생했습니다.");
		}
        System.out.println("스레드를 종료합니다.");
    }
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(new Worker());
        worker.start();
        
        // 1초 후에 스레드에 인터럽트를 보낸다.
        Thread.sleep(1000);
        worker.interrupt();
    }
}

스레드 대기하기

join()

다른 스레드가 완료될 때까지 기다리려면 join() 메서드를 사용할 수 있습니다. 스레드 T가 실행 중일 때 끝날 때까지 기다리려면 아래와 같이 쓸 수 있습니다.

T.join();

만약에 스레드 T가 아직 시작되지 않았거나 이미 종료된 경우에는 기다리지 않고 join() 메서드를 빠져나오므로, 바로 다음 문장이 실행됩니다.

Thread worker = new Thread(new Worker());
worker.join();
System.out.println("이런 경우에는 바로 다음 문장이 실행됩니다.");

만약에 join() 메서드를 호출하여 대기 중인데, 스레드 T의 작업이 너무 오래 걸려서 대기가 끝나지 않는 경우에는 아래와 같이 시간을 지정할 수 있습니다. 시간의 단위는 밀리초(ms)입니다.

// 스레드 T의 실행이 종료될 때까지 최대 300ms를 기다립니다.
T.join(300);

join() 메서드를 호출하여 대기 중인 스레드에 다른 스레드가 인터럽트를 보내면 InterruptedException 예외가 발생할 수 있으므로, join() 메서드를 호출한 부분을 아래처럼 try~catch문으로 예외 처리하거나 throws로 예외를 던져야 합니다.

Thread worker = new Thread(new Worker());
try {
    worker.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

아래에 있는 코드를 컴파일 후 결과를 살펴보면 이를 확인하실 수 있습니다.

class Worker implements Runnable {
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
        }
        System.out.println(Thread.currentThread().getName() + " 스레드가 종료되었습니다.");
    }
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Thread workerA = new Thread(new Worker());
        Thread workerB = new Thread(new Worker());
        Thread workerC = new Thread(new Worker());

        workerA.start();
        workerA.join();

        workerB.start();
        workerB.join();

        workerC.start();
        workerC.join();
    }
}

Thread.sleep()

이미 자연스레 사용하고 있었지만, Thread.sleep() 메서드는 지정된 시간만큼 현재 실행 중인 스레드를 일시 중지시키는 기능을 합니다. 현재 실행 중인 스레드를 잠시 멈추는 것이므로, 메인 스레드에서 호출하면 메인 스레드가 멈추고 자식 스레드에서 호출하면 자식 스레드가 멈춥니다.

Thread.sleep(밀리초);

여기서 주의할 점은, sleep() 메서드는 정적 메서드이기 때문에 아래와 같이 호출해도 Thread.sleep() 메서드를 호출한 것과 동일합니다. 즉, worker 스레드가 멈추는 것이 아니라 sleep() 메서드를 호출한 스레드가 멈추게 됩니다.

Thread worker = new Thread(new Worker());
worker.start();
worker.sleep(3000); // = Thread.sleep(3000)

이 메서드도 join() 메서드와 마찬가지로, sleep() 메서드를 호출하여 대기 중인 스레드에 다른 스레드가 인터럽트를 보내면 InterruptedException 예외가 발생할 수 있습니다. 따라서, 아래처럼 sleep() 메서드를 호출한 부분을 아래처럼 try~catch문으로 예외 처리하거나 throws로 예외를 던져야 합니다.

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

경쟁 상태(race condition)

경쟁 상태는 두 개 이상의 스레드가 공유 데이터에 접근할 수 있고 동시에 같은 데이터를 변경하려고 할 때 발생합니다. 경쟁 상태에는 크게 두 가지 패턴이 있습니다. 우선 그전에 원자성이 무엇인지 잠깐 살펴보고 가도록 하겠습니다.

원자성(Atomicity)

원자를 의미하는 영어 단어인 atom은 '더 이상 쪼갤 수 없다'라는 의미의 그리스어 'atomos'에서 온 것입니다. 원자성은 더 이상 쪼개질 수 없는 성질을 말하는데, 어떤 것이 원자성을 가지고 있다면 원자적(atomic)이라고 합니다. 그러면 원자적 연산(atomic operation)은 무엇일까요? 말 그대로 쪼갤 수 없는 연산을 말합니다. 아래 연산은 원자성을 가지고 있다고 말할 수 있을까요?

a = a + 1;

이는 물론 하나의 연산이 아닙니다. 더 낮은 수준에서 바라보면 이 연산은 아래와 같이 쪼갤 수 있습니다. 여기서 피연산자 스택은 간단히 말하면 중간 연산 결과를 임시적으로 저장하기 위해 사용되는 공간입니다.

그럼 아래의 연산은 원자성을 가지고 있다고 할 수 있을까요?

a++;

이 연산 역시도 원자성을 가지고 있지 않습니다. 만약 a가 클래스의 인스턴스 변수라면 이 연산은 아래와 같이 쪼갤 수 있습니다.

Read-Modify-Write 패턴

위에서 들었던 예들은 '읽기 변경 쓰기(read-modify-write)' 패턴이라고 할 수 있습니다. 위에서 본 바이트코드가 제대로 무엇을 하는지 몰라도, a의 값을 1만큼 증가시키기 위해서는 컴퓨터 내에서 최소한 아래와 같은 과정을 거쳐야 한다는 것을 알 수 있습니다.

  1. a의 값을 읽는다. (읽기)
  2. 이 값에 1을 더한다. (변경)
  3. 더한 값을 a에 저장한다. (쓰기)

만약 실행 중인 두 개의 스레드가 변수 a에 접근하여 변수 a의 값을 1만큼 증가시키는 연산을 수행한다면 아래와 같은 일이 벌어질 수 있습니다.

스레드 A가 연산 후 결과를 저장하기 전에 스레드 B가 접근한다면 변수 a에 137이 아니라 136이 저장될 수 있습니다. 아래의 코드를 컴파일 후 실행하면 예상과는 전혀 다른 값을 얻게 됩니다.

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getValue() {
        return count;
    }
}

class ThreadA implements Runnable {
    private Counter counter;

    public ThreadA(Counter counter) {
        this.counter = counter;
    }

    public void run() {
        for (int i = 0; i < 10000; i++) {
            counter.increment();
        }
    }
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread threadA = new Thread(new ThreadA(counter));
        Thread threadB = new Thread(new ThreadA(counter));
        Thread threadC = new Thread(new ThreadA(counter));

        threadA.start();
        threadB.start();
        threadC.start();

        threadA.join();
        threadB.join();
        threadC.join();

        System.out.println(counter.getValue()); // 30000이 나올까요?
    }
}

Check-Then-Act 패턴

이번에는 '확인 후 행동(check-then-act)' 패턴을 살펴보도록 하겠습니다. 프로그래밍을 하다보면 아래와 같이 어떤 값을 검사하고 특정 행동을 할 때가 많습니다.

public void decrement() {
    if (count > 0) { // 확인(Check)
        count = count - 1; // 행동(Act)
    }
}

만약 실행 중인 두 개의 스레드가 아래와 같은 순서로 접근한다면 'count가 0보다 큰 경우에만' 이라는 조건을 붙였음에도 불구하고 count의 값이 -1이 될 수 있습니다.

아래 코드를 컴파일 후 실행하면 항상 결과가 0이 나오는 것은 아니라는 걸 확인하실 수 있습니다.

class Counter {
    private Integer count = 1000;

    public void decrement() {
        if (count > 0) {
        	count = count - 1;
        }
    }

    public int getValue() {
        return count;
    }
}

class ThreadA implements Runnable {
    private Counter counter;

    public ThreadA(Counter counter) {
        this.counter = counter;
    }

    public void run() {
        for (int i = 0; i < 1000; i++) {
            counter.decrement();
        }
    }
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread[] threads = new Thread[10];
        
        for (int i = 0; i < threads.length; i++)
        	threads[i] = new Thread(new ThreadA(counter));
        
        for (int i = 0; i < threads.length; i++)
        	threads[i].start();
        
        for (int i = 0; i < threads.length; i++)
        	threads[i].join();

        System.out.println(counter.getValue()); // 0이 나올까요?
    }
}

스레드 동기화(Thread Synchronization)

이렇게 경쟁 상태(race condition)가 발생하는 걸 막으려면 어떻게 해야 할까요? 자바에서는 특정 시점에 하나의 스레드만 자원에 접근할 수 있도록 synchronized 키워드를 제공합니다. 이를 알아보기 전에, 크리티컬 섹션이 무엇인지 살펴보고 가겠습니다.

크리티컬 섹션(Critical Section)

크리티컬 섹션은 임계 영역 또는 임계 구역, 공유변수 영역으로 부르기도 합니다. critical이 '치명적인', '대단히 중요한'이라는 뜻으로, 크리티컬 섹션은 중요한 영역이기에 보호되어야 하는 영역이라고 할 수 있습니다. 즉, 다른 스레드에게 방해받지 않도록 하나의 스레드만 실행되어야 하는 코드 영역을 말합니다.

public void increment() {
    count++;
}

둘 이상의 스레드가 공유 자원인 count에 동시에 접근하려고 할 때 경쟁 상태가 발생하므로 이를 막기 위해선 크리티컬 섹션을 표시해야 합니다. 이런 크리티컬 섹션을 표시하는 방법 중 하나로 synchronized 키워드를 사용할 수 있습니다.

동기화 블록(Synchronized Blocks)

동기화 블록은 다음과 같이 만들 수 있습니다. 여기서 동기화 블록으로 코드를 감쌀 때 공유 데이터를 변경하는 코드 부분만, 즉 크리티컬 섹션만 동기화 블록으로 감싸는 것이 좋습니다. 한 스레드가 동기화 블록 안으로 들어가면 자동으로 락(lock)을 얻는데, 나와서 락을 풀어주기 전까지는 다른 스레드는 동기화 블록 안으로 들어갈 수 없습니다. 여기서 락은 일종의 잠금장치라고 생각하셔도 괜찮습니다. 자바의 모든 객체는 저마다 고유 락(intrinsic lock)을 가지고 있으며, 이 락을 모니터 락(monitor lock)이나 간단하게 모니터(monitor)라고 부르기도 합니다.

synchronized (객체) {
	// 크리티컬 섹션
}

스레드는 위와 같이 동기화 블록 안으로 들어가려 할 때 자동으로 객체의 고유 락을 얻으며, 빠져나올 때 고유 락을 해제합니다. 그리고 한 스레드가 객체의 고유 락을 획득하면 다른 스레드는 락이 해제되기 전까지는 그 객체의 고유 락을 얻을 수 없으므로 기다려야 합니다.

class Counter {
    private int count = 0;

    public void increment() {
    	synchronized (this) {
    		count++;
    	}
    }

    /* ... */
}

동기화 메서드(Synchronized Methods)

동기화 메서드는 메서드 선언에 synchronized 키워드를 사용합니다. 동기화 메서드는 동기화 블록과 마찬가지로 하나의 스레드만 들어갈 수 있으며, 해당 스레드가 동기화 메서드에서 나올 때까지 다른 스레드의 실행이 차단됩니다.

public synchronized void increment() {
    count++;
}

기능만 보면 아래의 코드와 동일합니다.

public void increment() {
    synchronized (this) {
        count++;
    }
}

데드락(Deadlock)

이를 교착 상태라고 부르기도 합니다. 교착의 사전적 정의 그대로 어떤 상태가 그대로 고정되어 조금도 변동이나 진전이 없는 상태로 머물러 있는 것을 말합니다. 이를 스레드의 세상으로 가져오면, 둘 이상의 스레드가 서로의 락이 풀리기를 기다리면서 영원히 대기하는 것을 말합니다.

교차로에서의 교착 상태

발생 조건

아래 4개의 조건이 모두 참이면 데드락에 빠질 수 있습니다. 프로세스 말고도 스레드에도 아래의 조건을 적용할 수 있습니다.

  • 상호 배제(Mutual exclusion): 자원은 한 번에 하나의 프로세스만이 사용할 수 있다.
  • 점유 대기(Hold and wait): 프로세스는 하나 이상의 자원을 점유하고 있으면서 그와 동시에 다른 프로세스에서 점유 중인 자원을 추가로 점유하기 위해 대기하고 있어야 한다.
  • 비선점(No preemption): 어떤 프로세스가 사용 중인 자원을 다른 프로세스가 요구하면 해당 프로세스는 자원의 사용이 끝날 때까지 기다려야 하며 강제로 빼앗을 수 없다.
  • 순환 대기(Circular wait): 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다. 즉, 프로세스 집합 \(\{p_0, p_1, \cdots, p_n\}\)이 있으면 \(p_0\)은 \(p_1\)이 사용 중인 자원을 요구하며, \(p_1\)은 \(p_2\)가 사용 중인 자원을 요구하고, \(p_{n-1}\)은 \(p_n\)이 사용 중인 자원을 요구하고, \(p_n\)은 \(p_0\)가 사용 중인 자원을 요구하는 상황을 말한다.

조건 중 하나라도 거짓이면 데드락에 빠지지 않습니다. 이중 순환 대기 조건은 점유 대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위의 4가지 조건은 서로 완전히 독립적인 것은 아닙니다.

식사하는 철학자들 문제(Dining philosophers problem)

식사하는 철학자들 문제는 컴퓨터 과학에서 데드락을 설명하기 위해서 자주 등장하는 문제입니다. 식당의 한 가운데에는 의자 5개가 놓인 원형 테이블이 있으며 각 의자에는 철학자가 한 명씩 앉아있습니다. 철학자는 서로 말을 할 수 없습니다. 각 철학자의 앞에는 스파게티가 놓인 접시가 있으며, 철학자들 사이에는 젓가락이 하나씩 있습니다. 철학자는 각자 깊은 사색에 빠져 있으며, 배가 고프면 잠시 생각을 멈추고 좌우에 있는 젓가락을 집어들어 스파게티를 먹을 수 있습니다. 젓가락을 집어들 때, 좌우에 있는 젓가락을 동시에 들 수 없으며 한 번에 하나의 젓가락만 들 수 있습니다. 식사가 끝나면 젓가락을 내려놓고 다시 생각하기 시작합니다.

이를 코드로 나타내면 대략적으로 아래와 같을 것입니다. 좌우에 있는 젓가락의 락을 획득하고, 식사를 마치면 다른 철학자가 젓가락을 쓸 수 있도록 락을 해제합니다.

while (true) {
	think();
    pickUp(leftChopstick, rightChopstick);
    eat();
    putDown(leftChopstick, rightChopstick);
}

하지만 한 번에 하나의 젓가락만 들 수 있으므로, 왼쪽 젓가락의 락을 획득하고 바로 이어서 오른쪽 젓가락의 락을 획득하도록 구현합니다. synchronized를 이용해 이 문제를 구현하면 다음과 같습니다.

class Philosopher implements Runnable {
	private final String name;
	private final Object leftChopstick;
	private final Object rightChopstick;
	
	public Philosopher(String name, Object leftChopstick, Object rightChopstick) {
		this.name = name;
		this.leftChopstick = leftChopstick;
		this.rightChopstick = rightChopstick;
	}
	
	public void run() {
		try {
			while (true) {
				doAction("생각 중");
				synchronized (leftChopstick) {
					doAction("왼쪽 젓가락을 집어듬");
					synchronized (rightChopstick) {
						doAction("오른쪽 젓가락을 집어들고 스파게티를 먹음");
						doAction("오른쪽 젓가락을 내려놓음");
					}
					doAction("왼쪽 젓가락을 내려놓음");
				}
			}
		}
		catch (InterruptedException ex) {
			Thread.currentThread().interrupt();
		}
	}
	
	private void doAction(String action) throws InterruptedException {
		System.out.println(name + ": " + action);
		Thread.sleep((int)(Math.random() * 100));
	}
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Philosopher[] philosophers = new Philosopher[5];
        Object[] chopsticks = new Object[philosophers.length];
        
        for (int i = 0; i < chopsticks.length; i++) {
        	chopsticks[i] = new Object();
        }
        
        for (int i = 0; i < philosophers.length; i++) {
        	Object leftChopstick = chopsticks[i];
        	Object rightChopstick = chopsticks[(i + 1) % chopsticks.length];
        	
        	philosophers[i] = new Philosopher("철학자 " + (i + 1), leftChopstick, rightChopstick);
        	
        	Thread p = new Thread(philosophers[i]);
        	p.start();
        }
    }
}

어쩔 때는 막힘없이 잘 실행되는 것 같지만, 또 어쩔 때는 아래와 같은 상황이 벌어집니다. 모든 철학자가 동시에 왼쪽 젓가락을 집어들면, 오른쪽 젓가락은 다른 철학자가 사용하고 있으므로 음식을 먹지 못하고 계속해서 대기합니다. 즉, 순환 대기가 발생합니다.

데드락에 빠지는 것을 막으려면 발생 조건 중 하나를 해결해야 합니다. 이 문제의 해결책을 떠올려 보자면 원형 테이블이 5명이 아니라 4명의 철학자를 앉힌다던가, 좌우의 젓가락을 동시에 쓸 수 있는 경우만 젓가락을 고르는 등이 있을 수 있습니다. 여기에서는 자원 계층(resource hierarchy)을 사용하여 순환 대기를 해결합니다. 이 방법은 젓가락(자원)에 순서대로 번호를 부여하고, 각 철학자들이 낮은 번호의 젓가락을 먼저 들어야 한다는 규칙을 추가합니다.

이렇게 하면 5명 중 4명의 철학자가 동시에 낮은 번호의 젓가락을 집는다고 하더라도 가장 높은 번호의 젓가락이 남기 때문에, 젓가락을 집은 4명의 철학자 중 한 명은 두개의 젓가락을 집을 수 있게되어 음식을 먹을 수 있습니다.

이를 코드로 나타내면 다음과 같습니다. 여기서 이 문제의 해결책이 중요한 것이 아니라, 데드락이 무엇이고 어떤 조건에서 발생할 수 있는지 이해할 수 있으면 넘어가셔도 충분합니다.

class Chopstick {
	public final int number;
	
	public Chopstick(int number) {
		this.number = number;
	}
}

class Philosopher implements Runnable {
	private final String name;
	private final Chopstick firstChopstick;
	private final Chopstick secondChopstick;
	
	public Philosopher(String name, Chopstick firstChopstick, Chopstick secondChopstick) {
		this.name = name;
		// 첫 번째로 집어드는 젓가락은 낮은 번호의 젓가락이다.
		if (firstChopstick.number > secondChopstick.number) {
			this.firstChopstick = secondChopstick;
			this.secondChopstick = firstChopstick;
		}
		else {
			this.firstChopstick = firstChopstick;
			this.secondChopstick = secondChopstick;
		}
	}
	
	public void run() {
		try {
			while (true) {
				doAction("생각 중");
				synchronized (firstChopstick) {
					doAction(firstChopstick.number + "번 젓가락을 집어듬");
					synchronized (secondChopstick) {
						doAction(secondChopstick.number + "번 젓가락을 집어들고 스파게티를 먹음");
						doAction(secondChopstick.number + "번 젓가락을 내려놓음");
					}
					doAction(firstChopstick.number + "번 젓가락을 내려놓음");
				}
			}
		}
		catch (InterruptedException ex) {
			Thread.currentThread().interrupt();
		}
	}
	
	private void doAction(String action) throws InterruptedException {
		System.out.println(name + ": " + action);
		Thread.sleep((int)(Math.random() * 100));
	}
}

public class JavaTutorial28 {
    public static void main(String[] args) throws InterruptedException {
        Philosopher[] philosophers = new Philosopher[5];
        Chopstick[] chopsticks = new Chopstick[philosophers.length];
        
        for (int i = 0; i < chopsticks.length; i++) {
        	chopsticks[i] = new Chopstick(i);
        }
        
        for (int i = 0; i < philosophers.length; i++) {
        	Chopstick leftChopstick = chopsticks[i];
        	Chopstick rightChopstick = chopsticks[(i + 1) % chopsticks.length];
        	
        	philosophers[i] = new Philosopher("철학자 " + (i + 1), leftChopstick, rightChopstick);
        	
        	Thread p = new Thread(philosophers[i]);
        	p.start();
        }
    }
}