본문 바로가기
Java/Java Basic

[Java Basic] 44 - Java Thread 3 - Thread 동기화(Synchronized, wait(), notify())

by Rosmary 2022. 9. 23.
728x90
반응형



Thread의 실행제어를 통해 이런 저런 코드를 만들어서 동작시키다보면, 문득 이런 생각이 든다.

"만약 서로 다른 Thread가 하나의 공유 자원을 가지고 있을 때, 동시에 Thread가 실행되어 발생할 문제는 없는 것인가?"

예를 들어보자. 뷔폐가 하나 있고, 음식 중 삶은 계란이 나온다. 삶은 계란은 15초마다 5개씩 접시에 담기며, 20개가 삶아지면 더 이상 추가되지 않는다. 우선 이 부분을 코드로 작성해보자.

계란의 현재 개수를 반환받는 getAmount()도 정의가 되어 있다. 간단한 것이라 스크린샷은 잘랐다.



접시에 계란이 20개를 초과하지 않는 이상, 뷔폐는 15초마다 5개의 계란을 만들어 추가하는 것이 보인다. 이제 이 계란을 가져갈 고객에 대한 내용을 클래스로 만들고 하나의 인스턴스만 생성하여 10초에 1~2개씩 계란을 가져간다고 해보자.

 




고객이 단 한 명일 때는 수요보다 생산량이 많아 풍요롭고 평화롭다. 이제 고객을 늘려보자.

있었는데요, 없었습니다.

 



Thread를 여러 개 사용하면서 단 하나의 자원을 공유하게 되면 발생하는 문제점이 위에 나타나 있다. Thread 사이 전환이 빈번하다보니 간간히 공유값에 대한 연산과 결과 출력이 시간간격을 두고 시행되어 값이 일치하지 않는 사태가 벌어진다. 처음 계란을 가져간 것도 customer4와 customer2가 각각 1개, 2개를 가져갔으나, customer2는 계란 0개를 가져갔다는 이상한 결과가 출력된다. 즉, Thread 사이 공유하는 자원에 대해 동기화가 진행되지 않아 발생하는 일인데, 이 때문에 Java에서는 Thread 사이 동기화를 진행해주는 몇몇 클래스를 가지고 있다.

이번 포스팅에서는 Thread의 동기화 클래스 및 그와 관련된 매서드들에 대해 알아보려 한다.




1. Synchrozined 제어자

일전에 제어자에 대한 포스팅을 진행하면서 필자가 넘긴 몇몇 제어자들 중 Synchronized라는 녀석이 있다. 이 제어자는 여러 Thread를 공유하는 자원에 적용한다. 적용 가능한 단위는 매서드와 참조변수며, 이 제어자가 적용된 매서드와 참조변수는 해당 매서드와 참조변수를 공유하는 Thread 중 하나에게 lock이라 불리는 제어권을 넘겨줌으로써 다른 Thread의 접근을 차단한다. 마치 화장실 한 칸에 여러 사람이 몰려있을 때, 하나의 화장실을 짧은 시간 동안 여러 사람이 번갈아 사용하는 것이 아니라(!!), 한 명이 문을 잠그고(lock) 쓰고 있는 동안 나머지는 기다리고, 화장실을 사용한 한 명이 볼 일을 마치고 나오면(unlock), 기다리던 사람 중 한 명이 화장실을 잠그고(lock) 사용하는 것과 동일한 원리라 보면 된다 (어째서 비유가...).

Synchronized를 사용하려면 Thread들이 공유하는 자원에, Thread가 공통적으로 사용하는 매서드, 참조변수가 존재해야 한다. 현재 필자가 만든 코드를 보면 Customer에서 계란을 가져가는 내용에 대해 정의되어 있는데, 이 코드를 공통 자원인 Egg 클래스 내부로 옮겨야만 한다. 코드를 옮기고 synchronized를 적용해보자.


CustomerThread에서는 계란을 가져갈 경우 단순히 Egg Thread 내부의 customerServed() 매서드를 호출하도록 만들었다.



이제 결과를 보자. 서두의 예시와 다르게, 계란을 0개 가져가는 고객은 더 이상 나타나지 않는다.


메서드에 적용하지 않고 synchronized() 블럭을 사용하여 동기화를 진행할 코드에만 synchronized 설정을 진행하는 방법도 있다.


코드 결과가 한결 보기 편해졌다.

선생님, 좋은 짤 감사합니다



그런데, 위에서 synchronized로 작성한 코드는 한 가지 단점이 있다. 아까 화장실 예시로 돌아가면....한 사람이 화장실을 점유(lock)하고 있다가 편안한 상태(unlock)로 나오면, 오래 기다린 사람이 다음 사용자가 된다는 보장이 없다. 위의 뷔폐 예시만 보더라도, 첫 부분에서 Customer2와 Customer3이 계란을 가져가서 Customer1, 4가 계란이 없다고 아우성인데, 계란이 접시에 담기자마자 계란을 가져가는것은 Customer2다. 이는 customerServed() 매서드에 while 조건문 추가 및 코드의 순서 변경만으로도 간단히 해결할 수 있다.



본격적으로 뷔폐 영업을 시작해보자. 계란 뿐만 아니라 몇 가지 음식을 조금 더 추가하고, 테이블 위에 개별 음식을 올려두면 고객들이 각자가 원하는 음식을 가져가도록 만들어보려한다. 그럼, 음식을 만드는 클래스와 고객은 Table이라는 공통의 자원을 가지게 되며, 고객 클래스들은 음식들을 공통 자원으로 가지게 된다.

테이블의 크기를 고려하여 음식은 5개만 상 위에 올릴 수 있는 것으로 정의하려한다. 먼저 Table과 Chef를 정의해보자.



table 부분에 add() 및 getFoodsInTable() 매서드가 synchronized 제어자가 적용된 것을 확인하자. Table 자체 클래스의 Thread와 Chef 클래스의 Thread가 foods ArrayList를 공유하기 때문에 반드시 동기화를 진행해야한다. 만약 이 동기화가 이루어지지 않는다면, Chef에서 음식을 테이블에 올리는 순간과 Table 목록 출력이 동시간에 진행될 경우 concurrentModificationException이라는 에러가 발생한다.

에러 발생 원인은 위를 참조하자. Java Documentation에 있는 내용이다.


사실 이 코드는 add()매서드와 getFoodsInTable() 매서드와 합치면 된다. 서로 다른 두 개의 매서드가 한 번에 공유자원을 사용하기 위해 몰려오다보니 벌어진 일인데, Thread 반복 수행 시간도 1초로 동일하니, 필자는 그냥 아래와 같이 두 매서드를 하나로 합치려한다.



다음으로 Customer 클래스를 정의해보자. Customer는 각 Thread의 이름을 얻기위해 Runnable 인터페이스를 구현하지 않고 Thread 클래스를 상속받아 진행하려한다.


Customer 클래스 역시 인스턴스를 Thread화하여 코드를 실행해보면, 잘 돌아가는 듯 하다. 하지만 여러 번 이 코드를 실행하다보면 낮은 확률로 두 가지 문제가 발생한다.

remove에 i 값을 출력하는 코드를 하나 더 집어넣었다.


첫 번째 문제부터 보자. 에러명은IndexOutOfBoundsException이라고 배열에서 크기를 초과한 index로 값을 반환받을 때 자주 보았던 것이다. 원인은 remove() 매서드에 동기화가 진행되지 않은 것인데, 하나씩 살펴보자.

모든 고객이 첫 접시를 들고 간 뒤 테이블에는 Pasta, Pasta, Egg, Pizza, Egg 순으로 음식이 놓여있다. 2번 고객이 pasta를, 1번 고객이 Egg를, 3번 고객이 Pizza를 들고가기 위해 확인한 접시 번호(index)는 각각 0번, 2번, 3번이다. 2번 고객이 0번의 Pasta를 들고가면서 남은 음식의 index값에 -1 연산이 적용되었다. 2번 접시를 들고가야하는 1번 고객이 Egg가 아닌 Pizza를 테이블에서 들고가게 되면서 테이블에는 Pasta, Egg, Egg 단 세 개의 음식만 남게 된다. 마지막으로 음식을 들고가는 3번 고객은 자신이 들고가야하는 접시 번호인 3번을 찾아보지만, 테이블 위에는 단 3개의(index 0~2) 음식만 남아있으므로 음식을 가져가지 못한다. 이 때문에 에러가 발생한 것이다. 그리고 예외가 발생한 Customer3 Thread는 예외로 인해 바로 실행이 종료된다.


두 번 째 문제는 매우, 매우 낮은 확률로 Chef Thread가 추가하는 음식 중, null 값이 포함되는 경우가 발생한다는 것이다. 필자가 이 오류는 재현하기가 심히 어려워 정확한 원인은 파악이 되지 않는데, 이 역시 앞서 설명한대로 add(), remove()의 매서드 동기화로 인한 공유값의 오류로 판단하고 있다.


발생 확률이 적다고 하더라도 프로그램 실행 결과에는 치명적이기 때문에 이 예외 상황을 만들지 않기 위해서는 Chef Thread가 Table에 영향을 주는 add() 매서드와, Customer Thread가 Table에 영향을 주는 remove() 매서드가 동기화 되어야할 필요성이 있다. add() 뿐만 아니라 remove()에도 synchronized 제어자를 적용하면, 에러 없이 정상적으로 코드가 동작함을 확인할 수 있다.

대략 50 사이클 이상 돌린 듯 한데 예외는 전혀 발생하지 않았다.



동기화가 적용되지 않았을 때와 동기화가 적용되었을 때의 Thread의 실행을 그림으로 나타내면 아래와 같다.

add()만 동기화 적용 시.

 

add(), remove() 모두 동기화 적용 시






2. wait(), notify(), notifyAll()

이번에는 뷔페 진상 손놈(?)을 일부러 만들어보려한다. 지금 필자가 만든 뷔페 고갱님들은 음식이 없으면 우선 제자리로 갔다가 시간이 지나면 다시 와서 음식이 나왔는지 확인하는 유형이다. 따라서 같은 음식을 두고 경쟁하는 고객이 있다면, 먼저 음식이 떨어진 것을 인지했다고 하더라도 다른 경쟁 손님보다 음식을 늦게 들고 갈 수도 있다.

다행히 원하는 결과가 빨리 나왔다.


위의 결과를 보면 pizza 하나를 두고 3번, 4번 고객이 경쟁하는 상황인데, 4번이 Pizza가 없어 자리로 돌아간 사이 Pizza 한 개가 테이블에 올라왔고, 마침 지나가던 3번 고객이 이 피자를 낼름 한 것이다...

이제 Pizza를 가져가는 고갱님들은 손놈으로 바꿔보자. 음식이 나올 때까지 Table을 점유하는 것이다. 이러기 위해서는 Pizza를 원하는 고객 Thread가 synchronized 매서드를 계속해서 점유해 줄 필요가 있으니, remove() 매서드 코드를 아래와 같이 변경시켜보자.



Table에 있던 단 하나의 Pizza를 3번 고객이 가져가서 4번 고객이 Pizza를 못가져가자, 테이블 옆 땅바닥에 누워 진상을 부리고 있는 형태다. 문제는, 고객이 테이블 옆에서 진상을 부리고 있기 때문에(Lock), 테이블에 음식을 올려야하는 Chef Thread 또한 고객의 진상짓이 끝날때까지 음식을 만들어 올리지 못하게 된다. 왜냐하면 add() 매서드 역시 synchronized로 동기화되어 있기 때문에.

강제로 저 진상 손놈을 잠깐 테이블에서 멀리 떨어뜨릴 수 있는 방법이 없을까? 이렇게 하나의 Thread가 lock을 오래 공유하고 있는 경우 프로그램이 정상적으로 동작하지 못하기 때문에 정의된 매서드가 wait()이다.

그런데, 아무리 진상 손놈이라도 식당에 돈은 내었으니 음식이 나오면 테이블로 접근이 가능하도록(재진입이라고 한다) 만들어주어야 한다. 이 때 사용하는 매서드가 notify()다.

wait()과 notify()는 Thread 클래스에 정의된 매서드가 아니라, 최상위 클래스인 Object에 정의되어 있다. 따라서 모든 클래스는 이 매서드를 가지고 있으나, 매서드 성격 상 Thread를 상속받거나 Runnable 인터페이스를 구현하는 경우가 아니라면 사용할 일이 없다.

wait()과 notify()는 동기화된 Thread의 제어에 사용되나, Thread 객체 자체에서 호출하는 것이 아니라 synchronized 제어자가 적용된 매서드나 블럭 내에서 호출된다. 이 외의 부분에서 wait(), notify(), notifyAll()을 호출하면 IllegalMonitorState Exception 예외가 발생한다.

진상 손놈을 테이블에서 떨어뜨려보자.




위의 경우는 테이블에 Pizza가 하나도 없어 3번 고객이 PIzza를 내놓으라고 협박하는 상황이다. 다행히 한 번의 협박 뒤에는 wait()으로 인해 테이블에서 멀어지게 되어 Chef와 다른 고객들이 음식을 서빙하고 가져가는데 큰 문제가 없다.

참고로 wait() 매서드는 InterruptedException을 던지므로, 반드시 try-catch 문 내에 작성하여 예외 처리를 진행해야 한다.



이제 이 코드를 계속 실행시켜보자. 계속 실행시키다보면 이상한 점이 눈에 띄는데 Pizza를 달라고 단 한 번이라도 난동을 부린 손님은, 테이블에 Pizza가 올라왔음에도 음식을 가져가는 시도조차 못하고 있다.



wait()으로 테이블에서 떨어뜨려놓기, 즉 강제로 lock을 반환하도록 만들었는데 다시 lock을 얻기 위해서는 wait()으로 걸려있는 대기 상태를 해제해주어야 한다. wait()을 경비요원이라고 비유한다면, 경비요원이 "이제 Pizza 나왔네요. 가서 식사하세요!" 하고 3번 고객을 풀어줘야한다는 말이다. 이 때 wait()으로 인해 대기 상태에 놓인 Thread에게 다시 lock을 할당받을 수 있도록 풀어주는 매서드가 notify()다.

우선은 한 번 난동을 부려서 wait()에 걸린 고객은 Pizza가 만들어지면 notify()로 풀어주도록 하자.

한 번 난동을 부렸던 진상 손놈이 다시 Table로 돌아올 수 있게 되었다(그래봐야 또 Pizza 없으면 달라고 진상짓 하는데..).


여기서 눈여겨봐야 할 부분은 notify() 코드가 작성된 위치다. wait()은 remove() 매서드에 정의되어 있으나 notify()는 add() 매서드에 정의되어 있다. 하지만 두 매서드는 synchronized로 동기화되어있기 때문에 add() 매서드에 notify()가 정의되었더라도 remove() 매서드에서 wait()이 걸린 Thread라도 문제없이 lock을 사용할 수 있는 기회를 줄 수 있게 되는 것이다.

wait()을 사용하면 Chef Thread 또한 테이블이 가득 차 있을 때, 음식을 만드는 코드를 실행하지 않도록 만들 수 있다. 이 부분은 각자 진행해보자.



4. 기아 상태와 경쟁상태, notifyAll()

Chef와 Customer Thread 모두가 동기화로 공유하는 코드에 wait()이 적용되어 있다고 가정해보자. 지독하게 운이 좋지 않아서, 테이블에는 음식 5가지가 가득 찼는데, 3번과 4번 고객이 원하는 Pizza가 없어 난동을 피우는 상황이 되었다고 해보자. Chef는 테이블에 음식이 가득 차 있어서 wait()이 걸렸고, 3번 및 4번 고객은 난동을 피우는 바람에 wait()이 걸린 상태다. 현재 필자가 작성한 코드는 wait()에 진입하는 조건문이 구체적이어서 두 Thread가 동시에 wait()이 걸릴 확률이 적은데, 만약 조건문이 허술하여 두 Thread가 동시에 wait()에 걸렸을 때 notify()를 받으면 두 Thread는 lock을 얻기 위해 경쟁을 하게 된다. 이를 경쟁상태(Race Condition)라고 한다.

경쟁 상태가 지속되는 와중에, - 확률은 매우매우 낮지만 - 고객 Thread만 lock을 획득한다고 가정해보자. Chef Thread는 lock을 얻기 위해 계속 기다리기만 하는 상황이 발생하는데 이를 기아 상태(Starvation Condition)라고 한다.

두 개 이상의 Thread가 동시에 wait()이 걸린 상태에서 notify() 매서드가 호출되면, 하나의 매서드만이 lock을 얻을 수 있는 상태로 전환된다. 따라서 이로 인해 발생할 수 있는 기아/경쟁 상태를 예방하기 위한 매서드가 notifyAll()이다. notifyAll()은 wait() 상태로 전환된 모든 Thread를 lock 획득이 가능한 상태로 전환한다.



그럼, 여기서 드는 의문. 경쟁 상태에 놓인 Thread 중, 하나를 선별하여 notify()를 통지할 수 있는 방법은 없을까? 다음 포스팅에서는 선별 통지를 위한 클래스인 Lock과 Condition에 대해 알아보려 한다.



Fin.

반응형

댓글