• [JAVA/자바] 스레드 동기화(Thread Synchronization)

    2023. 6. 7.

    by. 개발도비

    멀티스레드는 다수의 작업을 동시에 실행시키는 응용프로그램 작성 기법이지만 다수의 스레드가 공유 자원 혹은 공유 데이터에 동시에 접근할 때 예상치 못한 결과가 생길 수 있다. 

    멀티스레드 프로그램을 작성할 때 주의할 점은 다수의 스레드가 공유 데이터에 동시 접근하는 경우에 대한 처리이다. 이에 대한 해결책이 스레드 동기화(Thread Synchronization)이다. 스레드 동기화란 공유 데이터에 접근하고자 하는 다수의 스레드가 서로 순서대로 충돌 없이 공유데이터를 배타적으로 접근하기 위해 상호협력(coordination)하는 것을 말한다. 공유 데이터에 대한 접근은 배타적이고 독점적으로 이루어져야 하며, 독점적으로 공유 데이터를 다루는 프로그램 코드를 임계 영역(critical section)이라고 부른다. 자바에서 스레드 동기화에는 2가지 방법을 사용한다.

     

    * synchronizaed로 동기화 블록 지정

    * wait()-notify() 메소드로 스레드 실행 순서 제어 

     

    자바의 스레드 동기화를 위한 synchronized 키워드

    : 가장 쉬운 스레드 동기화 기법은, 스레드가 공유 데이터에 접근할 때 하나씩 순차적으로 실행하도록 제어하는 기법이다. 한 스레드가 공유 데이터의 접근을 시작한 순간, 데이터를 잠가(lock) 다른 스레드가 대기하도록 하면 된다. 

    synchronized 키워드는 스레드 동기화를 위한 장치로서, 임의의 코드 블록을 동기화가 설정된 임계 영역으로 지정한다. synchronized 키워드로 하나의 메소드를 통째로 임계 영역으로 지정하는 방법과, 임의의 코드 블록을 임계 영역으로 지정하는 두 가지 방법이 있다. 어떤 방법을 사용하든지 synchronized 블록은, 진입할 때 락(lock)이 걸리고 빠져나올 때 락이 풀리는(unlock) 동작이 자동으로 이루어지도록 컴파일된다. 그러므로 먼저 synchronized 블록에 진입하는 스레드가 락을 소유하며, 락을 소유하지 못한 다른 스레드는 synchronized블록 앞에서 락을 소유할 때까지 대기한다.

     

    * 메소드 전체를 임계 영역으로 지정

    : 아래 코드와 같이 synchronized를 선언하면 add() 메소드를 통째로 임계 영역으로 지정한다. 

    synchronized void add() {
    	int n = getCurrentSum(); //현재 합을 알아낸다
        n += 10; //10만큼 증가시킨다
        setCurrentSum(n); //증가된 결과를 기록한다
     }

    add() 메소드가 호출되면 자동으로 동기화된다. 한 스레드가 add() 메소드를 호출하여 실행하고 있는 도중에 다른 스레드가 add() 메소드를 호출하면, 이 스레드는 첫 번째 스레드가 add() 실행을 마치고 완전히 빠져나오기까지 자동으로 대기하게 된다. 

     

    * 코드 블록을 임계 영역으로 지정

    : 중괄호({ })를 이용하여 코드 블록을 synchronized로 지정할 수 있다

    void execute() {
      .....................
        synchronized(this){
         int n = getCurrentSum(); 
         n += 10; 
         setCurrentSum(n); 
      }
      ..................
    }

    한 스레드가 synchronized 블록 내의 코드를 실행하고 있을 때 다른 스레드가 이 블록을 실행하고자 하면, 먼저 실행 중인 스레드가 synchronized 블록의 실행을 마칠 때까지 자동으로 대기한다. 여기서 synchronized(this)는 synchronized 블록에서 인자로 주어진 객체(this)와 연계된 락(lock)을 사용하도록 개발자가 지정한 것이다. 개발자는 this 대신 다른 객체의 레퍼런스를 사용할 수 있으며 그 객체가 락과 연관된다. 

     

    참고) 스레드가 synchronized 코드 블록을 실행하는 순간 그 블록에 대한 모니터(monitor)를 소유하게 된다. 모니터란 락(lock)과 같은 개념으로 생각하면 된다. 즉, 모니터와 연관된 해당 객체나 코드를 독점적으로 사용할 수 있는 권한으로서, 모니터를 먼저 소유한 스레드가 모니터를 내놓을 때까지 다른 스레드는 기다려야만 한다.

     

     

    synchronized 사용 예제 

    : 학생을 스레드로 만들고 공유 집계판을 클래스로 작성하였다

     

    * 집계판 : class SharedBoard 작성

    * 각 학생 : class StudentThread(각 학생은 하나의 스레드임) 작성

     

    synchronized를 사용하여 잘 동작하는 집계판 응용 

    : 아래의 코드는 synchronized를 사용한 예이다. SharedBoard의 멤버 add()는 집계판 이전 값을 읽고 10을 더하여 기록하는 기능을 구현하고 synchronized 메소드로 지정하였다. 이것은 add()가 집계 합을 나타내는 공유 변수 sum에 접근하고 있으며, add()는 여러 StudentThread 스레드(학생들)가 동시에 호출할 수 있는 임계영역이기 때문이다.

    main()은 이름이 "Lee", "Park"인 2개의 StudentThread 스레드를 생성하며, 스레드는 루프를 돌면서 SharedBoard의 add()메소드를 10번 호출한다

     

    Lee와 Park이 각각 10번씩 add()를 호출했고, 동기화가 잘 이루어져서 최종 누적 점수 sum이 200이 된다

    실행 결과를 보면 "Lee"와 "Park" 이름이 각 StudentThread 스레드 모두 10번씩 add()를 호출하여 순차적으로 sum값이 갱신되고 있음을 알 수 있으며 최종 결과 sum값이 200이 된다 

     

     

    * synchronized를 사용하지 않아 정상적으로 작동하지 않는 집계판 응용

    : 예제 코드의 ShardBoard 클래스의 add()메소드에서 synchronized 키워드를 제거한 코드를 실행해보자 

    여러 번 실행해보자

    실행 결과는, 순차적으로 증가하지 않는다. 한 스레드가 add()를 실행 중에 다른 스레드 add()를 호출하여 sum에 대한 충돌이 생기게 된다. 

     

    참고) 충돌 상황이 잘 발생하지 않으면 StudentThread의 run()메소드에서 for문의 반복 횟수를 100 혹은 더 큰 값으로 수정하면 된다. 컴퓨터 속도가 너무 빨라서 집계판에 접근하는 횟수를 늘려야 그만큼 충돌 가능성이 높아지기 때문이다. 

     

     

    wait(), notify(), notifyAll()을 이용한 스레드 동기화

    wait()-notify()를 이용한 스레드 동기화가 필요한 이유 

    : 스레드들이 synchronized를 이용하여 공유 데이터에 순차적으로 잘 접근하도록 만들어진 경우라도  여전히 동기화가 필요한 상황이 있다. 대표적인 경우가 공유 메모리를 통해 두 스레드가 데이터를 주고받을 때, 공유 메모리에 대해 두 스레드가 동시에 접근하는 producer-consumer 문제이다.

    예를 들어, 비디오 스트리밍 소프트웨어는 네트워크 서버로부터 압축된 비디오 프레임을 주기적으로 가지고 와서 비디오 버퍼에 저장하는 입력 스레드(producer)와 비디오 버퍼에 들어있는 프레임을 디코딩하여 화면에 출력하는 재생 스레드(consumer)로 구성된다. 보통의 경우 문제가 없다가, 네트워크의 속도가 갑자기 떨어져서 비디오 프레임의 공급이 일시적으로 늦어지게 되면 비디오 버퍼가 비게 되고, 재생 스레드는 버퍼가 찰 때까지 기다리게(wait) 된다. 잠시 후 입력 스레드가 버퍼에 비디오 프레임을 공급하고 대기 중인 재생 스레드(notify)를 깨운다. 반대로 네트워크 속도가 순간적으로 빨라져서 입력 스레드가 채운 버퍼를 재생 스레드가 미처 소비하지 못한 경우, 입력 스레드는 비디오 버퍼가 비워지기를 기다린다(wait). 잠시 후 재생 스레드는 버퍼를 소비하고 나서 기다리는 입력 스레드를 깨운다(notify).

     

    비디오 스트리밍 응용에서 입력 스레드와 재생 스레드의 버퍼 사용에 대한 동기화

     

    Object의 wait(), notify() 메소드 

    : wait()-notify()를 이용하면 앞의 producer-consumer 문제의 스레드 동기화를 해결할 수 있다. java.lang.Object클래스는 스레드 사이에 동기화를 위한 3개의 메소드 wait(), notify(), notifyAll()를 제공한다. 모든 객체는 Object클래스를 상속받기 때문에, 자바는 모든 객체가 동기화 객체가 될 수 있도록 설계하였다. 위의 그림에서 비디오 버퍼가 동기화 객체이다. wait(), notify(), notifyAll() 메소드를 간략히 설명하면 다음과 같고, 이들 메소드를 호출하는 코드는 synchronized로 지정되어 있어야 한다. 그렇지 않으면 실행시 예외가 발생한다.

     

    * wait()

    : 다른 스레드가 이 객체의 notify()를 불러줄 때까지 대기한다

    * notify()

    : 이 객체에 대기 중인 스레드를 깨워 RUNNABLE 상태로 만든다. 2개 이상의 스레드가 대기 중이라도 오직 한 개의 스레드만 깨워 RUNNABLE 상태로 만든다. 

    * notifyAll() 

    : 이 객체에 대기 중인 모든 스레드를 깨우고 모두 RUNNABLE 상태로 한다

     

    wait(), notify(), notifyAll()이 호출되는 여러 경우는 다음과 같다. 그림에서 ObjectS는 멀티스레드가 공유하는 객체이다. 스레드가 ObjectS에 대해 락(lock)을 소유하면 문제없이 실행된다. 이미 다른 스레드가 락을 소유하고 있다면 ObjectS.wait()을 호출하여 락을 소유한 스레드가 ObjectS.notify()나 ObjectS.notifyAll()을 호출하여 깨워줄 때까지 기다린다

     

    두 스레드가 Objects 객체에 동시접근하는 경우, wait()과 notify()

    Thread A가 먼저 ObjectS의 data에 접근하고 있을 때 Thread B는 ObjectS.wait()을 호출하여 기다리고 있는 경우이다. Thread A가 data를 가지고 처리할 작업을 끝내면 ObjectS.notify()를 호출하여 Thread B를 깨운다. 이제 Thread B는 data를 가지고 필요한 작업을 수행하게 된다. 

     

    여러 스레드가 Objects 객체에 동시 접근하는 경우, wait()과 notify()

    Thread A가 먼저 ObjectS의 data에 접근하고 있을 때 Thread B, Thread C, Thread D, Thread E가 도착하여 모두 각각 ObjectS.wait()을 호출하여 기다리고 있는 경우이다. Thread A가 data를 가지고 처리할 작업을 끝내면 ObjectS.notify()를 호출한다. 그러면 JVM은 Thread B, Thread C, Thread D, Thread E 중에 하나만 깨운다. 깨어난 스레드는 이제 data를 가지고 필요한 작업을 수행하게 된다. 4개의 스레드 중에서 어떤 스레드를 깨울 것인지는 JVM이 결정한다

     

     

    여러 스레드가 Objects 객체에 동시 접근하는 경우, wait()과 notifyAll()

    Thread A가 먼저 ObjectS의 data에 접근하고 있을 때 Thread B, Thread C, Thread D, Thread E가 도착하여 모두 각각 ObjectS.wait()을 호출하여 기다리고 있는 상황이다. Thread A가 data를 가지고 처리할 작업을 끝내면 ObjectS.notifyAll()을 호출하여 Thread B, Thread C, Thread D, Thread E 모두 깨운다. 깨어난 각 스레드는 이제 data를 가지고 필요한 작업을 수행하게 된다. 그러나 조심해야 할 것은 또다시 깨어나 스레드들 사이에서 data에 대한 충돌이 생길 수 있다는 것이다. 그러므로 깨어난 스레드 중 한 개의 스레드만 data를 소유하고 나머지는 다시 wait()을 호출하여 잠을 자도록 잘 코딩해야 한다. 

    댓글