본문 바로가기

자바공부

자바 스레드 동기화(Thread Synchronization)

728x90
반응형

* 멀티스레드는 다수의 작업을 동시에 하게 하는 주요한 프로그래밍 도우깅다.

다수의 스레드가 공유 자원 혹은 공유 데이터에 동시에 접근할 때 예상치 못하는 경과를 낳을 수도 있다.

 

* 다수의 스레드가 공유 자원에 동시 접근하는 두 사례

1) 공유 프린터에 동시 접근하는 경우

두 개의 프린터 스레드 A, B가 짧은 시간 간격을 두고 거의 동시에 프린팅을 실행하는 경우이다.

스레드 A가 간발의 차이로 프린팅을 먼저 시작하여 프린터를 소유하고 다른 스레드가 프린터에 접근하지 못하도록 조치를 취한 결과, 스레드 B가 스레드 A의 작업이 끝나기를 기다리고 있다.

공유 프린터에 대한 멀티스레드의 동시 접근을 순차화하면 첫번째 그림과 같은 문제는 발생하지 않는다.

 

2) 공유 집계판에 동시 접근하는 경우

학생들이 공유 집계판에 동시에 접근하는 경우

* 멀티스레드 프로그램을 작성할 때 주의할 점은 다수의 스레드가 공유 데이터에 동시 접근하는 경우에 대한 처리이다.

해결책은 스레드 동기화(Thread Synchronization)이다.

스레드 동기화란 공유 데이터에 접근하고자 하는 다수의 스레드가 서로 순서대로 충돌 없이 공유 데이터를 배타적으로 접근하기 위해 상호 협력(coordination)하는 것을 말한다.

공유 데이터에 대한 접근은 배타적이고 독점적으로 이루어져야 하며, 이런 경우 공유 데이터를 다루는 프로그램 코드를 임계 영역(critical section)이라고 부른다.

즉, 임계 영역에 대한 멀티스레드의 동기화가 필요하다.

 

* synchronized 키워드는 자바에서 스레드 동기화를 위한 장치이다.

synchronized 키워드는 임의의 코드 블록을 동기화가 설정된 임계 영역으로 지정한다.

한마디로 임계 영역 표시 키워드이다.

 

* synchronized 키워드를 이용하여 하나의 메소드 전체를 임계 영역으로 지정하는 방법과 임의 코드 블록을 임계 영역으로 지정하는 두 가지 방법이 있다.

어떤 방법을 사용하든지 synchronized 블록은 진입할 때 락(lock)을 걸고 빠져 나올 때 락을 푸는(unlock) 동작이 자동으로 이루어진다.

그러므로 먼저 synchronized 블록에 진입하는 스레드가 락(Lock)을 걸고 소유하며, 락(lock)을 소유하지 못한 다른 스레드는 synchronized 블록 앞에서 락(lock)을 소유할 때까지 대기한다.

 

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

하나의 메소드가 통체로 synchronized 블록으로 지정되는 경우이다.

예를 들면, 다음과 같이 메소드 앞에 synchronized로 선언하면 된다.

synchronized void add(){
    int n = getCurrentSum();
    n += 10;
    setCurrentSum(n);
}

add() 메소드의 호출 시 자동으로 동기화된다.

즉, 한 스레드가 먼저 add() 메소드를 호출한 경우 add() 메소드의 실행을 끝내기 전에 다른 스레드가 add() 메소드를 호출하면 두 번째 스레드는 첫 번째 스레드가 add()를 완전히 빠져나오기까지 자동으로 대기하게 된다.

 

2) 임의의 코드 블록을 임께 영역으로 지정

임의의 코드 블록을 synchronized 블록으로 지정하는 경우이다.

아래와 같이 몇 줄의 코드를 synchronized 키워드를 사용하여 블록으로 설정한다.

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

한 스레드가 synchronized 블록 내의 코드를 먼저 실행 중일 때 다른 스레드가 이 블록을 실행하고자 하면 먼저 실행 중인 스레드가 synchronized 블록의 실행을 마칠 때까지 자동으로 대기한다.

여기서 synchronized(this)는 synchronized 블록에서 인자로 주어진 객체(this)와 연계된 락(lock)을 사용하도록 개발자가 지정한 것이다.

개발자는 this 대신 다른 객체의 레퍼런스를 사용할 수 있으며 그 객체가 락(lock)과 연관된다.

 

* 스레드가 synchronized 코드 블록으로 진입하는 순간 그 블록에 대한 모니터(monitor)를 소유하게 된다.

모니터란 락(lock)과 동일한 개념으로 생각하면 된다.

즉, 모니터와 연관된 해당 객체나 코드를 독점적으로 사용할 수 있는 권한으로서, 모니터를 먼저 소유한 스레드가 모니터를 내놓을 때까지 다른 스레드는 기다려야 한다.

 

* synchronized 사용 예제

synchronized를 사용한 경우와 사용하지 않는 경우의 결과를 대조하여 synchronized의 필요성을 알아보자

집계판 사례를 자바 프로그램으로 구현한 예시

✅ 집계판 : class SyncObject

✅ 각 학생 : class WorkerThread(각 학생은 하나의 스레드임)

 

SyncObject의 멤버 add()를 synchronized 메소드로 지정하였다. add()는 두 명의 학생, 즉 WorkerThread 스레드가 동시에 호출할 수 있는 임계 영역이기 때문이다. 또한 add()메소드는 sum 공유 변수에 접근하고 있기 때문이다.

WorkerThread는 루프를 돌면서 10번 SyncObject의 add() 메소드를 호출하여 SyncObject의 sum 멤버 값에 10을 더하고 리턴한다.

main()은 이름이 "kitae", "hyosoo"인 두 개의 WorkerThread를 생성한다.

package Ch1306ex;

public class SynchronizedEx {
	public static void main(String[] args) {
		SyncObject obj = new SyncObject(); // 집계판 공유 데이터 생성
		
		
		// 스레드 생성 시 집계판의 주소를 알려준다. 두 스레드는 집계판에 동시에 접근한다.
		Thread th1 = new WorkerThread("kitae", obj);
		Thread th2 = new WorkerThread("hyosoo", obj);
		
		// 두 스레드를 실행시킨다.
		th1.start();
		th2.start();
		
	}
}

// 공유 데이터인 집계판을 시뮬레이션하는 클래스
// 두 WorkerThread 스레드에 의해 동시 접근됨
class SyncObject{
	int sum = 0;
	synchronized void add() {
		int n = sum;
		Thread.yield(); // 스레드의실행 속도가 너무 빠르기 때문에 충돌 발생이 쉽지 않다. 그러므로 고의로 add()메소드에 대한 충돌을 쉽게 발생시키기 위해 다른 스레드에 양보호도록 하였다.
		n += 10; // 10을 증가시킨다.
		sum = n; // 증가한 값을 집계 합에 쓴다.
		System.out.println(Thread.currentThread().getName() + " : " + sum);
	}
	int getSum() {return sum;}
}

// 학생을 시뮬레이션하는 스레드 클래스
class WorkerThread extends Thread {
	SyncObject sObj; // 집계판의 주소
	
	WorkerThread(String name, SyncObject sObj){
		super(name);
		this.sObj = sObj;
	}
	
	public void run() {
		int i=0;
		while(i<10) {
			sObj.add();
			i++;
		}
	}
}

synchronized를 사용한 경우 정상적인 결과

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

 

* 앞의 예제 코드에서 SyncObject 클래스에 synchronized 키워드를 제어한 코드를 실행해보자

class SyncObject{
	int sum = 0;
//	synchronized void add() {
	void add() {
		int n = sum;
		Thread.yield(); // 스레드의실행 속도가 너무 빠르기 때문에 충돌 발생이 쉽지 않다. 그러므로 고의로 add()메소드에 대한 충돌을 쉽게 발생시키기 위해 다른 스레드에 양보호도록 하였다.
		n += 10; // 10을 증가시킨다.
		sum = n; // 증가한 값을 집계 합에 쓴다.
		System.out.println(Thread.currentThread().getName() + " : " + sum);
	}
	int getSum() {return sum;}
}

충돌이 생기게되고 최종 값 200에 도달하지 못하게 된다.

 

* synchronized 키워드를 사용하는 방법 외에 java.lang.Object 클래스의 wait(), notify(), notifyAll() 메소드를 이용하여 스레드를 동기화하는 방법이 있다.

java.lang.Object 클래스는 스레드 사이에 동기화를 위한 3개의 메소드 wait(), notify(), notifyAll()를 제공한다.

그러므로 모든 자바 클래스가 이 3개의 메소드를 가지고 있는 셈이다.

1) wait()

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

2) notify()

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

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

❗️ Thread A가 먼저 ObjectS의 data에 접근하고 있을 때 Thread B는 ObjectS.wait()을 호출하여 기다리고 있는 경우이다.

Thread A가 data를 가지고 처리할 작업을 끝내면 ObjectS.notify()를 호출하여 Thread B를 깨운다.

이제 Thread B는 data를 가지고 필요한 작업을 수행하게 된다.

5개의 스레드가 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이 결정한다.

 

3) notifyAll()

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

5개의 스레드가 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()를 호출하여 잠을 자도록 잘 코딩해야 한다.

 

* 세 그림에서 ObjectS는 멀티스레드가 공유하고자 하는 객체이다. ObjectS에 대해 락(lock)을 소유하면 스레드는 문제없이 실행된다.

그러나 만일 다른 스레드가 락(Lock)을 소유하고 있다면 ObjectS.wait()을 호출하여 락(lock)을 소유한 스레드가 ObjectS.notify()나 ObjectS.notifyAll()을 호출하여 깨워줄 때까지 기다린다.

반응형